初始提交:实习工作日志系统(设计方案 + Vue3 应用 + Electron 打包)

This commit is contained in:
root
2026-10-15 23:55:27 +08:00
commit cfb984627d
50 changed files with 9223 additions and 0 deletions
+377
View File
@@ -0,0 +1,377 @@
<script setup lang="ts">
import { onMounted, ref } 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 { 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)
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', '')
}
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 || ''
])
}
downloadCsv('工作记录_' + t.replace(/-/g, '') + '.csv', rows)
showToast('工作记录 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 || ''
])
}
downloadCsv('考勤记录_' + t.replace(/-/g, '') + '.csv', rows)
showToast('考勤 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() {
const text = await exportAll()
downloadText('实习日志备份_' + todayStr().replace(/-/g, '') + '.json', text)
}
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">当前用户:{{ 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">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">数据保存在浏览器 IndexedDB 中,建议定期导出 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>