初始提交:实习工作日志系统(设计方案 + Vue3 应用 + Electron 打包)
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user