568 lines
22 KiB
Vue
568 lines
22 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, ref, watch } from 'vue'
|
||
import Icon from '../components/Icon.vue'
|
||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||
import InputDialog from '../components/InputDialog.vue'
|
||
import {
|
||
addProject, exportAll, getLocationPresets, getSetting, getWorkRule, importAll,
|
||
listAttendanceBetween, listLogItemsBetween, listProjects,
|
||
renameProject, saveLocationPresets, setProjectArchived, setSetting, type WorkRule
|
||
} from '../db/repo'
|
||
import { categoryLabel, type Project } from '../db/schema'
|
||
import { getLogCategories, setLogCategories, getLedgerCategories, setLedgerCategories } from '../db/categories'
|
||
import { downloadText, } from '../lib/download'
|
||
import { downloadCsv } from '../lib/csv'
|
||
import { hhmm, parseDateStr, todayStr } from '../lib/date'
|
||
import { computeDay } from '../domain/attendance'
|
||
import { changeLoginPassword, deleteCurrentUser, getSession } from '../db/auth'
|
||
import { ensureCloudBinding, getSyncLastTime, isSyncConfigured, syncWithStoredConfig, syncLastError, type SyncResult } from '../sync/engine'
|
||
import { getCloudCreds } from '../db/auth'
|
||
import { showToast } from '../lib/toast'
|
||
|
||
const rule = ref<WorkRule>({ work_start: '09:00', work_end: '18:00', grace_minutes: 10, lunch_minutes: 60, remind_weekly: 'friday' })
|
||
const projectsAll = ref<Project[]>([])
|
||
const newName = ref('')
|
||
const projTip = ref('')
|
||
const locationsText = ref('')
|
||
const aiBase = ref('')
|
||
const aiKey = ref('')
|
||
const aiModel = ref('')
|
||
const editingId = ref<number | null>(null)
|
||
const editName = ref('')
|
||
// ---- 分类管理 ----
|
||
const logCats = ref<string[]>([])
|
||
const expenseCats = ref<string[]>([])
|
||
const incomeCats = ref<string[]>([])
|
||
const editingCat = ref<{ kind: 'log' | 'expense' | 'income'; index: number; value: string } | null>(null)
|
||
|
||
async function loadCategories() {
|
||
logCats.value = await getLogCategories()
|
||
expenseCats.value = await getLedgerCategories('expense')
|
||
incomeCats.value = await getLedgerCategories('income')
|
||
}
|
||
// ---- 分类编辑:自绘输入弹窗 + 危险确认框(替代浏览器原生 prompt/confirm)----
|
||
type CatKind = 'log' | 'expense' | 'income'
|
||
const catDialog = ref<{ open: boolean; kind: CatKind; index: number; initial: string }>({ open: false, kind: 'log', index: -1, initial: '' })
|
||
const delCat = ref<{ kind: CatKind; index: number; name: string } | null>(null)
|
||
|
||
function catListOf(kind: CatKind): string[] {
|
||
return kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
|
||
}
|
||
async function writeCats(kind: CatKind, next: string[]) {
|
||
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
|
||
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
|
||
else { incomeCats.value = next; await setLedgerCategories('income', next) }
|
||
}
|
||
function openCatAdd(kind: CatKind) {
|
||
catDialog.value = { open: true, kind, index: -1, initial: '' }
|
||
}
|
||
function openCatRename(kind: CatKind, index: number, oldName: string) {
|
||
catDialog.value = { open: true, kind, index, initial: oldName }
|
||
}
|
||
function validateCat(v: string): string | null {
|
||
const { kind, index } = catDialog.value
|
||
const list = catListOf(kind)
|
||
if (list.includes(v) && !(index >= 0 && list[index] === v)) return '该分类已存在'
|
||
return null
|
||
}
|
||
async function onCatDialogConfirm(v: string) {
|
||
const { kind, index } = catDialog.value
|
||
if (index >= 0) {
|
||
await writeCats(kind, catListOf(kind).map((x, i) => (i === index ? v : x)))
|
||
showToast('分类已更新(历史记录自动跟随显示)')
|
||
} else {
|
||
await writeCats(kind, [...catListOf(kind), v])
|
||
showToast('分类已添加')
|
||
}
|
||
catDialog.value.open = false
|
||
}
|
||
function askRemoveCat(kind: CatKind, index: number) {
|
||
const list = catListOf(kind)
|
||
if (list.length <= 1) { showToast('至少保留一个分类'); return }
|
||
delCat.value = { kind, index, name: list[index] }
|
||
}
|
||
async function confirmRemoveCat() {
|
||
if (!delCat.value) return
|
||
const { kind, index } = delCat.value
|
||
await writeCats(kind, catListOf(kind).filter((_, i) => i !== index))
|
||
showToast('分类已删除')
|
||
delCat.value = null
|
||
}
|
||
|
||
const fileInput = ref<HTMLInputElement | null>(null)
|
||
const pendingImport = ref<{ name: string; text: string } | null>(null)
|
||
const importError = ref('')
|
||
const username = ref(getSession()?.username || '')
|
||
const oldLoginPwd = ref('')
|
||
const newLoginPwd1 = ref('')
|
||
const newLoginPwd2 = ref('')
|
||
const accError = ref('')
|
||
const deleteAccConfirm = ref(false)
|
||
const appVersion = __APP_VERSION__
|
||
const cloudUser = ref('')
|
||
const syncStatus = ref('')
|
||
const syncError = ref('')
|
||
const autoSyncErr = ref('')
|
||
const syncing = ref(false)
|
||
const lastSync = ref('')
|
||
|
||
async function loadSyncInfo() {
|
||
cloudUser.value = getCloudCreds()?.username || ''
|
||
lastSync.value = await getSyncLastTime()
|
||
autoSyncErr.value = syncLastError.value
|
||
}
|
||
watch(syncLastError, v => { autoSyncErr.value = v })
|
||
|
||
|
||
async function rebindCloud() {
|
||
syncError.value = ''
|
||
try {
|
||
await ensureCloudBinding()
|
||
showToast('云端绑定已重建')
|
||
} catch (e) {
|
||
syncError.value = e instanceof Error ? e.message : '绑定失败'
|
||
}
|
||
}
|
||
|
||
async function doSyncNow() {
|
||
if (syncing.value) return
|
||
syncError.value = ''
|
||
autoSyncErr.value = syncLastError.value
|
||
syncing.value = true
|
||
try {
|
||
const result: SyncResult = await syncWithStoredConfig()
|
||
lastSync.value = result.at
|
||
syncStatus.value = '推送 ' + result.pushed + ' 条 · 拉取 ' + result.pulled + ' 条 · 删除 ' + result.removed + ' 条'
|
||
autoSyncErr.value = ''
|
||
showToast('同步完成')
|
||
} catch (e) {
|
||
syncError.value = e instanceof Error ? e.message : '同步失败'
|
||
}
|
||
syncing.value = false
|
||
}
|
||
|
||
async function doChangeLoginPwd() {
|
||
accError.value = ''
|
||
if (newLoginPwd1.value !== newLoginPwd2.value) {
|
||
accError.value = '两次输入的新密码不一致'
|
||
return
|
||
}
|
||
try {
|
||
await changeLoginPassword(oldLoginPwd.value, newLoginPwd1.value)
|
||
oldLoginPwd.value = ''
|
||
newLoginPwd1.value = ''
|
||
newLoginPwd2.value = ''
|
||
showToast('登录密码已修改')
|
||
} catch (e) {
|
||
accError.value = e instanceof Error ? e.message : '修改失败'
|
||
}
|
||
}
|
||
|
||
async function doDeleteAccount() {
|
||
await deleteCurrentUser()
|
||
window.location.reload()
|
||
}
|
||
|
||
async function load() {
|
||
rule.value = await getWorkRule()
|
||
projectsAll.value = await listProjects(true)
|
||
locationsText.value = (await getLocationPresets()).join('\n')
|
||
aiBase.value = await getSetting('ai_base_url', '')
|
||
aiKey.value = await getSetting('ai_api_key', '')
|
||
aiModel.value = await getSetting('ai_model', '')
|
||
await loadSyncInfo()
|
||
await loadCategories()
|
||
}
|
||
|
||
async function saveAi() {
|
||
await setSetting('ai_base_url', aiBase.value.trim())
|
||
await setSetting('ai_api_key', aiKey.value.trim())
|
||
await setSetting('ai_model', aiModel.value.trim())
|
||
showToast('AI 配置已保存')
|
||
}
|
||
|
||
async function doExportLogsCsv() {
|
||
const t = todayStr()
|
||
const found = await Promise.all([listLogItemsBetween('2000-01-01', t), listProjects(true)])
|
||
const items = found[0]
|
||
const projects = found[1]
|
||
const pmap = new Map<number, string>(projects.map(p => [p.id as number, p.name]))
|
||
const rows: (string | number | null)[][] = [['日期', '分类', '项目', '内容', '耗时(分钟)', '成果', '待跟进', '收获', '备注']]
|
||
for (const it of items) {
|
||
rows.push([
|
||
it.date,
|
||
categoryLabel(it.category),
|
||
it.project_id ? (pmap.get(it.project_id) || '') : '',
|
||
it.content,
|
||
it.minutes == null ? '' : it.minutes,
|
||
it.is_milestone ? '是' : '',
|
||
it.needs_followup ? '是' : '',
|
||
it.takeaway || '',
|
||
it.remark || ''
|
||
])
|
||
}
|
||
const p = await downloadCsv('工作记录_' + t.replace(/-/g, '') + '.csv', rows)
|
||
showToast(p ? 'CSV 已导出到:' + p : 'CSV 已导出到系统下载目录')
|
||
}
|
||
|
||
async function doExportAttendanceCsv() {
|
||
const t = todayStr()
|
||
const found = await Promise.all([getWorkRule(), listAttendanceBetween('2000-01-01', t)])
|
||
const rule = found[0]
|
||
const atts = found[1]
|
||
const wdNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||
const rows: (string | number | null)[][] = [['日期', '星期', '状态', '上班时间', '下班时间', '地点', '类型', '说明']]
|
||
for (const a of atts) {
|
||
const info = computeDay(a, a.date, rule, a.date === t)
|
||
rows.push([
|
||
a.date,
|
||
wdNames[parseDateStr(a.date).getDay()],
|
||
info.label,
|
||
a.clock_in_at ? hhmm(a.clock_in_at) : '',
|
||
a.clock_out_at ? hhmm(a.clock_out_at) : '',
|
||
a.location || '',
|
||
a.kind === 'makeup' ? '补卡' : a.kind === 'leave' ? '休假' : '正常',
|
||
a.note || ''
|
||
])
|
||
}
|
||
const p = await downloadCsv('考勤记录_' + t.replace(/-/g, '') + '.csv', rows)
|
||
showToast(p ? 'CSV 已导出到:' + p : 'CSV 已导出到系统下载目录')
|
||
}
|
||
onMounted(load)
|
||
|
||
async function saveRule() {
|
||
await setSetting('work_start', rule.value.work_start)
|
||
await setSetting('work_end', rule.value.work_end)
|
||
await setSetting('grace_minutes', String(rule.value.grace_minutes))
|
||
await setSetting('lunch_minutes', String(rule.value.lunch_minutes))
|
||
await setSetting('remind_weekly', rule.value.remind_weekly)
|
||
showToast('打卡规则已保存')
|
||
}
|
||
|
||
async function saveLocations() {
|
||
await saveLocationPresets(locationsText.value.split('\n'))
|
||
showToast('常用地点已保存')
|
||
}
|
||
|
||
async function doAdd() {
|
||
const n = newName.value.trim()
|
||
if (!n) return
|
||
try {
|
||
await addProject(n)
|
||
projTip.value = ''
|
||
newName.value = ''
|
||
} catch {
|
||
projTip.value = '项目名已存在'
|
||
}
|
||
await load()
|
||
}
|
||
|
||
function startRename(p: Project) {
|
||
editingId.value = p.id as number
|
||
editName.value = p.name
|
||
}
|
||
async function saveRename(p: Project) {
|
||
const n = editName.value.trim()
|
||
if (n) {
|
||
try {
|
||
await renameProject(p.id as number, n)
|
||
projTip.value = ''
|
||
} catch {
|
||
projTip.value = '项目名已存在'
|
||
}
|
||
}
|
||
editingId.value = null
|
||
await load()
|
||
}
|
||
async function toggleArchive(p: Project) {
|
||
await setProjectArchived(p.id as number, p.archived !== 1)
|
||
await load()
|
||
}
|
||
|
||
async function doExport() {
|
||
try {
|
||
const text = await exportAll()
|
||
const p = await downloadText('实习日志备份_' + todayStr().replace(/-/g, '') + '.json', text)
|
||
showToast(p ? '备份已导出到:' + p : '备份已导出到系统下载目录')
|
||
} catch (e) {
|
||
showToast('导出失败:' + (e instanceof Error ? e.message : String(e)))
|
||
}
|
||
}
|
||
|
||
function onFile(e: Event) {
|
||
const input = e.target as HTMLInputElement
|
||
const f = input.files && input.files[0]
|
||
importError.value = ''
|
||
if (!f) return
|
||
const name = f.name
|
||
f.text().then(tx => { pendingImport.value = { name, text: tx } })
|
||
input.value = ''
|
||
}
|
||
async function doImport() {
|
||
if (!pendingImport.value) return
|
||
try {
|
||
await importAll(pendingImport.value.text)
|
||
pendingImport.value = null
|
||
importError.value = ''
|
||
await load()
|
||
showToast('数据导入完成')
|
||
} catch (err) {
|
||
pendingImport.value = null
|
||
importError.value = err instanceof Error ? err.message : '导入失败'
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="settings">
|
||
<div class="card section">
|
||
<div class="section-title">账号与隐私</div>
|
||
<div class="muted acc-line">v{{ appVersion }} · 当前账号 {{ username }} · 登录密码仅存 PBKDF2 哈希</div>
|
||
<div class="ai-grid">
|
||
<div class="rule-item">
|
||
<label>旧登录密码</label>
|
||
<input type="password" class="input" v-model="oldLoginPwd" />
|
||
</div>
|
||
<div class="rule-item">
|
||
<label>新登录密码(至少 6 位)</label>
|
||
<input type="password" class="input" v-model="newLoginPwd1" />
|
||
</div>
|
||
<div class="rule-item">
|
||
<label>确认新登录密码</label>
|
||
<input type="password" class="input" v-model="newLoginPwd2" />
|
||
</div>
|
||
</div>
|
||
<div v-if="accError" class="form-error">{{ accError }}</div>
|
||
<div class="rule-save">
|
||
<button class="btn btn-primary btn-sm" @click="doChangeLoginPwd">修改登录密码</button>
|
||
<button class="btn btn-sm btn-danger" @click="deleteAccConfirm = true">删除此用户及全部数据</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card section">
|
||
<div class="section-title">打卡规则</div>
|
||
<div class="rule-grid">
|
||
<div class="rule-item">
|
||
<label>上班时间</label>
|
||
<input type="time" class="input" v-model="rule.work_start" />
|
||
</div>
|
||
<div class="rule-item">
|
||
<label>下班时间</label>
|
||
<input type="time" class="input" v-model="rule.work_end" />
|
||
</div>
|
||
<div class="rule-item">
|
||
<label>迟到宽限(分钟)</label>
|
||
<input type="number" class="input" min="0" v-model.number="rule.grace_minutes" />
|
||
</div>
|
||
<div class="rule-item">
|
||
<label>午休扣除(分钟)</label>
|
||
<input type="number" class="input" min="0" v-model.number="rule.lunch_minutes" />
|
||
</div>
|
||
<div class="rule-item">
|
||
<label>周记提醒</label>
|
||
<select class="select" v-model="rule.remind_weekly">
|
||
<option value="friday">每周五提醒</option>
|
||
<option value="off">关闭</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="rule-save">
|
||
<button class="btn btn-primary btn-sm" @click="saveRule">保存规则</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card section">
|
||
<div class="section-title">项目管理</div>
|
||
<div class="proj-add">
|
||
<input class="input" v-model="newName" placeholder="新项目名称" @keydown.enter="doAdd" />
|
||
<button class="btn btn-sm" @click="doAdd">添加</button>
|
||
</div>
|
||
<div v-if="projTip" class="muted proj-tip">{{ projTip }}</div>
|
||
<div v-for="p in projectsAll" :key="p.id" class="proj-row">
|
||
<template v-if="editingId === p.id">
|
||
<input class="input" v-model="editName" @keydown.enter="saveRename(p)" />
|
||
<button class="btn btn-sm" @click="saveRename(p)">保存</button>
|
||
<button class="btn btn-sm" @click="editingId = null">取消</button>
|
||
</template>
|
||
<template v-else>
|
||
<span class="proj-name" :class="{ archived: p.archived === 1 }">{{ p.name }}</span>
|
||
<button class="icon-btn" title="重命名" @click="startRename(p)">
|
||
<Icon name="edit" :size="14" />
|
||
</button>
|
||
<button class="btn btn-sm" @click="toggleArchive(p)">{{ p.archived === 1 ? '恢复' : '归档' }}</button>
|
||
</template>
|
||
</div>
|
||
<div v-if="!projectsAll.length" class="muted">暂无项目,先添加一个</div>
|
||
</div>
|
||
|
||
<div class="card section">
|
||
<div class="section-title">常用地点</div>
|
||
<textarea class="textarea" v-model="locationsText" placeholder="每行一个地点,如:公司、客户现场、出差、居家办公"></textarea>
|
||
<div class="rule-save">
|
||
<button class="btn btn-primary btn-sm" @click="saveLocations">保存地点</button>
|
||
<span class="muted loc-note">打卡时可直接输入新地点,保存后自动加入列表</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card section">
|
||
<div class="section-title">多端数据同步</div>
|
||
<div class="muted acc-line">云端账号:{{ cloudUser || '未绑定' }} · 数据存于服务器,其他端变更秒级自动刷新</div>
|
||
<div class="rule-save">
|
||
<button class="btn btn-primary btn-sm" :disabled="syncing" @click="doSyncNow">{{ syncing ? '同步中' : '立即同步' }}</button>
|
||
<button class="btn btn-sm" @click="rebindCloud">重新校验连接</button>
|
||
</div>
|
||
<div v-if="syncStatus" class="muted acc-line">上次同步:{{ lastSync || '--' }} · {{ syncStatus }}</div>
|
||
<div v-if="syncError" class="form-error">{{ syncError }}</div>
|
||
<div v-if="autoSyncErr" class="form-error">最近自动同步失败:{{ autoSyncErr }}(可点「立即同步」重试)</div>
|
||
</div>
|
||
|
||
<div class="card section">
|
||
<div class="section-title">AI 润色配置(可选)</div>
|
||
<div class="ai-grid">
|
||
<div class="rule-item">
|
||
<label>接口地址(OpenAI 兼容,到 /v1 为止)</label>
|
||
<input class="input" v-model="aiBase" placeholder="https://api.example.com/v1" />
|
||
</div>
|
||
<div class="rule-item">
|
||
<label>API Key</label>
|
||
<input class="input" v-model="aiKey" placeholder="sk-..." />
|
||
</div>
|
||
<div class="rule-item">
|
||
<label>模型名称</label>
|
||
<input class="input" v-model="aiModel" placeholder="如:deepseek-chat" />
|
||
</div>
|
||
</div>
|
||
<div class="rule-save">
|
||
<button class="btn btn-primary btn-sm" @click="saveAi">保存 AI 配置</button>
|
||
<span class="muted loc-note">配置仅保存在本机;总结页「AI 润色」需此处先配置</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card section">
|
||
<div class="section-title">工作分类</div>
|
||
<div class="cat-block">
|
||
<div class="cat-head"><span class="muted">工作记录分类</span><button class="btn btn-xs" @click="openCatAdd('log')">添加</button></div>
|
||
<div class="cat-chips">
|
||
<span v-for="(c, i) in logCats" :key="c + i" class="cat-chip">
|
||
{{ c }}
|
||
<button class="chip-btn" title="修改" @click="openCatRename('log', i, c)">改</button>
|
||
<button class="chip-btn" title="删除" @click="askRemoveCat('log', i)">删</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="cat-block">
|
||
<div class="cat-head"><span class="muted">记账支出分类</span><button class="btn btn-xs" @click="openCatAdd('expense')">添加</button></div>
|
||
<div class="cat-chips">
|
||
<span v-for="(c, i) in expenseCats" :key="c + i" class="cat-chip">
|
||
{{ c }}
|
||
<button class="chip-btn" title="修改" @click="openCatRename('expense', i, c)">改</button>
|
||
<button class="chip-btn" title="删除" @click="askRemoveCat('expense', i)">删</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="cat-block">
|
||
<div class="cat-head"><span class="muted">记账收入分类</span><button class="btn btn-xs" @click="openCatAdd('income')">添加</button></div>
|
||
<div class="cat-chips">
|
||
<span v-for="(c, i) in incomeCats" :key="c + i" class="cat-chip">
|
||
{{ c }}
|
||
<button class="chip-btn" title="修改" @click="openCatRename('income', i, c)">改</button>
|
||
<button class="chip-btn" title="删除" @click="askRemoveCat('income', i)">删</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card section">
|
||
<div class="section-title">数据备份</div>
|
||
<div class="backup-row">
|
||
<button class="btn btn-sm" @click="doExport">导出 JSON 备份</button>
|
||
<button class="btn btn-sm" @click="fileInput?.click()">导入 JSON 备份</button>
|
||
<button class="btn btn-sm" @click="doExportLogsCsv">导出工作记录 CSV</button>
|
||
<button class="btn btn-sm" @click="doExportAttendanceCsv">导出考勤 CSV</button>
|
||
<input
|
||
ref="fileInput" type="file" accept="application/json,.json"
|
||
style="display: none" @change="onFile"
|
||
/>
|
||
</div>
|
||
<div v-if="importError" class="import-error">{{ importError }}</div>
|
||
<div class="muted backup-note">数据保存在云端服务器;导出的 JSON 备份用于跨设备迁移或灾后恢复。</div>
|
||
</div>
|
||
|
||
<InputDialog
|
||
:open="catDialog.open"
|
||
:title="catDialog.index >= 0 ? '修改分类名称' : '添加分类'"
|
||
label="分类名称"
|
||
placeholder="如:制度学习 / 差旅交通"
|
||
:initial="catDialog.initial"
|
||
:validate="validateCat"
|
||
confirm-text="保存"
|
||
@cancel="catDialog.open = false"
|
||
@confirm="onCatDialogConfirm"
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
:open="!!delCat"
|
||
title="删除分类"
|
||
:message="delCat ? '将删除分类「' + delCat.name + '」。历史记录不受影响,仍按原分类展示。' : ''"
|
||
confirm-text="删除" danger
|
||
@cancel="delCat = null"
|
||
@confirm="confirmRemoveCat"
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
:open="!!pendingImport"
|
||
title="导入确认"
|
||
:message="pendingImport ? '将从文件「' + pendingImport.name + '」导入备份。现有全部数据将被覆盖,且不可恢复,请先确认已导出当前备份。' : ''"
|
||
confirm-text="覆盖导入" danger
|
||
@cancel="pendingImport = null"
|
||
@confirm="doImport"
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
:open="deleteAccConfirm"
|
||
title="删除用户确认"
|
||
:message="'将永久删除云端账号「' + username + '」及其服务器上的全部数据:工作日志、考勤、备忘录、账号密码库都将被清除,任何设备都无法再取回,且不可恢复。请确认。'"
|
||
confirm-text="永久删除" danger
|
||
@cancel="deleteAccConfirm = false"
|
||
@confirm="doDeleteAccount"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.settings { display: flex; flex-direction: column; gap: 12px; max-width: 640px; }
|
||
.acc-line { font-size: 12px; margin-bottom: 10px; line-height: 1.7; }
|
||
|
||
.rule-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; }
|
||
.rule-item label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
|
||
.rule-save { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
|
||
|
||
.proj-add { display: flex; gap: 8px; }
|
||
.proj-tip { font-size: 12px; margin-top: 6px; }
|
||
.proj-row {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 7px 0; border-bottom: 1px solid var(--border);
|
||
}
|
||
.proj-row:last-child { border-bottom: none; }
|
||
.proj-name { flex: 1; word-break: break-all; }
|
||
.proj-name.archived { color: var(--text-3); text-decoration: line-through; }
|
||
|
||
.loc-note { font-size: 12px; }
|
||
.ai-grid { display: flex; flex-direction: column; gap: 10px; }
|
||
.backup-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.cat-block { margin-top: 12px; }
|
||
.cat-block:first-of-type { margin-top: 0; }
|
||
.cat-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 12px; }
|
||
.cat-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||
.cat-chip {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
border: 1px solid var(--border); border-radius: 999px;
|
||
padding: 2px 8px; font-size: 12px;
|
||
background: var(--bg-subtle);
|
||
}
|
||
.chip-btn { border: none; background: transparent; color: var(--text-3); cursor: pointer; font-size: 11px; padding: 0 1px; }
|
||
.chip-btn:hover { color: var(--accent); }
|
||
.import-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
|
||
.backup-note { font-size: 12px; margin-top: 10px; }
|
||
</style>
|