初始提交:实习工作日志系统(设计方案 + 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
+209
View File
@@ -0,0 +1,209 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import Icon from '../components/Icon.vue'
import DayPanel from '../components/log/DayPanel.vue'
import { getWorkRule, listAttendanceBetween, listLogItemsBetween } from '../db/repo'
import { computeDay } from '../domain/attendance'
import { addMonths, toDateStr, todayStr } from '../lib/date'
import { attendanceChanged, focusDate } from '../lib/bus'
const monthRef = ref(todayStr())
const year = computed(() => Number(monthRef.value.slice(0, 4)))
const month0 = computed(() => Number(monthRef.value.slice(5, 7)) - 1)
const title = computed(() => year.value + ' 年 ' + (month0.value + 1) + ' 月')
const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日']
const cells = computed(() => {
const first = new Date(year.value, month0.value, 1)
const lead = (first.getDay() + 6) % 7
const total = new Date(year.value, month0.value + 1, 0).getDate()
const arr: { date: string; inMonth: boolean; weekend: boolean }[] = []
for (let i = 0; i < lead; i++) arr.push({ date: '', inMonth: false, weekend: false })
for (let d = 1; d <= total; d++) {
const dt = new Date(year.value, month0.value, d)
const wd = dt.getDay()
arr.push({ date: toDateStr(dt), inMonth: true, weekend: wd === 0 || wd === 6 })
}
return arr
})
interface CellInfo { status: string; label: string; location: string; hasLog: boolean; hasMilestone: boolean }
const infoMap = ref(new Map<string, CellInfo>())
const t = todayStr()
async function loadMonth() {
const start = toDateStr(new Date(year.value, month0.value, 1))
const end = toDateStr(new Date(year.value, month0.value + 1, 0))
const results = await Promise.all([
getWorkRule(),
listAttendanceBetween(start, end),
listLogItemsBetween(start, end)
])
const rule = results[0]
const attMap = new Map(results[1].map(a => [a.date, a]))
const items = results[2]
const m = new Map<string, CellInfo>()
for (const c of cells.value) {
if (!c.inMonth) continue
const di = computeDay(attMap.get(c.date), c.date, rule, c.date === t)
const dayItems = items.filter(i => i.date === c.date)
m.set(c.date, {
status: di.status,
label: di.label,
location: (attMap.get(c.date)?.location) || '',
hasLog: dayItems.length > 0,
hasMilestone: dayItems.some(i => i.is_milestone === 1)
})
}
infoMap.value = m
}
watch([year, month0], loadMonth, { immediate: true })
// 打卡、补卡、休假等变更后实时刷新格子状态
watch(attendanceChanged, () => { loadMonth() })
function statusOf(date: string): string { return infoMap.value.get(date)?.status ?? '' }
function cellTitle(date: string): string {
const info = infoMap.value.get(date)
if (!info) return date
return date + ' · ' + info.label + (info.location ? ' · ' + info.location : '')
}
function hasLogOf(date: string): boolean { return !!infoMap.value.get(date)?.hasLog }
function hasStarOf(date: string): boolean { return !!infoMap.value.get(date)?.hasMilestone }
function prevMonth() { monthRef.value = addMonths(monthRef.value, -1) }
function nextMonth() { monthRef.value = addMonths(monthRef.value, 1) }
function goToday() { monthRef.value = todayStr() }
const selected = ref<string | null>(null)
function selectDay(date: string, inMonth: boolean) {
if (inMonth) selected.value = date
}
// 全局搜索命中工作记录后跳转并打开当日抽屉
watch(focusDate, d => {
if (!d) return
monthRef.value = d
selected.value = d
focusDate.value = null
}, { immediate: true })
</script>
<template>
<div class="cal-page">
<div class="cal-toolbar">
<button class="icon-btn" @click="prevMonth"><Icon name="chevron-left" :size="16" /></button>
<span class="cal-title num">{{ title }}</span>
<button class="icon-btn" @click="nextMonth"><Icon name="chevron-right" :size="16" /></button>
<button class="btn btn-sm" @click="goToday">回到今天</button>
</div>
<div class="cal-head">
<span v-for="w in WEEK_LABELS" :key="w" class="cal-week muted">{{ w }}</span>
</div>
<div class="cal-grid">
<button
v-for="(c, idx) in cells" :key="idx"
class="cal-cell"
:class="{ blank: !c.inMonth, weekend: c.weekend, selected: selected === c.date }"
:title="c.inMonth ? cellTitle(c.date) : undefined"
@click="selectDay(c.date, c.inMonth)"
>
<template v-if="c.inMonth">
<span class="daynum num" :class="{ today: c.date === t }">{{ Number(c.date.slice(8, 10)) }}</span>
<span class="cal-marks">
<span v-if="statusOf(c.date)" class="dot" :class="statusOf(c.date)"></span>
<Icon v-if="hasLogOf(c.date)" name="check-circle" :size="12" class="mark-log" />
<Icon v-if="hasStarOf(c.date)" name="star" :size="12" class="mark-star" />
</span>
</template>
</button>
</div>
<transition name="drawer">
<aside v-if="selected" class="drawer">
<div class="drawer-head">
<span class="drawer-title">当日详情</span>
<button class="icon-btn" @click="selected = null"><Icon name="close" :size="16" /></button>
</div>
<div class="drawer-body">
<DayPanel :date="selected" />
</div>
</aside>
</transition>
</div>
</template>
<style scoped>
.cal-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.cal-title { font-size: 14px; font-weight: 600; min-width: 96px; text-align: center; }
.cal-head {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-top: 1px solid var(--border);
border-left: 1px solid var(--border);
}
.cal-week {
text-align: center;
padding: 6px 0;
font-size: 12px;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-top: 1px solid var(--border);
border-left: 1px solid var(--border);
}
.cal-cell {
min-height: 92px;
padding: 6px 8px;
text-align: left;
background: var(--bg-page);
border: none;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
cursor: pointer;
display: block;
font-size: 13px;
}
.cal-cell:hover { background: var(--bg-subtle); }
.cal-cell.blank { cursor: default; }
.cal-cell.blank:hover { background: var(--bg-page); }
.cal-cell.selected { background: var(--accent-bg); }
.daynum.today { color: var(--accent); font-weight: 600; }
.cal-cell.weekend .daynum { color: var(--text-3); }
.cal-marks { display: flex; align-items: center; gap: 4px; margin-top: 8px; height: 14px; }
.mark-log { color: var(--text-3); }
.mark-star { color: var(--accent); }
.drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(420px, 100%);
background: var(--bg-page);
border-left: 1px solid var(--border);
box-shadow: var(--shadow-float);
z-index: 20;
display: flex;
flex-direction: column;
}
.drawer-head {
height: 48px;
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--border);
}
.drawer-title { font-weight: 600; }
.drawer-body { flex: 1; overflow: auto; padding: 16px; }
.drawer-enter-active, .drawer-leave-active { transition: opacity 0.15s ease; }
.drawer-enter-from, .drawer-leave-to { opacity: 0; }
</style>
+147
View File
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import Icon from '../components/Icon.vue'
import { createUser, legacyDataExists, login, type Session } from '../db/auth'
const emit = defineEmits<{ (e: 'login', s: Session): void }>()
const mode = ref<'login' | 'create'>('login')
const username = ref('')
const password = ref('')
const password2 = ref('')
const migrate = ref(true)
const legacyExists = ref(false)
const error = ref('')
const busy = ref(false)
onMounted(async () => {
legacyExists.value = await legacyDataExists()
})
function switchMode(m: 'login' | 'create') {
mode.value = m
error.value = ''
password2.value = ''
}
async function submit() {
if (busy.value) return
error.value = ''
busy.value = true
try {
let s: Session
if (mode.value === 'login') {
s = await login(username.value, password.value)
} else {
if (password.value.length < 6) {
error.value = '登录密码至少 6 位'
busy.value = false
return
}
if (password.value !== password2.value) {
error.value = '两次输入的密码不一致'
busy.value = false
return
}
s = await createUser(username.value, password.value, migrate.value)
}
emit('login', s)
} catch (e) {
error.value = e instanceof Error ? e.message : '操作失败'
}
busy.value = false
}
</script>
<template>
<div class="login-page">
<div class="card login-card">
<div class="login-brand">
<div class="brand-icon"><Icon name="calendar" :size="22" /></div>
<div class="login-title">实习工作日志</div>
<div class="muted login-sub">打卡 · 记录 · 总结 · 备忘录 · 密码库</div>
<div class="muted login-sub">每位用户独立数据库,数据彼此隔离并保存在本机</div>
</div>
<div class="seg">
<button class="seg-btn" :class="{ active: mode === 'login' }" @click="switchMode('login')">登录</button>
<button class="seg-btn" :class="{ active: mode === 'create' }" @click="switchMode('create')">创建用户</button>
</div>
<input class="input" v-model="username" placeholder="用户名(2 到 20 个字符)" @keydown.enter="submit" />
<input type="password" class="input" v-model="password" :placeholder="mode === 'login' ? '登录密码' : '设置登录密码(至少 6 位)'" @keydown.enter="submit" />
<input v-if="mode === 'create'" type="password" class="input" v-model="password2" placeholder="再次输入密码" @keydown.enter="submit" />
<label v-if="mode === 'create' && legacyExists" class="mig-row">
<input type="checkbox" v-model="migrate" />
<span>导入本机原有工作日志数据到此用户(原数据保留不删除)</span>
</label>
<div v-if="error" class="form-error">{{ error }}</div>
<button class="btn btn-primary login-btn" :disabled="busy" @click="submit">
{{ mode === 'login' ? '登录' : '创建并进入' }}
</button>
<div class="muted login-note">
登录密码仅在本机用于身份校验,系统只保存密码的 PBKDF2 哈希;忘记密码将无法访问该用户数据
</div>
</div>
</div>
</template>
<style scoped>
.login-page {
min-height: 100vh;
background: var(--bg-subtle);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-card {
width: 400px;
max-width: 100%;
padding: 28px 26px;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--bg-page);
}
.login-brand { text-align: center; margin-bottom: 6px; }
.brand-icon {
width: 46px;
height: 46px;
margin: 0 auto 10px;
border-radius: var(--radius-md);
background: var(--accent-bg);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
}
.login-title { font-size: 18px; font-weight: 600; color: var(--text-1); }
.login-sub { font-size: 12px; margin-top: 4px; line-height: 1.7; }
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
.seg-btn {
flex: 1;
height: 30px;
border: none;
background: var(--bg-page);
color: var(--text-2);
cursor: pointer;
font-size: 13px;
}
.seg-btn + .seg-btn { border-left: 1px solid var(--border); }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
.mig-row {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
color: var(--text-2);
cursor: pointer;
line-height: 1.6;
}
.mig-row input { margin-top: 3px; }
.form-error { color: var(--danger); font-size: 12px; }
.login-btn { margin-top: 2px; }
.login-note { font-size: 12px; text-align: center; line-height: 1.7; }
</style>
+255
View File
@@ -0,0 +1,255 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { addMemo, deleteMemo, listMemos, updateMemo, type Memo } from '../db/repo'
import { showToast } from '../lib/toast'
import { focusMemo } from '../lib/bus'
const memos = ref<Memo[]>([])
const current = ref<Memo | null>(null)
const editing = ref(false)
const draftTitle = ref('')
const draftContent = ref('')
const deleteTarget = ref<Memo | null>(null)
const pendingAction = ref<null | { type: 'select'; memo: Memo } | { type: 'new' }>(null)
async function load() {
memos.value = await listMemos()
if (current.value && current.value.id) {
const fresh = memos.value.find(m => m.id === current.value!.id)
if (fresh) current.value = fresh
}
}
onMounted(async () => {
await load()
if (memos.value.length) applySelect(memos.value[0])
window.addEventListener('keydown', onGlobalKey)
})
onUnmounted(() => window.removeEventListener('keydown', onGlobalKey))
// 全局搜索命中备忘录后跳转并选中
watch(focusMemo, async id => {
if (!id) return
if (!memos.value.length) await load()
const m = memos.value.find(x => x.id === id)
focusMemo.value = null
if (m) selectMemo(m)
}, { immediate: true })
// Ctrl+S / Cmd+S 保存当前备忘
function onGlobalKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
if (editing.value && dirty.value) {
e.preventDefault()
save()
}
}
}
const dirty = computed(() => {
if (!editing.value) return false
const baseTitle = current.value ? current.value.title : ''
const baseContent = current.value ? current.value.content : ''
return draftTitle.value !== baseTitle || draftContent.value !== baseContent
})
function applySelect(m: Memo) {
current.value = m
editing.value = true
draftTitle.value = m.title
draftContent.value = m.content
}
function selectMemo(m: Memo) {
if (editing.value && current.value && current.value.id === m.id) return
if (dirty.value) {
pendingAction.value = { type: 'select', memo: m }
return
}
applySelect(m)
}
function newMemo() {
if (dirty.value) {
pendingAction.value = { type: 'new' }
return
}
startNew()
}
function startNew() {
current.value = null
editing.value = true
draftTitle.value = ''
draftContent.value = ''
}
function resetDraft() {
if (current.value) applySelect(current.value)
else startNew()
}
async function save() {
if (!draftTitle.value.trim() && !draftContent.value.trim()) {
showToast('标题与内容不能都为空')
return
}
if (current.value && current.value.id) {
await updateMemo(current.value.id, draftTitle.value, draftContent.value)
showToast('备忘已保存')
} else {
const m = await addMemo(draftTitle.value, draftContent.value)
current.value = m
draftTitle.value = m.title
draftContent.value = m.content
showToast('备忘已创建')
}
await load()
}
function askDelete() {
if (current.value && current.value.id) deleteTarget.value = current.value
}
async function confirmDelete() {
if (deleteTarget.value && deleteTarget.value.id) {
await deleteMemo(deleteTarget.value.id)
showToast('备忘已删除')
}
deleteTarget.value = null
await load()
startNew()
}
function confirmProceed() {
const a = pendingAction.value
pendingAction.value = null
if (!a) return
if (a.type === 'select') applySelect(a.memo)
else startNew()
}
function snippet(m: Memo): string {
return (m.content || '').replace(/\s+/g, ' ').slice(0, 30)
}
function fmtTime(s: string): string {
return s.replace('T', ' ').slice(0, 16)
}
</script>
<template>
<div class="memo-page">
<aside class="card memo-list">
<div class="pane-head">
<span class="pane-title">备忘录({{ memos.length }})</span>
<button class="btn btn-primary btn-sm" @click="newMemo">新建</button>
</div>
<div class="memo-scroll">
<div
v-for="m in memos" :key="m.id"
class="memo-item" :class="{ active: editing && current && current.id === m.id }"
@click="selectMemo(m)"
>
<div class="memo-item-title">{{ m.title || '无标题' }}</div>
<div class="muted memo-item-sub">{{ snippet(m) || '空' }}</div>
<div class="muted num memo-item-time">{{ fmtTime(m.updated_at) }}</div>
</div>
<div v-if="!memos.length" class="muted empty">暂无备忘</div>
</div>
</aside>
<section class="card memo-editor">
<template v-if="editing">
<input class="input title-input" v-model="draftTitle" placeholder="标题" />
<textarea class="textarea content-textarea" v-model="draftContent" placeholder="内容"></textarea>
<div class="editor-foot">
<span class="muted foot-info">
<template v-if="dirty">有未保存修改</template>
<template v-else-if="current && current.id">更新于 {{ fmtTime(current.updated_at) }}</template>
<template v-else>新备忘</template>
</span>
<span class="flex1"></span>
<button v-if="current && current.id" class="btn btn-sm" @click="askDelete">删除</button>
<button class="btn btn-sm" :disabled="!dirty" @click="resetDraft">放弃修改</button>
<button class="btn btn-primary btn-sm" :disabled="!dirty" @click="save">保存</button>
</div>
</template>
<div v-else class="empty muted editor-empty">选择左侧备忘,或点击右上角新建</div>
</section>
<ConfirmDialog
:open="!!deleteTarget"
title="删除确认"
:message="deleteTarget ? '将删除备忘「' + (deleteTarget.title || '无标题') + '」。删除后不可恢复。' : ''"
confirm-text="删除" danger
@cancel="deleteTarget = null"
@confirm="confirmDelete"
/>
<ConfirmDialog
:open="!!pendingAction"
title="未保存提示"
message="当前备忘有未保存的修改,继续操作将丢失这些修改。"
confirm-text="放弃修改并继续"
@cancel="pendingAction = null"
@confirm="confirmProceed"
/>
</div>
</template>
<style scoped>
.memo-page {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 12px;
align-items: stretch;
}
.memo-list {
display: flex;
flex-direction: column;
max-height: calc(100vh - 140px);
}
.pane-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 12px 8px;
border-bottom: 1px solid var(--border);
flex: none;
}
.pane-title { font-size: 13px; font-weight: 600; }
.memo-scroll { overflow: auto; padding: 6px; }
.memo-item {
padding: 8px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
margin-bottom: 2px;
}
.memo-item:hover { background: var(--bg-hover); }
.memo-item.active { background: var(--accent-bg); }
.memo-item.active .memo-item-title { color: var(--accent); }
.memo-item-title { font-size: 13px; font-weight: 500; word-break: break-all; }
.memo-item-sub { font-size: 12px; margin-top: 2px; word-break: break-all; }
.memo-item-time { font-size: 11px; margin-top: 2px; }
.memo-editor {
padding: 16px;
min-height: 420px;
display: flex;
flex-direction: column;
}
.title-input { font-size: 14px; font-weight: 500; margin-bottom: 10px; }
.content-textarea { flex: 1; min-height: 340px; line-height: 1.8; }
.editor-foot {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.foot-info { font-size: 12px; }
.editor-empty {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
padding: 60px 0;
}
@media (max-width: 800px) {
.memo-page { grid-template-columns: 1fr; }
.memo-list { max-height: 240px; }
.content-textarea { min-height: 240px; }
}
</style>
+241
View File
@@ -0,0 +1,241 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import Icon from '../components/Icon.vue'
import { buildReport, reportToMarkdown, type ReportData } from '../domain/report'
import { getWeeklyNote, saveWeeklyNote } from '../db/repo'
import { addDays, todayStr, weekStart } from '../lib/date'
import { downloadText } from '../lib/download'
import { copyText } from '../lib/clipboard'
import { polishReport } from '../domain/ai'
import { showToast } from '../lib/toast'
type RangeKey = 'week' | 'month' | 'all' | 'custom'
const RANGE_OPTIONS: { key: RangeKey; label: string }[] = [
{ key: 'week', label: '本周' },
{ key: 'month', label: '本月' },
{ key: 'all', label: '全部' },
{ key: 'custom', label: '自定义' }
]
const rangeKey = ref<RangeKey>('week')
const customStart = ref(weekStart(todayStr()))
const customEnd = ref(todayStr())
const data = ref<ReportData | null>(null)
const markdown = ref('')
const generating = ref(false)
const polishBusy = ref(false)
const prePolish = ref<string | null>(null)
const aiError = ref('')
const noteWeekStart = ref(weekStart(todayStr()))
const noteHighlights = ref('')
const noteChallenges = ref('')
const notePlan = ref('')
const noteSaved = ref(false)
async function loadNote() {
const note = await getWeeklyNote(noteWeekStart.value)
noteHighlights.value = note?.highlights || ''
noteChallenges.value = note?.challenges || ''
notePlan.value = note?.plan_next || ''
noteSaved.value = false
}
async function saveNote() {
await saveWeeklyNote(noteWeekStart.value, {
highlights: noteHighlights.value.trim() || null,
challenges: noteChallenges.value.trim() || null,
plan_next: notePlan.value.trim() || null
})
noteSaved.value = true
await generate()
}
function rangeOf(key: RangeKey): { start: string; end: string; label: string } {
const t = todayStr()
if (key === 'week') {
const ws = weekStart(t)
return { start: ws, end: addDays(ws, 6), label: '本周' }
}
if (key === 'month') {
const ym = t.slice(0, 7)
return { start: ym + '-01', end: t, label: ym + ' 本月' }
}
if (key === 'all') return { start: '2000-01-01', end: t, label: '全部实习期' }
return { start: customStart.value, end: customEnd.value, label: customStart.value + ' ~ ' + customEnd.value }
}
async function generate() {
generating.value = true
const r = rangeOf(rangeKey.value)
const d = await buildReport(r.start, r.end, r.label)
data.value = d
markdown.value = reportToMarkdown(d)
generating.value = false
}
watch(rangeKey, generate)
watch([customStart, customEnd], () => {
if (rangeKey.value === 'custom') generate()
})
onMounted(async () => {
await loadNote()
await generate()
})
function doExport() {
const t = todayStr().replace(/-/g, '')
downloadText('实习工作总结_' + t + '.md', markdown.value)
}
async function doCopy() {
await copyText(markdown.value)
showToast('总结已复制到剪贴板')
}
async function doPolish() {
if (polishBusy.value) return
aiError.value = ''
const src = markdown.value.trim()
if (!src) {
aiError.value = '当前草稿为空,无法润色'
return
}
polishBusy.value = true
try {
prePolish.value = markdown.value
markdown.value = await polishReport(src)
showToast('AI 润色完成')
} catch (e) {
prePolish.value = null
aiError.value = e instanceof Error ? e.message : '润色失败'
}
polishBusy.value = false
}
function undoPolish() {
if (prePolish.value !== null) markdown.value = prePolish.value
prePolish.value = null
}
</script>
<template>
<div class="report">
<div class="report-toolbar">
<div class="seg">
<button
v-for="o in RANGE_OPTIONS" :key="o.key"
class="seg-btn" :class="{ active: rangeKey === o.key }"
@click="rangeKey = o.key"
>{{ o.label }}</button>
</div>
<template v-if="rangeKey === 'custom'">
<input type="date" class="input w-date" v-model="customStart" />
<span class="muted">至</span>
<input type="date" class="input w-date" v-model="customEnd" />
</template>
<span class="flex1"></span>
<button class="btn btn-sm" :disabled="polishBusy" @click="doPolish">
<Icon name="edit" :size="14" />AI 润色
</button>
<button v-if="prePolish !== null" class="btn btn-sm" @click="undoPolish">还原润色前</button>
<button class="btn btn-sm" :disabled="generating" @click="generate">
<Icon name="edit" :size="14" />重新生成
</button>
<button class="btn btn-sm" @click="doCopy">复制</button>
<button class="btn btn-primary btn-sm" @click="doExport">
<Icon name="export" :size="14" />导出 Markdown
</button>
</div>
<div v-if="aiError" class="ai-error">{{ aiError }}</div>
<div class="report-grid">
<div class="report-left">
<div class="card section">
<div class="section-title">本周周记({{ noteWeekStart }} 起)</div>
<label class="note-label muted">本周亮点</label>
<textarea class="textarea" v-model="noteHighlights" placeholder="本周最有价值的一件事" @blur="saveNote"></textarea>
<label class="note-label muted">踩的坑</label>
<textarea class="textarea" v-model="noteChallenges" placeholder="遇到的问题与教训" @blur="saveNote"></textarea>
<label class="note-label muted">下周计划</label>
<textarea class="textarea" v-model="notePlan" placeholder="下周准备做什么" @blur="saveNote"></textarea>
<div class="muted note-saved">{{ noteSaved ? '已保存' : '失焦自动保存' }}</div>
</div>
<div class="card section">
<div class="section-title">总结草稿(可编辑)</div>
<textarea class="textarea md-editor" v-model="markdown" spellcheck="false"></textarea>
</div>
</div>
<div class="report-right">
<div v-if="data" class="card section">
<div class="section-title">数据摘要</div>
<div class="sum-row"><span class="muted">出勤</span><span class="num">{{ data.attendDays }} 天</span></div>
<div class="sum-row"><span class="muted">工时</span><span class="num">{{ data.totalHours }} 小时</span></div>
<div class="sum-row"><span class="muted">补卡</span><span class="num">{{ data.makeupCount }} 次</span></div>
<div class="sum-row"><span class="muted">请假</span><span class="num">{{ data.leaveCount }} 天</span></div>
<div class="sum-row"><span class="muted">记录</span><span class="num">{{ data.itemCount }} 条</span></div>
<div class="section-sub">分类分布</div>
<div v-for="c in data.categoryStats" :key="c.key" class="sum-row">
<span class="muted">{{ c.label }}</span>
<span class="num">{{ c.count }} 条 · {{ c.percent }}%</span>
</div>
<div v-if="!data.categoryStats.length" class="muted sum-empty">(无)</div>
<div class="section-sub">主要成果</div>
<div v-for="(m, i) in data.milestones" :key="'m' + i" class="sum-item">
{{ m.content }}<span class="muted">({{ m.date }})</span>
</div>
<div v-if="!data.milestones.length" class="muted sum-empty">(无)</div>
<div class="section-sub">待跟进</div>
<div v-for="(f, i) in data.followups" :key="'f' + i" class="sum-item">
{{ f.content }}<span class="muted">({{ f.date }})</span>
</div>
<div v-if="!data.followups.length" class="muted sum-empty">(无)</div>
<div class="section-sub">学习与成长</div>
<div v-for="(tk, i) in data.takeaways" :key="'t' + i" class="sum-item">
{{ tk.text }}<span class="muted">({{ tk.date }})</span>
</div>
<div v-if="!data.takeaways.length" class="muted sum-empty">(无)</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.report-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.ai-error { color: var(--danger); font-size: 12px; margin: 0 0 8px; }
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: 6px; overflow: hidden; }
.seg-btn {
height: 28px; padding: 0 12px; border: none;
background: var(--bg-page); color: var(--text-2);
cursor: pointer; font-size: 12px;
border-right: 1px solid var(--border);
}
.seg-btn:last-child { border-right: none; }
.seg-btn:hover { background: var(--bg-hover); }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
.w-date { width: 140px; }
.report-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 12px; align-items: start; }
.section { padding: 14px 16px; }
.section-title { font-weight: 600; margin-bottom: 8px; font-size: 13px; }
.section + .section { margin-top: 12px; }
.note-label { display: block; font-size: 12px; margin: 8px 0 4px; }
.note-saved { font-size: 12px; margin-top: 6px; }
.md-editor { min-height: 420px; line-height: 1.7; }
.sum-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 12px; }
.section-sub { font-weight: 600; margin: 10px 0 4px; font-size: 12px; }
.sum-item { font-size: 12px; padding: 2px 0; word-break: break-all; }
.sum-empty { font-size: 12px; }
@media (max-width: 960px) {
.report-grid { grid-template-columns: 1fr; }
}
</style>
+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>
+206
View File
@@ -0,0 +1,206 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { getWorkRule, listAttendanceBetween, listLogItemsBetween, listProjects } from '../db/repo'
import { computeDay } from '../domain/attendance'
import { CATEGORIES } from '../db/schema'
import { addDays, todayStr, weekStart } from '../lib/date'
import { attendanceChanged } from '../lib/bus'
const cards = ref({ attendDays: 0, totalHours: '0', itemCount: 0, streak: 0 })
const heat = ref<{ date: string; count: number }[][]>([])
const catStats = ref<{ key: string; label: string; count: number; percent: number }[]>([])
const projStats = ref<{ name: string; count: number; percent: number }[]>([])
const locStats = ref<{ name: string; count: number; percent: number }[]>([])
async function loadAll(): Promise<void> {
const t = todayStr()
const start = '2000-01-01'
const results = await Promise.all([
getWorkRule(),
listAttendanceBetween(start, t),
listLogItemsBetween(start, t),
listProjects(true)
])
const rule = results[0]
const atts = results[1]
const items = results[2]
const projects = results[3]
const valid = atts.filter(a => a.clock_in_at || a.kind === 'makeup')
let worked = 0
for (const a of valid) {
const info = computeDay(a, a.date, rule, a.date === t)
worked += info.workedMinutes ?? 0
}
const dateSet = new Set(items.map(i => i.date))
let streak = 0
let cursor = t
if (!dateSet.has(cursor)) cursor = addDays(t, -1)
while (dateSet.has(cursor)) {
streak++
cursor = addDays(cursor, -1)
}
cards.value = {
attendDays: valid.length,
totalHours: (worked / 60).toFixed(1),
itemCount: items.length,
streak
}
const lcount = new Map<string, number>()
for (const a of valid) {
const name = a.location || '未记录'
lcount.set(name, (lcount.get(name) || 0) + 1)
}
locStats.value = Array.from(lcount.entries())
.map(pair => ({ name: pair[0], count: pair[1], percent: Math.round((pair[1] / (valid.length || 1)) * 100) }))
.sort((a, b) => b.count - a.count)
const total = items.length || 1
catStats.value = CATEGORIES
.map(c => {
const count = items.filter(i => i.category === c.key).length
return { key: c.key, label: c.label, count, percent: Math.round((count / total) * 100) }
})
.filter(c => c.count > 0)
.sort((a, b) => b.count - a.count)
const pname = new Map<number, string>(projects.map(p => [p.id as number, p.name]))
const pcount = new Map<string, number>()
for (const it of items) {
const name = it.project_id ? (pname.get(it.project_id) || '已删除项目') : '无项目'
pcount.set(name, (pcount.get(name) || 0) + 1)
}
projStats.value = Array.from(pcount.entries())
.map(pair => ({ name: pair[0], count: pair[1], percent: Math.round((pair[1] / total) * 100) }))
.sort((a, b) => b.count - a.count)
const countMap = new Map<string, number>()
for (const it of items) countMap.set(it.date, (countMap.get(it.date) || 0) + 1)
const thisWeek = weekStart(t)
const weeks: { date: string; count: number }[][] = []
for (let w = 11; w >= 0; w--) {
const ws = addDays(thisWeek, -7 * w)
const col: { date: string; count: number }[] = []
for (let d = 0; d < 7; d++) {
const date = addDays(ws, d)
col.push({ date, count: countMap.get(date) || 0 })
}
weeks.push(col)
}
heat.value = weeks
}
onMounted(async () => {
await loadAll()
})
// 打卡、休假等考勤变更后实时刷新统计
watch(attendanceChanged, () => { loadAll() })
function level(count: number): number {
if (count === 0) return 0
if (count === 1) return 1
if (count <= 3) return 2
if (count <= 5) return 3
return 4
}
function heatTitle(date: string, count: number): string {
return date + ' · ' + (count ? count + ' 条' : '无记录')
}
</script>
<template>
<div class="stats">
<div class="cards">
<div class="card stat-card"><div class="muted">出勤天数</div><div class="stat-num num">{{ cards.attendDays }}</div></div>
<div class="card stat-card"><div class="muted">累计工时(小时)</div><div class="stat-num num">{{ cards.totalHours }}</div></div>
<div class="card stat-card"><div class="muted">记录条数</div><div class="stat-num num">{{ cards.itemCount }}</div></div>
<div class="card stat-card"><div class="muted">连续记录(天)</div><div class="stat-num num">{{ cards.streak }}</div></div>
</div>
<div class="card section">
<div class="section-title">记录热力图(近 12 周)</div>
<div class="heat">
<div v-for="(col, ci) in heat" :key="ci" class="heat-col">
<span
v-for="cell in col" :key="cell.date"
class="heat-cell" :class="'lv' + level(cell.count)"
:title="heatTitle(cell.date, cell.count)"
></span>
</div>
</div>
<div class="heat-legend muted">
<span>少</span>
<span class="heat-cell lv0"></span>
<span class="heat-cell lv1"></span>
<span class="heat-cell lv2"></span>
<span class="heat-cell lv3"></span>
<span class="heat-cell lv4"></span>
<span>多</span>
</div>
</div>
<div class="two-col">
<div class="card section">
<div class="section-title">分类分布</div>
<div v-for="c in catStats" :key="c.key" class="bar-row">
<span class="bar-label">{{ c.label }}</span>
<span class="bar-track"><span class="bar-fill" :style="{ width: c.percent + '%' }"></span></span>
<span class="muted num bar-num">{{ c.count }} 条 · {{ c.percent }}%</span>
</div>
<div v-if="!catStats.length" class="muted">暂无数据</div>
</div>
<div class="card section">
<div class="section-title">项目投入</div>
<div v-for="p in projStats" :key="p.name" class="bar-row">
<span class="bar-label">{{ p.name }}</span>
<span class="bar-track"><span class="bar-fill" :style="{ width: p.percent + '%' }"></span></span>
<span class="muted num bar-num">{{ p.count }} 条 · {{ p.percent }}%</span>
</div>
<div v-if="!projStats.length" class="muted">暂无数据</div>
</div>
</div>
<div class="card section">
<div class="section-title">地点分布(按出勤天)</div>
<div v-for="l in locStats" :key="l.name" class="bar-row">
<span class="bar-label loc-label">{{ l.name }}</span>
<span class="bar-track"><span class="bar-fill" :style="{ width: l.percent + '%' }"></span></span>
<span class="muted num bar-num">{{ l.count }} 天 · {{ l.percent }}%</span>
</div>
<div v-if="!locStats.length" class="muted">暂无数据</div>
</div>
</div>
</template>
<style scoped>
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.stat-card { padding: 14px 16px; }
.stat-num { font-size: 20px; font-weight: 600; margin-top: 4px; }
.section { padding: 14px 16px; margin-top: 12px; }
.section-title { font-weight: 600; margin-bottom: 10px; font-size: 13px; }
.heat { display: flex; gap: 3px; overflow-x: auto; padding-bottom: 4px; }
.heat-col { display: flex; flex-direction: column; gap: 3px; }
.heat-cell { width: 12px; height: 12px; border-radius: 2px; display: inline-block; }
.lv0 { background: var(--bg-subtle); border: 1px solid var(--border); }
.lv1 { background: #D8ECDF; }
.lv2 { background: #A9D8BC; }
.lv3 { background: #6FBF92; }
.lv4 { background: var(--ok); }
.heat-legend { display: flex; align-items: center; gap: 3px; margin-top: 8px; font-size: 12px; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.bar-row { display: flex; align-items: center; gap: 10px; margin: 6px 0; }
.bar-label { width: 72px; flex: none; color: var(--text-2); font-size: 12px; text-align: right; }
.bar-track { flex: 1; height: 8px; border-radius: 4px; background: var(--bg-subtle); overflow: hidden; }
.bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; }
.bar-num { font-size: 12px; width: 92px; flex: none; }
.loc-label { width: 110px; text-align: left; }
@media (max-width: 720px) {
.cards { grid-template-columns: repeat(2, 1fr); }
.two-col { grid-template-columns: 1fr; }
}
</style>
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">
import DayPanel from '../components/log/DayPanel.vue'
import { todayStr } from '../lib/date'
const today = todayStr()
</script>
<template>
<div class="today-page">
<DayPanel :date="today" />
</div>
</template>
+437
View File
@@ -0,0 +1,437 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Icon from '../components/Icon.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import CredentialDialog from '../components/CredentialDialog.vue'
import {
changeMasterPassword, deleteCredential, isVaultInitialized, isVaultUnlocked,
listCredentials, lockVault, revealCredentialPassword, saveCredential, setupVault, unlockVault
} from '../db/vault'
import type { Credential } from '../db/schema'
import { copyText } from '../lib/clipboard'
import { showToast } from '../lib/toast'
const initialized = ref<boolean | null>(null)
const unlocked = ref(isVaultUnlocked())
const busy = ref(false)
const setup1 = ref('')
const setup2 = ref('')
const setupError = ref('')
const unlockPwd = ref('')
const unlockError = ref('')
const rows = ref<Credential[]>([])
const search = ref('')
const revealed = ref<Record<number, boolean>>({})
const dialogOpen = ref(false)
const editingId = ref<number | null>(null)
const dialogInitial = ref({ system: '', account: '', password: '', remark: '' })
const deleteTarget = ref<Credential | null>(null)
const pwdDialogOpen = ref(false)
const oldPwd = ref('')
const newPwd1 = ref('')
const newPwd2 = ref('')
const pwdError = ref('')
async function loadRows() {
rows.value = await listCredentials()
revealed.value = {}
}
// ---- 自动锁定:解锁状态下 5 分钟无操作自动锁定 ----
const IDLE_MS = 5 * 60 * 1000
let idleTimer: number | null = null
function armIdleTimer() {
if (idleTimer !== null) window.clearTimeout(idleTimer)
idleTimer = window.setTimeout(() => {
if (unlocked.value) {
lockVault()
unlocked.value = false
rows.value = []
revealed.value = {}
showToast('长时间未操作,密码库已自动锁定')
}
}, IDLE_MS)
}
function onActivity() {
if (unlocked.value) armIdleTimer()
}
onMounted(async () => {
initialized.value = await isVaultInitialized()
window.addEventListener('click', onActivity)
window.addEventListener('keydown', onActivity)
if (unlocked.value) armIdleTimer()
})
onUnmounted(() => {
window.removeEventListener('click', onActivity)
window.removeEventListener('keydown', onActivity)
if (idleTimer !== null) window.clearTimeout(idleTimer)
})
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return rows.value
return rows.value.filter(r => (r.system + ' ' + r.account + ' ' + (r.remark || '')).toLowerCase().includes(q))
})
async function doSetup() {
if (busy.value) return
setupError.value = ''
if (!setup1.value) { setupError.value = '请输入主密码'; return }
if (setup1.value.length < 6) { setupError.value = '主密码至少 6 位'; return }
if (setup1.value !== setup2.value) { setupError.value = '两次输入的主密码不一致'; return }
busy.value = true
try {
await setupVault(setup1.value)
setup1.value = ''
setup2.value = ''
unlocked.value = true
armIdleTimer()
await loadRows()
showToast('主密码已设置,密码库已解锁')
} catch (e) {
setupError.value = e instanceof Error ? e.message : '设置失败'
}
busy.value = false
}
async function doUnlock() {
if (busy.value) return
unlockError.value = ''
if (!unlockPwd.value) { unlockError.value = '请输入主密码'; return }
busy.value = true
try {
await unlockVault(unlockPwd.value)
unlockPwd.value = ''
unlocked.value = true
armIdleTimer()
await loadRows()
} catch (e) {
unlockError.value = e instanceof Error ? e.message : '解锁失败'
}
busy.value = false
}
function doLock() {
lockVault()
unlocked.value = false
rows.value = []
revealed.value = {}
if (idleTimer !== null) window.clearTimeout(idleTimer)
showToast('密码库已锁定')
}
async function toggleReveal(row: Credential) {
const id = row.id as number
if (revealed.value[id]) {
revealed.value[id] = false
return
}
try {
;(row as Credential & { passwordPlain?: string }).passwordPlain = await revealCredentialPassword(id)
revealed.value[id] = true
} catch (e) {
showToast(e instanceof Error ? e.message : '解密失败')
}
}
async function copyPassword(row: Credential) {
try {
const plain = await revealCredentialPassword(row.id as number)
await copyText(plain)
showToast('密码已复制')
} catch (e) {
showToast(e instanceof Error ? e.message : '复制失败')
}
}
async function copyAccount(row: Credential) {
await copyText(row.account)
showToast('账号已复制')
}
function openNew() {
editingId.value = null
dialogInitial.value = { system: '', account: '', password: '', remark: '' }
dialogOpen.value = true
}
async function openEdit(row: Credential) {
try {
const pwd = await revealCredentialPassword(row.id as number)
editingId.value = row.id as number
dialogInitial.value = { system: row.system, account: row.account, password: pwd, remark: row.remark || '' }
dialogOpen.value = true
} catch (e) {
showToast(e instanceof Error ? e.message : '解密失败')
}
}
async function onDialogSave(payload: { system: string; account: string; password: string; remark: string }) {
const id = editingId.value ?? undefined
await saveCredential(payload, id)
dialogOpen.value = false
await loadRows()
showToast(id ? '账号已更新' : '账号已添加')
}
async function confirmDelete() {
if (deleteTarget.value && deleteTarget.value.id) {
await deleteCredential(deleteTarget.value.id)
showToast('账号已删除')
}
deleteTarget.value = null
await loadRows()
}
function openPwdDialog() {
oldPwd.value = ''
newPwd1.value = ''
newPwd2.value = ''
pwdError.value = ''
pwdDialogOpen.value = true
}
async function doChangePwd() {
pwdError.value = ''
try {
const n = await changeMasterPassword(oldPwd.value, newPwd1.value)
pwdDialogOpen.value = false
oldPwd.value = ''
newPwd1.value = ''
newPwd2.value = ''
showToast('主密码已修改,' + n + ' 条账号密码已重新加密')
} catch (e) {
pwdError.value = e instanceof Error ? e.message : '修改失败'
}
}
</script>
<template>
<div class="vault-page">
<div v-if="initialized === null" class="empty muted">加载中</div>
<div v-else-if="!unlocked" class="card lock-card">
<div class="lock-badge"><Icon name="lock" :size="26" /></div>
<template v-if="initialized === false">
<div class="lock-title">设置主密码</div>
<div class="muted lock-tip">主密码用于加密保存在本机的账号密码,遗忘后无法找回,请务必牢记</div>
<input type="password" class="input" v-model="setup1" placeholder="设置主密码(至少 6 位)" @keydown.enter="doSetup" />
<input type="password" class="input" v-model="setup2" placeholder="再次输入主密码" @keydown.enter="doSetup" />
<div v-if="setupError" class="form-error">{{ setupError }}</div>
<button class="btn btn-primary lock-btn" :disabled="busy" @click="doSetup">完成设置并解锁</button>
</template>
<template v-else>
<div class="lock-title">密码库已锁定</div>
<div class="muted lock-tip">输入主密码解锁后查看与复制密码</div>
<input type="password" class="input" v-model="unlockPwd" placeholder="主密码" @keydown.enter="doUnlock" />
<div v-if="unlockError" class="form-error">{{ unlockError }}</div>
<button class="btn btn-primary lock-btn" :disabled="busy" @click="doUnlock">解锁</button>
</template>
</div>
<template v-else>
<div class="vault-toolbar">
<div class="search-wrap">
<Icon name="search" :size="14" class="search-icon" />
<input class="input" v-model="search" placeholder="搜索系统名称、账号或备注" />
</div>
<span class="flex1"></span>
<button class="btn btn-sm" @click="openPwdDialog">修改主密码</button>
<button class="btn btn-sm" @click="doLock">锁定</button>
<button class="btn btn-primary btn-sm" @click="openNew">新增账号</button>
</div>
<div class="card table-card">
<table class="cred-table">
<thead>
<tr>
<th>系统名称</th>
<th>账号</th>
<th>密码</th>
<th>备注</th>
<th class="th-ops">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filtered" :key="row.id">
<td class="td-system">{{ row.system }}</td>
<td>
<span class="copyable" title="点击复制账号" @click="copyAccount(row)">{{ row.account }}</span>
</td>
<td>
<div class="pwd-cell">
<span class="num pwd-text">{{ revealed[row.id as number] ? row.passwordPlain : '........' }}</span>
<button class="btn btn-xs" @click="toggleReveal(row)">{{ revealed[row.id as number] ? '隐藏' : '显示' }}</button>
<button class="btn btn-xs" @click="copyPassword(row)">复制</button>
</div>
</td>
<td class="td-remark">{{ row.remark || '--' }}</td>
<td>
<div class="ops">
<button class="btn btn-xs" @click="openEdit(row)">编辑</button>
<button class="btn btn-xs" @click="deleteTarget = row">删除</button>
</div>
</td>
</tr>
<tr v-if="!filtered.length">
<td colspan="5" class="empty muted">暂无账号,点击右上角新增</td>
</tr>
</tbody>
</table>
</div>
<div class="muted vault-note num">共 {{ rows.length }} 条账号记录 · 密码以 AES-256-GCM 加密存储于本机</div>
</template>
<CredentialDialog
:open="dialogOpen"
:title="editingId ? '编辑账号' : '新增账号'"
:initial="dialogInitial"
@cancel="dialogOpen = false"
@save="onDialogSave"
/>
<ConfirmDialog
:open="!!deleteTarget"
title="删除确认"
:message="deleteTarget ? '将删除系统「' + deleteTarget.system + '」的账号记录。删除后不可恢复。' : ''"
confirm-text="删除" danger
@cancel="deleteTarget = null"
@confirm="confirmDelete"
/>
<teleport to="body">
<transition name="modal">
<div v-if="pwdDialogOpen" class="mask" @click.self="pwdDialogOpen = false">
<div class="dialog pwd-dialog">
<div class="dialog-head">
<span class="dialog-title">修改主密码</span>
<button class="icon-btn" title="关闭" @click="pwdDialogOpen = false">
<Icon name="close" :size="16" />
</button>
</div>
<div class="dialog-body">
<div class="vp-field">
<label>旧主密码</label>
<input type="password" class="input" v-model="oldPwd" />
</div>
<div class="vp-field">
<label>新主密码(至少 6 位)</label>
<input type="password" class="input" v-model="newPwd1" />
</div>
<div class="vp-field">
<label>确认新主密码</label>
<input type="password" class="input" v-model="newPwd2" />
</div>
<div v-if="pwdError" class="form-error">{{ pwdError }}</div>
<div class="muted vp-tip">确认后所有账号密码将使用新主密码重新加密</div>
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="pwdDialogOpen = false">取消</button>
<button class="btn btn-primary btn-sm" @click="doChangePwd">确认修改</button>
</div>
</div>
</div>
</transition>
</teleport>
</div>
</template>
<style scoped>
.lock-card {
max-width: 380px;
margin: 60px auto 0;
padding: 28px 24px;
text-align: center;
display: flex;
flex-direction: column;
gap: 10px;
}
.lock-badge {
width: 52px;
height: 52px;
margin: 0 auto;
border-radius: 50%;
background: var(--bg-subtle);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-2);
}
.lock-title { font-size: 14px; font-weight: 600; }
.lock-tip { font-size: 12px; line-height: 1.7; }
.lock-btn { margin-top: 4px; }
.form-error { color: var(--danger); font-size: 12px; }
.empty { text-align: center; padding: 24px 0; }
.vault-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.search-wrap { position: relative; width: 280px; max-width: 100%; }
.search-wrap .input { padding-left: 30px; }
.search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-3); }
.table-card { overflow-x: auto; }
.cred-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.cred-table th {
text-align: left;
font-weight: 500;
color: var(--text-2);
background: var(--bg-subtle);
padding: 10px 12px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.cred-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: top; word-break: break-all; }
.cred-table tbody tr:hover { background: var(--bg-subtle); }
.cred-table tbody tr:last-child td { border-bottom: none; }
.td-system { font-weight: 500; }
.th-ops { width: 150px; }
.pwd-cell { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.pwd-text { letter-spacing: 1px; }
.copyable { cursor: pointer; }
.copyable:hover { color: var(--accent); }
.ops { display: flex; gap: 4px; flex-wrap: wrap; }
.vault-note { margin-top: 10px; font-size: 12px; text-align: right; }
.mask {
position: fixed;
inset: 0;
background: rgba(29, 33, 41, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
}
.dialog {
width: 440px;
max-width: calc(100vw - 32px);
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
}
.dialog-title { font-size: 14px; font-weight: 600; }
.dialog-body { padding: 16px 20px; }
.dialog-btns {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid var(--border);
}
.vp-field { margin-bottom: 10px; }
.vp-field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.vp-tip { font-size: 12px; margin-top: 8px; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>