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