Files
daywork/intern-log/src/views/SettingsView.vue
T

440 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>