440 lines
17 KiB
Vue
440 lines
17 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 {
|
||
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 { 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 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()
|
||
}
|
||
|
||
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="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>
|
||
|
||
<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; }
|
||
.section { padding: 14px 16px; }
|
||
.section-title { font-weight: 600; margin-bottom: 10px; font-size: 13px; }
|
||
|
||
.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; }
|
||
|
||
.proj-add { display: flex; gap: 8px; }
|
||
.proj-tip { font-size: 12px; margin-top: 6px; }
|
||
.proj-row {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 6px 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; }
|
||
.import-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
|
||
.backup-note { font-size: 12px; margin-top: 10px; }
|
||
</style>
|