初始提交:实习工作日志系统(设计方案 + 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
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
release/
*.local
+30
View File
@@ -0,0 +1,30 @@
# 实习工作日志
本地优先的实习工作记录工具:考勤打卡、月历、随手记、周记、一键生成总结。
对应设计方案文档:实习工作日志系统-设计方案.md
## 运行
pnpm install
pnpm dev # 开发模式,默认 http://localhost:5173
pnpm build # 构建产物到 dist/
pnpm preview # 预览构建产物,默认 http://localhost:4173
## 说明
- 技术栈:Vue 3 + TypeScript + Vite + Dexie(IndexedDB),无后端、无外部图标库
- 数据保存在浏览器 IndexedDB(数据库名 intern_worklog)
- 「设置」页可导出/导入 JSON 全量备份;「总结」页可导出 Markdown
- 支持上班地点记录(公司、客户现场、出差等),可直接输入自定义地点并自动记住,统计与总结含地点分布
- 打卡时间支持行内直接修改(含补录下班时间),考勤状态自动重算
- 表单带标签与必填校验;删除与备份覆盖导入使用统一确认弹窗;操作结果轻提示反馈
- 工作记录支持弹窗完整信息编辑(全部字段),点击条目或编辑图标打开
- 备忘录:双栏列表与编辑,未保存修改有守卫
- 密码库:企业账号密码管理,主密码 PBKDF2 派生 AES-256-GCM 加密,支持解锁、锁定、搜索、复制、随机密码生成、修改主密码;备份文件中密码保持密文
- 用户系统:每位用户独立 IndexedDB 数据库隔离存储;登录密码仅存 PBKDF2 哈希;30 分钟无操作自动退出;创建用户时可迁移升级前数据
- PWA:在 Edge/Chrome 中打开后,地址栏右侧点「安装」或菜单选择「将此站点作为应用安装」,即可获得独立窗口与桌面图标,离线可用
- Electron 桌面版:pnpm dist 产出 release/InternLog-Setup-<版本>.exe(NSIS 安装包)与 release/win-unpacked/InternLog.exe(免安装版);数据存于系统用户目录,与浏览器数据互不影响
- 全局搜索:Ctrl+K 唤起,覆盖工作记录与备忘录,点击结果跳转定位
- 考勤支持休假标记;支持导出工作记录与考勤 CSV;总结草稿可复制、可 AI 润色(设置页配置 OpenAI 兼容接口);密码库 5 分钟无操作自动锁定
- 图标为自绘 SVG(src/components/icons.ts),界面不含表情与特殊符号
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+6
View File
@@ -0,0 +1,6 @@
from PIL import Image
src = Image.open('../public/icons/icon-512.png').convert('RGBA')
src.save('icon.png')
src.save('icon.ico', sizes=[(256, 256), (128, 128), (64, 64), (48, 48), (32, 32), (16, 16)])
print('icon.ico + icon.png generated')
+43
View File
@@ -0,0 +1,43 @@
const { app, BrowserWindow, Menu, shell } = require('electron')
const path = require('path')
const DEV_URL = process.env.VITE_DEV_SERVER_URL || ''
function createWindow() {
const win = new BrowserWindow({
width: 1240,
height: 820,
minWidth: 940,
minHeight: 620,
title: '实习工作日志',
icon: path.join(__dirname, '..', 'build', 'icon.png'),
autoHideMenuBar: true,
backgroundColor: '#FFFFFF',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
spellcheck: false
}
})
Menu.setApplicationMenu(null)
win.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:/i.test(url)) shell.openExternal(url)
return { action: 'deny' }
})
if (DEV_URL) {
win.loadURL(DEV_URL)
} else {
win.loadFile(path.join(__dirname, '..', 'dist', 'index.html'))
}
}
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#FFFFFF" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
<title>实习工作日志</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
{
"name": "intern-log",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"electron": "electron electron/main.cjs",
"dist": "vite build && electron-builder"
},
"dependencies": {
"dexie": "^4.0.8",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.3",
"vite": "^5.4.11",
"electron": "^33.2.0",
"electron-builder": "^25.1.8"
},
"description": "实习工作日志:打卡、记录、备忘录与密码库",
"main": "electron/main.cjs",
"build": {
"appId": "com.internlog.desktop",
"productName": "InternLog",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"package.json"
],
"win": {
"target": [
"nsis",
"dir"
],
"icon": "build/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"shortcutName": "实习工作日志"
},
"artifactName": "InternLog-Setup-${version}.${ext}"
}
}
+3685
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
esbuild: true
electron: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="1.6" y="1.6" width="20.8" height="20.8" rx="4.6" fill="#3370FF"/>
<rect x="5.8" y="7.2" width="12.4" height="11.5" rx="1.1" fill="#FFFFFF"/>
<rect x="5.8" y="7.2" width="12.4" height="2.2" fill="#3370FF"/>
<rect x="7.9" y="4.9" width="1.1" height="3.2" rx="0.5" fill="#FFFFFF"/>
<rect x="15.0" y="4.9" width="1.1" height="3.2" rx="0.5" fill="#FFFFFF"/>
<circle cx="8.6" cy="12.4" r="0.62" fill="#3370FF"/>
<circle cx="12" cy="12.4" r="0.62" fill="#3370FF"/>
<circle cx="15.4" cy="12.4" r="0.62" fill="#3370FF"/>
<circle cx="8.6" cy="15.4" r="0.62" fill="#3370FF"/>
<circle cx="12" cy="15.4" r="0.62" fill="#3370FF"/>
<circle cx="15.4" cy="15.4" r="0.62" fill="#3370FF"/>
</svg>

After

Width:  |  Height:  |  Size: 770 B

+23
View File
@@ -0,0 +1,23 @@
from PIL import Image, ImageDraw
ACCENT = (51, 112, 255, 255)
WHITE = (255, 255, 255, 255)
def make(size, path):
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
m = size * 0.07
d.rounded_rectangle([m, m, size - m, size - m], radius=size * 0.19, fill=ACCENT)
x0, y0, x1, y1 = size * 0.24, size * 0.30, size * 0.76, size * 0.78
d.rounded_rectangle([x0, y0, x1, y1], radius=size * 0.045, fill=WHITE)
d.rectangle([x0, y0 + size * 0.045, x1, y0 + size * 0.09], fill=ACCENT)
for cx in (0.36, 0.64):
d.rounded_rectangle([size * cx - size * 0.02, size * 0.20, size * cx + size * 0.02, size * 0.33], radius=size * 0.02, fill=WHITE)
for dx in (0.34, 0.5, 0.66):
for dy in (0.5, 0.63):
d.ellipse([size * dx - size * 0.025, size * dy - size * 0.025, size * dx + size * 0.025, size * dy + size * 0.025], fill=ACCENT)
img.save(path)
make(192, 'icon-192.png')
make(512, 'icon-512.png')
print('icons generated')
+15
View File
@@ -0,0 +1,15 @@
{
"name": "实习工作日志",
"short_name": "工作日志",
"description": "实习打卡、工作记录、备忘录与加密密码库",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#FFFFFF",
"theme_color": "#FFFFFF",
"icons": [
{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "./icons/icon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}
+46
View File
@@ -0,0 +1,46 @@
const CACHE = 'intern-log-v1'
const CORE = ['./', './index.html']
self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(c => c.addAll(CORE)).then(() => self.skipWaiting()))
})
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
.then(() => self.clients.claim())
)
})
self.addEventListener('fetch', event => {
const req = event.request
if (req.method !== 'GET') return
const url = new URL(req.url)
if (url.origin !== location.origin) return
// 页面导航:网络优先,离线回退缓存的应用壳
if (req.mode === 'navigate') {
event.respondWith(
fetch(req)
.then(resp => {
const clone = resp.clone()
caches.open(CACHE).then(c => c.put('./index.html', clone))
return resp
})
.catch(() => caches.match('./index.html'))
)
return
}
// 静态资源:缓存优先,未命中时拉取并写入缓存
event.respondWith(
caches.match(req).then(hit => hit || fetch(req).then(resp => {
if (resp.ok) {
const clone = resp.clone()
caches.open(CACHE).then(c => c.put(req, clone))
}
return resp
}))
)
})
+425
View File
@@ -0,0 +1,425 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import Icon from './components/Icon.vue'
import CalendarView from './views/CalendarView.vue'
import TodayView from './views/TodayView.vue'
import StatsView from './views/StatsView.vue'
import ReportView from './views/ReportView.vue'
import MemoView from './views/MemoView.vue'
import VaultView from './views/VaultView.vue'
import SettingsView from './views/SettingsView.vue'
import LoginView from './views/LoginView.vue'
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth'
import { clockIn, getAttendance, getWorkRule, getWeeklyNote, searchLogItems, searchMemos, smartClockOut, type WorkRule } from './db/repo'
import { addDays, hhmm, todayStr, weekStart } from './lib/date'
import { useToasts } from './lib/toast'
import { attendanceChanged, focusDate, focusMemo, notifyAttendanceChanged } from './lib/bus'
import { categoryLabel, type Attendance, type LogItem, type Memo } from './db/schema'
const toasts = useToasts()
// ---- 用户会话 ----
const session = ref<Session | null>(getSession())
const IDLE_LOGOUT_MS = 30 * 60 * 1000
let sessionIdleTimer: number | null = null
function armSessionTimer() {
if (sessionIdleTimer !== null) window.clearTimeout(sessionIdleTimer)
if (!session.value) return
sessionIdleTimer = window.setTimeout(() => { doLogout(true) }, IDLE_LOGOUT_MS)
}
function onActivity() {
if (session.value) armSessionTimer()
}
async function onLogin(s: Session) {
session.value = s
location.hash = '#/calendar'
route.value = 'calendar'
armSessionTimer()
await refreshClock()
await refreshTip()
showToast('欢迎,' + s.username)
const mt = sessionStorage.getItem('dsh_migrate_toast')
if (mt) {
sessionStorage.removeItem('dsh_migrate_toast')
showToast('已迁移原有数据 ' + mt + ' 条')
}
}
async function doLogout(auto: boolean) {
await logoutUser()
session.value = null
route.value = 'calendar'
location.hash = '#/calendar'
showToast(auto ? '长时间未操作已自动退出' : '已退出登录')
}
// ---- 全局搜索 ----
const gsInput = ref<HTMLInputElement | null>(null)
const searchQ = ref('')
const searchFocus = ref(false)
const results = ref<{ logs: (LogItem & { date: string })[]; memos: Memo[] }>({ logs: [], memos: [] })
let searchTimer: number | null = null
const hasResults = computed(() => !!(results.value.logs.length || results.value.memos.length))
function onSearchInput() {
if (searchTimer) window.clearTimeout(searchTimer)
const q = searchQ.value
searchTimer = window.setTimeout(async () => {
const needle = q.trim()
if (!needle) {
results.value = { logs: [], memos: [] }
return
}
const found = await Promise.all([searchLogItems(needle, 8), searchMemos(needle, 4)])
results.value = { logs: found[0], memos: found[1] }
}, 200)
}
function clearSearch() {
searchQ.value = ''
results.value = { logs: [], memos: [] }
searchFocus.value = false
}
function openLog(item: LogItem & { date: string }) {
focusDate.value = item.date
nav('calendar')
clearSearch()
}
function openMemo(m: Memo) {
focusMemo.value = m.id as number
nav('memo')
clearSearch()
}
function onGsBlur() {
window.setTimeout(() => { searchFocus.value = false }, 150)
}
function onGlobalKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
gsInput.value?.focus()
}
}
const VIEWS = [
{ key: 'calendar', name: '日历', icon: 'calendar' },
{ key: 'today', name: '今日', icon: 'edit' },
{ key: 'stats', name: '统计', icon: 'chart' },
{ key: 'report', name: '总结', icon: 'export' },
{ key: 'memo', name: '备忘录', icon: 'memo' },
{ key: 'vault', name: '密码库', icon: 'lock' },
{ key: 'settings', name: '设置', icon: 'settings' }
]
function readRoute(): string {
const h = (location.hash || '').replace('#/', '')
return VIEWS.some(v => v.key === h) ? h : 'calendar'
}
const route = ref(readRoute())
window.addEventListener('hashchange', () => { route.value = readRoute() })
function nav(key: string) { location.hash = '#/' + key }
const current = computed(() => VIEWS.find(v => v.key === route.value) || VIEWS[0])
// ---- 工具栏打卡 ----
const today = ref(todayStr())
const att = ref<Attendance | undefined>(undefined)
const yesterdayPending = ref(false)
async function refreshClock() {
att.value = await getAttendance(today.value)
yesterdayPending.value = false
const now = new Date()
if (now.getHours() < 4) {
const yRec = await getAttendance(addDays(today.value, -1))
yesterdayPending.value = !!(yRec && yRec.clock_in_at && !yRec.clock_out_at)
}
}
const hasActiveSession = computed(() => {
const a = att.value
if (a && a.clock_in_at && !a.clock_out_at) return true
return yesterdayPending.value && !(a && a.clock_in_at)
})
const clockDone = computed(() => {
const a = att.value
return !!(a && a.clock_in_at && a.clock_out_at)
})
const clockLabel = computed(() => {
const a = att.value
if (hasActiveSession.value) return '下班打卡'
if (clockDone.value) return '已打卡 ' + hhmm(a?.clock_in_at) + ' - ' + hhmm(a?.clock_out_at)
return '上班打卡'
})
async function doClock() {
if (hasActiveSession.value) {
try { await smartClockOut() } catch { /* 没有进行中的打卡 */ }
} else {
await clockIn(today.value)
}
await refreshClock()
notifyAttendanceChanged()
}
// 任何视图发生的考勤变更(含今日面板、日历抽屉)都实时同步到顶栏
watch(attendanceChanged, () => {
if (session.value) refreshClock()
})
// ---- 周记提醒 ----
const weekTip = ref(false)
async function refreshTip() {
const rule: WorkRule = await getWorkRule()
if (rule.remind_weekly === 'off') { weekTip.value = false; return }
const wd = new Date().getDay()
if (wd === 5 || wd === 6 || wd === 0) {
const note = await getWeeklyNote(weekStart(today.value))
weekTip.value = !note || (!note.highlights && !note.challenges && !note.plan_next)
} else {
weekTip.value = false
}
}
onMounted(async () => {
window.addEventListener('keydown', onGlobalKey)
window.addEventListener('click', onActivity)
if (session.value) {
openUserDatabase(session.value.userId)
armSessionTimer()
await refreshClock()
await refreshTip()
}
})
</script>
<template>
<LoginView v-if="!session" @login="onLogin" />
<div v-else class="app">
<nav class="side">
<button
v-for="v in VIEWS" :key="v.key"
class="nav-btn" :class="{ active: route === v.key }"
:data-tip="v.name" @click="nav(v.key)"
>
<Icon :name="v.icon" :size="18" />
</button>
</nav>
<div class="main">
<header class="topbar">
<span class="title">{{ current.name }}</span>
<div class="gsearch">
<Icon name="search" :size="14" class="gs-icon" />
<input
ref="gsInput" class="input gs-input" v-model="searchQ"
placeholder="搜索工作记录与备忘录(Ctrl+K)"
@input="onSearchInput" @focus="searchFocus = true" @blur="onGsBlur" @keydown.esc="clearSearch"
/>
<div v-if="searchFocus && searchQ.trim()" class="gs-panel">
<template v-if="hasResults">
<div v-if="results.logs.length" class="gs-group muted">工作记录</div>
<div v-for="r in results.logs" :key="'l' + r.id" class="gs-item" @mousedown.prevent="openLog(r)">
<span class="tag">{{ categoryLabel(r.category) }}</span>
<span class="gs-text">{{ r.content }}</span>
<span class="muted num gs-date">{{ r.date }}</span>
</div>
<div v-if="results.memos.length" class="gs-group muted">备忘录</div>
<div v-for="m in results.memos" :key="'m' + m.id" class="gs-item" @mousedown.prevent="openMemo(m)">
<span class="gs-text">{{ m.title || '无标题' }}</span>
<span class="muted gs-sub">{{ m.content }}</span>
</div>
</template>
<div v-else class="gs-empty muted">无匹配结果</div>
</div>
</div>
<span class="flex1"></span>
<button v-if="weekTip" class="week-tip" @click="nav('report')">
<span class="dot" style="background: var(--warn)"></span>
填写本周周记
</button>
<span class="user-chip num">{{ session?.username }}</span>
<button class="btn btn-sm" @click="doLogout(false)">退出</button>
<button class="btn btn-primary btn-sm" :disabled="clockDone" @click="doClock">
<Icon :name="clockDone ? 'check-circle' : 'clock'" :size="14" />
<span class="num">{{ clockLabel }}</span>
</button>
</header>
<main class="content">
<div class="content-inner">
<CalendarView v-if="route === 'calendar'" />
<TodayView v-else-if="route === 'today'" />
<StatsView v-else-if="route === 'stats'" />
<ReportView v-else-if="route === 'report'" />
<MemoView v-else-if="route === 'memo'" />
<VaultView v-else-if="route === 'vault'" />
<SettingsView v-else />
</div>
</main>
</div>
<div class="toast-wrap">
<transition-group name="toast">
<div v-for="t in toasts" :key="t.id" class="toast">{{ t.text }}</div>
</transition-group>
</div>
</div>
</template>
<style scoped>
.app { display: flex; height: 100vh; overflow: hidden; }
.side {
width: 56px;
flex: none;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 10px;
gap: 4px;
background: var(--bg-page);
}
.nav-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
color: var(--text-3);
cursor: pointer;
border: none;
background: transparent;
position: relative;
}
.nav-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.nav-btn.active { background: var(--accent-bg); color: var(--accent); }
.nav-btn::after {
content: attr(data-tip);
position: absolute;
left: 46px;
top: 50%;
transform: translateY(-50%);
background: var(--text-1);
color: #FFFFFF;
font-size: 12px;
line-height: 1.4;
padding: 2px 8px;
border-radius: 4px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.nav-btn:hover::after { opacity: 1; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar {
height: 56px;
flex: none;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
padding: 0 20px;
}
.title { font-size: 14px; font-weight: 600; }
.gsearch { position: relative; width: 300px; max-width: 38vw; }
.gs-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-3); }
.gs-input { height: 30px; padding-left: 30px; }
.gs-panel {
position: absolute;
top: 36px;
left: 0;
right: 0;
background: var(--bg-page);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-float);
z-index: 50;
max-height: 420px;
overflow: auto;
padding: 6px;
}
.gs-group { font-size: 12px; padding: 6px 8px 2px; }
.gs-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.gs-item:hover { background: var(--bg-hover); }
.gs-text, .gs-sub {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gs-text { font-size: 13px; }
.gs-sub { font-size: 12px; }
.gs-date { font-size: 12px; flex: none; }
.gs-empty { padding: 10px; font-size: 12px; text-align: center; }
.user-chip {
font-size: 12px;
color: var(--text-2);
padding: 0 4px;
}
.week-tip {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--warn);
color: var(--warn);
background: var(--bg-page);
border-radius: 999px;
height: 26px;
padding: 0 10px;
font-size: 12px;
cursor: pointer;
}
.week-tip:hover { background: var(--bg-subtle); }
.content { flex: 1; overflow: auto; }
.content-inner { max-width: 1200px; margin: 0 auto; padding: 20px; }
.toast-wrap {
position: fixed;
top: 68px;
right: 20px;
z-index: 60;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
pointer-events: none;
}
.toast {
background: var(--bg-page);
border: 1px solid var(--border);
box-shadow: var(--shadow-float);
border-radius: var(--radius-sm);
padding: 8px 14px;
font-size: 13px;
color: var(--text-1);
}
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(-4px); }
.toast-enter-active, .toast-leave-active { transition: all 0.15s ease; }
@media (max-width: 720px) {
.app { flex-direction: column; }
.side {
order: 2;
width: 100%;
height: 56px;
flex-direction: row;
justify-content: center;
border-right: none;
border-top: 1px solid var(--border);
padding-top: 0;
}
.main { order: 1; height: calc(100vh - 56px); }
.nav-btn::after { display: none; }
}
</style>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { onUnmounted, watch } from 'vue'
const props = withDefaults(defineProps<{
open: boolean
title: string
message: string
confirmText?: string
cancelText?: string
danger?: boolean
}>(), { confirmText: '确认', cancelText: '取消', danger: false })
const emit = defineEmits<{ (e: 'cancel'): void; (e: 'confirm'): void }>()
function onKey(e: KeyboardEvent) {
if (!props.open) return
if (e.key === 'Escape') emit('cancel')
}
watch(() => props.open, v => {
if (v) window.addEventListener('keydown', onKey)
else window.removeEventListener('keydown', onKey)
})
onUnmounted(() => window.removeEventListener('keydown', onKey))
</script>
<template>
<teleport to="body">
<transition name="modal">
<div v-if="open" class="mask" @click.self="emit('cancel')">
<div class="dialog" role="dialog" :aria-label="title">
<div class="dialog-title">{{ title }}</div>
<div class="dialog-msg">{{ message }}</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('cancel')">{{ cancelText }}</button>
<button class="btn btn-sm" :class="danger ? 'btn-danger-solid' : 'btn-primary'" @click="emit('confirm')">{{ confirmText }}</button>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<style scoped>
.mask {
position: fixed;
inset: 0;
background: rgba(29, 33, 41, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
width: 380px;
max-width: calc(100vw - 32px);
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
padding: 20px;
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.dialog-msg { margin-top: 8px; color: var(--text-2); font-size: 13px; line-height: 1.7; word-break: break-all; }
.dialog-btns { margin-top: 20px; display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #FFFFFF; }
.btn-danger-solid:hover { background: #D13A3F; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { nextTick, onUnmounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
const props = defineProps<{
open: boolean
title: string
initial: { system: string; account: string; password: string; remark: string }
}>()
const emit = defineEmits<{
(e: 'cancel'): void
(e: 'save', payload: { system: string; account: string; password: string; remark: string }): void
}>()
const fSystem = ref('')
const fAccount = ref('')
const fPassword = ref('')
const fRemark = ref('')
const fError = ref('')
const systemInput = ref<HTMLInputElement | null>(null)
function onKey(e: KeyboardEvent) {
if (!props.open) return
if (e.key === 'Escape') emit('cancel')
}
watch(() => props.open, async v => {
if (v) {
fSystem.value = props.initial.system
fAccount.value = props.initial.account
fPassword.value = props.initial.password
fRemark.value = props.initial.remark
fError.value = ''
window.addEventListener('keydown', onKey)
await nextTick()
systemInput.value?.focus()
} else {
window.removeEventListener('keydown', onKey)
}
})
onUnmounted(() => window.removeEventListener('keydown', onKey))
// 生成 15 位随机密码:大小写字母与数字为主体,末尾追加一个符号
function genPassword() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'
const symbols = '!@#$%^&*'
const arr = crypto.getRandomValues(new Uint8Array(14))
let s = ''
for (let i = 0; i < arr.length; i++) s += chars[arr[i] % chars.length]
s += symbols[crypto.getRandomValues(new Uint8Array(1))[0] % symbols.length]
fPassword.value = s
fError.value = ''
}
function save() {
if (!fSystem.value.trim() || !fAccount.value.trim() || !fPassword.value) {
fError.value = '系统名称、账号、密码为必填项'
return
}
emit('save', {
system: fSystem.value.trim(),
account: fAccount.value.trim(),
password: fPassword.value,
remark: fRemark.value.trim()
})
}
</script>
<template>
<teleport to="body">
<transition name="modal">
<div v-if="open" class="mask" @click.self="emit('cancel')">
<div class="dialog" role="dialog" :aria-label="title">
<div class="dialog-head">
<span class="dialog-title">{{ title }}</span>
<button class="icon-btn" title="关闭" @click="emit('cancel')">
<Icon name="close" :size="16" />
</button>
</div>
<div class="dialog-body">
<div class="form-grid">
<div class="field span2">
<label>系统名称(必填)</label>
<input ref="systemInput" class="input" v-model="fSystem" placeholder="如:企业邮箱 / OA / VPN" />
</div>
<div class="field span2">
<label>账号(必填)</label>
<input class="input" v-model="fAccount" placeholder="用户名、工号或邮箱" />
</div>
<div class="field span2">
<label>密码(必填)</label>
<div class="pwd-row">
<input class="input" v-model="fPassword" placeholder="输入或点击生成" />
<button class="btn btn-sm" @click="genPassword">生成</button>
</div>
</div>
<div class="field span2">
<label>备注</label>
<textarea class="textarea" v-model="fRemark" placeholder="用途、到期时间、负责人等(选填)"></textarea>
</div>
</div>
<div v-if="fError" class="form-error">{{ fError }}</div>
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('cancel')">取消</button>
<button class="btn btn-primary btn-sm" @click="save">保存</button>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<style scoped>
.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: 480px;
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; color: var(--text-1); }
.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);
}
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.pwd-row { display: flex; gap: 8px; }
.pwd-row .input { flex: 1; }
.form-error { color: var(--danger); 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>
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ICONS } from './icons'
const props = withDefaults(defineProps<{ name: string; size?: number }>(), { size: 16 })
const html = computed(() => ICONS[props.name] || '')
</script>
<template>
<span class="icon" :style="{ width: size + 'px', height: size + 'px' }" v-html="html"></span>
</template>
+25
View File
@@ -0,0 +1,25 @@
// 自绘 SVG 图标:24 画布 / 1.5 描边 / 圆角端点 / currentColor(设计方案 第 8 节)
function svg(body: string): string {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' + body + '</svg>'
}
export const ICONS: Record<string, string> = {
calendar: svg('<rect x="4" y="5.5" width="16" height="14" rx="2"/><path d="M8.5 3.5v3.5M15.5 3.5v3.5M4 10.5h16"/>'),
clock: svg('<circle cx="12" cy="12" r="8.25"/><path d="M12 7.75V12l2.75 1.75"/>'),
'check-circle': svg('<circle cx="12" cy="12" r="8.25"/><path d="M8.5 12.4l2.4 2.4 4.6-5.2"/>'),
edit: svg('<path d="M4.5 19.5v-3.2L16 4.8a1.9 1.9 0 0 1 2.7 2.7L7.2 19l-2.7.5z"/>'),
tag: svg('<path d="M4.5 5.5a1 1 0 0 1 1-1h5.6l8.4 8.4-6.6 6.6-8.4-8.4V5.5z"/><circle cx="8.8" cy="8.8" r="1.1"/>'),
star: svg('<path d="M12 4l2.5 5.1 5.6.8-4 3.9.9 5.6-5-2.7-5 2.7.9-5.6-4-3.9 5.6-.8L12 4z"/>'),
close: svg('<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>'),
flag: svg('<path d="M6.5 21V4"/><path d="M6.5 4.5h10.8l-2.6 3.7 2.6 3.7H6.5"/>'),
chart: svg('<path d="M4.5 19.5h15"/><path d="M7.5 19.5V12M12 19.5V6.5M16.5 19.5V9.5"/>'),
export: svg('<path d="M12 4.5V14M8.25 10.25L12 14l3.75-3.75"/><path d="M5 19.5h14"/>'),
'chevron-left': svg('<path d="M14.5 6l-6 6 6 6"/>'),
'chevron-right': svg('<path d="M9.5 6l6 6-6 6"/>'),
'chevron-down': svg('<path d="M6 9.5l6 6 6-6"/>'),
plus: svg('<path d="M12 5.5v13M5.5 12h13"/>'),
search: svg('<circle cx="11" cy="11" r="6.5"/><path d="M15.8 15.8L20 20"/>'),
settings: svg('<circle cx="12" cy="12" r="3"/><path d="M12 4.5v2.2M12 17.3v2.2M4.5 12h2.2M17.3 12h2.2M6.8 6.8l1.6 1.6M15.6 15.6l1.6 1.6M17.2 6.8l-1.6 1.6M8.4 15.6l-1.6 1.6"/>'),
memo: svg('<path d="M6.5 3.5h7.5L18.5 8v11a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-15a1 1 0 0 1 1-1z"/><path d="M14 3.5V8h4.5M9.5 12h5M9.5 15.5h5"/>'),
lock: svg('<rect x="5.5" y="10.5" width="13" height="9.5" rx="1.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>')
}
+542
View File
@@ -0,0 +1,542 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import Icon from '../Icon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import EditLogDialog from './EditLogDialog.vue'
import {
addLogItem, clockIn, clockOut, deleteLogItem, getAttendance, getLastLocation, getLastPick,
getLocationPresets, getWorkRule, listLogItemsByDate, listProjects, saveMakeup,
setAttendanceLocation, setAttendanceTime, setLeave, updateLogItem
} from '../../db/repo'
import { CATEGORIES, categoryLabel, type Attendance, type LogItem, type Project } from '../../db/schema'
import { formatDateCN, hhmm, todayStr } from '../../lib/date'
import { computeDay, type DayAttendanceInfo } from '../../domain/attendance'
import type { WorkRule } from '../../db/repo'
import { showToast } from '../../lib/toast'
import { attendanceChanged, notifyAttendanceChanged } from '../../lib/bus'
const props = defineProps<{ date: string }>()
const loading = ref(true)
const rule = ref<WorkRule | null>(null)
const att = ref<Attendance | undefined>(undefined)
const items = ref<LogItem[]>([])
const projectsAll = ref<Project[]>([])
const locationVal = ref('')
const locationPresets = ref<string[]>([])
const dlId = computed(() => 'loc-list-' + props.date)
const isToday = computed(() => props.date === todayStr())
async function load() {
loading.value = true
rule.value = await getWorkRule()
att.value = await getAttendance(props.date)
items.value = await listLogItemsByDate(props.date)
projectsAll.value = await listProjects(true)
locationPresets.value = await getLocationPresets()
locationVal.value = (att.value && att.value.location) || (await getLastLocation())
loading.value = false
}
onMounted(load)
watch(() => props.date, load)
// 顶栏或其他视图发生考勤变更时实时刷新本面板
watch(attendanceChanged, () => { load() })
const projects = computed(() => projectsAll.value.filter(p => p.archived !== 1))
function projectName(id: number | null): string {
if (!id) return ''
const found = projectsAll.value.find(p => p.id === id)
return found ? found.name : '已删除项目'
}
const dayInfo = computed<DayAttendanceInfo | null>(() => {
if (!rule.value) return null
return computeDay(att.value, props.date, rule.value, isToday.value)
})
const totalMinutes = computed(() => items.value.reduce((s, i) => s + (i.minutes || 0), 0))
// ---- 打卡 ----
const busy = ref(false)
async function doIn() {
if (busy.value) return
busy.value = true
await clockIn(props.date, locationVal.value)
await load()
notifyAttendanceChanged()
showToast('上班打卡成功')
busy.value = false
}
async function doOut() {
if (busy.value) return
busy.value = true
await clockOut(props.date)
await load()
notifyAttendanceChanged()
showToast('下班打卡成功')
busy.value = false
}
// ---- 上班地点 ----
async function onLocationChange() {
await setAttendanceLocation(props.date, locationVal.value)
notifyAttendanceChanged()
showToast('地点已更新')
}
// ---- 休假标记 ----
const leaveConfirmOpen = ref(false)
const isLeave = computed(() => att.value?.kind === 'leave')
function toggleLeave() {
if (att.value && att.value.clock_in_at) {
leaveConfirmOpen.value = true
return
}
applyLeave(true)
}
async function applyLeave(leave: boolean) {
leaveConfirmOpen.value = false
await setLeave(props.date, leave)
await load()
notifyAttendanceChanged()
showToast(leave ? '已标记为休假' : '已取消休假')
}
// ---- 打卡时间行内编辑 ----
const editingField = ref<'in' | 'out' | null>(null)
const editTimeVal = ref('')
const timeInput = ref<HTMLInputElement | null>(null)
watch(editingField, async v => {
if (v) {
await nextTick()
timeInput.value?.focus()
}
})
function startTimeEdit(field: 'in' | 'out') {
const cur = field === 'in' ? att.value?.clock_in_at : att.value?.clock_out_at
editingField.value = field
editTimeVal.value = cur ? hhmm(cur) : (field === 'in' ? '09:00' : '18:00')
}
function cancelTimeEdit() {
editingField.value = null
}
async function saveTimeEdit() {
const f = editingField.value
if (!f) return
editingField.value = null
if (!editTimeVal.value) return
await setAttendanceTime(props.date, f === 'in' ? 'clock_in_at' : 'clock_out_at', editTimeVal.value)
await load()
notifyAttendanceChanged()
showToast('打卡时间已修改')
}
// ---- 补卡 ----
const showMakeup = ref(false)
const mkIn = ref('09:00')
const mkOut = ref('18:00')
const mkNote = ref('')
const mkLocation = ref('')
const mkError = ref('')
function openMakeup() {
mkLocation.value = locationVal.value
mkError.value = ''
showMakeup.value = !showMakeup.value
}
async function saveMakeupForm() {
if (!mkIn.value || !mkOut.value) {
mkError.value = '请填写上班与下班时间'
return
}
await saveMakeup(props.date, props.date + 'T' + mkIn.value + ':00', props.date + 'T' + mkOut.value + ':00', mkNote.value.trim(), mkLocation.value)
showMakeup.value = false
mkNote.value = ''
mkError.value = ''
await load()
notifyAttendanceChanged()
showToast('补卡已保存')
}
// ---- 新增工作记录 ----
const content = ref('')
const contentError = ref('')
watch(content, () => {
if (contentError.value) contentError.value = ''
})
const projectId = ref<number | null>(null)
const category = ref('dev')
const minutesVal = ref<number | null>(null)
const takeawayVal = ref('')
const remarkVal = ref('')
const submitting = ref(false)
const formDirty = computed(() => !!(content.value.trim() || minutesVal.value || takeawayVal.value.trim() || remarkVal.value.trim()))
onMounted(async () => {
const lp = await getLastPick()
projectId.value = lp.project_id
category.value = lp.category
})
function resetForm() {
content.value = ''
minutesVal.value = null
takeawayVal.value = ''
remarkVal.value = ''
contentError.value = ''
}
async function submit() {
const text = content.value.trim()
if (!text) {
contentError.value = '请填写工作内容'
return
}
if (submitting.value) return
submitting.value = true
await addLogItem(props.date, {
content: text,
project_id: projectId.value,
category: category.value,
minutes: minutesVal.value && minutesVal.value > 0 ? minutesVal.value : null,
takeaway: takeawayVal.value.trim() || null,
remark: remarkVal.value.trim() || null
})
resetForm()
await load()
showToast('记录已添加')
submitting.value = false
}
// ---- 条目操作 ----
const editTarget = ref<LogItem | null>(null)
function openEdit(it: LogItem) {
editTarget.value = it
}
async function onEditSave(payload: {
id: number
content: string
project_id: number | null
category: string
minutes: number | null
takeaway: string | null
remark: string | null
is_milestone: number
needs_followup: number
}) {
const id = payload.id
const patch = {
content: payload.content,
project_id: payload.project_id,
category: payload.category,
minutes: payload.minutes,
takeaway: payload.takeaway,
remark: payload.remark,
is_milestone: payload.is_milestone,
needs_followup: payload.needs_followup
}
await updateLogItem(id, patch)
editTarget.value = null
await load()
showToast('修改已保存')
}
async function toggleMilestone(it: LogItem) {
await updateLogItem(it.id as number, { is_milestone: it.is_milestone ? 0 : 1 })
await load()
}
async function toggleFollowup(it: LogItem) {
await updateLogItem(it.id as number, { needs_followup: it.needs_followup ? 0 : 1 })
await load()
}
const deleteTarget = ref<LogItem | null>(null)
async function confirmDelete() {
if (deleteTarget.value && deleteTarget.value.id) {
await deleteLogItem(deleteTarget.value.id)
}
deleteTarget.value = null
await load()
showToast('记录已删除')
}
</script>
<template>
<div class="day-panel">
<div class="dp-head">
<div class="dp-date">{{ formatDateCN(date) }}</div>
</div>
<div class="card sec">
<div class="sec-title">出勤信息</div>
<div class="att-grid">
<div class="att-cell">
<label>上班时间</label>
<template v-if="editingField === 'in'">
<div class="edit-row">
<input
ref="timeInput" type="time" class="input time-input"
v-model="editTimeVal" @keydown.enter="saveTimeEdit" @keydown.esc="cancelTimeEdit"
/>
<button class="btn btn-primary btn-sm" @click="saveTimeEdit">确认</button>
<button class="btn btn-sm" @click="cancelTimeEdit">取消</button>
</div>
</template>
<div v-else class="att-val">
<span class="num" :class="{ 'val-empty': !(att && att.clock_in_at) }">{{ att && att.clock_in_at ? hhmm(att.clock_in_at) : '未打卡' }}</span>
<button v-if="att && att.id" class="icon-btn" title="修改上班时间" @click="startTimeEdit('in')">
<Icon name="edit" :size="13" />
</button>
</div>
</div>
<div class="att-cell">
<label>下班时间</label>
<template v-if="editingField === 'out'">
<div class="edit-row">
<input
ref="timeInput" type="time" class="input time-input"
v-model="editTimeVal" @keydown.enter="saveTimeEdit" @keydown.esc="cancelTimeEdit"
/>
<button class="btn btn-primary btn-sm" @click="saveTimeEdit">确认</button>
<button class="btn btn-sm" @click="cancelTimeEdit">取消</button>
</div>
</template>
<div v-else class="att-val">
<span class="num" :class="{ 'val-empty': !(att && att.clock_out_at) }">{{ att && att.clock_out_at ? hhmm(att.clock_out_at) : '未打卡' }}</span>
<button v-if="att && att.id" class="icon-btn" title="修改下班时间" @click="startTimeEdit('out')">
<Icon name="edit" :size="13" />
</button>
</div>
</div>
<div class="att-cell">
<label>上班地点</label>
<input
class="input" v-model="locationVal"
:list="dlId" placeholder="可直接输入" title="上班地点,可自由输入" @change="onLocationChange"
/>
<datalist :id="dlId">
<option v-for="opt in locationPresets" :key="opt" :value="opt"></option>
</datalist>
</div>
<div class="att-cell">
<label>考勤状态</label>
<div class="att-val status-val">
<template v-if="dayInfo">
<span class="dot" :class="dayInfo.status"></span>
<span>{{ dayInfo.label }}</span>
<span v-if="att && att.kind === 'makeup'" class="tag">补卡</span>
</template>
<span v-else class="muted">--</span>
</div>
</div>
</div>
<div v-if="att && att.note" class="att-note muted">补卡说明:{{ att.note }}</div>
<div class="att-actions">
<button
v-if="isToday && (!att || !att.clock_in_at)"
class="btn btn-primary btn-sm" :disabled="busy" @click="doIn"
>上班打卡</button>
<button
v-else-if="isToday && att && att.clock_in_at && !att.clock_out_at"
class="btn btn-primary btn-sm" :disabled="busy" @click="doOut"
>下班打卡</button>
<button
v-if="!isToday && (!att || !att.clock_in_at)"
class="btn btn-sm" @click="openMakeup"
>补卡</button>
<button
v-if="!isLeave" class="btn btn-sm" :disabled="busy" @click="toggleLeave"
>标记休假</button>
<button
v-else class="btn btn-sm" :disabled="busy" @click="applyLeave(false)"
>取消休假</button>
</div>
</div>
<div v-if="showMakeup" class="card sec makeup">
<div class="sec-title">补卡登记</div>
<div class="form-grid">
<div class="field">
<label>上班时间</label>
<input type="time" class="input" v-model="mkIn" />
</div>
<div class="field">
<label>下班时间</label>
<input type="time" class="input" v-model="mkOut" />
</div>
<div class="field span2">
<label>上班地点</label>
<input class="input" :list="dlId" v-model="mkLocation" placeholder="可直接输入" />
</div>
<div class="field span2">
<label>补卡原因</label>
<input class="input" v-model="mkNote" placeholder="如:外出拜访客户忘记打卡" />
</div>
</div>
<div v-if="mkError" class="form-error">{{ mkError }}</div>
<div class="form-footer">
<button class="btn btn-sm" @click="showMakeup = false">取消</button>
<button class="btn btn-primary btn-sm" @click="saveMakeupForm">保存补卡</button>
</div>
</div>
<div class="card sec">
<div class="sec-head">
<span class="sec-title">新增工作记录</span>
<span class="muted form-hint">内容必填,其余选填</span>
</div>
<div class="form-grid">
<div class="field span2">
<label>工作内容(必填)</label>
<input
class="input" :class="{ invalid: contentError }"
v-model="content" placeholder="做了什么,一句话一条" @keydown.enter="submit"
/>
<div v-if="contentError" class="form-error">{{ contentError }}</div>
</div>
<div class="field">
<label>所属项目</label>
<select class="select" v-model="projectId">
<option :value="null">无项目</option>
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="field">
<label>工作分类</label>
<select class="select" v-model="category">
<option v-for="c in CATEGORIES" :key="c.key" :value="c.key">{{ c.label }}</option>
</select>
</div>
<div class="field">
<label>耗时(分钟)</label>
<input class="input" type="number" min="0" v-model.number="minutesVal" placeholder="选填" />
</div>
<div class="field">
<label>主要收获</label>
<input class="input" v-model="takeawayVal" placeholder="一句话:学到了什么" />
</div>
<div class="field span2">
<label>备注</label>
<textarea class="textarea" v-model="remarkVal" placeholder="背景、链接、未决事项(选填)"></textarea>
</div>
</div>
<div class="form-footer">
<button class="btn btn-sm" :disabled="!formDirty" @click="resetForm">重置</button>
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="submit">添加记录</button>
</div>
</div>
<div class="list-sec">
<div class="sec-title">工作记录({{ items.length }})</div>
<div v-if="!items.length && !loading" class="empty muted">暂无记录</div>
<div v-for="it in items" :key="it.id" class="item">
<div class="item-main">
<span class="tag">{{ categoryLabel(it.category) }}</span>
<span class="item-content" title="点击编辑" @click="openEdit(it)">{{ it.content }}</span>
<span v-if="it.project_id" class="muted item-meta">{{ projectName(it.project_id) }}</span>
<span v-if="it.minutes" class="muted num item-meta">{{ it.minutes }} 分钟</span>
<span class="flex1"></span>
<button class="icon-btn" title="编辑完整信息" @click="openEdit(it)">
<Icon name="edit" :size="14" />
</button>
<button
class="icon-btn" :class="{ active: it.is_milestone === 1 }"
title="成果标记" @click="toggleMilestone(it)"
>
<Icon name="star" :size="14" />
</button>
<button
class="icon-btn" :class="{ 'active-warn': it.needs_followup === 1 }"
title="待跟进" @click="toggleFollowup(it)"
>
<Icon name="flag" :size="14" />
</button>
<button class="icon-btn" title="删除" @click="deleteTarget = it">
<Icon name="close" :size="14" />
</button>
</div>
<div v-if="it.takeaway || it.remark" class="item-sub">
<div v-if="it.takeaway" class="muted2">收获:{{ it.takeaway }}</div>
<div v-if="it.remark" class="muted">备注:{{ it.remark }}</div>
</div>
</div>
<div v-if="items.length" class="dp-foot muted num">
共 {{ items.length }} 条事项
<template v-if="totalMinutes"> · {{ (totalMinutes / 60).toFixed(1) }} 小时</template>
</div>
</div>
<ConfirmDialog
:open="!!deleteTarget"
title="删除确认"
:message="deleteTarget ? '将删除工作记录「' + deleteTarget.content + '」。删除后不可恢复。' : ''"
confirm-text="删除" danger
@cancel="deleteTarget = null"
@confirm="confirmDelete"
/>
<ConfirmDialog
:open="leaveConfirmOpen"
title="标记休假确认"
message="该日已有打卡记录,标记休假将清空上下班时间。是否继续?"
confirm-text="标记休假" danger
@cancel="leaveConfirmOpen = false"
@confirm="applyLeave(true)"
/>
<EditLogDialog
:open="!!editTarget"
:item="editTarget"
:projects="projectsAll"
:date="date"
@cancel="editTarget = null"
@save="onEditSave"
/>
</div>
</template>
<style scoped>
.dp-head { margin-bottom: 12px; }
.dp-date { font-size: 14px; font-weight: 600; }
.sec { padding: 14px 16px; margin-bottom: 12px; }
.sec-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; color: var(--text-1); }
.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
.sec-head .sec-title { margin-bottom: 0; }
.form-hint { font-size: 12px; }
.att-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.att-cell label, .field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.att-val { display: flex; align-items: center; gap: 6px; min-height: 32px; font-size: 13px; }
.status-val { min-height: 32px; }
.val-empty { color: var(--text-3); }
.edit-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.time-input { width: 104px; }
.att-note { margin-top: 10px; font-size: 12px; }
.att-actions { display: flex; gap: 8px; margin-top: 12px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.form-footer {
display: flex; justify-content: flex-end; gap: 8px;
margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);
}
.form-error { color: var(--danger); font-size: 12px; margin-top: 4px; }
.input.invalid { border-color: var(--danger); }
.input.invalid:focus { border-color: var(--danger); }
.list-sec { margin-bottom: 4px; }
.item { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; margin-bottom: 6px; background: var(--bg-page); }
.item-main { display: flex; align-items: center; gap: 8px; min-height: 26px; flex-wrap: wrap; }
.item-content { cursor: text; word-break: break-all; }
.item-meta { font-size: 12px; }
.item-sub {
margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border);
font-size: 12px; display: flex; flex-direction: column; gap: 2px;
}
.empty { text-align: center; padding: 24px 0; }
.dp-foot { margin-top: 10px; font-size: 12px; text-align: right; }
@media (max-width: 560px) {
.att-grid, .form-grid { grid-template-columns: 1fr; }
.field.span2 { grid-column: auto; }
}
</style>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import Icon from '../Icon.vue'
import { CATEGORIES, type LogItem, type Project } from '../../db/schema'
const props = defineProps<{
open: boolean
item: LogItem | null
projects: Project[]
date?: string
}>()
const emit = defineEmits<{
(e: 'cancel'): void
(e: 'save', payload: {
id: number
content: string
project_id: number | null
category: string
minutes: number | null
takeaway: string | null
remark: string | null
is_milestone: number
needs_followup: number
}): void
}>()
const formContent = ref('')
const formProjectId = ref<number | null>(null)
const formCategory = ref('dev')
const formMinutes = ref<number | null>(null)
const formTakeaway = ref('')
const formRemark = ref('')
const formMilestone = ref(0)
const formFollowup = ref(0)
const contentError = ref('')
const contentInput = ref<HTMLInputElement | null>(null)
// 归档项目回显:当前条目引用了已归档项目时,下拉中追加该项避免丢失
const projectOptions = computed(() => {
const active = props.projects.filter(p => p.archived !== 1)
const cur = props.item?.project_id
if (cur && !active.some(p => p.id === cur)) {
const arch = props.projects.find(p => p.id === cur)
const list = active.slice()
if (arch) list.unshift({ ...arch, name: arch.name + '(已归档)' })
return list
}
return active
})
function onKey(e: KeyboardEvent) {
if (!props.open) return
if (e.key === 'Escape') emit('cancel')
}
watch(() => props.open, async v => {
if (v) {
const it = props.item
formContent.value = it?.content ?? ''
formProjectId.value = it?.project_id ?? null
formCategory.value = it?.category ?? 'dev'
formMinutes.value = it?.minutes ?? null
formTakeaway.value = it?.takeaway ?? ''
formRemark.value = it?.remark ?? ''
formMilestone.value = it?.is_milestone ?? 0
formFollowup.value = it?.needs_followup ?? 0
contentError.value = ''
window.addEventListener('keydown', onKey)
await nextTick()
contentInput.value?.focus()
} else {
window.removeEventListener('keydown', onKey)
}
})
onUnmounted(() => window.removeEventListener('keydown', onKey))
watch(formContent, () => {
if (contentError.value) contentError.value = ''
})
function save() {
const text = formContent.value.trim()
if (!text) {
contentError.value = '请填写工作内容'
return
}
if (!props.item || !props.item.id) {
emit('cancel')
return
}
emit('save', {
id: props.item.id,
content: text,
project_id: formProjectId.value,
category: formCategory.value,
minutes: formMinutes.value && formMinutes.value > 0 ? formMinutes.value : null,
takeaway: formTakeaway.value.trim() || null,
remark: formRemark.value.trim() || null,
is_milestone: formMilestone.value ? 1 : 0,
needs_followup: formFollowup.value ? 1 : 0
})
}
</script>
<template>
<teleport to="body">
<transition name="modal">
<div v-if="open" class="mask" @click.self="emit('cancel')">
<div class="dialog" role="dialog" aria-label="编辑工作记录">
<div class="dialog-head">
<div>
<div class="dialog-title">编辑工作记录</div>
<div v-if="date" class="muted dialog-sub">{{ date }}</div>
</div>
<button class="icon-btn" title="关闭" @click="emit('cancel')">
<Icon name="close" :size="16" />
</button>
</div>
<div class="dialog-body">
<div class="form-grid">
<div class="field span2">
<label>工作内容(必填)</label>
<input
ref="contentInput" class="input" :class="{ invalid: contentError }"
v-model="formContent" @keydown.enter="save"
/>
<div v-if="contentError" class="form-error">{{ contentError }}</div>
</div>
<div class="field">
<label>所属项目</label>
<select class="select" v-model="formProjectId">
<option :value="null">无项目</option>
<option v-for="p in projectOptions" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="field">
<label>工作分类</label>
<select class="select" v-model="formCategory">
<option v-for="c in CATEGORIES" :key="c.key" :value="c.key">{{ c.label }}</option>
</select>
</div>
<div class="field">
<label>耗时(分钟)</label>
<input class="input" type="number" min="0" v-model.number="formMinutes" placeholder="选填" />
</div>
<div class="field">
<label>主要收获</label>
<input class="input" v-model="formTakeaway" placeholder="一句话:学到了什么" />
</div>
<div class="field span2">
<label>备注</label>
<textarea class="textarea" v-model="formRemark" placeholder="背景、链接、未决事项(选填)"></textarea>
</div>
<div class="field">
<label>成果标记</label>
<button
class="toggle-btn" :class="{ on: formMilestone === 1 }"
@click="formMilestone = formMilestone === 1 ? 0 : 1"
>{{ formMilestone === 1 ? '已标记为成果' : '未标记' }}</button>
</div>
<div class="field">
<label>待跟进</label>
<button
class="toggle-btn" :class="{ onwarn: formFollowup === 1 }"
@click="formFollowup = formFollowup === 1 ? 0 : 1"
>{{ formFollowup === 1 ? '需要跟进' : '无需跟进' }}</button>
</div>
</div>
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('cancel')">取消</button>
<button class="btn btn-primary btn-sm" @click="save">保存修改</button>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<style scoped>
.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: 560px;
max-width: calc(100vw - 32px);
max-height: 85vh;
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
display: flex;
flex-direction: column;
}
.dialog-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
flex: none;
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.dialog-sub { font-size: 12px; margin-top: 2px; }
.dialog-body { padding: 16px 20px; overflow: auto; }
.dialog-btns {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid var(--border);
flex: none;
}
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.form-error { color: var(--danger); font-size: 12px; margin-top: 4px; }
.input.invalid, .input.invalid:focus { border-color: var(--danger); }
.toggle-btn {
height: 32px;
width: 100%;
border: 1px solid var(--border-strong);
background: var(--bg-page);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
color: var(--text-2);
}
.toggle-btn:hover { background: var(--bg-hover); }
.toggle-btn.on { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
.toggle-btn.onwarn { color: var(--warn); border-color: var(--warn); }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>
+160
View File
@@ -0,0 +1,160 @@
import Dexie, { type Table } from 'dexie'
import { bindDatabase, db, unbindDatabase, WorklogDB } from './schema'
import { lockVault } from './vault'
import { pbkdf2Hash, randomSaltB64 } from '../lib/crypto'
interface UserRecord {
id: string
username: string
pwd_hash: string
pwd_salt: string
legacy_imported_at?: string
created_at: string
}
// 用户注册表:独立小库,仅存用户名与密码哈希,不存任何业务数据
class UserRegistry extends Dexie {
user!: Table<UserRecord, string>
constructor() {
super('intern_worklog_users')
this.version(1).stores({ user: 'id, &username' })
}
}
const registry = new UserRegistry()
const LEGACY_DB_NAME = 'intern_worklog'
const SESSION_KEY = 'dsh_session_user'
export interface Session {
userId: string
username: string
}
// 会话存 sessionStorage:关闭标签页即退出,刷新页面保持登录
export function getSession(): Session | null {
try {
const raw = sessionStorage.getItem(SESSION_KEY)
if (!raw) return null
const s = JSON.parse(raw)
if (s && s.userId && s.username) return s as Session
} catch { /* 忽略损坏会话 */ }
return null
}
function setSession(s: Session): void {
sessionStorage.setItem(SESSION_KEY, JSON.stringify(s))
}
function clearSession(): void {
sessionStorage.removeItem(SESSION_KEY)
}
export function openUserDatabase(userId: string): void {
bindDatabase(new WorklogDB(LEGACY_DB_NAME + '_' + userId))
}
export async function createUser(username: string, password: string, migrateLegacy: boolean): Promise<Session> {
const name = username.trim()
if (name.length < 2 || name.length > 20) throw new Error('用户名需 2 到 20 个字符')
if (password.length < 6) throw new Error('登录密码至少 6 位')
const exists = await registry.user.where('username').equals(name).first()
if (exists) throw new Error('用户名已存在')
const salt = randomSaltB64()
const hash = await pbkdf2Hash(password, salt)
const id = 'u_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6)
await registry.user.add({ id, username: name, pwd_hash: hash, pwd_salt: salt, created_at: new Date().toISOString() })
openUserDatabase(id)
let copied = 0
if (migrateLegacy && await legacyDataExists()) {
copied = await migrateLegacyInto()
await registry.user.update(id, { legacy_imported_at: new Date().toISOString() })
}
const session: Session = { userId: id, username: name }
setSession(session)
if (copied) showToastCopied(copied)
return session
}
export async function login(username: string, password: string): Promise<Session> {
const name = username.trim()
const rec = await registry.user.where('username').equals(name).first()
if (!rec) throw new Error('用户不存在')
const hash = await pbkdf2Hash(password, rec.pwd_salt)
if (hash !== rec.pwd_hash) throw new Error('密码不正确')
openUserDatabase(rec.id)
const session: Session = { userId: rec.id, username: rec.username }
setSession(session)
return session
}
export async function logoutUser(): Promise<void> {
lockVault()
unbindDatabase()
clearSession()
}
export async function changeLoginPassword(oldPwd: string, newPwd: string): Promise<void> {
const s = getSession()
if (!s) throw new Error('未登录')
if (newPwd.length < 6) throw new Error('新密码至少 6 位')
const rec = await registry.user.get(s.userId)
if (!rec) throw new Error('用户不存在')
const oldHash = await pbkdf2Hash(oldPwd, rec.pwd_salt)
if (oldHash !== rec.pwd_hash) throw new Error('旧密码不正确')
const salt = randomSaltB64()
const hash = await pbkdf2Hash(newPwd, salt)
await registry.user.update(rec.id, { pwd_hash: hash, pwd_salt: salt })
}
export async function deleteCurrentUser(): Promise<void> {
const s = getSession()
if (!s) throw new Error('未登录')
lockVault()
unbindDatabase()
await Dexie.delete(LEGACY_DB_NAME + '_' + s.userId)
await registry.user.delete(s.userId)
clearSession()
}
// 旧单机数据检测与迁移(迁移只复制,不删除原库,作为安全兜底)
export async function legacyDataExists(): Promise<boolean> {
return Dexie.exists(LEGACY_DB_NAME)
}
async function migrateLegacyInto(): Promise<number> {
if (!db) throw new Error('用户数据库未就绪')
const legacy = new WorklogDB(LEGACY_DB_NAME)
const legacyData = await Promise.all([
legacy.project.toArray(),
legacy.work_log.toArray(),
legacy.log_item.toArray(),
legacy.attendance.toArray(),
legacy.weekly_note.toArray(),
legacy.setting.toArray(),
legacy.memo.toArray(),
legacy.credential.toArray()
])
await db.transaction('rw', [db.project, db.work_log, db.log_item, db.attendance, db.weekly_note, db.setting, db.memo, db.credential], async () => {
await Promise.all([
db.project.clear(), db.work_log.clear(), db.log_item.clear(),
db.attendance.clear(), db.weekly_note.clear(), db.setting.clear(),
db.memo.clear(), db.credential.clear()
])
if (legacyData[0].length) await db.project.bulkAdd(legacyData[0] as never[])
if (legacyData[1].length) await db.work_log.bulkAdd(legacyData[1] as never[])
if (legacyData[2].length) await db.log_item.bulkAdd(legacyData[2] as never[])
if (legacyData[3].length) await db.attendance.bulkAdd(legacyData[3] as never[])
if (legacyData[4].length) await db.weekly_note.bulkAdd(legacyData[4] as never[])
if (legacyData[5].length) await db.setting.bulkAdd(legacyData[5] as never[])
if (legacyData[6].length) await db.memo.bulkAdd(legacyData[6] as never[])
if (legacyData[7].length) await db.credential.bulkAdd(legacyData[7] as never[])
})
const count = legacyData[0].length + legacyData[1].length + legacyData[2].length +
legacyData[3].length + legacyData[4].length + legacyData[6].length + legacyData[7].length
legacy.close()
return count
}
function showToastCopied(count: number): void {
// 迁移完成的反馈由登录后的轻提示呈现(此处在会话建立前,暂存标记)
try { sessionStorage.setItem('dsh_migrate_toast', String(count)) } catch { /* 忽略 */ }
}
+405
View File
@@ -0,0 +1,405 @@
import { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type Project, type WeeklyNote, type WorkLog } from './schema'
import { addDays, nowLocalISO, toDateStr } from '../lib/date'
// ---------- 设置 ----------
export async function getSetting(key: string, fallback: string): Promise<string> {
const row = await db.setting.get(key)
return row ? row.value : fallback
}
export async function setSetting(key: string, value: string): Promise<void> {
await db.setting.put({ key, value })
}
export interface WorkRule {
work_start: string
work_end: string
grace_minutes: number
lunch_minutes: number
remind_weekly: string
}
export async function getWorkRule(): Promise<WorkRule> {
const values = await Promise.all([
getSetting('work_start', '09:00'),
getSetting('work_end', '18:00'),
getSetting('grace_minutes', '10'),
getSetting('lunch_minutes', '60'),
getSetting('remind_weekly', 'friday')
])
return {
work_start: values[0],
work_end: values[1],
grace_minutes: Number(values[2]) || 0,
lunch_minutes: Number(values[3]) || 0,
remind_weekly: values[4]
}
}
export async function getLastPick(): Promise<{ project_id: number | null; category: string }> {
const lp = await getSetting('last_project', '')
const lc = await getSetting('last_category', 'dev')
return { project_id: lp ? Number(lp) : null, category: lc || 'dev' }
}
export async function getLastLocation(): Promise<string> {
return getSetting('last_location', '')
}
// 记录某天的上班地点;当天尚无打卡记录时先建一条仅含地点的记录
export async function setAttendanceLocation(date: string, location: string): Promise<void> {
const loc = location.trim()
await setSetting('last_location', loc)
if (loc) await rememberLocation(loc)
const row = await getAttendance(date)
if (row && row.id) {
await db.attendance.update(row.id, { location: loc || null })
} else if (loc) {
await db.attendance.add({
date, clock_in_at: null, clock_out_at: null,
kind: 'normal', note: null, location: loc, created_at: nowLocalISO()
})
}
}
export async function getLocationPresets(): Promise<string[]> {
const raw = await getSetting('location_presets', '')
if (!raw) return DEFAULT_LOCATIONS.slice()
try {
const arr = JSON.parse(raw)
if (Array.isArray(arr)) {
return arr.filter(x => typeof x === 'string' && x.trim()).map(x => (x as string).trim())
}
} catch { /* 忽略损坏数据,回退默认 */ }
return DEFAULT_LOCATIONS.slice()
}
export async function saveLocationPresets(list: string[]): Promise<void> {
const clean = Array.from(new Set(list.map(x => x.trim()).filter(Boolean)))
await setSetting('location_presets', JSON.stringify(clean))
}
// 自定义输入的地点自动加入常用列表,下次可直接选
export async function rememberLocation(loc: string): Promise<void> {
const v = loc.trim()
if (!v) return
const presets = await getLocationPresets()
if (!presets.includes(v)) {
await saveLocationPresets([...presets, v])
}
}
// ---------- 项目 ----------
export async function listProjects(includeArchived = false): Promise<Project[]> {
const all = await db.project.orderBy('name').toArray()
return includeArchived ? all : all.filter(p => p.archived !== 1)
}
export async function addProject(name: string): Promise<void> {
await db.project.add({ name: name.trim(), archived: 0, created_at: nowLocalISO() })
}
export async function renameProject(id: number, name: string): Promise<void> {
await db.project.update(id, { name: name.trim() })
}
export async function setProjectArchived(id: number, archived: boolean): Promise<void> {
await db.project.update(id, { archived: archived ? 1 : 0 })
}
// ---------- 打卡 ----------
export async function getAttendance(date: string): Promise<Attendance | undefined> {
return db.attendance.where('date').equals(date).first()
}
export async function clockIn(date: string, location?: string): Promise<void> {
const loc = ((location === undefined ? await getSetting('last_location', '') : location) || '').trim()
const row = await getAttendance(date)
if (row && row.id) {
if (!row.clock_in_at) {
const patch: Partial<Attendance> = { clock_in_at: nowLocalISO() }
if (loc) patch.location = loc
await db.attendance.update(row.id, patch)
}
if (loc) {
await setSetting('last_location', loc)
await rememberLocation(loc)
}
return
}
await db.attendance.add({
date, clock_in_at: nowLocalISO(), clock_out_at: null,
kind: 'normal', note: null, location: loc || null, created_at: nowLocalISO()
})
if (loc) {
await setSetting('last_location', loc)
await rememberLocation(loc)
}
}
export async function clockOut(date: string): Promise<void> {
const row = await getAttendance(date)
if (row && row.id && row.clock_in_at && !row.clock_out_at) {
await db.attendance.update(row.id, { clock_out_at: nowLocalISO() })
}
}
export async function smartClockOut(): Promise<string> {
const now = new Date()
const today = toDateStr(now)
if (now.getHours() < 4) {
const yRec = await getAttendance(addDays(today, -1))
if (yRec && yRec.id && yRec.clock_in_at && !yRec.clock_out_at) {
await db.attendance.update(yRec.id, { clock_out_at: nowLocalISO() })
return yRec.date
}
}
const tRec = await getAttendance(today)
if (tRec && tRec.id && tRec.clock_in_at && !tRec.clock_out_at) {
await db.attendance.update(tRec.id, { clock_out_at: nowLocalISO() })
return tRec.date
}
throw new Error('没有进行中的打卡')
}
export async function saveMakeup(date: string, inAt: string, outAt: string, note: string, location?: string): Promise<void> {
if ((location || '').trim()) await rememberLocation(location as string)
const row = await getAttendance(date)
const patch = {
date,
clock_in_at: inAt,
clock_out_at: outAt,
kind: 'makeup',
note: note || null,
location: (location || '').trim() || null,
created_at: nowLocalISO()
}
if (row && row.id) await db.attendance.update(row.id, patch)
else await db.attendance.add(patch)
}
// 直接修改某天的上/下班时间(行内编辑),状态由 computeDay 运行时自动重算
export async function setAttendanceTime(date: string, field: 'clock_in_at' | 'clock_out_at', time: string): Promise<void> {
const t = (time || '').trim()
if (!t) return
const row = await getAttendance(date)
if (row && row.id) {
await db.attendance.update(row.id, { [field]: date + 'T' + t + ':00' })
}
}
export async function listAttendanceBetween(start: string, end: string): Promise<Attendance[]> {
return db.attendance.where('date').between(start, end, true, true).toArray()
}
// 休假标记:标记时清空上下班时间,取消后恢复可打卡状态
export async function setLeave(date: string, leave: boolean): Promise<void> {
const row = await getAttendance(date)
if (leave) {
const base = { date, kind: 'leave', clock_in_at: null, clock_out_at: null, created_at: nowLocalISO() }
if (row && row.id) await db.attendance.update(row.id, base)
else await db.attendance.add({ ...base, note: null, location: null })
} else if (row && row.id) {
await db.attendance.update(row.id, { kind: 'normal' })
}
}
// ---------- 工作日志 ----------
export async function ensureWorkLog(date: string): Promise<WorkLog> {
const found = await db.work_log.where('date').equals(date).first()
if (found) return found
const id = await db.work_log.add({ date, created_at: nowLocalISO() })
return { id, date, created_at: nowLocalISO() }
}
export async function getWorkLogByDate(date: string): Promise<WorkLog | undefined> {
return db.work_log.where('date').equals(date).first()
}
export async function listLogItemsByDate(date: string): Promise<LogItem[]> {
const wl = await getWorkLogByDate(date)
if (!wl || !wl.id) return []
const items = await db.log_item.where('work_log_id').equals(wl.id).toArray()
return items.sort((a, b) => (b.id as number) - (a.id as number))
}
export interface NewLogItem {
content: string
project_id: number | null
category: string
minutes: number | null
takeaway: string | null
remark: string | null
}
export async function addLogItem(date: string, data: NewLogItem): Promise<void> {
const wl = await ensureWorkLog(date)
await db.log_item.add({
work_log_id: wl.id as number,
content: data.content,
project_id: data.project_id,
category: data.category,
minutes: data.minutes,
is_milestone: 0,
needs_followup: 0,
takeaway: data.takeaway,
remark: data.remark,
sort_order: 0,
created_at: nowLocalISO()
})
await setSetting('last_project', data.project_id ? String(data.project_id) : '')
await setSetting('last_category', data.category)
}
export async function updateLogItem(id: number, patch: Partial<LogItem>): Promise<void> {
await db.log_item.update(id, patch)
}
export async function deleteLogItem(id: number): Promise<void> {
await db.log_item.delete(id)
}
export async function listLogItemsBetween(start: string, end: string): Promise<(LogItem & { date: string })[]> {
const wls = await db.work_log.where('date').between(start, end, true, true).toArray()
if (!wls.length) return []
const map = new Map<number, string>(wls.map(w => [w.id as number, w.date]))
const items = await db.log_item.where('work_log_id').anyOf(wls.map(w => w.id as number)).toArray()
return items
.map(it => ({ ...it, date: map.get(it.work_log_id) || '' }))
.sort((a, b) => (a.date === b.date ? (a.id as number) - (b.id as number) : a.date < b.date ? -1 : 1))
}
// ---------- 周记 ----------
export async function getWeeklyNote(weekStart: string): Promise<WeeklyNote | undefined> {
return db.weekly_note.where('week_start').equals(weekStart).first()
}
export async function saveWeeklyNote(
weekStart: string,
patch: { highlights?: string | null; challenges?: string | null; plan_next?: string | null }
): Promise<void> {
const row = await getWeeklyNote(weekStart)
if (row && row.id) {
await db.weekly_note.update(row.id, patch)
} else {
await db.weekly_note.add({
week_start: weekStart,
highlights: patch.highlights ?? null,
challenges: patch.challenges ?? null,
plan_next: patch.plan_next ?? null,
created_at: nowLocalISO()
})
}
}
export async function listWeeklyNotesBetween(start: string, end: string): Promise<WeeklyNote[]> {
return db.weekly_note.where('week_start').between(start, end, true, true).toArray()
}
// ---------- 备忘录 ----------
export async function listMemos(): Promise<Memo[]> {
const rows = await db.memo.toArray()
return rows.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
}
export async function addMemo(title: string, content: string): Promise<Memo> {
const now = nowLocalISO()
const id = await db.memo.add({ title: title.trim(), content, created_at: now, updated_at: now })
return { id, title: title.trim(), content, created_at: now, updated_at: now }
}
export async function updateMemo(id: number, title: string, content: string): Promise<void> {
await db.memo.update(id, { title: title.trim(), content, updated_at: nowLocalISO() })
}
export async function deleteMemo(id: number): Promise<void> {
await db.memo.delete(id)
}
// ---------- 全局搜索 ----------
export async function searchLogItems(q: string, limit = 8): Promise<(LogItem & { date: string })[]> {
const needle = q.trim().toLowerCase()
if (!needle) return []
const wls = await db.work_log.toArray()
const map = new Map<number, string>(wls.map(w => [w.id as number, w.date]))
const all = await db.log_item.toArray()
return all
.filter(it => ((it.content || '') + ' ' + (it.takeaway || '') + ' ' + (it.remark || '')).toLowerCase().includes(needle))
.sort((a, b) => (b.id as number) - (a.id as number))
.slice(0, limit)
.map(it => ({ ...it, date: map.get(it.work_log_id) || '' }))
}
export async function searchMemos(q: string, limit = 4): Promise<Memo[]> {
const needle = q.trim().toLowerCase()
if (!needle) return []
const all = await db.memo.toArray()
return all
.filter(m => ((m.title || '') + ' ' + (m.content || '')).toLowerCase().includes(needle))
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
.slice(0, limit)
}
// ---------- 备份 ----------
export async function exportAll(): Promise<string> {
const tables = await Promise.all([
db.project.toArray(),
db.work_log.toArray(),
db.log_item.toArray(),
db.attendance.toArray(),
db.weekly_note.toArray(),
db.setting.toArray(),
db.memo.toArray(),
db.credential.toArray()
])
return JSON.stringify({
schema_version: SCHEMA_VERSION,
exported_at: nowLocalISO(),
data: {
project: tables[0],
work_log: tables[1],
log_item: tables[2],
attendance: tables[3],
weekly_note: tables[4],
setting: tables[5],
memo: tables[6],
credential: tables[7]
}
}, null, 2)
}
export async function importAll(jsonText: string): Promise<void> {
let parsed: { schema_version?: number; data?: Record<string, unknown[]> } | null = null
try {
parsed = JSON.parse(jsonText)
} catch {
throw new Error('不是有效的 JSON 文件')
}
if (!parsed || (parsed.schema_version !== 1 && parsed.schema_version !== 2) || !parsed.data) {
throw new Error('文件格式或 schema 版本不匹配')
}
const d = parsed.data
await db.transaction('rw', [db.project, db.work_log, db.log_item, db.attendance, db.weekly_note, db.setting, db.memo, db.credential], async () => {
await Promise.all([
db.project.clear(), db.work_log.clear(), db.log_item.clear(),
db.attendance.clear(), db.weekly_note.clear(), db.setting.clear(),
db.memo.clear(), db.credential.clear()
])
if (d.project && d.project.length) await db.project.bulkAdd(d.project as never[])
if (d.work_log && d.work_log.length) await db.work_log.bulkAdd(d.work_log as never[])
if (d.log_item && d.log_item.length) await db.log_item.bulkAdd(d.log_item as never[])
if (d.attendance && d.attendance.length) await db.attendance.bulkAdd(d.attendance as never[])
if (d.weekly_note && d.weekly_note.length) await db.weekly_note.bulkAdd(d.weekly_note as never[])
if (d.setting && d.setting.length) await db.setting.bulkAdd(d.setting as never[])
if (d.memo && d.memo.length) await db.memo.bulkAdd(d.memo as never[])
if (d.credential && d.credential.length) await db.credential.bulkAdd(d.credential as never[])
})
}
+125
View File
@@ -0,0 +1,125 @@
import Dexie, { type Table } from 'dexie'
export interface Project {
id?: number
name: string
archived: number
created_at: string
}
export interface WorkLog {
id?: number
date: string
created_at: string
}
export interface LogItem {
id?: number
work_log_id: number
content: string
project_id: number | null
category: string
minutes: number | null
is_milestone: number
needs_followup: number
takeaway: string | null
remark: string | null
sort_order: number
created_at: string
}
export interface Attendance {
id?: number
date: string
clock_in_at: string | null
clock_out_at: string | null
kind: string
note: string | null
location?: string | null
created_at: string
}
export interface WeeklyNote {
id?: number
week_start: string
highlights: string | null
challenges: string | null
plan_next: string | null
created_at: string
}
export interface Memo {
id?: number
title: string
content: string
created_at: string
updated_at: string
}
export interface Credential {
id?: number
system: string
account: string
password: string
remark: string
created_at: string
updated_at: string
}
export class WorklogDB extends Dexie {
project!: Table<Project, number>
work_log!: Table<WorkLog, number>
log_item!: Table<LogItem, number>
attendance!: Table<Attendance, number>
weekly_note!: Table<WeeklyNote, number>
setting!: Table<{ key: string; value: string }, string>
memo!: Table<Memo, number>
credential!: Table<Credential, number>
constructor(dbName = 'intern_worklog') {
super(dbName)
this.version(1).stores({
project: '++id, &name, archived',
work_log: '++id, &date',
log_item: '++id, work_log_id, project_id, category',
attendance: '++id, &date',
weekly_note: '++id, &week_start',
setting: 'key'
})
this.version(2).stores({
memo: '++id, updated_at',
credential: '++id, system, updated_at'
})
}
}
// 当前登录用户绑定的数据库实例:不同用户物理隔离不同库,登录后由 auth 模块注入
export let db: WorklogDB | undefined
export function bindDatabase(instance: WorklogDB): void {
db = instance
}
export function unbindDatabase(): void {
if (db) db.close()
db = undefined
}
export const SCHEMA_VERSION = 2
export const DEFAULT_LOCATIONS: string[] = ['公司', '客户现场', '出差', '居家办公']
export const CATEGORIES: { key: string; label: string }[] = [
{ key: 'dev', label: '开发' },
{ key: 'bugfix', label: 'BUG修复' },
{ key: 'test', label: '测试' },
{ key: 'learning', label: '学习成长' },
{ key: 'meeting', label: '会议沟通' },
{ key: 'doc', label: '文档输出' },
{ key: 'other', label: '其他' }
]
export function categoryLabel(key: string): string {
const found = CATEGORIES.find(c => c.key === key)
return found ? found.label : '其他'
}
+142
View File
@@ -0,0 +1,142 @@
import { db, type Credential } from './schema'
import { getSetting, setSetting } from './repo'
import { aesGcmDecrypt, aesGcmEncrypt, bufToB64, deriveKeyFromPassword, randomSaltB64 } from '../lib/crypto'
import { nowLocalISO } from '../lib/date'
const VERIFY_TEXT = 'intern-log-vault-verify'
const ENCRYPT_PREFIX = 'enc:v1:'
// 主密钥仅存于内存,页面刷新或手动锁定后需重新解锁
let masterKey: CryptoKey | null = null
export function isVaultUnlocked(): boolean {
return masterKey !== null
}
export function lockVault(): void {
masterKey = null
}
export async function isVaultInitialized(): Promise<boolean> {
return (await getSetting('pwd_salt', '')) !== ''
}
export async function setupVault(password: string): Promise<void> {
if (password.length < 6) throw new Error('主密码至少 6 位')
if (await isVaultInitialized()) throw new Error('主密码已设置')
const salt = randomSaltB64()
const key = await deriveKeyFromPassword(password, salt)
const verifier = await aesGcmEncrypt(key, VERIFY_TEXT)
await setSetting('pwd_salt', salt)
await setSetting('pwd_verifier', verifier)
masterKey = key
}
export async function unlockVault(password: string): Promise<void> {
const salt = await getSetting('pwd_salt', '')
if (!salt) throw new Error('主密码尚未设置')
const key = await deriveKeyFromPassword(password, salt)
const verifier = await getSetting('pwd_verifier', '')
try {
await aesGcmDecrypt(key, verifier)
} catch {
throw new Error('主密码不正确')
}
masterKey = key
}
async function requireKey(): Promise<CryptoKey> {
if (!masterKey) throw new Error('密码库未解锁')
return masterKey
}
export async function encryptSecret(plain: string): Promise<string> {
const key = await requireKey()
return ENCRYPT_PREFIX + await aesGcmEncrypt(key, plain)
}
export async function decryptSecret(packed: string | null | undefined): Promise<string> {
if (!packed) return ''
if (!packed.startsWith(ENCRYPT_PREFIX)) return packed
const key = await requireKey()
return aesGcmDecrypt(key, packed.slice(ENCRYPT_PREFIX.length))
}
// ---------- 账号密码 CRUD ----------
export async function listCredentials(): Promise<Credential[]> {
return db.credential.orderBy('system').toArray()
}
export interface CredentialInput {
system: string
account: string
password: string
remark: string
}
export async function saveCredential(input: CredentialInput, id?: number): Promise<void> {
const enc = await encryptSecret(input.password)
const base = {
system: input.system.trim(),
account: input.account.trim(),
password: enc,
remark: input.remark.trim()
}
if (id) {
await db.credential.update(id, { ...base, updated_at: nowLocalISO() })
} else {
await db.credential.add({ ...base, created_at: nowLocalISO(), updated_at: nowLocalISO() })
}
}
export async function deleteCredential(id: number): Promise<void> {
await db.credential.delete(id)
}
export async function revealCredentialPassword(id: number): Promise<string> {
const row = await db.credential.get(id)
if (!row) throw new Error('记录不存在')
return decryptSecret(row.password)
}
// ---------- 修改主密码:旧密钥解密全部记录后用新密钥重新加密 ----------
export async function changeMasterPassword(oldPwd: string, newPwd: string): Promise<number> {
if (!oldPwd) throw new Error('请输入旧主密码')
if (newPwd.length < 6) throw new Error('新主密码至少 6 位')
if (newPwd === oldPwd) throw new Error('新主密码不能与旧主密码相同')
const salt = await getSetting('pwd_salt', '')
if (!salt) throw new Error('密码库尚未初始化')
const oldKey = await deriveKeyFromPassword(oldPwd, salt)
const verifier = await getSetting('pwd_verifier', '')
try {
await aesGcmDecrypt(oldKey, verifier)
} catch {
throw new Error('旧主密码不正确')
}
const newKey = await deriveKeyFromPassword(newPwd, salt)
const rows = await db.credential.toArray()
let count = 0
for (const row of rows) {
if (row.id == null) continue
const plain = await decryptSecretWithKey(row.password, oldKey)
const enc = ENCRYPT_PREFIX + await aesGcmEncrypt(newKey, plain)
await db.credential.update(row.id, { password: enc })
count++
}
await setSetting('pwd_verifier', await aesGcmEncrypt(newKey, VERIFY_TEXT))
masterKey = newKey
return count
}
async function decryptSecretWithKey(packed: string | null | undefined, key: CryptoKey): Promise<string> {
if (!packed) return ''
if (!packed.startsWith(ENCRYPT_PREFIX)) return packed
return aesGcmDecrypt(key, packed.slice(ENCRYPT_PREFIX.length))
}
// 供备份模块使用:生成新的随机盐(占位,保持导出接口一致)
export function newSaltB64(): string {
return bufToB64(crypto.getRandomValues(new Uint8Array(16)).buffer)
}
+44
View File
@@ -0,0 +1,44 @@
import { getSetting } from '../db/repo'
// AI 润色:调用 OpenAI 兼容的 Chat Completions 接口
export async function polishReport(markdown: string): Promise<string> {
const values = await Promise.all([
getSetting('ai_base_url', ''),
getSetting('ai_api_key', ''),
getSetting('ai_model', '')
])
const base = values[0].trim().replace(/\/+$/, '')
const key = values[1].trim()
const model = values[2].trim()
if (!base || !model) throw new Error('请先在设置页配置 AI 接口地址与模型名称')
const url = base + '/chat/completions'
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (key) headers['Authorization'] = 'Bearer ' + key
const system = '你是一名严谨的中文助理。请将用户提供的工作总结 Markdown 草稿润色为通顺、专业、简洁的工作总结。要求:保留原有章节结构与全部事实数据(数字、日期、条目),不得虚构或遗漏内容,只输出 Markdown 正文。'
const body = JSON.stringify({
model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: markdown }
],
temperature: 0.3
})
let resp: Response
try {
resp = await fetch(url, { method: 'POST', headers, body })
} catch {
throw new Error('无法访问 AI 接口:请检查地址是否正确、服务端是否允许浏览器跨域访问')
}
if (!resp.ok) {
let msg = '接口返回 ' + resp.status
try {
const j = await resp.json()
if (j && j.error && j.error.message) msg += ':' + j.error.message
} catch { /* 忽略非 JSON 响应 */ }
throw new Error(msg)
}
const j = await resp.json()
const text = j && j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content
if (!text) throw new Error('接口未返回内容')
return String(text)
}
+46
View File
@@ -0,0 +1,46 @@
import type { Attendance } from '../db/schema'
import type { WorkRule } from '../db/repo'
import { hhmm, isWeekend, tsMinutes } from '../lib/date'
export type DayStatus = 'normal' | 'late' | 'early' | 'incomplete' | 'absent' | 'none' | 'rest'
export interface DayAttendanceInfo {
status: DayStatus
label: string
makeup: boolean
workedMinutes: number | null
}
function toMin(t: string): number {
const parts = t.split(':')
return Number(parts[0]) * 60 + Number(parts[1])
}
// 考勤状态运行时计算,不落库(设计方案 4.3)
export function computeDay(rec: Attendance | undefined, date: string, rule: WorkRule, isToday: boolean): DayAttendanceInfo {
if (rec && rec.kind === 'leave') {
return { status: 'leave', label: '请假', makeup: false, workedMinutes: null }
}
if (isWeekend(date)) {
return { status: 'rest', label: '休息日', makeup: false, workedMinutes: null }
}
if (!rec || !rec.clock_in_at) {
if (isToday) return { status: 'none', label: '未打卡', makeup: false, workedMinutes: null }
return { status: 'absent', label: '缺勤', makeup: !!(rec && rec.kind === 'makeup'), workedMinutes: null }
}
const makeup = rec.kind === 'makeup'
if (!rec.clock_out_at) {
return { status: 'incomplete', label: isToday ? '进行中' : '未完成', makeup, workedMinutes: null }
}
const inMin = toMin(hhmm(rec.clock_in_at))
const outMin = toMin(hhmm(rec.clock_out_at))
const startMin = toMin(rule.work_start)
const endMin = toMin(rule.work_end)
let status: DayStatus = 'normal'
if (!makeup && inMin > startMin + rule.grace_minutes) status = 'late'
else if (!makeup && outMin < endMin) status = 'early'
const span = tsMinutes(rec.clock_in_at, rec.clock_out_at)
const worked = Math.max(0, span - rule.lunch_minutes)
const label = makeup ? '补卡' : status === 'late' ? '迟到' : status === 'early' ? '早退' : '正常'
return { status, label, makeup, workedMinutes: worked }
}
+178
View File
@@ -0,0 +1,178 @@
import { getWorkRule, listAttendanceBetween, listLogItemsBetween, listProjects, listWeeklyNotesBetween } from '../db/repo'
import { CATEGORIES, type LogItem } from '../db/schema'
import { todayStr } from '../lib/date'
import { computeDay } from './attendance'
export interface ReportRange {
start: string
end: string
label: string
}
export interface CategoryStat {
key: string
label: string
count: number
percent: number
}
export interface ProjectStat {
name: string
count: number
}
export interface ReportData {
range: ReportRange
attendDays: number
totalHours: string
makeupCount: number
leaveCount: number
itemCount: number
categoryStats: CategoryStat[]
projectStats: ProjectStat[]
locationStats: { name: string; count: number }[]
milestones: { date: string; content: string }[]
followups: { date: string; content: string; remark: string }[]
takeaways: { date: string; text: string }[]
weekNotes: { weekStart: string; highlights: string; challenges: string; plan_next: string }[]
}
export async function buildReport(start: string, end: string, label: string): Promise<ReportData> {
const t = todayStr()
const results = await Promise.all([
getWorkRule(),
listAttendanceBetween(start, end),
listLogItemsBetween(start, end),
listProjects(true),
listWeeklyNotesBetween(start, end)
])
const rule = results[0]
const atts = results[1]
const items = results[2]
const projects = results[3]
const notes = results[4]
let attendDays = 0
let workedMin = 0
let makeupCount = 0
for (const a of atts) {
if (a.clock_in_at || a.kind === 'makeup') attendDays++
if (a.kind === 'makeup') makeupCount++
const info = computeDay(a, a.date, rule, a.date === t)
workedMin += info.workedMinutes ?? 0
}
const lcount = new Map<string, number>()
for (const a of atts) {
if (!(a.clock_in_at || a.kind === 'makeup')) continue
const name = a.location || '未记录'
lcount.set(name, (lcount.get(name) || 0) + 1)
}
const locationStats = Array.from(lcount.entries())
.map(pair => ({ name: pair[0], count: pair[1] }))
.sort((a, b) => b.count - a.count)
const total = items.length || 1
const categoryStats: CategoryStat[] = CATEGORIES
.map(c => {
const count = items.filter((i: LogItem) => 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)
}
const projectStats: ProjectStat[] = Array.from(pcount.entries())
.map(pair => ({ name: pair[0], count: pair[1] }))
.sort((a, b) => b.count - a.count)
const milestones = items.filter(i => i.is_milestone === 1).map(i => ({ date: i.date, content: i.content }))
const followups = items.filter(i => i.needs_followup === 1).map(i => ({ date: i.date, content: i.content, remark: i.remark || '' }))
const takeaways = items.filter(i => !!i.takeaway).map(i => ({ date: i.date, text: i.takeaway as string }))
const weekNotes = notes.map(n => ({
weekStart: n.week_start,
highlights: n.highlights || '',
challenges: n.challenges || '',
plan_next: n.plan_next || ''
}))
return {
range: { start, end, label },
attendDays,
totalHours: (workedMin / 60).toFixed(1),
makeupCount,
leaveCount: atts.filter(a => a.kind === 'leave').length,
itemCount: items.length,
categoryStats,
projectStats,
locationStats,
milestones,
followups,
takeaways,
weekNotes
}
}
export function reportToMarkdown(d: ReportData): string {
const out: string[] = []
out.push('# 实习工作总结(' + d.range.start + ' ~ ' + d.range.end + ')')
out.push('')
out.push('## 一、考勤概况')
out.push('')
out.push('出勤 ' + d.attendDays + ' 天,累计工时 ' + d.totalHours + ' 小时,补卡 ' + d.makeupCount + ' 次' + (d.leaveCount ? ',请假 ' + d.leaveCount + ' 天' : ''))
const locParts = d.locationStats.filter(l => l.name !== '未记录')
if (locParts.length) {
out.push('其中:' + locParts.map(l => l.name + ' ' + l.count + ' 天').join(','))
}
out.push('')
out.push('## 二、工作内容分布')
out.push('')
out.push(d.categoryStats.length
? d.categoryStats.map(c => c.label + ' ' + c.percent + '%(' + c.count + ' 条)').join(',')
: '(无记录)')
out.push('')
out.push('## 三、项目投入')
out.push('')
out.push(d.projectStats.length
? d.projectStats.map(p => p.name + ' ' + p.count + ' 条').join(',')
: '(无记录)')
out.push('')
out.push('## 四、主要成果')
out.push('')
if (d.milestones.length) {
d.milestones.forEach((m, i) => out.push((i + 1) + '. 【成果】' + m.content + '(' + m.date + ')'))
} else {
out.push('(无)')
}
out.push('')
out.push('## 五、待跟进事项')
out.push('')
if (d.followups.length) {
d.followups.forEach(f => out.push('- 【待跟进】' + f.content + (f.remark ? '(' + f.date + ' 备注:' + f.remark + ')' : '(' + f.date + ')')))
} else {
out.push('(无)')
}
out.push('')
out.push('## 六、学习与成长')
out.push('')
if (d.takeaways.length) {
d.takeaways.forEach(tk => out.push('- ' + tk.text + '(' + tk.date + ')'))
} else {
out.push('(无)')
}
for (const w of d.weekNotes) {
out.push('')
out.push('### 周记(' + w.weekStart + ' 起)')
out.push('')
out.push('- 本周亮点:' + (w.highlights || '(无)'))
out.push('- 踩的坑:' + (w.challenges || '(无)'))
out.push('- 下周计划:' + (w.plan_next || '(无)'))
}
out.push('')
return out.join('\n')
}
+11
View File
@@ -0,0 +1,11 @@
import { ref } from 'vue'
// 跨视图定位信号:全局搜索命中后跳转到对应视图并聚焦
export const focusDate = ref<string | null>(null)
export const focusMemo = ref<number | null>(null)
// 考勤数据变更信号:打卡、修改时间、补卡、休假后自增,所有考勤展示视图监听并实时刷新
export const attendanceChanged = ref(0)
export function notifyAttendanceChanged(): void {
attendanceChanged.value++
}
+14
View File
@@ -0,0 +1,14 @@
export async function copyText(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text)
} catch {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
}
+62
View File
@@ -0,0 +1,62 @@
// 主密码加密:PBKDF2-SHA256 派生 AES-256-GCM 密钥(设计方案 3.8)
const PBKDF2_ITERATIONS = 150000
export function bufToB64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf)
let bin = ''
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
return btoa(bin)
}
function b64ToBytes(s: string): Uint8Array {
const bin = atob(s)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return bytes
}
export function randomSaltB64(): string {
return bufToB64(crypto.getRandomValues(new Uint8Array(16)).buffer)
}
// 登录密码哈希:仅存 PBKDF2 派生值,不存明文
export async function pbkdf2Hash(password: string, saltB64: string): Promise<string> {
const enc = new TextEncoder()
const base = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits'])
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: b64ToBytes(saltB64), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
base,
256
)
return bufToB64(bits)
}
export async function deriveKeyFromPassword(password: string, saltB64: string): Promise<CryptoKey> {
const enc = new TextEncoder()
const base = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: b64ToBytes(saltB64), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
base,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
)
}
// 密文打包格式:iv_base64:cipher_base64
export async function aesGcmEncrypt(key: CryptoKey, plaintext: string): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12))
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(plaintext))
return bufToB64(iv.buffer as ArrayBuffer) + ':' + bufToB64(cipher)
}
export async function aesGcmDecrypt(key: CryptoKey, packed: string): Promise<string> {
const parts = packed.split(':')
if (parts.length !== 2) throw new Error('密文格式错误')
const plain = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: b64ToBytes(parts[0]) },
key,
b64ToBytes(parts[1])
)
return new TextDecoder().decode(plain)
}
+16
View File
@@ -0,0 +1,16 @@
function csvCell(v: string | number | null | undefined): string {
const s = v == null ? '' : String(v)
return '"' + s.replace(/"/g, '""') + '"'
}
// 导出 UTF-8 BOM 的 CSV,Excel 可直接打开中文不乱码
export function downloadCsv(filename: string, rows: (string | number | null | undefined)[][]): void {
const content = rows.map(r => r.map(csvCell).join(',')).join('\r\n')
const blob = new Blob(['\ufeff' + content], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
+65
View File
@@ -0,0 +1,65 @@
export function pad(n: number): string {
return n < 10 ? '0' + n : String(n)
}
export function toDateStr(d: Date): string {
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
}
export function todayStr(): string {
return toDateStr(new Date())
}
export function nowLocalISO(): string {
const d = new Date()
return toDateStr(d) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
}
export function hhmm(iso: string | null | undefined): string {
if (!iso) return '--:--'
const i = iso.indexOf('T')
return i >= 0 ? iso.slice(i + 1, i + 6) : iso.slice(0, 5)
}
export function parseDateStr(s: string): Date {
const parts = s.split('-').map(Number)
return new Date(parts[0], parts[1] - 1, parts[2])
}
export function addDays(s: string, n: number): string {
const d = parseDateStr(s)
d.setDate(d.getDate() + n)
return toDateStr(d)
}
export function addMonths(s: string, n: number): string {
const d = parseDateStr(s)
const day = d.getDate()
d.setDate(1)
d.setMonth(d.getMonth() + n)
const last = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
d.setDate(Math.min(day, last))
return toDateStr(d)
}
export function weekStart(s: string): string {
const d = parseDateStr(s)
const wd = (d.getDay() + 6) % 7
d.setDate(d.getDate() - wd)
return toDateStr(d)
}
export function isWeekend(s: string): boolean {
const wd = parseDateStr(s).getDay()
return wd === 0 || wd === 6
}
export function tsMinutes(a: string, b: string): number {
return Math.round((new Date(b).getTime() - new Date(a).getTime()) / 60000)
}
export function formatDateCN(s: string): string {
const d = parseDateStr(s)
const names = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return d.getFullYear() + ' 年 ' + (d.getMonth() + 1) + ' 月 ' + d.getDate() + ' 日 ' + names[d.getDay()]
}
+9
View File
@@ -0,0 +1,9 @@
export function downloadText(filename: string, text: string): void {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
+22
View File
@@ -0,0 +1,22 @@
import { ref } from 'vue'
export interface ToastItem {
id: number
text: string
}
const toasts = ref<ToastItem[]>([])
let seq = 0
// 轻提示:操作结果统一在右上角反馈,2 秒自动消失
export function showToast(text: string): void {
const id = ++seq
toasts.value.push({ id, text })
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== id)
}, 2000)
}
export function useToasts() {
return toasts
}
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles/tokens.css'
createApp(App).mount('#app')
// PWA:注册 Service Worker,安装为应用后可离线使用
if ('serviceWorker' in navigator && location.protocol.startsWith('http')) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js').catch(() => { /* 注册失败不影响正常使用 */ })
})
}
+117
View File
@@ -0,0 +1,117 @@
:root {
--bg-page: #FFFFFF;
--bg-subtle: #F7F8FA;
--bg-hover: #F2F3F5;
--border: #EBEDF0;
--border-strong: #DCDEE3;
--text-1: #1D2129;
--text-2: #4E5969;
--text-3: #86909C;
--accent: #3370FF;
--accent-bg: #F0F5FF;
--ok: #23A757;
--warn: #E8842C;
--danger: #E5484D;
--dot-empty: #C9CDD4;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-float: 0 4px 16px rgba(29, 33, 41, 0.08);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg-page);
color: var(--text-1);
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
font-size: 13px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
button, input, select, textarea { font: inherit; color: inherit; }
.num { font-variant-numeric: tabular-nums; }
.flex1 { flex: 1; }
.muted { color: var(--text-3); }
.muted2 { color: var(--text-2); }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 12px;
height: 32px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--bg-page);
color: var(--text-1);
cursor: pointer;
white-space: nowrap;
}
.btn:hover { background: var(--bg-hover); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #FFFFFF; }
.btn-primary:hover { background: #2B5FD9; }
.btn-danger { color: var(--danger); border-color: var(--danger); }
.btn-sm { height: 26px; padding: 0 10px; font-size: 12px; }
.btn-xs { height: 22px; padding: 0 8px; font-size: 12px; }
.input, .select, .textarea {
height: 32px;
padding: 0 10px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--bg-page);
outline: none;
width: 100%;
}
.textarea { height: auto; min-height: 56px; padding: 8px 10px; resize: vertical; }
.input:focus, .select:focus, .textarea:focus { border-color: var(--accent); }
.input::placeholder, .textarea::placeholder { color: var(--text-3); }
.card { border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-page); }
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
flex: none;
border: none;
background: transparent;
border-radius: var(--radius-sm);
color: var(--text-3);
cursor: pointer;
}
.icon-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.icon-btn.active { color: var(--accent); }
.icon-btn.active-warn { color: var(--warn); }
.tag {
display: inline-block;
padding: 0 8px;
height: 22px;
line-height: 20px;
border-radius: 4px;
font-size: 12px;
background: var(--bg-subtle);
color: var(--text-2);
border: 1px solid var(--border);
white-space: nowrap;
}
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex: none; }
.dot.normal { background: var(--ok); }
.dot.late, .dot.early, .dot.incomplete { background: var(--warn); }
.dot.absent { background: var(--danger); }
.dot.none { background: var(--dot-empty); }
.dot.rest { background: var(--border); }
.icon { display: inline-flex; align-items: center; justify-content: center; flex: none; }
.icon svg { width: 100%; height: 100%; display: block; }
+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>
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
base: './'
})