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