Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc5ab15ae1 | ||
|
|
7a36fd4e04 | ||
|
|
0f268a495e | ||
|
|
284f8ebd46 | ||
|
|
a1f2a45623 | ||
|
|
08b0317d35 |
@@ -0,0 +1,25 @@
|
||||
# v0.4.0 发布记录
|
||||
|
||||
发布日期:2026-10-16
|
||||
|
||||
## 新功能:计划模式
|
||||
|
||||
- 新增「计划」页:按天规划时间段(开始/结束时间、类别、备注),侧边栏"记录"组新增入口,手机端底部标签直达
|
||||
- 条目级打卡:完成 / 跳过 / 恢复,日汇总(计划项数、完成数、计划时长、实际投入)
|
||||
- 计时时钟:任意条目一键开始/停止计时,自动累计实际投入分钟;同刻仅一个计时,开始新计时自动停止上一个
|
||||
- 计划数据进入同步体系,多端实时互通
|
||||
|
||||
## 新功能:范围化导出
|
||||
|
||||
- 设置 → 工作详情导出:当日 / 当周 / 当月 / 自定义时间段一键导出 CSV(含分类、项目、耗时、成果、待跟进、收获、备注)
|
||||
|
||||
## 新功能:分类自定义
|
||||
|
||||
- 工作记录分类改为用户自定义(默认:制度学习 / 产品学习 / 打杂 / 项目实操 / 售后服务),设置页可添加、改名、删除,随账号同步
|
||||
- 记账支出 / 收入分类同样自定义,记账弹窗内可直接"新增分类"
|
||||
- 历史数据自动迁移:旧固定分类(开发/BUG修复/学习成长…)按语义映射到新分类,报表与统计自动跟随
|
||||
|
||||
## 升级说明
|
||||
|
||||
- 服务器:cd /opt/daywork && git pull && cd server && docker compose up -d --build(需包含本次 plan 表的服务端支持)
|
||||
- 客户端覆盖安装即可,本地库自动升级到 v5 并完成分类迁移
|
||||
@@ -0,0 +1,25 @@
|
||||
# v0.5.0 发布记录
|
||||
|
||||
发布日期:2026-10-16
|
||||
|
||||
## 计划模式增强:长期计划
|
||||
|
||||
- 长期计划(目标)跨越多天:设置起止日期后自动**物化为每一天的任务**,落实到每天打卡与计时
|
||||
- 目标进度条:已完成天数 / 总天数一目了然;编辑目标自动级联更新未完成的每日任务
|
||||
- 每日任务带「长期」徽标,可与当日临时计划混排;删除目标时二次确认
|
||||
- 数据模型新增 goal 表(v6),goal ↔ plan 外键随多端同步(引擎级冒烟验证外键水合正确)
|
||||
|
||||
## 界面原则:内容优先
|
||||
|
||||
- 记账页:移除内联新增表单,改为「记一笔」按钮弹窗填写,月度汇总与账目表前置
|
||||
- 当日详情:移除内联快速记录表单,改为「添加记录」按钮弹窗填写,记录列表前置
|
||||
- 工作详情导出从设置页移至详情页,并支持 **Markdown 格式**(当日 / 当周 / 当月 / 自定义时间段,含考勤与逐条记录)
|
||||
|
||||
## 上一版(v0.4.0)要点回顾
|
||||
|
||||
计划模式(时间段+打卡+计时时钟)、工作与记账分类自定义、工作详情范围导出 CSV
|
||||
|
||||
## 升级说明
|
||||
|
||||
- 服务器:cd /opt/daywork && git pull && cd server && docker compose up -d --build
|
||||
- 客户端覆盖安装;本地库自动升级至 v6
|
||||
@@ -0,0 +1,29 @@
|
||||
# v0.6.0 发布说明(2026-10-26)
|
||||
|
||||
## 界面整体重设计
|
||||
|
||||
本版本对全部界面做了系统性改版,统一为「高级白」企业级视觉,信息层次更清晰:
|
||||
|
||||
- **桌面侧栏重做**:由纯图标改为「图标 + 文字」导航,按 记录 / 工具 / 分析 / 系统 分组;新增品牌区与当前用户区(头像、用户名、退出)。
|
||||
- **顶栏精简**:只保留页面标题、全局搜索与打卡按钮;用户信息与退出移入侧栏底部。
|
||||
- **内容层次**:内容区改为浅灰底 + 白色卡片,卡片带细边框与轻投影,主次分明。
|
||||
- **组件统一**:按钮、输入框、表格、弹窗、分段选择器、进度条等全部收敛为全局统一样式,各页面不再各自为政。
|
||||
- **逐页重排**:日历(网格线 + 选中描边)、详情(导航与导出合并为一条工具栏)、计划(长期计划与每日安排分区块、列表合并为单卡)、记账、统计(热力图改品牌蓝)、总结、备忘录、密码库、设置、用户管理。
|
||||
- **登录页升级**:品牌标识、卡片投影。
|
||||
- **平板适配**:721–1000px 侧栏自动折叠为图标栏。
|
||||
- **移动端**:底部标签栏与「更多」弹层沿用,并随新基座精修间距与弹窗。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 修复:详情页「添加记录」按钮点击无反应(弹窗开关与编辑目标状态混用所致)。
|
||||
- 修复:计划页有任务正在计时时,汇总行会触发未定义函数导致整页报错。
|
||||
- 修复:设置页导出工作记录 CSV 缺少分类标签导入,触发即报错。
|
||||
- 修复:记账弹窗切换收支类型时引用了不存在的分类常量,触发即报错。
|
||||
- 记录弹窗标题与按钮文案随「新增 / 编辑」模式正确切换。
|
||||
|
||||
## 升级与部署
|
||||
|
||||
- 桌面端:安装 InternLog-Setup-0.6.0.exe。
|
||||
- 安卓端:安装 InternLog-0.6.0-debug.apk。
|
||||
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端 bundle 随仓库更新)。
|
||||
- 数据结构无变更,v0.5.0 及之前版本可直接升级。
|
||||
@@ -0,0 +1,18 @@
|
||||
# v0.6.1 发布说明(2026-10-26)
|
||||
|
||||
## 数据安全:服务器数据纪元保护(重要)
|
||||
|
||||
- 服务端为每个用户的数据文件记录「数据纪元」(首次创建时间),随同步接口返回。
|
||||
- 客户端检测到纪元变化(说明服务器数据存储被重建,例如误删 data 目录后重启)时,自动作废本地同步镜像,**把本机全量数据重新推回服务器**,避免「服务器空了 → 拉到空快照 → 本地缓存也被清空」的连锁数据丢失。
|
||||
- 也就是说:只要还有任何一台设备保留着本地缓存,升级到本版本后第一次同步就会把数据恢复到服务器。
|
||||
- 服务器部署文档已移除危险的 `rm -rf data` 步骤,并明确标注 data 目录为正式数据、严禁删除。
|
||||
|
||||
## 功能修正
|
||||
|
||||
- 详情页导出:「当日」改为「指定日」——导出的是详情页正在浏览的那一天;切换浏览日期时,导出区间(周/月/自定义)锚点自动跟随。
|
||||
- 新增**全局计时时钟面板**:计划开始计时后,任何页面右下角都会悬浮显示计时任务(脉冲动画 + 实时走秒 + 一键停止),点击可跳回计划页;手机端悬浮于底部标签栏上方。
|
||||
|
||||
## 升级
|
||||
|
||||
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(**切勿删除 server/data 目录**)
|
||||
- 桌面 / 安卓:安装对应 0.6.1 安装包。
|
||||
-1
File diff suppressed because one or more lines are too long
+27
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,2 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-C-z1ujqe.js","./index-DT_fURyb.js","./index-2EGKD3r-.css"])))=>i.map(i=>d[i]);
|
||||
import{r as s,_ as i}from"./index-DT_fURyb.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-C-z1ujqe.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-BuQ7VOet.js","./index-B3bd8iWX.js","./index-OQmtzryQ.css"])))=>i.map(i=>d[i]);
|
||||
import{r as s,_ as i}from"./index-B3bd8iWX.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-BuQ7VOet.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
|
||||
-26
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -8,8 +8,8 @@
|
||||
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
||||
<title>实习工作日志</title>
|
||||
<script type="module" crossorigin src="./assets/index-DT_fURyb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-2EGKD3r-.css">
|
||||
<script type="module" crossorigin src="./assets/index-B3bd8iWX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-OQmtzryQ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "intern-log",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.6.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'fake-indexeddb/auto'
|
||||
const BASE = 'http://127.0.0.1:3780'
|
||||
const store = new Map<string, string>()
|
||||
;(globalThis as any).localStorage = {
|
||||
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k: string, v: string) => { store.set(k, String(v)) },
|
||||
removeItem: (k: string) => { store.delete(k) }
|
||||
}
|
||||
store.set('dsh_cloud_creds', JSON.stringify({ username: 'goal_smoke', password: 'goal123' }))
|
||||
store.set('dsh_sync_base', BASE)
|
||||
|
||||
async function main() {
|
||||
const reg = await fetch(BASE + '/api/auth/register', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'goal_smoke', password: 'goal123' })
|
||||
}).catch(() => null)
|
||||
const schema = await import('../src/db/schema')
|
||||
const engine = await import('../src/sync/engine')
|
||||
schema.bindDatabase(new schema.WorklogDB('gs_a'))
|
||||
const a = schema.db
|
||||
// 设备 A:建 goal + plan(goal_id 指向本地 goal)
|
||||
const gid = await a.goal.add({ title: '产品文档学习', kind: '学习', start_date: '2026-10-16', end_date: '2026-10-18', note: null, uuid: '', created_at: '2026-10-16T08:00:00', updated_at: '2026-10-16T08:00:00' } as never)
|
||||
await a.plan.add({ date: '2026-10-16', start_time: '08:00', end_time: '09:00', title: '产品文档学习', kind: '学习', note: null, status: 'todo', actual_minutes: 0, timer_started_at: null, goal_id: gid, uuid: '', created_at: '2026-10-16T08:00:00', updated_at: '2026-10-16T08:00:00' } as never)
|
||||
const rA = await engine.runSync({ baseUrl: BASE, username: 'goal_smoke', password: 'goal123' })
|
||||
console.log('A push:', JSON.stringify({ p: rA.pushed, e: rA.errors }))
|
||||
// 设备 B:空库拉取
|
||||
schema.bindDatabase(new schema.WorklogDB('gs_b'))
|
||||
await engine.runSync({ baseUrl: BASE, username: 'goal_smoke', password: 'goal123' })
|
||||
const b = schema.db
|
||||
const goals = await b.goal.toArray()
|
||||
const plans = await b.plan.toArray()
|
||||
const okGoal = goals.length === 1
|
||||
const okFk = plans.length === 1 && plans[0].goal_id === goals[0].id && plans[0].title === '产品文档学习'
|
||||
console.log((okGoal ? 'PASS' : 'FAIL') + ' B 拉到长期计划')
|
||||
console.log((okFk ? 'PASS' : 'FAIL') + ' plan.goal_id 正确水合到本地 goal(' + JSON.stringify({ goalId: plans[0] && plans[0].goal_id, localGoalId: goals[0] && goals[0].id }) + ')')
|
||||
process.exit(okGoal && okFk ? 0 : 1)
|
||||
}
|
||||
main().catch(e => { console.error('FAILED:', (e && (e.stack || e.message)) || e); process.exit(1) })
|
||||
@@ -26,7 +26,8 @@ async function main() {
|
||||
check('错误密码登录 401', bad.status === 401, bad.status)
|
||||
const ok = await post('/api/auth/login', { username: 'audit_a', password: 'aapass123' })
|
||||
check('正确密码登录 200', ok.status === 200 && !!ok.body.token, ok.status)
|
||||
const tokA = reg.body.token, tokA2 = ok.body.token
|
||||
let tokA = reg.body.token
|
||||
const tokA2 = ok.body.token
|
||||
const noAuth = await post('/api/sync', { tables: {} })
|
||||
check('无 token 同步被拒 401', noAuth.status === 401, noAuth.status)
|
||||
const regB = await post('/api/auth/register', { username: 'audit_b', password: 'bbpass123' })
|
||||
@@ -85,6 +86,10 @@ async function main() {
|
||||
check('旧密码立即失效 401', oldLogin.status === 401, oldLogin.status)
|
||||
const newLogin = await post('/api/auth/login', { username: 'audit_a', password: 'aapass456' })
|
||||
check('新密码登录 200', newLogin.status === 200, newLogin.status)
|
||||
// 改密后旧令牌立即失效(会话吊销)
|
||||
const oldTokSync = await post('/api/sync', { tables: {} }, tokA)
|
||||
check('改密后旧 token 同步被拒 401', oldTokSync.status === 401, oldTokSync.status)
|
||||
tokA = newLogin.body.token
|
||||
const chgWrong = await post('/api/auth/password', { oldPassword: 'zzz', newPassword: 'whatever1' }, tokA)
|
||||
check('错误旧密码改密被拒 400', chgWrong.status === 400, chgWrong.status)
|
||||
|
||||
@@ -123,6 +128,8 @@ async function main() {
|
||||
check('注册表仅存哈希不存明文密码', !!aRec && !JSON.stringify(aRec).includes('aapass'))
|
||||
await new Promise(r => globalThis.setTimeout(r, 400))
|
||||
check('启动自动备份已生成', fsMod.existsSync('server/data/backups/' + new Date().toISOString().slice(0, 10) + '/registry.json'))
|
||||
const hInfo = await fetch(BASE + '/api/health').then(x => x.json()).catch(() => ({}))
|
||||
check('health 暴露备份状态', hInfo && hInfo.backup === 'local-only', hInfo.backup)
|
||||
|
||||
const d1 = await post('/api/auth/delete', {}, tokA)
|
||||
check('删除账号 200', d1.status === 200, d1.body)
|
||||
|
||||
+152
-91
@@ -3,12 +3,14 @@ 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 PlanView from './views/PlanView.vue'
|
||||
import StatsView from './views/StatsView.vue'
|
||||
import ReportView from './views/ReportView.vue'
|
||||
import LedgerView from './views/LedgerView.vue'
|
||||
import MemoView from './views/MemoView.vue'
|
||||
import VaultView from './views/VaultView.vue'
|
||||
import SettingsView from './views/SettingsView.vue'
|
||||
import TimerPanel from './components/TimerPanel.vue'
|
||||
import UserAdminView from './views/UserAdminView.vue'
|
||||
import LoginView from './views/LoginView.vue'
|
||||
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth'
|
||||
@@ -110,15 +112,16 @@ function onGlobalKey(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
const VIEWS = [
|
||||
{ key: 'calendar', name: '日历', icon: 'calendar' },
|
||||
{ key: 'today', name: '详情', icon: 'edit' },
|
||||
{ key: 'stats', name: '统计', icon: 'chart' },
|
||||
{ key: 'report', name: '总结', icon: 'export' },
|
||||
{ key: 'ledger', name: '记账', icon: 'wallet' },
|
||||
{ key: 'memo', name: '备忘录', icon: 'memo' },
|
||||
{ key: 'vault', name: '密码库', icon: 'lock' },
|
||||
{ key: 'settings', name: '设置', icon: 'settings' },
|
||||
{ key: 'admin', name: '用户管理', icon: 'shield' }
|
||||
{ key: 'calendar', name: '日历', icon: 'calendar', group: '记录' },
|
||||
{ key: 'today', name: '详情', icon: 'edit', group: '记录' },
|
||||
{ key: 'plan', name: '计划', icon: 'plan', group: '记录' },
|
||||
{ key: 'ledger', name: '记账', icon: 'wallet', group: '工具' },
|
||||
{ key: 'memo', name: '备忘录', icon: 'memo', group: '工具' },
|
||||
{ key: 'stats', name: '统计', icon: 'chart', group: '分析' },
|
||||
{ key: 'report', name: '总结', icon: 'export', group: '分析' },
|
||||
{ key: 'vault', name: '密码库', icon: 'lock', group: '系统' },
|
||||
{ key: 'settings', name: '设置', icon: 'settings', group: '系统' },
|
||||
{ key: 'admin', name: '用户管理', icon: 'shield', group: '系统' }
|
||||
]
|
||||
|
||||
function readRoute(): string {
|
||||
@@ -145,7 +148,7 @@ watch([isAdminUser, route], () => {
|
||||
}, { immediate: true })
|
||||
|
||||
// 手机端底部标签页:4 个主功能 + 「更多」弹层(统计/总结/密码库/设置/退出)
|
||||
const TAB_KEYS = ['calendar', 'today', 'ledger', 'memo']
|
||||
const TAB_KEYS = ['calendar', 'today', 'plan', 'ledger']
|
||||
const TABS = VIEWS.filter(v => TAB_KEYS.includes(v.key))
|
||||
const moreOpen = ref(false)
|
||||
function navFromSheet(key: string) { moreOpen.value = false; nav(key) }
|
||||
@@ -228,18 +231,31 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoginView v-if="!session" @login="onLogin" />
|
||||
<div v-else class="app">
|
||||
<!-- 桌面侧栏:品牌 + 分组导航 + 当前用户 -->
|
||||
<nav class="side">
|
||||
<button
|
||||
v-for="v in shownViews" :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>
|
||||
<div class="brand" @click="nav('calendar')">
|
||||
<span class="brand-mark"><Icon name="calendar" :size="15" /></span>
|
||||
<span class="brand-name">实习工作日志</span>
|
||||
</div>
|
||||
<div class="side-nav">
|
||||
<template v-for="(v, i) in shownViews" :key="v.key">
|
||||
<div v-if="i === 0 || v.group !== shownViews[i - 1].group" class="nav-group">{{ v.group }}</div>
|
||||
<button class="nav-btn" :class="{ active: route === v.key }" :data-tip="v.name" @click="nav(v.key)">
|
||||
<Icon :name="v.icon" :size="17" />
|
||||
<span class="nav-label">{{ v.name }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="side-foot">
|
||||
<span class="avatar">{{ (session?.username || '用户').slice(0, 1).toUpperCase() }}</span>
|
||||
<span class="foot-name">{{ session?.username }}</span>
|
||||
<button class="icon-btn" title="退出登录" @click="doLogout">
|
||||
<Icon name="export" :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
@@ -277,9 +293,7 @@ onMounted(async () => {
|
||||
<span class="dot" style="background: var(--warn)"></span>
|
||||
填写本周周记
|
||||
</button>
|
||||
<span class="user-chip num mobile-hide">{{ session?.username }}</span>
|
||||
<button class="btn btn-sm mobile-hide" @click="doLogout">退出</button>
|
||||
<button class="btn btn-primary btn-sm" :disabled="clockDone" @click="doClock">
|
||||
<button class="btn btn-primary" :disabled="clockDone" @click="doClock">
|
||||
<Icon :name="clockDone ? 'check-circle' : 'clock'" :size="14" />
|
||||
<span class="num">{{ clockLabel }}</span>
|
||||
</button>
|
||||
@@ -289,6 +303,7 @@ onMounted(async () => {
|
||||
<div class="content-inner">
|
||||
<CalendarView v-if="route === 'calendar'" />
|
||||
<TodayView v-else-if="route === 'today'" />
|
||||
<PlanView v-else-if="route === 'plan'" />
|
||||
<StatsView v-else-if="route === 'stats'" />
|
||||
<ReportView v-else-if="route === 'report'" />
|
||||
<LedgerView v-else-if="route === 'ledger'" />
|
||||
@@ -365,6 +380,8 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 全局计时时钟面板:有计划在计时时悬浮显示 -->
|
||||
<TimerPanel v-if="session" />
|
||||
<div class="toast-wrap">
|
||||
<transition-group name="toast">
|
||||
<div v-for="t in toasts" :key="t.id" class="toast">{{ t.text }}</div>
|
||||
@@ -372,40 +389,73 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app { display: flex; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.app { display: flex; height: 100vh; height: 100dvh; overflow: hidden; background: var(--bg-app); }
|
||||
|
||||
/* ---------- 侧栏 ---------- */
|
||||
.side {
|
||||
width: 56px;
|
||||
width: var(--side-w);
|
||||
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);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.brand {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
height: var(--topbar-h); flex: none;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 26px; height: 26px; flex: none;
|
||||
border-radius: 7px;
|
||||
background: var(--accent);
|
||||
color: #FFFFFF;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.brand-name { font-size: 14px; font-weight: 600; letter-spacing: 0.3px; white-space: nowrap; }
|
||||
.side-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.nav-group {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
padding: 12px 8px 4px;
|
||||
letter-spacing: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-3);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
height: 36px; padding: 0 10px;
|
||||
border: none; background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.nav-btn:hover { background: var(--bg-hover); color: var(--text-1); }
|
||||
.nav-btn.active { background: var(--accent-bg); color: var(--accent); }
|
||||
.nav-btn.active { background: var(--accent-bg); color: var(--accent); font-weight: 500; }
|
||||
.nav-btn.active::before {
|
||||
content: '';
|
||||
position: absolute; left: 0; top: 8px; bottom: 8px;
|
||||
width: 3px; border-radius: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
.nav-label { white-space: nowrap; }
|
||||
.nav-btn::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
left: 46px;
|
||||
left: 50px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: var(--text-1);
|
||||
@@ -418,29 +468,57 @@ onMounted(async () => {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 40;
|
||||
}
|
||||
.side-foot {
|
||||
flex: none;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.avatar {
|
||||
width: 28px; height: 28px; flex: none;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 13px; font-weight: 600;
|
||||
}
|
||||
.foot-name {
|
||||
flex: 1; min-width: 0;
|
||||
font-size: 13px; color: var(--text-2);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.nav-btn:hover::after { opacity: 1; }
|
||||
|
||||
/* ---------- 主区 ---------- */
|
||||
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.topbar {
|
||||
height: 56px;
|
||||
height: var(--topbar-h);
|
||||
flex: none;
|
||||
background: var(--bg-page);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
z-index: 10;
|
||||
}
|
||||
.title { font-size: 14px; font-weight: 600; }
|
||||
.title { font-size: 15px; font-weight: 600; white-space: nowrap; }
|
||||
|
||||
.topbar-actions { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.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; }
|
||||
.gsearch { position: relative; width: 320px; max-width: 38vw; }
|
||||
.gs-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--text-3); }
|
||||
.gs-input {
|
||||
height: 32px; padding-left: 31px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-subtle);
|
||||
border-color: transparent;
|
||||
}
|
||||
.gs-input:focus { background: var(--bg-page); border-color: var(--accent); }
|
||||
.gs-panel {
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
top: 38px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-page);
|
||||
@@ -457,7 +535,7 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
padding: 7px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -474,12 +552,6 @@ onMounted(async () => {
|
||||
.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;
|
||||
@@ -488,15 +560,16 @@ onMounted(async () => {
|
||||
color: var(--warn);
|
||||
background: var(--bg-page);
|
||||
border-radius: 999px;
|
||||
height: 26px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.week-tip:hover { background: var(--bg-subtle); }
|
||||
.week-tip:hover { background: #FDF4EC; }
|
||||
|
||||
.content { flex: 1; overflow: auto; }
|
||||
.content-inner { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
.content-inner { max-width: 1280px; margin: 0 auto; padding: 20px 24px 48px; }
|
||||
|
||||
.toast-wrap {
|
||||
position: fixed;
|
||||
@@ -521,11 +594,11 @@ onMounted(async () => {
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(-4px); }
|
||||
.toast-enter-active, .toast-leave-active { transition: all 0.15s ease; }
|
||||
|
||||
/* ---- 新增的手机端组件样式(桌面端隐藏) ---- */
|
||||
/* ---------- 手机端组件 ---------- */
|
||||
.mob-search-btn {
|
||||
display: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -534,8 +607,6 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.mobile-hide { display: initial; }
|
||||
|
||||
.tabbar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
@@ -549,7 +620,7 @@ onMounted(async () => {
|
||||
}
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
height: calc(52px - 6px);
|
||||
height: 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -561,13 +632,13 @@ onMounted(async () => {
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
.tab-btn.active { color: var(--accent); }
|
||||
.tab-btn.active { color: var(--accent); font-weight: 500; }
|
||||
|
||||
.sheet-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 70;
|
||||
background: rgba(29, 33, 41, 0.35);
|
||||
background: rgba(29, 33, 41, 0.4);
|
||||
}
|
||||
.more-sheet {
|
||||
position: absolute;
|
||||
@@ -575,7 +646,7 @@ onMounted(async () => {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-page);
|
||||
border-radius: 14px 14px 0 0;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 10px 14px calc(14px + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -607,17 +678,8 @@ onMounted(async () => {
|
||||
.sheet-divider { height: 1px; background: var(--border); margin: 6px 0; }
|
||||
.sheet-danger { color: var(--danger); }
|
||||
|
||||
.msw-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 70;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
.msw {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.msw-mask { position: fixed; inset: 0; z-index: 70; background: var(--bg-page); }
|
||||
.msw { height: 100%; display: flex; flex-direction: column; }
|
||||
.msw-bar {
|
||||
height: 52px;
|
||||
flex: none;
|
||||
@@ -652,8 +714,19 @@ onMounted(async () => {
|
||||
}
|
||||
.msw-item:hover, .msw-item:active { background: var(--bg-hover); }
|
||||
|
||||
/* ---------- 平板(721-1000px):侧栏折叠为图标 ---------- */
|
||||
@media (max-width: 1000px) and (min-width: 721px) {
|
||||
.side { width: var(--side-w-collapsed); }
|
||||
.brand { justify-content: center; padding: 0; }
|
||||
.brand-name, .nav-label, .nav-group { display: none; }
|
||||
.nav-btn { justify-content: center; padding: 0; width: 40px; align-self: center; }
|
||||
.nav-btn.active::before { display: none; }
|
||||
.side-foot { flex-direction: column; gap: 6px; }
|
||||
.foot-name { display: none; }
|
||||
}
|
||||
|
||||
/* ---------- 手机端(<=720px) ---------- */
|
||||
@media (max-width: 720px) {
|
||||
/* 布局骨架:顶栏一行 + 内容 + 底部标签栏 */
|
||||
.app { display: block; height: 100dvh; }
|
||||
.side { display: none; }
|
||||
.main { display: flex; flex-direction: column; height: 100dvh; min-width: 0; }
|
||||
@@ -669,29 +742,17 @@ onMounted(async () => {
|
||||
.gsearch .gs-panel { display: none; }
|
||||
.mob-search-btn { display: flex; }
|
||||
.topbar-actions { margin-left: auto; gap: 6px; }
|
||||
.mobile-hide { display: none !important; }
|
||||
.week-tip { height: 28px; max-width: 118px; padding: 0 8px; white-space: nowrap; overflow: hidden; }
|
||||
.topbar-actions .btn { padding: 0 10px; }
|
||||
.topbar-actions .btn { padding: 0 10px; height: 32px; }
|
||||
.topbar-actions .btn .num { font-size: 12px; }
|
||||
|
||||
.tabbar { display: flex; }
|
||||
.content { flex: 1; min-height: 0; }
|
||||
.content-inner {
|
||||
max-width: none;
|
||||
padding: 12px 12px calc(66px + env(safe-area-inset-bottom));
|
||||
padding: 12px 12px calc(70px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* 卡片与表单:统一左右贴边、内距收紧 */
|
||||
.card { border-radius: var(--radius-md); }
|
||||
input.input, select.select, textarea.textarea { width: 100%; box-sizing: border-box; }
|
||||
.btn { touch-action: manipulation; }
|
||||
|
||||
/* 打卡按钮更醒目 */
|
||||
.topbar-actions .btn-primary {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* 提示信息对齐左上,避免遮挡底部标签栏 */
|
||||
.toast-wrap {
|
||||
top: 6px;
|
||||
right: 10px;
|
||||
|
||||
@@ -41,29 +41,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
</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 { width: 380px; }
|
||||
.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; }
|
||||
.dialog-btns { margin-top: 20px; }
|
||||
</style>
|
||||
|
||||
@@ -112,44 +112,7 @@ function save() {
|
||||
</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; }
|
||||
.dialog { width: 480px; }
|
||||
.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,154 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { listRunningPlan, stopPlanTimer, type PlanItem } from '../db/repo'
|
||||
import { planTimerPulse } from '../lib/bus'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
||||
// 全局计时时钟面板:任何页面都能看到正在计时的计划,实时走秒、一键停止、点击跳转计划页
|
||||
const plan = ref<PlanItem | null>(null)
|
||||
const nowMs = ref(Date.now())
|
||||
let poll: number | null = null
|
||||
let tick: number | null = null
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
plan.value = await listRunningPlan()
|
||||
} catch { /* 库未就绪时静默,下轮轮询重试 */ }
|
||||
}
|
||||
|
||||
const elapsedSec = computed(() => {
|
||||
const p = plan.value
|
||||
if (!p || !p.timer_started_at) return 0
|
||||
const base = (p.actual_minutes || 0) * 60
|
||||
const extra = Math.max(0, Math.floor((nowMs.value - new Date(p.timer_started_at).getTime()) / 1000))
|
||||
return base + extra
|
||||
})
|
||||
const elapsedText = computed(() => {
|
||||
const s = elapsedSec.value
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const ss = s % 60
|
||||
const p2 = (n: number) => String(n).padStart(2, '0')
|
||||
return h > 0 ? h + ':' + p2(m) + ':' + p2(ss) : m + ':' + p2(ss)
|
||||
})
|
||||
|
||||
async function stop() {
|
||||
const p = plan.value
|
||||
if (!p) return
|
||||
await stopPlanTimer(p.id as number)
|
||||
showToast('已停止计时:' + p.title)
|
||||
await refresh()
|
||||
}
|
||||
function goPlan() {
|
||||
location.hash = '#/plan'
|
||||
}
|
||||
|
||||
watch(planTimerPulse, () => { void refresh() })
|
||||
onMounted(() => {
|
||||
void refresh()
|
||||
poll = window.setInterval(() => { void refresh() }, 15000)
|
||||
tick = window.setInterval(() => { nowMs.value = Date.now() }, 1000)
|
||||
window.addEventListener('focus', refresh)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (poll !== null) window.clearInterval(poll)
|
||||
if (tick !== null) window.clearInterval(tick)
|
||||
window.removeEventListener('focus', refresh)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="tp">
|
||||
<div v-if="plan && plan.timer_started_at" class="tp" @click="goPlan" title="点击查看计划页">
|
||||
<span class="tp-pulse"></span>
|
||||
<span class="tp-main">
|
||||
<span class="tp-title">{{ plan.title }}</span>
|
||||
<span class="tp-meta">{{ plan.kind }} · 计时中</span>
|
||||
</span>
|
||||
<span class="tp-time num">{{ elapsedText }}</span>
|
||||
<span class="tp-stop" role="button" title="停止计时" @click.stop="stop">停止</span>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tp {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 340px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-float);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.tp:hover { border-color: var(--border-strong); }
|
||||
.tp-pulse {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
position: relative;
|
||||
flex: none;
|
||||
}
|
||||
.tp-pulse::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--accent);
|
||||
animation: tp-ring 1.6s ease-out infinite;
|
||||
}
|
||||
@keyframes tp-ring {
|
||||
0% { transform: scale(0.5); opacity: 0.6; }
|
||||
100% { transform: scale(1.5); opacity: 0; }
|
||||
}
|
||||
.tp-main { display: flex; flex-direction: column; min-width: 0; }
|
||||
.tp-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tp-meta { font-size: 11px; color: var(--text-3); }
|
||||
.tp-time {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: 2px;
|
||||
flex: none;
|
||||
}
|
||||
.tp-stop {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 10px;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.tp-stop:hover { color: var(--danger); border-color: var(--danger); background: #FDF0F0; }
|
||||
.tp-enter-active, .tp-leave-active { transition: all 0.2s ease; }
|
||||
.tp-enter-from, .tp-leave-to { opacity: 0; transform: translateY(10px); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.tp {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(66px + env(safe-area-inset-bottom));
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '../db/schema'
|
||||
import { getLedgerCategories, setLedgerCategories } from '../db/categories'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -15,7 +15,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const fType = ref<'expense' | 'income'>('expense')
|
||||
const fCategory = ref('餐饮')
|
||||
const fCategory = ref('')
|
||||
const fAmount = ref('')
|
||||
const fDate = ref('')
|
||||
const fNote = ref('')
|
||||
@@ -30,6 +30,7 @@ function onKey(e: KeyboardEvent) {
|
||||
}
|
||||
watch(() => props.open, async v => {
|
||||
if (v) {
|
||||
await loadCats()
|
||||
fType.value = (props.initial.type === 'income' ? 'income' : 'expense')
|
||||
fCategory.value = props.initial.category
|
||||
fAmount.value = props.initial.amountYuan
|
||||
@@ -47,11 +48,26 @@ watch(() => props.open, async v => {
|
||||
})
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
|
||||
watch(fType, t => {
|
||||
fCategory.value = t === 'expense' ? EXPENSE_CATEGORIES[0] : INCOME_CATEGORIES[0]
|
||||
// 切换收支类型后:重新拉取对应分类列表,并回落到第一项
|
||||
watch(fType, async () => {
|
||||
await loadCats()
|
||||
fCategory.value = categoryOptions.value[0] || ''
|
||||
})
|
||||
|
||||
const categoryOptions = computed(() => (fType.value === 'expense' ? EXPENSE_CATEGORIES : INCOME_CATEGORIES))
|
||||
const categoryOptions = ref<string[]>([])
|
||||
async function loadCats() {
|
||||
categoryOptions.value = await getLedgerCategories(fType.value)
|
||||
}
|
||||
const newCat = ref('')
|
||||
async function addCategory() {
|
||||
const name = newCat.value.trim()
|
||||
if (!name) return
|
||||
if (categoryOptions.value.includes(name)) { newCat.value = ''; return }
|
||||
categoryOptions.value.push(name)
|
||||
await setLedgerCategories(fType.value, categoryOptions.value)
|
||||
fCategory.value = name
|
||||
newCat.value = ''
|
||||
}
|
||||
function computedCategory() {
|
||||
return (fCategory.value && categoryOptions.value.includes(fCategory.value)) ? fCategory.value : categoryOptions.value[0]
|
||||
}
|
||||
@@ -109,10 +125,14 @@ function save() {
|
||||
<input class="input" type="date" v-model="fDate" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>分类</label>
|
||||
<label>分类(可直接新增)</label>
|
||||
<select class="select" v-model="fCategory">
|
||||
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<div class="add-cat">
|
||||
<input class="input add-cat-input" v-model="newCat" placeholder="新增分类名" @keydown.enter.prevent="addCategory" />
|
||||
<button class="btn btn-xs" type="button" @click="addCategory">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" v-if="fType === 'expense'">
|
||||
<label>报销状态</label>
|
||||
@@ -140,55 +160,7 @@ function save() {
|
||||
</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; }
|
||||
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.seg-btn {
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
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); }
|
||||
.form-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
|
||||
.input.invalid, .input.invalid:focus { border-color: var(--danger); }
|
||||
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
|
||||
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
||||
.dialog { width: 480px; }
|
||||
.add-cat { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.add-cat-input { flex: 1; height: 28px; font-size: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -24,5 +24,6 @@ export const ICONS: Record<string, string> = {
|
||||
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"/>'),
|
||||
wallet: svg('<rect x="3.5" y="6" width="17" height="12.5" rx="2"/><path d="M3.5 10h17"/><circle cx="16.4" cy="14.2" r="1.1"/>'),
|
||||
more: svg('<circle cx="5.4" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.6" cy="12" r="1.7"/>'),
|
||||
shield: svg('<path d="M12 3.5l7.2 2.7v5.1c0 4.7-3.1 7.8-7.2 9.4-4.1-1.6-7.2-4.7-7.2-9.4V6.2z"/><path d="M9.3 12.2l1.9 1.9 3.6-4"/>')
|
||||
shield: svg('<path d="M12 3.5l7.2 2.7v5.1c0 4.7-3.1 7.8-7.2 9.4-4.1-1.6-7.2-4.7-7.2-9.4V6.2z"/><path d="M9.3 12.2l1.9 1.9 3.6-4"/>'),
|
||||
plan: svg('<rect x="4.5" y="4" width="15" height="16.5" rx="2"/><path d="M8.5 9.5l1.4 1.4 2.6-2.9M8.5 15l1.4 1.4 2.6-2.9M15 9.8h.01M15 15.3h.01"/>')
|
||||
}
|
||||
|
||||
@@ -8,13 +8,16 @@ import {
|
||||
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 { categoryLabel, type Attendance, type LogItem, type Project } from '../../db/schema'
|
||||
import { getLogCategories } from '../../db/categories'
|
||||
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 logCats = ref<string[]>([])
|
||||
getLogCategories().then(l => { logCats.value = l })
|
||||
const props = defineProps<{ date: string }>()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -158,60 +161,18 @@ async function saveMakeupForm() {
|
||||
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
|
||||
}
|
||||
|
||||
// ---- 条目操作 ----
|
||||
// 弹窗开关与编辑目标分离:添加模式 editTarget 为 null,但弹窗也要打开
|
||||
const editTarget = ref<LogItem | null>(null)
|
||||
const dialogOpen = ref(false)
|
||||
function openEdit(it: LogItem) {
|
||||
editTarget.value = it
|
||||
dialogOpen.value = true
|
||||
}
|
||||
function openAdd() {
|
||||
editTarget.value = null
|
||||
dialogOpen.value = true
|
||||
}
|
||||
async function onEditSave(payload: {
|
||||
id: number
|
||||
@@ -224,21 +185,23 @@ async function onEditSave(payload: {
|
||||
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
|
||||
if (payload.id) {
|
||||
await updateLogItem(payload.id, {
|
||||
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
|
||||
})
|
||||
showToast('记录已更新')
|
||||
} else {
|
||||
await addLogItem(props.date, {
|
||||
content: payload.content, project_id: payload.project_id, category: payload.category,
|
||||
minutes: payload.minutes, takeaway: payload.takeaway, remark: payload.remark
|
||||
})
|
||||
showToast('记录已添加')
|
||||
}
|
||||
await updateLogItem(id, patch)
|
||||
editTarget.value = null
|
||||
dialogOpen.value = false
|
||||
await load()
|
||||
showToast('修改已保存')
|
||||
}
|
||||
async function toggleMilestone(it: LogItem) {
|
||||
await updateLogItem(it.id as number, { is_milestone: it.is_milestone ? 0 : 1 })
|
||||
@@ -378,54 +341,11 @@ async function confirmDelete() {
|
||||
</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 class="sec-head">
|
||||
<span class="sec-title">工作记录({{ items.length }})</span>
|
||||
<button class="btn btn-primary btn-sm" @click="openAdd"><Icon name="plus" :size="14" /> 添加记录</button>
|
||||
</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">
|
||||
@@ -483,11 +403,11 @@ async function confirmDelete() {
|
||||
/>
|
||||
|
||||
<EditLogDialog
|
||||
:open="!!editTarget"
|
||||
:open="dialogOpen"
|
||||
:item="editTarget"
|
||||
:projects="projectsAll"
|
||||
:date="date"
|
||||
@cancel="editTarget = null"
|
||||
@cancel="dialogOpen = false"
|
||||
@save="onEditSave"
|
||||
/>
|
||||
</div>
|
||||
@@ -497,34 +417,32 @@ async function confirmDelete() {
|
||||
.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-cell 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; }
|
||||
.att-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
|
||||
.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); }
|
||||
.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
|
||||
.sec-head .sec-title { margin-bottom: 0; }
|
||||
.item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 12px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--bg-subtle);
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
}
|
||||
.item:hover { border-color: var(--border-strong); 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; }
|
||||
@@ -532,11 +450,9 @@ async function confirmDelete() {
|
||||
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; }
|
||||
.att-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<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'
|
||||
import { type LogItem, type Project } from '../../db/schema'
|
||||
import { getLogCategories } from '../../db/categories'
|
||||
|
||||
const logCats = ref<string[]>([])
|
||||
getLogCategories().then(l => { logCats.value = l })
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
item: LogItem | null
|
||||
@@ -86,7 +89,7 @@ function save() {
|
||||
return
|
||||
}
|
||||
if (!props.item || !props.item.id) {
|
||||
emit('cancel')
|
||||
emit('save', { id: 0, 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, needs_followup: formFollowup.value })
|
||||
return
|
||||
}
|
||||
emit('save', {
|
||||
@@ -110,7 +113,7 @@ function save() {
|
||||
<div class="dialog" role="dialog" aria-label="编辑工作记录">
|
||||
<div class="dialog-head">
|
||||
<div>
|
||||
<div class="dialog-title">编辑工作记录</div>
|
||||
<div class="dialog-title">{{ item ? '编辑工作记录' : '添加工作记录' }}</div>
|
||||
<div v-if="date" class="muted dialog-sub">{{ date }}</div>
|
||||
</div>
|
||||
<button class="icon-btn" title="关闭" @click="emit('cancel')">
|
||||
@@ -137,7 +140,7 @@ function save() {
|
||||
<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>
|
||||
<option v-for="c in logCats" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -170,7 +173,7 @@ function save() {
|
||||
</div>
|
||||
<div class="dialog-btns">
|
||||
<button class="btn btn-sm" @click="emit('cancel')">取消</button>
|
||||
<button class="btn btn-primary btn-sm" @click="save">保存修改</button>
|
||||
<button class="btn btn-primary btn-sm" @click="save">{{ item ? '保存修改' : '添加' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,65 +182,5 @@ function save() {
|
||||
</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; }
|
||||
.dialog { width: 560px; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// 分类管理:工作分类与记账分类均为用户数据(存于服务器随账号同步)
|
||||
// 未自定义时返回默认值;分类值即显示文本(历史遗留 key 由 categoryLabel 直通转换)
|
||||
import { getSetting, setSetting } from './repo'
|
||||
|
||||
export const DEFAULT_LOG_CATEGORIES: string[] = ['制度学习', '产品学习', '打杂', '项目实操', '售后服务']
|
||||
export const DEFAULT_EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他']
|
||||
export const DEFAULT_INCOME_CATEGORIES: string[] = ['工资', '报销', '其他']
|
||||
|
||||
const LOG_KEY = 'log_categories'
|
||||
const EXPENSE_KEY = 'ledger_expense_categories'
|
||||
const INCOME_KEY = 'ledger_income_categories'
|
||||
|
||||
function parse(raw: string, fallback: string[]): string[] {
|
||||
if (!raw) return fallback.slice()
|
||||
try {
|
||||
const arr = JSON.parse(raw)
|
||||
if (Array.isArray(arr)) {
|
||||
const clean = arr.filter((x: unknown) => typeof x === 'string' && x.trim()).map((x: string) => x.trim())
|
||||
if (clean.length) return Array.from(new Set(clean))
|
||||
}
|
||||
} catch { /* 损坏数据回退默认 */ }
|
||||
return fallback.slice()
|
||||
}
|
||||
|
||||
export async function getLogCategories(): Promise<string[]> {
|
||||
return parse(await getSetting(LOG_KEY, ''), DEFAULT_LOG_CATEGORIES)
|
||||
}
|
||||
|
||||
export async function setLogCategories(list: string[]): Promise<void> {
|
||||
await setSetting(LOG_KEY, JSON.stringify(list))
|
||||
}
|
||||
|
||||
export async function getLedgerCategories(type: 'expense' | 'income'): Promise<string[]> {
|
||||
return parse(await getSetting(type === 'expense' ? EXPENSE_KEY : INCOME_KEY, ''), type === 'expense' ? DEFAULT_EXPENSE_CATEGORIES : DEFAULT_INCOME_CATEGORIES)
|
||||
}
|
||||
|
||||
export async function setLedgerCategories(type: 'expense' | 'income', list: string[]): Promise<void> {
|
||||
await setSetting(type === 'expense' ? EXPENSE_KEY : INCOME_KEY, JSON.stringify(list))
|
||||
}
|
||||
+203
-6
@@ -1,7 +1,10 @@
|
||||
// 数据仓库:全部业务数据只有服务器一份权威副本,本地 Dexie 是实时缓存。
|
||||
// 约定:每个写函数修改本地缓存后立即调用 commit() —— 推送差异给服务器,
|
||||
// 并以服务器返回的权威快照重建缓存;断网时抛出错误(纯在线模式)。
|
||||
import { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type Project, type Transaction, type WeeklyNote, type WorkLog } from './schema'
|
||||
import { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type Goal, type PlanItem, type Project, type Transaction, type WeeklyNote, type WorkLog } from './schema'
|
||||
|
||||
// 视图直接从 repo 导入这些类型:在此统一再导出
|
||||
export type { Attendance, Credential, LogItem, Memo, Goal, PlanItem, Project, Transaction, WeeklyNote, WorkLog }
|
||||
import { addDays, nowLocalISO, toDateStr } from '../lib/date'
|
||||
import { flushAfterWrite } from '../sync/engine'
|
||||
|
||||
@@ -360,6 +363,192 @@ export async function listWeeklyNotesBetween(start: string, end: string): Promis
|
||||
return db.weekly_note.where('week_start').between(start, end, true, true).toArray()
|
||||
}
|
||||
|
||||
// ---------- 计划模式 ----------
|
||||
|
||||
export interface PlanInput {
|
||||
date: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
title: string
|
||||
kind: string
|
||||
note: string
|
||||
}
|
||||
|
||||
export async function listPlansByDate(date: string): Promise<PlanItem[]> {
|
||||
const rows = await db.plan.where('date').equals(date).toArray()
|
||||
return rows.sort((a, b) => (a.start_time < b.start_time ? -1 : 1))
|
||||
}
|
||||
|
||||
// 全局计时时钟面板:查找正在进行中的计时任务(跨日期,任意一天开始的都算)
|
||||
export async function listRunningPlan(): Promise<PlanItem | null> {
|
||||
const rows = await db.plan.filter(p => !!p.timer_started_at).toArray()
|
||||
return rows.length ? rows[0] : null
|
||||
}
|
||||
|
||||
export async function addPlan(input: PlanInput): Promise<void> {
|
||||
const now = nowLocalISO()
|
||||
await db.plan.add({
|
||||
date: input.date,
|
||||
start_time: input.start_time,
|
||||
end_time: input.end_time,
|
||||
title: input.title.trim(),
|
||||
kind: input.kind.trim() || '其他',
|
||||
note: input.note || null,
|
||||
status: 'todo',
|
||||
actual_minutes: 0,
|
||||
timer_started_at: null,
|
||||
uuid: '',
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
} as never)
|
||||
await commit()
|
||||
}
|
||||
|
||||
export async function updatePlan(id: number, patch: Partial<PlanItem>): Promise<void> {
|
||||
await db.plan.update(id, { ...patch, updated_at: nowLocalISO() })
|
||||
await commit()
|
||||
}
|
||||
|
||||
export async function deletePlan(id: number): Promise<void> {
|
||||
await db.plan.delete(id)
|
||||
await commit()
|
||||
}
|
||||
|
||||
// 打卡:完成 / 跳过 / 恢复
|
||||
export async function setPlanStatus(id: number, status: 'todo' | 'done' | 'skip'): Promise<void> {
|
||||
const row = await db.plan.get(id)
|
||||
if (!row) return
|
||||
if (status !== 'todo' && row.timer_started_at) {
|
||||
await stopPlanTimer(id)
|
||||
}
|
||||
await db.plan.update(id, { status, updated_at: nowLocalISO() })
|
||||
await commit()
|
||||
}
|
||||
|
||||
// 计时时钟:同一时刻只允许一个条目在计时,开始新计时会自动停止上一个
|
||||
export async function startPlanTimer(id: number): Promise<void> {
|
||||
const running = await db.plan.filter(p => !!p.timer_started_at).toArray()
|
||||
for (const p of running) {
|
||||
if (p.id !== id) await stopPlanTimer(p.id as number)
|
||||
}
|
||||
await db.plan.update(id, { timer_started_at: nowLocalISO(), updated_at: nowLocalISO() })
|
||||
await commit()
|
||||
}
|
||||
|
||||
export async function stopPlanTimer(id: number): Promise<void> {
|
||||
const row = await db.plan.get(id)
|
||||
if (!row || !row.timer_started_at) return
|
||||
const elapsed = Math.max(0, Math.round((Date.now() - new Date(row.timer_started_at).getTime()) / 60000))
|
||||
await db.plan.update(id, {
|
||||
actual_minutes: (row.actual_minutes || 0) + elapsed,
|
||||
timer_started_at: null,
|
||||
updated_at: nowLocalISO()
|
||||
})
|
||||
await commit()
|
||||
}
|
||||
|
||||
// ---------- 长期计划(目标) ----------
|
||||
|
||||
export interface GoalInput {
|
||||
title: string
|
||||
kind: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
note: string
|
||||
}
|
||||
|
||||
const MAX_MATERIALIZE_DAYS = 366
|
||||
|
||||
function eachDate(start: string, end: string): string[] {
|
||||
const out: string[] = []
|
||||
let cur = start
|
||||
let guard = 0
|
||||
while (cur <= end && guard < MAX_MATERIALIZE_DAYS) {
|
||||
out.push(cur)
|
||||
cur = addDays(cur, 1)
|
||||
guard++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function getGoalProgress(): Promise<{ goalId: number; done: number; total: number }[]> {
|
||||
const goals = await db.goal.toArray()
|
||||
const out: { goalId: number; done: number; total: number }[] = []
|
||||
for (const g of goals) {
|
||||
const items = await db.plan.where('goal_id').equals(g.id as number).toArray()
|
||||
out.push({ goalId: g.id as number, done: items.filter(i => i.status === 'done').length, total: items.length })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function listGoals(): Promise<Goal[]> {
|
||||
const rows = await db.goal.toArray()
|
||||
return rows.sort((a, b) => (a.start_date < b.start_date ? -1 : 1))
|
||||
}
|
||||
|
||||
// 新建长期计划并物化每一天的任务(落实每日打卡与计时)
|
||||
export async function addGoal(input: GoalInput): Promise<void> {
|
||||
const now = nowLocalISO()
|
||||
const gid = await db.goal.add({
|
||||
title: input.title.trim(),
|
||||
kind: input.kind.trim() || '其他',
|
||||
start_date: input.start_date,
|
||||
end_date: input.end_date,
|
||||
note: input.note || null,
|
||||
uuid: '',
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
} as never)
|
||||
const goal = await db.goal.get(gid)
|
||||
const dates = eachDate(input.start_date, input.end_date)
|
||||
for (const d of dates) {
|
||||
await db.plan.add({
|
||||
date: d,
|
||||
start_time: '08:00',
|
||||
end_time: '09:00',
|
||||
title: input.title.trim(),
|
||||
kind: input.kind.trim() || '其他',
|
||||
note: input.note || null,
|
||||
status: 'todo',
|
||||
actual_minutes: 0,
|
||||
timer_started_at: null,
|
||||
goal_id: gid,
|
||||
uuid: '',
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
} as never)
|
||||
}
|
||||
void goal
|
||||
await commit()
|
||||
}
|
||||
|
||||
// 编辑目标:标题/类别级联到其每日任务(已完成项不改动)
|
||||
export async function updateGoal(id: number, patch: Partial<GoalInput>): Promise<void> {
|
||||
const goal = await db.goal.get(id)
|
||||
if (!goal) return
|
||||
const next = { ...patch, updated_at: nowLocalISO() }
|
||||
await db.goal.update(id, next)
|
||||
const items = await db.plan.where('goal_id').equals(id).toArray()
|
||||
for (const it of items) {
|
||||
if (it.status === 'done') continue
|
||||
await db.plan.update(it.id as number, {
|
||||
...(patch.title !== undefined ? { title: patch.title.trim() } : {}),
|
||||
...(patch.kind !== undefined ? { kind: patch.kind } : {}),
|
||||
...(patch.note !== undefined ? { note: patch.note || null } : {}),
|
||||
updated_at: nowLocalISO()
|
||||
})
|
||||
}
|
||||
await commit()
|
||||
}
|
||||
|
||||
// 删除目标:连同其每日任务一起删除(前端二次确认)
|
||||
export async function deleteGoal(id: number): Promise<void> {
|
||||
const items = await db.plan.where('goal_id').equals(id).toArray()
|
||||
for (const it of items) await db.plan.delete(it.id as number)
|
||||
await db.goal.delete(id)
|
||||
await commit()
|
||||
}
|
||||
|
||||
// ---------- 备忘录 ----------
|
||||
|
||||
export async function listMemos(): Promise<Memo[]> {
|
||||
@@ -457,7 +646,9 @@ export async function exportAll(): Promise<string> {
|
||||
db.setting.toArray(),
|
||||
db.memo.toArray(),
|
||||
db.credential.toArray(),
|
||||
db.table('transaction').toArray()
|
||||
db.table('transaction').toArray(),
|
||||
db.plan.toArray(),
|
||||
db.goal.toArray()
|
||||
])
|
||||
return JSON.stringify({
|
||||
schema_version: SCHEMA_VERSION,
|
||||
@@ -471,7 +662,9 @@ export async function exportAll(): Promise<string> {
|
||||
setting: tables[5],
|
||||
memo: tables[6],
|
||||
credential: tables[7],
|
||||
transaction: tables[8]
|
||||
transaction: tables[8],
|
||||
plan: tables[9],
|
||||
goal: tables[10]
|
||||
}
|
||||
}, null, 2)
|
||||
}
|
||||
@@ -483,17 +676,19 @@ export async function importAll(jsonText: string): Promise<void> {
|
||||
} catch {
|
||||
throw new Error('不是有效的 JSON 文件')
|
||||
}
|
||||
if (!parsed || (parsed.schema_version !== 1 && parsed.schema_version !== 2 && parsed.schema_version !== 3) || !parsed.data) {
|
||||
if (!parsed || (parsed.schema_version !== 1 && parsed.schema_version !== 2 && parsed.schema_version !== 3 && parsed.schema_version !== 4 && parsed.schema_version !== 5) || !parsed.data) {
|
||||
throw new Error('文件格式或 schema 版本不匹配')
|
||||
}
|
||||
const d = parsed.data
|
||||
const dl = db.table('log_item')
|
||||
const dt = db.table('transaction')
|
||||
await db.transaction('rw', [db.project, db.work_log, dl, db.attendance, db.weekly_note, db.setting, db.memo, db.credential, dt], async () => {
|
||||
const dp = db.plan
|
||||
const dg = db.goal
|
||||
await db.transaction('rw', [db.project, db.work_log, dl, db.attendance, db.weekly_note, db.setting, db.memo, db.credential, dt, dg, dp], async () => {
|
||||
await Promise.all([
|
||||
db.project.clear(), db.work_log.clear(), dl.clear(),
|
||||
db.attendance.clear(), db.weekly_note.clear(), db.setting.clear(),
|
||||
db.memo.clear(), db.credential.clear(), dt.clear()
|
||||
db.memo.clear(), db.credential.clear(), dt.clear(), dg.clear(), dp.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[])
|
||||
@@ -504,6 +699,8 @@ export async function importAll(jsonText: string): Promise<void> {
|
||||
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[])
|
||||
if (d.transaction && d.transaction.length) await dt.bulkAdd(d.transaction as never[])
|
||||
if (d.goal && d.goal.length) await dg.bulkAdd(d.goal as never[])
|
||||
if (d.plan && d.plan.length) await dp.bulkAdd(d.plan as never[])
|
||||
})
|
||||
// 导入即推送:以导入内容覆盖服务器(缺失行自动生成墓碑清除),随后以快照回读
|
||||
await commit()
|
||||
|
||||
+65
-12
@@ -48,6 +48,35 @@ export interface WeeklyNote {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Goal {
|
||||
id?: number
|
||||
title: string // 长期计划,如:学完产品文档 / 本月健身 20 次
|
||||
kind: string
|
||||
start_date: string // YYYY-MM-DD
|
||||
end_date: string
|
||||
note: string | null
|
||||
uuid: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PlanItem {
|
||||
id?: number
|
||||
date: string // YYYY-MM-DD
|
||||
start_time: string // HH:mm
|
||||
end_time: string // HH:mm
|
||||
title: string // 计划内容,如:产品学习 / 健身
|
||||
kind: string // 类别:学习 / 健身 / 自律 / 其他自定义
|
||||
note: string | null
|
||||
status: string // todo | done | skip
|
||||
actual_minutes: number // 计时时钟累计的实际投入分钟
|
||||
timer_started_at: string | null
|
||||
goal_id: number | null // 来源长期计划(本地 id)
|
||||
uuid: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Memo {
|
||||
id?: number
|
||||
title: string
|
||||
@@ -107,6 +136,8 @@ export class WorklogDB extends Dexie {
|
||||
// 因为 db.transaction 会与 Dexie 内置的 transaction() 方法冲突
|
||||
sync_mirror!: Table<SyncMirror, number>
|
||||
tombstone!: Table<SyncTombstone, number>
|
||||
plan!: Table<PlanItem, number>
|
||||
goal!: Table<Goal, number>
|
||||
|
||||
constructor(dbName = 'intern_worklog') {
|
||||
super(dbName)
|
||||
@@ -129,6 +160,27 @@ export class WorklogDB extends Dexie {
|
||||
sync_mirror: '++id, table, uuid',
|
||||
tombstone: '++id, table, uuid, deleted_at'
|
||||
})
|
||||
this.version(5).stores({
|
||||
plan: '++id, date, status'
|
||||
}).upgrade(async tx => {
|
||||
// 旧固定分类 key 迁移为用户自定义分类标签(v0.4.0 起分类为用户数据)
|
||||
const map: Record<string, string> = {
|
||||
dev: '项目实操',
|
||||
test: '项目实操',
|
||||
bugfix: '售后服务',
|
||||
learning: '产品学习',
|
||||
meeting: '打杂',
|
||||
doc: '打杂',
|
||||
other: '打杂'
|
||||
}
|
||||
await tx.table('log_item').toCollection().modify((item: { category?: string }) => {
|
||||
if (item.category && map[item.category]) item.category = map[item.category]
|
||||
})
|
||||
})
|
||||
this.version(6).stores({
|
||||
plan: '++id, date, status, goal_id',
|
||||
goal: '++id, start_date'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,24 +196,25 @@ export function unbindDatabase(): void {
|
||||
db = undefined
|
||||
}
|
||||
|
||||
export const SCHEMA_VERSION = 4
|
||||
export const SCHEMA_VERSION = 6
|
||||
|
||||
export const EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他']
|
||||
export const INCOME_CATEGORIES: string[] = ['工资', '报销', '其他']
|
||||
|
||||
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: '其他' }
|
||||
]
|
||||
// 旧版固定分类 key → 默认标签的映射(仅用于历史数据迁移与展示兜底)
|
||||
export const LEGACY_CATEGORY_LABELS: Record<string, string> = {
|
||||
dev: '项目实操',
|
||||
test: '项目实操',
|
||||
bugfix: '售后服务',
|
||||
learning: '产品学习',
|
||||
meeting: '打杂',
|
||||
doc: '打杂',
|
||||
other: '打杂'
|
||||
}
|
||||
|
||||
// v0.4.0 起分类即用户自定义标签本身;历史 key 在此直通转换
|
||||
export function categoryLabel(key: string): string {
|
||||
const found = CATEGORIES.find(c => c.key === key)
|
||||
return found ? found.label : '其他'
|
||||
return LEGACY_CATEGORY_LABELS[key] || key
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getWorkRule, listAttendanceBetween, listLogItemsBetween, listProjects, listWeeklyNotesBetween } from '../db/repo'
|
||||
import { CATEGORIES, type LogItem } from '../db/schema'
|
||||
import { type LogItem } from '../db/schema'
|
||||
import { getLogCategories } from '../db/categories'
|
||||
import { todayStr } from '../lib/date'
|
||||
import { computeDay } from './attendance'
|
||||
|
||||
@@ -44,13 +45,15 @@ export async function buildReport(start: string, end: string, label: string): Pr
|
||||
listAttendanceBetween(start, end),
|
||||
listLogItemsBetween(start, end),
|
||||
listProjects(true),
|
||||
listWeeklyNotesBetween(start, end)
|
||||
listWeeklyNotesBetween(start, end),
|
||||
getLogCategories()
|
||||
])
|
||||
const rule = results[0]
|
||||
const atts = results[1]
|
||||
const items = results[2]
|
||||
const projects = results[3]
|
||||
const notes = results[4]
|
||||
const logCats: string[] = results[5] as string[]
|
||||
|
||||
let attendDays = 0
|
||||
let workedMin = 0
|
||||
@@ -73,10 +76,14 @@ export async function buildReport(start: string, end: string, label: string): Pr
|
||||
.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) }
|
||||
const catCount2 = new Map<string, number>()
|
||||
for (const it of items) catCount2.set(it.category, (catCount2.get(it.category) || 0) + 1)
|
||||
const catSet2 = new Set(logCats)
|
||||
for (const k of catCount2.keys()) if (!catSet2.has(k)) logCats.push(k)
|
||||
const categoryStats: CategoryStat[] = logCats
|
||||
.map(label => {
|
||||
const count = catCount2.get(label) || 0
|
||||
return { key: label, label, count, percent: Math.round((count / total) * 100) }
|
||||
})
|
||||
.filter(c => c.count > 0)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
@@ -9,3 +9,9 @@ export const attendanceChanged = ref(0)
|
||||
export function notifyAttendanceChanged(): void {
|
||||
attendanceChanged.value++
|
||||
}
|
||||
|
||||
// 计划计时启停信号:全局计时时钟面板监听并即时刷新
|
||||
export const planTimerPulse = ref(0)
|
||||
export function notifyPlanTimer(): void {
|
||||
planTimerPulse.value++
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ export function showFatalError(title: string, detail: string): void {
|
||||
const box = document.createElement('div')
|
||||
box.style.cssText = 'border:1px solid #E5484D;border-radius:8px;padding:12px;margin-bottom:12px;word-break:break-all;white-space:pre-wrap;'
|
||||
box.textContent = title + '\n' + detail
|
||||
// 重试入口:纯在线模式下连不上服务器时(如启动失败),一键重载而不是干等着重启应用
|
||||
const retry = document.createElement('button')
|
||||
retry.textContent = '重新加载'
|
||||
retry.style.cssText = 'margin:-4px 0 12px;padding:6px 16px;border:1px solid #E5484D;border-radius:6px;background:#FFFFFF;color:#E5484D;font-size:13px;cursor:pointer;'
|
||||
retry.addEventListener('click', () => { try { location.reload() } catch { /* 忽略 */ } })
|
||||
el.appendChild(box)
|
||||
el.appendChild(retry)
|
||||
} catch { /* 浮层失败时保持静默 */ }
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
(App.vue 的壳层样式是 scoped,跨组件规则统一放这里,便于集中管理)
|
||||
============================================================ */
|
||||
|
||||
/* 通用的白底卡片在窄屏贴边 */
|
||||
@media (max-width: 720px) {
|
||||
/* 卡片在窄屏贴边、圆角略收 */
|
||||
.card { border-radius: 12px; }
|
||||
|
||||
input.input, select.select, textarea.textarea {
|
||||
@@ -13,18 +13,18 @@
|
||||
}
|
||||
.btn { touch-action: manipulation; }
|
||||
|
||||
/* ---------- 弹窗:窄屏一体布局,覆盖组件内固定宽度 ---------- */
|
||||
/* ---------- 弹窗:窄屏近全屏 ---------- */
|
||||
.dialog {
|
||||
width: calc(100vw - 20px) !important;
|
||||
max-width: calc(100vw - 20px) !important;
|
||||
max-height: 86vh;
|
||||
max-height: 86dvh;
|
||||
overflow: auto;
|
||||
max-height: 88vh;
|
||||
max-height: 88dvh;
|
||||
}
|
||||
.dialog-body { padding: 14px !important; }
|
||||
.form-grid { grid-template-columns: 1fr !important; }
|
||||
.dialog-btns { gap: 8px; }
|
||||
.dialog-head { padding: 14px 16px 10px !important; }
|
||||
.dialog-body { padding: 14px 16px !important; }
|
||||
.dialog-btns { padding: 10px 16px 14px !important; }
|
||||
.dialog-btns .btn { flex: 1; }
|
||||
.form-grid { grid-template-columns: 1fr !important; }
|
||||
|
||||
/* ---------- 日历当日抽屉:改为底部抽屉 ---------- */
|
||||
.drawer {
|
||||
@@ -35,6 +35,7 @@
|
||||
border-left: none !important;
|
||||
border-top: 1px solid var(--border);
|
||||
border-radius: 14px 14px 0 0 !important;
|
||||
box-shadow: 0 -8px 24px rgba(16, 24, 40, 0.10);
|
||||
}
|
||||
.drawer-enter-active, .drawer-leave-active { transition: transform 0.2s ease, opacity 0.2s ease; }
|
||||
.drawer-enter-from, .drawer-leave-to { transform: translateY(28px); opacity: 0; }
|
||||
@@ -66,7 +67,7 @@
|
||||
.tx-table td.empty { justify-content: center; }
|
||||
.tx-table td.empty::before { content: none !important; }
|
||||
|
||||
/* ---------- 密码库等其余宽表:横向滚动,不再挤压折行 ---------- */
|
||||
/* ---------- 密码库等其余宽表:横向滚动 ---------- */
|
||||
.table-card, .tx-card { overflow-x: auto; }
|
||||
.cred-table { min-width: 560px !important; }
|
||||
.th-ops { width: auto !important; }
|
||||
@@ -75,12 +76,18 @@
|
||||
.vault-toolbar .flex1 { display: none; }
|
||||
.vault-toolbar .btn { flex: none; }
|
||||
|
||||
/* ---------- 统计卡两列 ---------- */
|
||||
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 10px !important; }
|
||||
.stat-card { padding: 12px 14px !important; }
|
||||
.stat-num { font-size: 19px !important; }
|
||||
|
||||
/* ---------- 分区与栅格收窄 ---------- */
|
||||
.section, .sec { padding: 12px 12px !important; }
|
||||
.rule-grid { grid-template-columns: 1fr !important; }
|
||||
.two-col { grid-template-columns: 1fr !important; }
|
||||
|
||||
/* ---------- 日历格子收窄 ---------- */
|
||||
.cal-toolbar { margin-bottom: 8px; }
|
||||
.cal-cell { min-height: 54px !important; padding: 4px 5px !important; }
|
||||
.cal-marks { margin-top: 3px !important; height: 12px !important; gap: 3px !important; }
|
||||
|
||||
/* ---------- 各种桌面习惯型小间距收敛 ---------- */
|
||||
.section, .sec { padding: 12px 12px !important; }
|
||||
.rule-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
|
||||
@@ -1,50 +1,72 @@
|
||||
/* ============================================================
|
||||
设计基座 ·「高级白」企业级
|
||||
层次模型:内容区浅灰底(--bg-app) → 白色卡片(--bg-page) →
|
||||
浅灰内嵌块(--bg-subtle)。所有视图共用此处组件类,禁止各自造轮子。
|
||||
============================================================ */
|
||||
:root {
|
||||
--bg-page: #FFFFFF;
|
||||
--bg-subtle: #F7F8FA;
|
||||
/* 表面 */
|
||||
--bg-app: #F5F6F8; /* 应用内容区底色 */
|
||||
--bg-page: #FFFFFF; /* 卡片 / 面板 */
|
||||
--bg-subtle: #F7F8FA; /* 表头 / 内嵌块 */
|
||||
--bg-hover: #F2F3F5;
|
||||
--border: #EBEDF0;
|
||||
--border-strong: #DCDEE3;
|
||||
/* 文字 */
|
||||
--text-1: #1D2129;
|
||||
--text-2: #4E5969;
|
||||
--text-3: #86909C;
|
||||
/* 线 */
|
||||
--border: #E8EAEE;
|
||||
--border-strong: #DCDFE4;
|
||||
/* 品牌与语义色 */
|
||||
--accent: #3370FF;
|
||||
--accent-bg: #F0F5FF;
|
||||
--accent-deep: #2B5FD9;
|
||||
--accent-bg: #EEF4FF;
|
||||
--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);
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
/* 阴影 */
|
||||
--shadow-card: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
--shadow-float: 0 12px 32px -8px rgba(16, 24, 40, 0.14), 0 4px 12px rgba(16, 24, 40, 0.06);
|
||||
/* 布局 */
|
||||
--side-w: 216px;
|
||||
--side-w-collapsed: 60px;
|
||||
--topbar-h: 56px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body { margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--bg-page);
|
||||
background: var(--bg-app);
|
||||
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;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
|
||||
/* ---------- 通用工具类 ---------- */
|
||||
.num { font-variant-numeric: tabular-nums; }
|
||||
.flex1 { flex: 1; }
|
||||
.flex1 { flex: 1; min-width: 0; }
|
||||
.muted { color: var(--text-3); }
|
||||
.muted2 { color: var(--text-2); }
|
||||
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
/* ---------- 按钮 ---------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
padding: 0 14px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong);
|
||||
@@ -52,15 +74,39 @@ button, input, select, textarea { font: inherit; color: inherit; }
|
||||
color: var(--text-1);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
.btn:hover { background: var(--bg-hover); }
|
||||
.btn:hover { background: var(--bg-hover); border-color: var(--border-strong); }
|
||||
.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; }
|
||||
.btn-primary:hover { background: var(--accent-deep); border-color: var(--accent-deep); }
|
||||
.btn-danger { color: var(--danger); border-color: var(--danger); background: var(--bg-page); }
|
||||
.btn-danger:hover { background: #FDF0F0; }
|
||||
.btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #FFFFFF; }
|
||||
.btn-danger-solid:hover { background: #D13A3F; }
|
||||
.btn-sm { height: 28px; padding: 0 12px; font-size: 12px; }
|
||||
.btn-xs { height: 24px; padding: 0 10px; font-size: 12px; }
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.icon-btn:hover { background: var(--bg-hover); color: var(--text-1); }
|
||||
.icon-btn.active { color: var(--accent); }
|
||||
.icon-btn.active-warn { color: var(--warn); }
|
||||
|
||||
/* ---------- 表单 ---------- */
|
||||
.input, .select, .textarea {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
@@ -69,30 +115,30 @@ button, input, select, textarea { font: inherit; color: inherit; }
|
||||
background: var(--bg-page);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.textarea { height: auto; min-height: 64px; padding: 8px 10px; resize: vertical; line-height: 1.7; }
|
||||
.input:focus, .select:focus, .textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(51, 112, 255, 0.10);
|
||||
}
|
||||
.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); }
|
||||
.input.invalid, .input.invalid:focus { border-color: var(--danger); box-shadow: 0 0 0 3px rgba(229, 72, 77, 0.10); }
|
||||
|
||||
.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;
|
||||
/* ---------- 卡片与分区 ---------- */
|
||||
.card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-page);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.icon-btn:hover { background: var(--bg-hover); color: var(--text-1); }
|
||||
.icon-btn.active { color: var(--accent); }
|
||||
.icon-btn.active-warn { color: var(--warn); }
|
||||
.section { padding: 16px 18px; }
|
||||
.section-title, .sec-title { font-weight: 600; margin-bottom: 12px; font-size: 13px; color: var(--text-1); }
|
||||
.sec { padding: 16px 18px; }
|
||||
|
||||
.empty { text-align: center; padding: 28px 0; }
|
||||
|
||||
/* ---------- 标签与状态点 ---------- */
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 0 8px;
|
||||
@@ -105,19 +151,140 @@ button, input, select, textarea { font: inherit; color: inherit; }
|
||||
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, .dot.leave { background: var(--dot-empty); }
|
||||
.dot.rest { background: var(--border); }
|
||||
|
||||
/* 移动端:避免 iOS 聚焦缩放,输入字号提到 16px */
|
||||
/* ---------- 金额 ---------- */
|
||||
.amt-income { color: var(--ok); }
|
||||
.amt-expense { color: var(--text-1); }
|
||||
.amt-warn { color: var(--warn); }
|
||||
|
||||
/* ---------- 分段选择器(seg) ---------- */
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
.seg-btn {
|
||||
border: none;
|
||||
background: var(--bg-page);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-right: 1px solid var(--border);
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.seg-btn:last-child { border-right: none; }
|
||||
.seg-btn:hover { background: var(--bg-hover); }
|
||||
.seg-btn.active { background: var(--accent-bg); color: var(--accent); font-weight: 500; }
|
||||
|
||||
/* ---------- 开关按钮(toggle) ---------- */
|
||||
.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);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.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); background: #FDF4EC; }
|
||||
|
||||
/* ---------- 数据表格(记账/密码库/用户管理共用) ---------- */
|
||||
.tx-table, .cred-table, .admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.tx-table th, .cred-table th, .admin-table th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
background: var(--bg-subtle);
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tx-table td, .cred-table td, .admin-table td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
word-break: break-all;
|
||||
}
|
||||
.tx-table tbody tr:hover, .cred-table tbody tr:hover, .admin-table tbody tr:hover { background: #FAFBFC; }
|
||||
.tx-table tbody tr:last-child td, .cred-table tbody tr:last-child td, .admin-table tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* ---------- 弹窗(全部对话框共用) ---------- */
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(29, 33, 41, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 90;
|
||||
padding: 16px;
|
||||
}
|
||||
.dialog {
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: 86vh;
|
||||
max-height: 86dvh;
|
||||
overflow: auto;
|
||||
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; color: var(--text-3); }
|
||||
.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; }
|
||||
.form-grid .field.span2, .field.span2 { grid-column: span 2; }
|
||||
.field label, .form-row label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
|
||||
.form-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
|
||||
|
||||
/* ---------- 统计卡与横向条 ---------- */
|
||||
.stat-card { padding: 14px 16px; }
|
||||
.stat-num { font-size: 22px; font-weight: 600; margin-top: 2px; letter-spacing: -0.2px; }
|
||||
.bar-row { display: flex; align-items: center; gap: 10px; margin: 7px 0; }
|
||||
.bar-label { 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; flex: none; color: var(--text-3); }
|
||||
|
||||
/* ---------- 图标 ---------- */
|
||||
.icon { display: inline-flex; align-items: center; justify-content: center; flex: none; }
|
||||
.icon svg { width: 100%; height: 100%; display: block; }
|
||||
|
||||
/* ---------- 移动端:避免 iOS 聚焦缩放 ---------- */
|
||||
@media (max-width: 720px) {
|
||||
input, select, textarea { font-size: 16px !important; }
|
||||
input[type="checkbox"] { font-size: initial; }
|
||||
}
|
||||
.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; }
|
||||
|
||||
@@ -13,7 +13,7 @@ import { DEFAULT_SYNC_BASE, getSyncBase as baseOf, getToken, setToken, setSyncBa
|
||||
// 最近一次自动同步的错误(设置页展示用)
|
||||
export const syncLastError = ref('')
|
||||
|
||||
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
|
||||
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'goal', 'transaction', 'plan']
|
||||
|
||||
// 本地临时性键不参与同步,也不能从服务端写回本地缓存
|
||||
const EXCLUDE_SETTING_KEYS = new Set(['sync_cursor', 'sync_last_time', 'sync_token', 'last_project', 'last_category'])
|
||||
@@ -175,6 +175,7 @@ export async function flushAfterWrite(): Promise<void> {
|
||||
interface RefMaps {
|
||||
workLog: Map<number, AnyRow>
|
||||
project: Map<number, AnyRow>
|
||||
goal: Map<number, AnyRow>
|
||||
}
|
||||
|
||||
function normalizeRow(t: string, row: AnyRow, refs: RefMaps): AnyRow {
|
||||
@@ -191,15 +192,22 @@ function normalizeRow(t: string, row: AnyRow, refs: RefMaps): AnyRow {
|
||||
const pj = row.project_id ? refs.project.get(row.project_id) : null
|
||||
if (pj && pj.uuid) clean.puuid = pj.uuid
|
||||
}
|
||||
if (t === 'plan') {
|
||||
delete clean.goal_id
|
||||
const g = row.goal_id ? refs.goal.get(row.goal_id) : null
|
||||
if (g && g.uuid) clean.guuid = g.uuid
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
async function buildRefMaps(): Promise<RefMaps> {
|
||||
const wlRows = await db.work_log.toArray()
|
||||
const pjRows = await db.project.toArray()
|
||||
const gRows = await db.goal.toArray()
|
||||
return {
|
||||
workLog: new Map(wlRows.map(r => [r.id as number, r])),
|
||||
project: new Map(pjRows.map(p => [p.id as number, p]))
|
||||
project: new Map(pjRows.map(p => [p.id as number, p])),
|
||||
goal: new Map(gRows.map(r => [r.id as number, r]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,7 +300,7 @@ export async function runSync(config: SyncConfig): Promise<SyncResult> {
|
||||
}
|
||||
}
|
||||
|
||||
async function doRunSync(config: SyncConfig): Promise<SyncResult> {
|
||||
async function doRunSync(config: SyncConfig, epochRetry = false): Promise<SyncResult> {
|
||||
const base = config.baseUrl.trim().replace(/\/+$/, '')
|
||||
if (!base) throw new Error('请先配置同步服务地址')
|
||||
await ensureSyncFields()
|
||||
@@ -350,6 +358,18 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
|
||||
}
|
||||
if (!resp.ok) throw new Error('同步服务返回 ' + resp.status)
|
||||
const data = await resp.json()
|
||||
|
||||
// 服务器数据纪元检测:纪元变化说明服务端数据存储被重建(如误删 data 目录后重启)。
|
||||
// 此时本地镜像不可信(镜像认为「早已同步」而不推送,随后会拉到空快照把本地缓存清空)。
|
||||
// 处置:作废镜像并以本地全量数据重推一次,让服务器从本设备恢复。
|
||||
const epoch = String(data.dataEpoch || '')
|
||||
const epochKey = 'dsh_sync_epoch_' + (getCloudCreds()?.username || 'anon')
|
||||
const prevEpoch = epoch ? localStorage.getItem(epochKey) : null
|
||||
if (!epochRetry && prevEpoch && epoch && prevEpoch !== epoch) {
|
||||
await db.sync_mirror.clear()
|
||||
localStorage.setItem(epochKey, epoch)
|
||||
return doRunSync(config, true)
|
||||
}
|
||||
const serverTables: Record<string, AnyRow[]> = data.tables || {}
|
||||
const serverTombstones: { table: string; uuid: string; deleted_at: string }[] = data.tombstones || []
|
||||
|
||||
@@ -360,6 +380,7 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
|
||||
ensureSSE(base, getToken() || token)
|
||||
const at = nowLocalISO()
|
||||
await putSyncLastTime(at)
|
||||
if (epoch) localStorage.setItem(epochKey, epoch)
|
||||
if (applyResult.changed > 0 || applyResult.removed > 0 || pushed > 0) notifyAttendanceChanged()
|
||||
return { pushed, pulled: applyResult.changed, removed: applyResult.removed, errors: 0, at }
|
||||
}
|
||||
@@ -398,6 +419,7 @@ async function applySnapshot(
|
||||
let removed = 0
|
||||
const wlIds: { [uuid: string]: number } = {}
|
||||
const pjIds: { [uuid: string]: number } = {}
|
||||
const goalIds: { [uuid: string]: number } = {}
|
||||
|
||||
for (const t of TABLES) {
|
||||
const table = db.table(t)
|
||||
@@ -431,6 +453,15 @@ async function applySnapshot(
|
||||
srow.project_id = srow.puuid ? (pjIds[srow.puuid] ?? null) : null
|
||||
delete srow.puuid
|
||||
}
|
||||
if (t === 'plan') {
|
||||
if (srow.guuid) {
|
||||
const gid = goalIds[srow.guuid]
|
||||
srow.goal_id = gid ?? null
|
||||
delete srow.guuid
|
||||
} else {
|
||||
srow.goal_id = null
|
||||
}
|
||||
}
|
||||
let id: any = pkOf(t, srow0)
|
||||
if (t === 'setting') id = srow0.key
|
||||
else {
|
||||
@@ -445,6 +476,7 @@ async function applySnapshot(
|
||||
hydrated.push(rowOut)
|
||||
if (t === 'work_log') wlIds[srow0.uuid] = id
|
||||
if (t === 'project') pjIds[srow0.uuid] = id
|
||||
if (t === 'goal') goalIds[srow0.uuid] = id
|
||||
}
|
||||
void importance
|
||||
const outRows = hydrated.concat(inflight)
|
||||
|
||||
@@ -87,36 +87,37 @@ watch(focusDate, 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>
|
||||
<button class="icon-btn" title="上一月" @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>
|
||||
<button class="icon-btn" title="下一月" @click="nextMonth"><Icon name="chevron-right" :size="16" /></button>
|
||||
<button class="btn btn-sm" :disabled="monthRef === t" @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 class="card cal-card">
|
||||
<div class="cal-head">
|
||||
<span v-for="w in WEEK_LABELS" :key="w" class="cal-week">{{ 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>
|
||||
</div>
|
||||
|
||||
<transition name="drawer">
|
||||
@@ -132,30 +133,23 @@ watch(focusDate, d => {
|
||||
</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 {
|
||||
.cal-card { padding: 0; overflow: hidden; }
|
||||
.cal-head, .cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
border-top: 1px solid var(--border);
|
||||
border-left: 1px solid var(--border);
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.cal-week {
|
||||
background: var(--bg-subtle);
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
padding: 7px 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);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.cal-cell {
|
||||
min-height: 92px;
|
||||
@@ -163,16 +157,14 @@ watch(focusDate, d => {
|
||||
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;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.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); }
|
||||
.cal-cell.selected { background: var(--accent-bg); box-shadow: inset 0 0 0 2px var(--accent); }
|
||||
.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; }
|
||||
@@ -184,7 +176,7 @@ watch(focusDate, d => {
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(420px, 100%);
|
||||
width: min(440px, 100%);
|
||||
background: var(--bg-page);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-float);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
addTransaction, deleteTransaction, listTransactionsBetween,
|
||||
setTransactionReimbursed, updateTransaction, type Transaction
|
||||
} from '../db/repo'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '../db/schema'
|
||||
import { getLedgerCategories } from '../db/categories'
|
||||
import { addMonths, todayStr } from '../lib/date'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
||||
@@ -20,7 +20,7 @@ const loading = ref(true)
|
||||
// ---- 新增表单 ----
|
||||
const fType = ref<TxType>('expense')
|
||||
const fAmount = ref('')
|
||||
const fCategory = ref(EXPENSE_CATEGORIES[0])
|
||||
const fCategory = ref('')
|
||||
const fDate = ref(todayStr())
|
||||
const fNote = ref('')
|
||||
const fReimb = ref(false)
|
||||
@@ -30,7 +30,7 @@ const submitting = ref(false)
|
||||
|
||||
const dialogOpen = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const dialogInitial = ref({ type: 'expense', category: EXPENSE_CATEGORIES[0], amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 })
|
||||
const dialogInitial = ref({ type: 'expense', category: '', amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 })
|
||||
const deleteTarget = ref<Transaction | null>(null)
|
||||
|
||||
const monthStart = computed(() => monthRef.value.slice(0, 7) + '-01')
|
||||
@@ -56,9 +56,14 @@ async function load() {
|
||||
onMounted(load)
|
||||
watch(monthRef, load)
|
||||
|
||||
watch(fType, t => {
|
||||
fCategory.value = t === 'expense' ? EXPENSE_CATEGORIES[0] : INCOME_CATEGORIES[0]
|
||||
})
|
||||
const expenseCats = ref<string[]>([])
|
||||
const incomeCats = ref<string[]>([])
|
||||
async function loadCats() {
|
||||
expenseCats.value = await getLedgerCategories('expense')
|
||||
incomeCats.value = await getLedgerCategories('income')
|
||||
}
|
||||
loadCats()
|
||||
|
||||
|
||||
const sorted = computed(() =>
|
||||
rows.value.slice().sort((a, b) => (a.date === b.date ? (b.id as number) - (a.id as number) : (a.date < b.date ? 1 : -1)))
|
||||
@@ -183,12 +188,24 @@ function openEdit(row: Transaction) {
|
||||
}
|
||||
dialogOpen.value = true
|
||||
}
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
dialogInitial.value = { type: 'expense', category: (expenseCats.value[0] || ''), amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 }
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function onDialogSave(payload: { type: string; category: string; amount: number; date: string; note: string; reimbursable: number; reimbursed: number }) {
|
||||
const id = editingId.value as number
|
||||
await updateTransaction(id, payload)
|
||||
if (editingId.value) {
|
||||
await updateTransaction(editingId.value, payload)
|
||||
dialogOpen.value = false
|
||||
await load()
|
||||
showToast('账目已更新')
|
||||
return
|
||||
}
|
||||
await addTransaction({ type: payload.type, category: payload.category, amount: payload.amount, date: payload.date, note: payload.note, reimbursable: payload.reimbursable, reimbursed: payload.reimbursed })
|
||||
dialogOpen.value = false
|
||||
await load()
|
||||
showToast('账目已更新')
|
||||
showToast('已记一笔')
|
||||
}
|
||||
async function confirmDelete() {
|
||||
if (deleteTarget.value && deleteTarget.value.id) await deleteTransaction(deleteTarget.value.id)
|
||||
@@ -197,7 +214,6 @@ async function confirmDelete() {
|
||||
showToast('账目已删除')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ledger-page">
|
||||
<div class="ledger-toolbar">
|
||||
@@ -216,6 +232,7 @@ async function confirmDelete() {
|
||||
<option value="pending">待报销</option>
|
||||
<option value="done">已报销</option>
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" @click="openCreate"><Icon name="plus" :size="14" /> 记一笔</button>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
@@ -225,51 +242,6 @@ async function confirmDelete() {
|
||||
<div class="card stat-card"><div class="muted">待报销(元)</div><div class="stat-num num amt-warn">{{ yuan(summary.pending) }}</div></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>
|
||||
<div class="seg">
|
||||
<button class="seg-btn" :class="{ active: fType === 'expense' }" @click="fType = 'expense'">支出</button>
|
||||
<button class="seg-btn" :class="{ active: fType === 'income' }" @click="fType = 'income'">收入</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>金额(元,必填)</label>
|
||||
<input class="input" :class="{ invalid: fError }" v-model="fAmount" type="number" min="0" step="0.01" placeholder="0.00" @keydown.enter="submit" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>日期</label>
|
||||
<input class="input" type="date" v-model="fDate" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>分类</label>
|
||||
<select class="select" v-model="fCategory">
|
||||
<option v-for="c in (fType === 'expense' ? EXPENSE_CATEGORIES : INCOME_CATEGORIES)" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" v-if="fType === 'expense'">
|
||||
<label>报销</label>
|
||||
<button class="toggle-btn" :class="{ on: fReimb }" @click="fReimb = !fReimb">
|
||||
{{ fReimb ? '需要报销' : '无需报销' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="field span2">
|
||||
<label>备注</label>
|
||||
<input class="input" v-model="fNote" placeholder="选填,如:客户现场午餐" @keydown.enter="submit" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="fError" class="form-error">{{ fError }}</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="card sec" v-if="categoryStats.length">
|
||||
<div class="sec-title">本月支出分类</div>
|
||||
<div v-for="c in categoryStats" :key="c.name" class="bar-row">
|
||||
@@ -320,11 +292,11 @@ async function confirmDelete() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="muted vault-note num">筛选结果 {{ filtered.length }} 条 / 本月共 {{ rows.length }} 条</div>
|
||||
<div class="muted table-note num">本月 {{ rows.length }} 条 · 当前筛选 {{ filtered.length }} 条</div>
|
||||
|
||||
<TransactionDialog
|
||||
:open="dialogOpen"
|
||||
:title="editingId ? '编辑账目' : '编辑账目'"
|
||||
:title="editingId ? '编辑账目' : '记一笔'"
|
||||
:initial="dialogInitial"
|
||||
@cancel="dialogOpen = false"
|
||||
@save="onDialogSave"
|
||||
@@ -347,69 +319,20 @@ async function confirmDelete() {
|
||||
.w-filter { width: 110px; flex: none; }
|
||||
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
|
||||
.stat-card { padding: 14px 16px; }
|
||||
.stat-num { font-size: 20px; font-weight: 600; margin-top: 4px; }
|
||||
.amt-income { color: var(--ok); }
|
||||
.amt-expense { color: var(--text-1); }
|
||||
.amt-warn { color: var(--warn); }
|
||||
|
||||
.sec { padding: 14px 16px; margin-top: 12px; }
|
||||
.sec-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; }
|
||||
.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; }
|
||||
.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: 8px; }
|
||||
.form-footer {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.seg-btn {
|
||||
height: 30px; padding: 0 14px; 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); }
|
||||
.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); }
|
||||
.card.sec, .card.section { margin-top: 12px; }
|
||||
.bar-label { width: 56px; }
|
||||
.bar-num { width: 118px; }
|
||||
|
||||
.bar-row { display: flex; align-items: center; gap: 10px; margin: 6px 0; }
|
||||
.bar-label { width: 56px; 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: 110px; flex: none; }
|
||||
|
||||
.table-card { overflow-x: auto; }
|
||||
.tx-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.tx-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;
|
||||
}
|
||||
.tx-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: top; word-break: break-all; }
|
||||
.tx-table tbody tr:hover { background: var(--bg-subtle); }
|
||||
.tx-table tbody tr:last-child td { border-bottom: none; }
|
||||
.td-note { color: var(--text-2); }
|
||||
.tx-pending { color: var(--warn); font-size: 12px; }
|
||||
.tx-done { color: var(--ok); font-size: 12px; }
|
||||
.th-ops { width: 220px; }
|
||||
.ops { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.empty { text-align: center; padding: 24px 0; }
|
||||
.vault-note { margin-top: 10px; font-size: 12px; text-align: right; }
|
||||
.table-card { overflow-x: auto; }
|
||||
.table-note { margin-top: 10px; font-size: 12px; text-align: right; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.field.span2 { grid-column: auto; }
|
||||
.ledger-toolbar .w-filter { flex: 1; min-width: 0; width: auto; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -77,7 +77,8 @@ async function submit() {
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-subtle);
|
||||
min-height: 100dvh;
|
||||
background: var(--bg-app);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -86,20 +87,21 @@ async function submit() {
|
||||
.login-card {
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
padding: 28px 26px;
|
||||
padding: 30px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: var(--bg-page);
|
||||
box-shadow: var(--shadow-float);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.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);
|
||||
border-radius: 12px;
|
||||
background: var(--accent);
|
||||
color: #FFFFFF;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -107,31 +109,7 @@ async function submit() {
|
||||
.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-btn { margin-top: 2px; height: 36px; }
|
||||
.login-note { font-size: 12px; text-align: center; line-height: 1.7; }
|
||||
</style>
|
||||
|
||||
@@ -197,13 +197,13 @@ function fmtTime(s: string): string {
|
||||
.memo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 140px);
|
||||
max-height: calc(100vh - 150px);
|
||||
}
|
||||
.pane-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 12px 8px;
|
||||
padding: 12px 12px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: none;
|
||||
}
|
||||
@@ -223,12 +223,12 @@ function fmtTime(s: string): string {
|
||||
.memo-item-time { font-size: 11px; margin-top: 2px; }
|
||||
|
||||
.memo-editor {
|
||||
padding: 16px;
|
||||
padding: 16px 18px;
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.title-input { font-size: 14px; font-weight: 500; margin-bottom: 10px; }
|
||||
.title-input { font-size: 15px; font-weight: 600; margin-bottom: 10px; }
|
||||
.content-textarea { flex: 1; min-height: 340px; line-height: 1.8; }
|
||||
.editor-foot {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import {
|
||||
addPlan, deletePlan, listPlansByDate, setPlanStatus,
|
||||
startPlanTimer, stopPlanTimer, updatePlan, type PlanItem,
|
||||
addGoal, updateGoal, deleteGoal, listGoals, getGoalProgress, type Goal
|
||||
} from '../db/repo'
|
||||
import { addDays, parseDateStr, todayStr, toDateStr, formatDateCN } from '../lib/date'
|
||||
import { attendanceChanged, notifyPlanTimer } from '../lib/bus'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
||||
// ---- 长期计划 ----
|
||||
const goals = ref<Goal[]>([])
|
||||
const goalProg = ref<Record<string, { done: number; total: number }>>({})
|
||||
const goalOpen = ref(false)
|
||||
const goalEditing = ref<Goal | null>(null)
|
||||
const gTitle = ref('')
|
||||
const gKind = ref('学习')
|
||||
const gStart = ref(todayStr())
|
||||
const gEnd = ref(addDays(todayStr(), 6))
|
||||
const gNote = ref('')
|
||||
const gError = ref('')
|
||||
const delGoal = ref<Goal | null>(null)
|
||||
|
||||
function openGoalCreate() {
|
||||
goalEditing.value = null
|
||||
gTitle.value = ''
|
||||
gKind.value = '学习'
|
||||
gStart.value = todayStr()
|
||||
gEnd.value = addDays(todayStr(), 6)
|
||||
gNote.value = ''
|
||||
gError.value = ''
|
||||
goalOpen.value = true
|
||||
}
|
||||
function openGoalEdit(g: Goal) {
|
||||
goalEditing.value = g
|
||||
gTitle.value = g.title
|
||||
gKind.value = g.kind
|
||||
gStart.value = g.start_date
|
||||
gEnd.value = g.end_date
|
||||
gNote.value = g.note || ''
|
||||
gError.value = ''
|
||||
goalOpen.value = true
|
||||
}
|
||||
async function submitGoal() {
|
||||
if (!gTitle.value.trim()) { gError.value = '请填写计划名称'; return }
|
||||
if (gEnd.value < gStart.value) { gError.value = '结束日期不能早于开始日期'; return }
|
||||
if (goalEditing.value) {
|
||||
await updateGoal(goalEditing.value.id as number, {
|
||||
title: gTitle.value.trim(), kind: gKind.value, note: gNote.value
|
||||
})
|
||||
showToast('长期计划已更新')
|
||||
} else {
|
||||
await addGoal({ title: gTitle.value, kind: gKind.value, start_date: gStart.value, end_date: gEnd.value, note: gNote.value })
|
||||
showToast('长期计划已创建,已生成每日任务')
|
||||
}
|
||||
goalOpen.value = false
|
||||
await load()
|
||||
}
|
||||
async function removeGoal() {
|
||||
if (!delGoal.value) return
|
||||
await deleteGoal(delGoal.value.id as number)
|
||||
showToast('已删除长期计划及其每日任务')
|
||||
delGoal.value = null
|
||||
await load()
|
||||
}
|
||||
|
||||
const date = ref(todayStr())
|
||||
const items = ref<PlanItem[]>([])
|
||||
const loading = ref(true)
|
||||
const now = ref(Date.now())
|
||||
let tick: number | null = null
|
||||
|
||||
const title = computed(() => formatDateCN(date.value))
|
||||
const isToday = computed(() => date.value === todayStr())
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const [plans, gs, prog] = await Promise.all([
|
||||
listPlansByDate(date.value),
|
||||
listGoals(),
|
||||
getGoalProgress()
|
||||
])
|
||||
items.value = plans
|
||||
goals.value = gs
|
||||
const map: Record<string, { done: number; total: number }> = {}
|
||||
for (const p of prog) map[String(p.goalId)] = { done: p.done, total: p.total }
|
||||
goalProg.value = map
|
||||
loading.value = false
|
||||
}
|
||||
watch(date, load)
|
||||
watch(attendanceChanged, load)
|
||||
onMounted(() => {
|
||||
load()
|
||||
tick = window.setInterval(() => { now.value = Date.now() }, 30000)
|
||||
})
|
||||
onUnmounted(() => { if (tick) window.clearInterval(tick) })
|
||||
|
||||
function prevDay() { date.value = addDays(date.value, -1) }
|
||||
function nextDay() { date.value = addDays(date.value, 1) }
|
||||
function goToday() { date.value = todayStr() }
|
||||
|
||||
// ---- 汇总 ----
|
||||
const summary = computed(() => {
|
||||
const total = items.value.length
|
||||
const done = items.value.filter(p => p.status === 'done').length
|
||||
const plannedMin = items.value.reduce((acc, p) => acc + spanMinutes(p), 0)
|
||||
const actualMin = items.value.reduce((acc, p) => acc + (p.actual_minutes || 0), 0)
|
||||
let liveExtra = 0
|
||||
const runningId = running.value
|
||||
if (runningId) {
|
||||
const rp = items.value.find(p => p.id === runningId)
|
||||
if (rp) liveExtra = liveElapsed(rp)
|
||||
}
|
||||
return { total, done, plannedMin, actualMin: actualMin + liveExtra }
|
||||
})
|
||||
function spanMinutes(p: PlanItem): number {
|
||||
const [sh, sm] = p.start_time.split(':').map(Number)
|
||||
const [eh, em] = p.end_time.split(':').map(Number)
|
||||
return Math.max(0, (eh * 60 + em) - (sh * 60 + sm))
|
||||
}
|
||||
function fmtMin(n: number): string {
|
||||
if (n < 60) return n + ' 分钟'
|
||||
return Math.floor(n / 60) + ' 小时 ' + (n % 60) + ' 分'
|
||||
}
|
||||
|
||||
// ---- 计时时钟 ----
|
||||
const running = computed(() => {
|
||||
const r = items.value.find(p => !!p.timer_started_at)
|
||||
return r ? (r.id as number) : null
|
||||
})
|
||||
function liveElapsed(p: PlanItem): number {
|
||||
if (!p.timer_started_at) return p.actual_minutes || 0
|
||||
const extra = Math.max(0, Math.round((now.value - new Date(p.timer_started_at).getTime()) / 60000))
|
||||
return (p.actual_minutes || 0) + extra
|
||||
}
|
||||
async function toggleTimer(p: PlanItem) {
|
||||
if (p.timer_started_at) {
|
||||
await stopPlanTimer(p.id as number)
|
||||
showToast('已停止计时:' + p.title)
|
||||
} else {
|
||||
await startPlanTimer(p.id as number)
|
||||
showToast('开始计时:' + p.title)
|
||||
}
|
||||
notifyPlanTimer()
|
||||
await load()
|
||||
}
|
||||
|
||||
// ---- 打卡 ----
|
||||
async function mark(p: PlanItem, status: 'todo' | 'done' | 'skip') {
|
||||
await setPlanStatus(p.id as number, status)
|
||||
await load()
|
||||
}
|
||||
|
||||
// ---- 新增 / 编辑 ----
|
||||
const dialogOpen = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const fTitle = ref('')
|
||||
const fKind = ref('学习')
|
||||
const fStart = ref('09:00')
|
||||
const fEnd = ref('10:00')
|
||||
const fNote = ref('')
|
||||
const fError = ref('')
|
||||
const KINDS = ['学习', '健身', '自律', '工作', '阅读', '其他']
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
fTitle.value = ''
|
||||
fKind.value = '学习'
|
||||
fStart.value = '09:00'
|
||||
fEnd.value = '10:00'
|
||||
fNote.value = ''
|
||||
fError.value = ''
|
||||
dialogOpen.value = true
|
||||
}
|
||||
function openEdit(p: PlanItem) {
|
||||
editingId.value = p.id as number
|
||||
fTitle.value = p.title
|
||||
fKind.value = p.kind
|
||||
fStart.value = p.start_time
|
||||
fEnd.value = p.end_time
|
||||
fNote.value = p.note || ''
|
||||
fError.value = ''
|
||||
dialogOpen.value = true
|
||||
}
|
||||
async function submitPlan() {
|
||||
if (!fTitle.value.trim()) { fError.value = '请填写计划内容'; return }
|
||||
if (fEnd.value <= fStart.value) { fError.value = '结束时间需晚于开始时间'; return }
|
||||
if (editingId.value) {
|
||||
await updatePlan(editingId.value, {
|
||||
title: fTitle.value.trim(), kind: fKind.value, start_time: fStart.value,
|
||||
end_time: fEnd.value, note: fNote.value
|
||||
})
|
||||
showToast('计划已更新')
|
||||
} else {
|
||||
await addPlan({ date: date.value, start_time: fStart.value, end_time: fEnd.value, title: fTitle.value, kind: fKind.value, note: fNote.value })
|
||||
showToast('计划已添加')
|
||||
}
|
||||
dialogOpen.value = false
|
||||
await load()
|
||||
}
|
||||
|
||||
async function remove(p: PlanItem) {
|
||||
await deletePlan(p.id as number)
|
||||
showToast('已删除')
|
||||
await load()
|
||||
}
|
||||
|
||||
const wdNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
function weekday(s: string): string {
|
||||
return wdNames[parseDateStr(s).getDay()]
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="plan-page">
|
||||
<!-- 长期计划 -->
|
||||
<div class="blk-head">
|
||||
<div class="blk-head-text">
|
||||
<div class="blk-title">长期计划</div>
|
||||
<div class="muted blk-sub">跨越多天的目标,自动落实到每一天</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" @click="openGoalCreate"><Icon name="plus" :size="14" /> 新建长期计划</button>
|
||||
</div>
|
||||
<div v-if="!goals.length" class="card empty muted">
|
||||
暂无长期计划。创建一个跨越多天的目标(如「产品文档学习」),系统会自动生成每天的打卡任务。
|
||||
</div>
|
||||
<div v-for="g in goals" :key="g.id" class="card goal-card">
|
||||
<div class="goal-main">
|
||||
<div class="goal-line">
|
||||
<span class="tag">{{ g.kind }}</span>
|
||||
<span class="goal-name">{{ g.title }}</span>
|
||||
<span class="muted num goal-range">{{ g.start_date.slice(5) }} ~ {{ g.end_date.slice(5) }}</span>
|
||||
</div>
|
||||
<div class="goal-bar">
|
||||
<span
|
||||
class="goal-bar-fill"
|
||||
:style="{ width: (goalProg[String(g.id)] ? Math.round(goalProg[String(g.id)].done / Math.max(1, goalProg[String(g.id)].total) * 100) : 0) + '%' }"
|
||||
></span>
|
||||
</div>
|
||||
<div class="muted num goal-meta">
|
||||
{{ goalProg[String(g.id)] ? goalProg[String(g.id)].done + '/' + goalProg[String(g.id)].total + ' 天已完成' : '0/' + (goalProg[String(g.id)] ? goalProg[String(g.id)].total : 0) + ' 天' }}
|
||||
<template v-if="g.note"> · {{ g.note }}</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="goal-ops">
|
||||
<button class="icon-btn" title="编辑" @click="openGoalEdit(g)"><Icon name="edit" :size="14" /></button>
|
||||
<button class="icon-btn" title="删除" @click="delGoal = g"><Icon name="close" :size="14" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 每日安排 -->
|
||||
<div class="blk-head day-head">
|
||||
<div class="blk-head-text">
|
||||
<div class="blk-title">每日安排</div>
|
||||
<div class="muted blk-sub num">
|
||||
共 {{ summary.total }} 项 · 完成 {{ summary.done }} 项 · 计划 {{ fmtMin(summary.plannedMin) }} · 实际投入 {{ fmtMin(summary.actualMin) }}
|
||||
<template v-if="running"> · <span class="timer-live">计时中</span></template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="day-ops">
|
||||
<button class="icon-btn" title="前一天" @click="prevDay"><Icon name="chevron-left" :size="16" /></button>
|
||||
<span class="date-chip num" :class="{ today: isToday }">{{ date.slice(5) }} {{ weekday(date) }}</span>
|
||||
<button class="icon-btn" title="后一天" @click="nextDay"><Icon name="chevron-right" :size="16" /></button>
|
||||
<button class="btn btn-sm" :class="{ 'btn-primary': !isToday }" @click="goToday">今天</button>
|
||||
<button class="btn btn-primary btn-sm" @click="openCreate"><Icon name="plus" :size="14" /> 添加计划</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card list-card">
|
||||
<div v-if="!loading && !items.length" class="empty muted">今天还没有计划,点「添加计划」开始规划。</div>
|
||||
<div
|
||||
v-for="p in items" :key="p.id"
|
||||
class="plan-item"
|
||||
:class="{ done: p.status === 'done', skip: p.status === 'skip', running: !!p.timer_started_at }"
|
||||
>
|
||||
<div class="pi-time num">
|
||||
{{ p.start_time }}<span class="muted"> - {{ p.end_time }}</span>
|
||||
</div>
|
||||
<div class="pi-main">
|
||||
<div class="pi-title">
|
||||
<span class="tag">{{ p.kind }}</span>
|
||||
<span v-if="p.goal_id" class="tag tag-goal">长期</span>
|
||||
<span class="pi-text" :class="{ strike: p.status === 'done' }">{{ p.title }}</span>
|
||||
<span v-if="p.status === 'done'" class="state-done">已完成</span>
|
||||
<span v-else-if="p.status === 'skip'" class="state-skip">已跳过</span>
|
||||
</div>
|
||||
<div v-if="p.note" class="muted pi-note">{{ p.note }}</div>
|
||||
<div class="muted pi-meta num">
|
||||
<template v-if="p.timer_started_at">
|
||||
<span class="timer-live">计时中 · 已 {{ liveElapsed(p) }} 分钟</span>
|
||||
</template>
|
||||
<template v-else-if="p.actual_minutes > 0">实际投入 {{ p.actual_minutes }} 分钟</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pi-ops">
|
||||
<button
|
||||
v-if="p.status !== 'done'" class="btn btn-xs"
|
||||
:class="{ 'timer-on': !!p.timer_started_at }"
|
||||
@click="toggleTimer(p)"
|
||||
>{{ p.timer_started_at ? '停止计时' : '计时' }}</button>
|
||||
<button v-if="p.status !== 'done'" class="btn btn-xs" @click="mark(p, 'done')">完成</button>
|
||||
<button v-if="p.status === 'todo'" class="btn btn-xs" @click="mark(p, 'skip')">跳过</button>
|
||||
<button v-if="p.status !== 'todo'" class="btn btn-xs" @click="mark(p, 'todo')">恢复</button>
|
||||
<button class="icon-btn" title="编辑" @click="openEdit(p)"><Icon name="edit" :size="14" /></button>
|
||||
<button class="icon-btn" title="删除" @click="remove(p)"><Icon name="close" :size="14" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<teleport to="body">
|
||||
<div v-if="dialogOpen" class="mask" @click.self="dialogOpen = false">
|
||||
<div class="dialog" role="dialog" aria-label="计划">
|
||||
<div class="dialog-head">
|
||||
<span class="dialog-title">{{ editingId ? '编辑计划' : '添加计划' }}</span>
|
||||
<button class="icon-btn" @click="dialogOpen = false"><Icon name="close" :size="16" /></button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-row">
|
||||
<label>计划内容(学什么、做什么)</label>
|
||||
<input class="input" v-model="fTitle" placeholder="如:产品文档第三章 / 晨跑 5 公里" @keydown.enter="submitPlan" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>类别</label>
|
||||
<select class="select" v-model="fKind">
|
||||
<option v-for="k in KINDS" :key="k" :value="k">{{ k }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row two">
|
||||
<div>
|
||||
<label>开始时间</label>
|
||||
<input class="input" type="time" v-model="fStart" />
|
||||
</div>
|
||||
<div>
|
||||
<label>结束时间</label>
|
||||
<input class="input" type="time" v-model="fEnd" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>备注(选填)</label>
|
||||
<input class="input" v-model="fNote" placeholder="目标、资料、提醒等" @keydown.enter="submitPlan" />
|
||||
</div>
|
||||
<div v-if="fError" class="form-error">{{ fError }}</div>
|
||||
</div>
|
||||
<div class="dialog-btns">
|
||||
<button class="btn btn-sm" @click="dialogOpen = false">取消</button>
|
||||
<button class="btn btn-primary btn-sm" @click="submitPlan">{{ editingId ? '保存' : '添加' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
<teleport to="body">
|
||||
<div v-if="goalOpen" class="mask" @click.self="goalOpen = false">
|
||||
<div class="dialog" role="dialog" aria-label="长期计划">
|
||||
<div class="dialog-head">
|
||||
<span class="dialog-title">{{ goalEditing ? '编辑长期计划' : '新建长期计划' }}</span>
|
||||
<button class="icon-btn" @click="goalOpen = false"><Icon name="close" :size="16" /></button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-row">
|
||||
<label>计划名称(落实为每天一条任务)</label>
|
||||
<input class="input" v-model="gTitle" placeholder="如:产品文档学习 / 健身月计划" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>类别</label>
|
||||
<select class="select" v-model="gKind">
|
||||
<option v-for="k in KINDS" :key="k" :value="k">{{ k }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row two">
|
||||
<div>
|
||||
<label>开始日期</label>
|
||||
<input class="input" type="date" v-model="gStart" />
|
||||
</div>
|
||||
<div>
|
||||
<label>结束日期</label>
|
||||
<input class="input" type="date" v-model="gEnd" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>备注(选填)</label>
|
||||
<input class="input" v-model="gNote" />
|
||||
</div>
|
||||
<div v-if="gError" class="form-error">{{ gError }}</div>
|
||||
<div class="muted goal-hint">创建后区间内每一天都会生成一条任务,可在每日安排中逐日打卡与计时。</div>
|
||||
</div>
|
||||
<div class="dialog-btns">
|
||||
<button class="btn btn-sm" @click="goalOpen = false">取消</button>
|
||||
<button class="btn btn-primary btn-sm" @click="submitGoal">{{ goalEditing ? '保存' : '创建' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="!!delGoal"
|
||||
title="删除长期计划"
|
||||
:message="delGoal ? '将删除「' + delGoal.title + '」及其全部每日任务(含已完成记录),不可恢复。' : ''"
|
||||
confirm-text="删除" danger
|
||||
@cancel="delGoal = null"
|
||||
@confirm="removeGoal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.plan-page { display: flex; flex-direction: column; }
|
||||
|
||||
.blk-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 12px; flex-wrap: wrap;
|
||||
margin: 4px 2px 10px;
|
||||
}
|
||||
.blk-head-text { min-width: 0; }
|
||||
.blk-title { font-size: 14px; font-weight: 600; }
|
||||
.blk-sub { font-size: 12px; margin-top: 1px; }
|
||||
.day-head { margin-top: 18px; }
|
||||
.day-ops { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.date-chip { min-width: 74px; text-align: center; font-weight: 600; font-size: 13px; }
|
||||
.date-chip.today { color: var(--accent); }
|
||||
.timer-live { color: var(--accent); font-weight: 600; }
|
||||
|
||||
.goal-card { display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px; margin-bottom: 10px; }
|
||||
.goal-main { flex: 1; min-width: 0; }
|
||||
.goal-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.goal-name { font-size: 14px; font-weight: 500; word-break: break-all; }
|
||||
.goal-range { font-size: 12px; }
|
||||
.goal-bar { height: 6px; border-radius: 3px; background: var(--bg-subtle); overflow: hidden; margin-top: 10px; }
|
||||
.goal-bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 3px; }
|
||||
.goal-meta { font-size: 12px; margin-top: 6px; }
|
||||
.goal-ops { display: flex; gap: 4px; }
|
||||
|
||||
.list-card { overflow: hidden; }
|
||||
.plan-item {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.plan-item:last-child { border-bottom: none; }
|
||||
.plan-item.done { opacity: 0.6; }
|
||||
.plan-item.skip { opacity: 0.48; }
|
||||
.plan-item.running { box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.pi-time { font-size: 13px; font-weight: 600; min-width: 92px; padding-top: 2px; }
|
||||
.pi-main { flex: 1; min-width: 0; }
|
||||
.pi-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.pi-text { font-size: 14px; font-weight: 500; word-break: break-all; }
|
||||
.pi-text.strike { text-decoration: line-through; }
|
||||
.state-done { color: var(--ok); font-size: 12px; }
|
||||
.state-skip { color: var(--text-3); font-size: 12px; }
|
||||
.pi-note { font-size: 12px; margin-top: 3px; }
|
||||
.pi-meta { font-size: 12px; margin-top: 3px; }
|
||||
.pi-ops { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.timer-on { color: var(--accent); border-color: var(--accent); }
|
||||
.empty { padding: 28px 16px; }
|
||||
|
||||
.dialog { width: 420px; }
|
||||
.form-row { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
|
||||
.form-row.two { flex-direction: row; gap: 10px; }
|
||||
.form-row.two > div { flex: 1; display: flex; flex-direction: column; gap: 5px; }
|
||||
.goal-hint { font-size: 12px; margin-top: 8px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.plan-item { flex-wrap: wrap; }
|
||||
.pi-ops { width: 100%; justify-content: flex-start; }
|
||||
.day-ops { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -210,21 +210,10 @@ function undoPolish() {
|
||||
<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; }
|
||||
.w-date { width: 140px; flex: none; }
|
||||
|
||||
.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-title { margin-bottom: 10px; }
|
||||
.section + .section { margin-top: 12px; }
|
||||
|
||||
.note-label { display: block; font-size: 12px; margin: 8px 0 4px; }
|
||||
@@ -232,7 +221,7 @@ function undoPolish() {
|
||||
.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; }
|
||||
.section-sub { font-weight: 600; margin: 12px 0 4px; font-size: 12px; color: var(--text-2); }
|
||||
.sum-item { font-size: 12px; padding: 2px 0; word-break: break-all; }
|
||||
.sum-empty { font-size: 12px; }
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
renameProject, saveLocationPresets, setProjectArchived, setSetting, type WorkRule
|
||||
} from '../db/repo'
|
||||
import { categoryLabel, type Project } from '../db/schema'
|
||||
import { getLogCategories, setLogCategories, getLedgerCategories, setLedgerCategories } from '../db/categories'
|
||||
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 { setSyncBaseOverride } from '../lib/server'
|
||||
import { ensureCloudBinding, getSyncBase, getSyncLastTime, isSyncConfigured, syncWithStoredConfig, syncLastError, type SyncResult } from '../sync/engine'
|
||||
import { ensureCloudBinding, getSyncLastTime, isSyncConfigured, syncWithStoredConfig, syncLastError, type SyncResult } from '../sync/engine'
|
||||
import { getCloudCreds } from '../db/auth'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
||||
@@ -28,6 +28,50 @@ const aiKey = ref('')
|
||||
const aiModel = ref('')
|
||||
const editingId = ref<number | null>(null)
|
||||
const editName = ref('')
|
||||
// ---- 分类管理 ----
|
||||
const logCats = ref<string[]>([])
|
||||
const expenseCats = ref<string[]>([])
|
||||
const incomeCats = ref<string[]>([])
|
||||
const editingCat = ref<{ kind: 'log' | 'expense' | 'income'; index: number; value: string } | null>(null)
|
||||
|
||||
async function loadCategories() {
|
||||
logCats.value = await getLogCategories()
|
||||
expenseCats.value = await getLedgerCategories('expense')
|
||||
incomeCats.value = await getLedgerCategories('income')
|
||||
}
|
||||
async function addCat(kind: 'log' | 'expense' | 'income') {
|
||||
const name = window.prompt('新分类名称')
|
||||
if (!name || !name.trim()) return
|
||||
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
|
||||
const clean = name.trim()
|
||||
if (list.includes(clean)) { showToast('分类已存在'); return }
|
||||
const next = [...list, clean]
|
||||
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
|
||||
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
|
||||
else { incomeCats.value = next; await setLedgerCategories('income', next) }
|
||||
showToast('分类已添加')
|
||||
}
|
||||
async function renameCat(kind: 'log' | 'expense' | 'income', index: number, oldName: string) {
|
||||
const name = window.prompt('修改分类名称', oldName)
|
||||
if (!name || !name.trim() || name.trim() === oldName) return
|
||||
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
|
||||
const next = list.map((x, i) => (i === index ? name.trim() : x))
|
||||
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
|
||||
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
|
||||
else { incomeCats.value = next; await setLedgerCategories('income', next) }
|
||||
showToast('分类已更新(历史记录自动跟随显示)')
|
||||
}
|
||||
async function removeCat(kind: 'log' | 'expense' | 'income', index: number) {
|
||||
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
|
||||
if (list.length <= 1) { showToast('至少保留一个分类'); return }
|
||||
if (!window.confirm('删除分类「' + list[index] + '」?历史记录不受影响。')) return
|
||||
const next = list.filter((_, i) => i !== index)
|
||||
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
|
||||
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
|
||||
else { incomeCats.value = next; await setLedgerCategories('income', next) }
|
||||
showToast('分类已删除')
|
||||
}
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const pendingImport = ref<{ name: string; text: string } | null>(null)
|
||||
const importError = ref('')
|
||||
@@ -38,7 +82,6 @@ const newLoginPwd2 = ref('')
|
||||
const accError = ref('')
|
||||
const deleteAccConfirm = ref(false)
|
||||
const appVersion = __APP_VERSION__
|
||||
const syncBase = ref('')
|
||||
const cloudUser = ref('')
|
||||
const syncStatus = ref('')
|
||||
const syncError = ref('')
|
||||
@@ -47,7 +90,6 @@ const syncing = ref(false)
|
||||
const lastSync = ref('')
|
||||
|
||||
async function loadSyncInfo() {
|
||||
syncBase.value = await getSyncBase()
|
||||
cloudUser.value = getCloudCreds()?.username || ''
|
||||
lastSync.value = await getSyncLastTime()
|
||||
autoSyncErr.value = syncLastError.value
|
||||
@@ -55,11 +97,6 @@ async function loadSyncInfo() {
|
||||
watch(syncLastError, v => { autoSyncErr.value = v })
|
||||
|
||||
|
||||
async function saveSyncConfig() {
|
||||
await setSyncBaseOverride(syncBase.value.trim())
|
||||
showToast('同步地址已保存')
|
||||
}
|
||||
|
||||
async function rebindCloud() {
|
||||
syncError.value = ''
|
||||
try {
|
||||
@@ -76,7 +113,6 @@ async function doSyncNow() {
|
||||
autoSyncErr.value = syncLastError.value
|
||||
syncing.value = true
|
||||
try {
|
||||
await saveSyncConfig()
|
||||
const result: SyncResult = await syncWithStoredConfig()
|
||||
lastSync.value = result.at
|
||||
syncStatus.value = '推送 ' + result.pushed + ' 条 · 拉取 ' + result.pulled + ' 条 · 删除 ' + result.removed + ' 条'
|
||||
@@ -118,6 +154,7 @@ async function load() {
|
||||
aiKey.value = await getSetting('ai_api_key', '')
|
||||
aiModel.value = await getSetting('ai_model', '')
|
||||
await loadSyncInfo()
|
||||
await loadCategories()
|
||||
}
|
||||
|
||||
async function saveAi() {
|
||||
@@ -263,7 +300,7 @@ async function doImport() {
|
||||
<div class="settings">
|
||||
<div class="card section">
|
||||
<div class="section-title">账号与隐私</div>
|
||||
<div class="muted acc-line">版本 v{{ appVersion }} · 当前用户:{{ username }} · 每位用户独立数据库隔离存储于本机 · 登录密码仅保存 PBKDF2 哈希</div>
|
||||
<div class="muted acc-line">v{{ appVersion }} · 当前账号 {{ username }} · 登录密码仅存 PBKDF2 哈希</div>
|
||||
<div class="ai-grid">
|
||||
<div class="rule-item">
|
||||
<label>旧登录密码</label>
|
||||
@@ -352,15 +389,10 @@ async function doImport() {
|
||||
|
||||
<div class="card section">
|
||||
<div class="section-title">多端数据同步</div>
|
||||
<div class="muted acc-line">云端账号:{{ cloudUser || '未绑定' }} · 数据仅保存在服务器,本机只是实时缓存(变更 ~3 秒推上云端 + 其他端秒级自动刷新);密码库同步密文,主密码永不出本机</div>
|
||||
<div class="rule-item">
|
||||
<label>同步服务地址(默认即可)</label>
|
||||
<input class="input" v-model="syncBase" placeholder="https://daywk.linyux.cn" />
|
||||
</div>
|
||||
<div class="muted acc-line">云端账号:{{ cloudUser || '未绑定' }} · 数据存于服务器,其他端变更秒级自动刷新</div>
|
||||
<div class="rule-save">
|
||||
<button class="btn btn-sm" @click="saveSyncConfig">保存地址</button>
|
||||
<button class="btn btn-primary btn-sm" :disabled="syncing" @click="doSyncNow">{{ syncing ? '同步中' : '立即同步' }}</button>
|
||||
<button class="btn btn-sm" @click="rebindCloud">重建云端绑定</button>
|
||||
<button class="btn btn-sm" @click="rebindCloud">重新校验连接</button>
|
||||
</div>
|
||||
<div v-if="syncStatus" class="muted acc-line">上次同步:{{ lastSync || '--' }} · {{ syncStatus }}</div>
|
||||
<div v-if="syncError" class="form-error">{{ syncError }}</div>
|
||||
@@ -385,7 +417,41 @@ async function doImport() {
|
||||
</div>
|
||||
<div class="rule-save">
|
||||
<button class="btn btn-primary btn-sm" @click="saveAi">保存 AI 配置</button>
|
||||
<span class="muted loc-note">配置仅保存在本机浏览器;总结页可用 AI 润色草稿,需服务端允许浏览器跨域</span>
|
||||
<span class="muted loc-note">配置仅保存在本机;总结页「AI 润色」需此处先配置</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card section">
|
||||
<div class="section-title">工作分类</div>
|
||||
<div class="cat-block">
|
||||
<div class="cat-head"><span class="muted">工作记录分类</span><button class="btn btn-xs" @click="addCat('log')">添加</button></div>
|
||||
<div class="cat-chips">
|
||||
<span v-for="(c, i) in logCats" :key="c + i" class="cat-chip">
|
||||
{{ c }}
|
||||
<button class="chip-btn" title="修改" @click="renameCat('log', i, c)">改</button>
|
||||
<button class="chip-btn" title="删除" @click="removeCat('log', i)">删</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cat-block">
|
||||
<div class="cat-head"><span class="muted">记账支出分类</span><button class="btn btn-xs" @click="addCat('expense')">添加</button></div>
|
||||
<div class="cat-chips">
|
||||
<span v-for="(c, i) in expenseCats" :key="c + i" class="cat-chip">
|
||||
{{ c }}
|
||||
<button class="chip-btn" title="修改" @click="renameCat('expense', i, c)">改</button>
|
||||
<button class="chip-btn" title="删除" @click="removeCat('expense', i)">删</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cat-block">
|
||||
<div class="cat-head"><span class="muted">记账收入分类</span><button class="btn btn-xs" @click="addCat('income')">添加</button></div>
|
||||
<div class="cat-chips">
|
||||
<span v-for="(c, i) in incomeCats" :key="c + i" class="cat-chip">
|
||||
{{ c }}
|
||||
<button class="chip-btn" title="修改" @click="renameCat('income', i, c)">改</button>
|
||||
<button class="chip-btn" title="删除" @click="removeCat('income', i)">删</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -402,7 +468,7 @@ async function doImport() {
|
||||
/>
|
||||
</div>
|
||||
<div v-if="importError" class="import-error">{{ importError }}</div>
|
||||
<div class="muted backup-note">数据保存在浏览器 IndexedDB 中,建议定期导出 JSON 备份。</div>
|
||||
<div class="muted backup-note">数据保存在云端服务器;导出的 JSON 备份用于跨设备迁移或灾后恢复。</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -428,18 +494,16 @@ async function doImport() {
|
||||
<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; }
|
||||
.rule-save { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
|
||||
|
||||
.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);
|
||||
padding: 7px 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.proj-row:last-child { border-bottom: none; }
|
||||
.proj-name { flex: 1; word-break: break-all; }
|
||||
@@ -447,7 +511,19 @@ async function doImport() {
|
||||
|
||||
.loc-note { font-size: 12px; }
|
||||
.ai-grid { display: flex; flex-direction: column; gap: 10px; }
|
||||
.backup-row { display: flex; gap: 8px; }
|
||||
.backup-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.cat-block { margin-top: 12px; }
|
||||
.cat-block:first-of-type { margin-top: 0; }
|
||||
.cat-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 12px; }
|
||||
.cat-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.cat-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
border: 1px solid var(--border); border-radius: 999px;
|
||||
padding: 2px 8px; font-size: 12px;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.chip-btn { border: none; background: transparent; color: var(--text-3); cursor: pointer; font-size: 11px; padding: 0 1px; }
|
||||
.chip-btn:hover { color: var(--accent); }
|
||||
.import-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
|
||||
.backup-note { font-size: 12px; margin-top: 10px; }
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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 { getLogCategories } from '../db/categories'
|
||||
import { addDays, todayStr, weekStart } from '../lib/date'
|
||||
import { attendanceChanged } from '../lib/bus'
|
||||
|
||||
@@ -57,10 +57,15 @@ async function loadAll(): Promise<void> {
|
||||
.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) }
|
||||
const logCats = await getLogCategories()
|
||||
const catCount = new Map<string, number>()
|
||||
for (const it of items) catCount.set(it.category, (catCount.get(it.category) || 0) + 1)
|
||||
const catSet = new Set(logCats)
|
||||
for (const k of catCount.keys()) if (!catSet.has(k)) logCats.push(k)
|
||||
catStats.value = logCats
|
||||
.map(label => {
|
||||
const count = catCount.get(label) || 0
|
||||
return { key: label, label, count, percent: Math.round((count / total) * 100) }
|
||||
})
|
||||
.filter(c => c.count > 0)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
@@ -175,32 +180,28 @@ function heatTitle(date: string, count: number): string {
|
||||
|
||||
<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; }
|
||||
.section { margin-top: 12px; }
|
||||
|
||||
.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; }
|
||||
.heat-cell { width: 12px; height: 12px; border-radius: 3px; 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; }
|
||||
.lv1 { background: #E4EEFF; }
|
||||
.lv2 { background: #BFD6FF; }
|
||||
.lv3 { background: #7FA8F5; }
|
||||
.lv4 { background: var(--accent); }
|
||||
.heat-legend { display: flex; align-items: center; gap: 3px; margin-top: 10px; 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; }
|
||||
.two-col .section { margin-top: 0; }
|
||||
.bar-label { width: 72px; }
|
||||
.bar-num { width: 92px; }
|
||||
.loc-label { width: 110px; text-align: left; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.cards { grid-template-columns: repeat(2, 1fr); }
|
||||
.two-col { grid-template-columns: 1fr; }
|
||||
.two-col .section { margin-top: 12px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import DayPanel from '../components/log/DayPanel.vue'
|
||||
import { addDays, formatDateCN, todayStr } from '../lib/date'
|
||||
import { listLogItemsBetween, listAttendanceBetween, listProjects, getWorkRule } from '../db/repo'
|
||||
import { computeDay } from '../domain/attendance'
|
||||
import { addDays, formatDateCN, hhmm, todayStr, weekStart } from '../lib/date'
|
||||
import { downloadText } from '../lib/download'
|
||||
import { showToast } from '../lib/toast'
|
||||
import { categoryLabel } from '../db/schema'
|
||||
|
||||
// 详情视图:自由切换日期,对任意一天进行详细查看与修改
|
||||
const date = ref(todayStr())
|
||||
@@ -11,39 +16,129 @@ const isToday = computed(() => date.value === todayStr())
|
||||
function prevDay() { date.value = addDays(date.value, -1) }
|
||||
function nextDay() { date.value = addDays(date.value, 1) }
|
||||
function goToday() { date.value = todayStr() }
|
||||
|
||||
// ---- 工作详情导出(Markdown) ----
|
||||
const expRange = ref<'day' | 'week' | 'month' | 'custom'>('day')
|
||||
const expStart = ref(todayStr())
|
||||
const expEnd = ref(todayStr())
|
||||
const exporting = ref(false)
|
||||
function rangeStartEnd(): { start: string; end: string; label: string } {
|
||||
// 「指定日」导出:导的是详情页当前浏览的那一天,而不是今天
|
||||
if (expRange.value === 'day') return { start: date.value, end: date.value, label: date.value }
|
||||
if (expRange.value === 'week') {
|
||||
const ws = weekStart(expStart.value)
|
||||
return { start: ws, end: addDays(ws, 6), label: ws + '_' + addDays(ws, 6) }
|
||||
}
|
||||
if (expRange.value === 'month') {
|
||||
const y = expStart.value.slice(0, 4)
|
||||
const m = expStart.value.slice(5, 7)
|
||||
const last = new Date(Number(y), Number(m), 0).getDate()
|
||||
return { start: y + '-' + m + '-01', end: y + '-' + m + '-' + last, label: y + '-' + m }
|
||||
}
|
||||
return { start: expStart.value, end: expEnd.value, label: expStart.value + '_' + expEnd.value }
|
||||
}
|
||||
async function doExportMd() {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const { start, end, label } = rangeStartEnd()
|
||||
const [rule, atts, items, projects] = await Promise.all([
|
||||
getWorkRule(),
|
||||
listAttendanceBetween(start, end),
|
||||
listLogItemsBetween(start, end),
|
||||
listProjects(true)
|
||||
])
|
||||
const pname = new Map<number, string>(projects.map(p => [p.id as number, p.name]))
|
||||
const lines: string[] = ['# 工作详情 ' + label, '']
|
||||
const wd = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
let cur = start
|
||||
let guard = 0
|
||||
while (cur <= end && guard < 400) {
|
||||
const dayItems = items.filter(i => i.date === cur)
|
||||
const att = atts.find(a => a.date === cur)
|
||||
if (att || dayItems.length) {
|
||||
lines.push('## ' + cur + ' ' + wd[new Date(cur + 'T00:00:00').getDay()], '')
|
||||
if (att && (att.clock_in_at || att.kind === 'makeup')) {
|
||||
const info = computeDay(att, cur, rule, cur === todayStr())
|
||||
const seg = [hhmm(att.clock_in_at), '–', hhmm(att.clock_out_at), '·', info.label]
|
||||
if (att.location) seg.push('· 地点:' + att.location)
|
||||
lines.push('考勤:' + seg.join(' '), '')
|
||||
}
|
||||
for (const it of dayItems) {
|
||||
const proj = it.project_id ? (pname.get(it.project_id) || '') : ''
|
||||
const head = '- 【' + categoryLabel(it.category) + '】' + it.content + (it.minutes ? '(' + it.minutes + ' 分钟)' : '') + (proj ? ' @' + proj : '')
|
||||
lines.push(head)
|
||||
if (it.takeaway) lines.push(' - 收获:' + it.takeaway)
|
||||
if (it.needs_followup === 1 && it.remark) lines.push(' - 待跟进:' + it.remark)
|
||||
else if (it.remark) lines.push(' - 备注:' + it.remark)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
cur = addDays(cur, 1)
|
||||
guard++
|
||||
}
|
||||
await downloadText('工作详情_' + label + '.md', lines.join('\n'))
|
||||
showToast('已导出 Markdown')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDateInput(e: Event) {
|
||||
const v = (e.target as HTMLInputElement).value
|
||||
if (v) date.value = v
|
||||
}
|
||||
</script>
|
||||
|
||||
// 浏览日期变化时,导出区间锚点跟随(周/月/自定义默认围绕当前指定日)
|
||||
watch(date, d => { expStart.value = d; if (expRange.value === 'custom') expEnd.value = d })
|
||||
</script>
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="detail-toolbar">
|
||||
<button class="icon-btn" title="前一天" @click="prevDay"><Icon name="chevron-left" :size="16" /></button>
|
||||
<span class="date-title num" :class="{ 'is-today': isToday }">{{ formatDateCN(date) }}</span>
|
||||
<button class="icon-btn" title="后一天" @click="nextDay"><Icon name="chevron-right" :size="16" /></button>
|
||||
<input type="date" class="input w-date" :value="date" @change="onDateInput" />
|
||||
<button class="btn btn-sm" :disabled="isToday" @click="goToday">回到今天</button>
|
||||
<div class="card toolbar">
|
||||
<div class="nav-row">
|
||||
<button class="icon-btn" title="前一天" @click="prevDay"><Icon name="chevron-left" :size="16" /></button>
|
||||
<span class="date-title num" :class="{ 'is-today': isToday }">{{ formatDateCN(date) }}</span>
|
||||
<button class="icon-btn" title="后一天" @click="nextDay"><Icon name="chevron-right" :size="16" /></button>
|
||||
<input type="date" class="input w-date" :value="date" @change="onDateInput" />
|
||||
<button class="btn btn-sm" :disabled="isToday" @click="goToday">回到今天</button>
|
||||
</div>
|
||||
<div class="tool-sep"></div>
|
||||
<div class="exp-row">
|
||||
<span class="muted exp-label">导出</span>
|
||||
<div class="seg">
|
||||
<button class="seg-btn" :class="{ active: expRange === 'day' }" @click="expRange = 'day'">指定日</button>
|
||||
<button class="seg-btn" :class="{ active: expRange === 'week' }" @click="expRange = 'week'">当周</button>
|
||||
<button class="seg-btn" :class="{ active: expRange === 'month' }" @click="expRange = 'month'">当月</button>
|
||||
<button class="seg-btn" :class="{ active: expRange === 'custom' }" @click="expRange = 'custom'">自定义</button>
|
||||
</div>
|
||||
<input v-if="expRange !== 'day'" class="input w-exp" type="date" v-model="expStart" />
|
||||
<input v-if="expRange === 'custom'" class="input w-exp" type="date" v-model="expEnd" />
|
||||
<button class="btn btn-sm" :disabled="exporting" @click="doExportMd">导出 Markdown</button>
|
||||
</div>
|
||||
</div>
|
||||
<DayPanel :date="date" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
.detail-page { display: flex; flex-direction: column; }
|
||||
.toolbar {
|
||||
padding: 10px 16px;
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.nav-row { display: flex; align-items: center; gap: 6px; }
|
||||
.date-title { font-size: 14px; font-weight: 600; min-width: 170px; text-align: center; }
|
||||
.date-title.is-today { color: var(--accent); }
|
||||
.w-date { width: 150px; }
|
||||
.w-date { width: 150px; flex: none; }
|
||||
.tool-sep { width: 1px; align-self: stretch; background: var(--border); }
|
||||
.exp-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.exp-label { font-size: 12px; }
|
||||
.w-exp { width: 140px; flex: none; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.detail-toolbar { gap: 8px; }
|
||||
.tool-sep { display: none; }
|
||||
.nav-row { flex: 1; }
|
||||
.date-title { min-width: 0; flex: 1; text-align: left; }
|
||||
.w-date { flex: 1; min-width: 120px; width: auto; }
|
||||
}
|
||||
|
||||
@@ -150,24 +150,20 @@ async function doDelete() {
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page">
|
||||
<div class="card sec">
|
||||
<div class="card table-card">
|
||||
<div class="toolbar">
|
||||
<div class="head-wrap">
|
||||
<div class="sec-title">用户管理</div>
|
||||
<div class="muted head-sub">云端账号统一管理 · 管理员:xionglin</div>
|
||||
<div class="tb-title">用户管理</div>
|
||||
<div class="muted tb-sub">云端账号统一管理 · 管理员:xionglin</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click="openCreate">
|
||||
<Icon name="plus" :size="14" />
|
||||
新增用户
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="error" class="form-error">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card table-card admin-table-card">
|
||||
<div v-if="error" class="form-error tb-error">{{ error }}</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -202,8 +198,8 @@ onMounted(load)
|
||||
|
||||
<!-- 新增用户弹窗 -->
|
||||
<teleport to="body">
|
||||
<div v-if="createOpen" class="fixed-mask" @click.self="createOpen = false">
|
||||
<div class="dialog create-dialog" role="dialog" aria-label="新增用户">
|
||||
<div v-if="createOpen" class="mask" @click.self="createOpen = false">
|
||||
<div class="dialog" role="dialog" aria-label="新增用户">
|
||||
<div class="dialog-head">
|
||||
<span class="dialog-title">新增用户</span>
|
||||
<button class="icon-btn" @click="createOpen = false"><Icon name="close" :size="16" /></button>
|
||||
@@ -233,8 +229,8 @@ onMounted(load)
|
||||
|
||||
<!-- 重置密码弹窗 -->
|
||||
<teleport to="body">
|
||||
<div v-if="resetTarget" class="fixed-mask" @click.self="resetTarget = null">
|
||||
<div class="dialog create-dialog" role="dialog" aria-label="重置密码">
|
||||
<div v-if="resetTarget" class="mask" @click.self="resetTarget = null">
|
||||
<div class="dialog" role="dialog" aria-label="重置密码">
|
||||
<div class="dialog-head">
|
||||
<span class="dialog-title">重置「{{ resetTarget.username }}」的登录密码</span>
|
||||
<button class="icon-btn" @click="resetTarget = null"><Icon name="close" :size="16" /></button>
|
||||
@@ -264,60 +260,46 @@ onMounted(load)
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-page { display: flex; flex-direction: column; gap: 12px; max-width: 860px; }
|
||||
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; }
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.head-wrap { flex: 1; min-width: 0; }
|
||||
.sec-title { font-weight: 600; font-size: 13px; }
|
||||
.head-sub { font-size: 12px; margin-top: 2px; }
|
||||
.sec { padding: 14px 16px; }
|
||||
.tb-title { font-weight: 600; font-size: 14px; }
|
||||
.tb-sub { font-size: 12px; margin-top: 2px; }
|
||||
.tb-error { padding: 8px 16px 0; }
|
||||
|
||||
.admin-table-card { overflow-x: auto; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.admin-table th { text-align: left; font-weight: 500; color: var(--text-3); font-size: 12px; padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
.admin-table td { padding: 9px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.admin-table td:last-child { border-bottom: none; }
|
||||
.table-card { overflow-x: auto; }
|
||||
.admin-table td { white-space: nowrap; }
|
||||
.td-name { font-weight: 500; }
|
||||
.role-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
padding: 1px 8px;
|
||||
line-height: 18px;
|
||||
padding: 1px 9px;
|
||||
}
|
||||
.load-line { padding: 12px; font-size: 12px; }
|
||||
.load-line { padding: 12px 16px; font-size: 12px; }
|
||||
.admin-note { font-size: 12px; padding: 0 2px; }
|
||||
.ops { display: inline-flex; gap: 6px; }
|
||||
.btn-danger { color: var(--danger); }
|
||||
|
||||
.form-row { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
|
||||
.form-row label { font-size: 12px; color: var(--text-2); }
|
||||
.fixed-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: rgba(29, 33, 41, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.dialog { background: var(--bg-page); border-radius: 12px; width: 420px; max-width: 100%; }
|
||||
.dialog-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 0; }
|
||||
.dialog-title { font-weight: 600; font-size: 14px; }
|
||||
.dialog-body { padding: 6px 16px 4px; }
|
||||
.dialog-btns { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 16px 16px; }
|
||||
.btn-danger { color: var(--danger); }
|
||||
.dialog { width: 420px; }
|
||||
|
||||
.empty { text-align: center; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.toolbar { flex-wrap: wrap; row-gap: 8px; }
|
||||
.admin-table td, .admin-table th { white-space: normal; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -284,7 +284,7 @@ async function doChangePwd() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="muted vault-note num">共 {{ rows.length }} 条账号记录 · 密码以 AES-256-GCM 加密,服务器仅存密文</div>
|
||||
<div class="muted table-note num">共 {{ rows.length }} 条账号记录 · 密码以 AES-256-GCM 加密,服务器仅存密文</div>
|
||||
</template>
|
||||
|
||||
<CredentialDialog
|
||||
@@ -356,18 +356,15 @@ async function doChangePwd() {
|
||||
height: 52px;
|
||||
margin: 0 auto;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.lock-title { font-size: 14px; font-weight: 600; }
|
||||
.lock-title { font-size: 15px; 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%; }
|
||||
@@ -375,19 +372,6 @@ async function doChangePwd() {
|
||||
.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; }
|
||||
@@ -395,43 +379,9 @@ async function doChangePwd() {
|
||||
.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; }
|
||||
.table-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);
|
||||
}
|
||||
.dialog { width: 440px; }
|
||||
.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>
|
||||
|
||||
+19
-3
@@ -14,10 +14,11 @@
|
||||
cd /opt
|
||||
git clone https://gitea.linyux.cn/root/daywork.git daywork 2>/dev/null || (cd daywork && git pull)
|
||||
cd daywork/server
|
||||
rm -rf data
|
||||
docker compose up -d --build
|
||||
|
||||
注:rm -rf data 清除开发期残留的测试密钥,服务端首次启动会自动生成全新的签名密钥。
|
||||
**警告:绝对不要删除 server/data 目录** —— 这里是所有用户的正式数据(注册表、业务数据、每日备份)。
|
||||
首次全新部署(且确认 data 目录从未存在过)无需任何清理动作,服务端首次启动会自动生成签名密钥。
|
||||
另外,客户端内置「数据纪元」保护:即使服务器数据目录意外重建,任一保留本地缓存的设备在下次同步时会自动把全量数据推回服务器。
|
||||
|
||||
容器 internlog-sync 加入 mynet 网络(与 caddy 同网络,Caddy 可用容器名访问)。
|
||||
|
||||
@@ -62,7 +63,22 @@ Caddy 会自动为 daywk.linyux.cn 申请并续期 HTTPS 证书(需 DNS 已生
|
||||
- 数据目录:/opt/daywork/server/data
|
||||
- registry.json 用户注册表(用户名 / 角色 / PBKDF2 哈希)
|
||||
- users/<id>.json 每用户九张表数据 + 墓碑
|
||||
- backups/<日期>/ **每日自动备份**(启动与每次注册表变更也会触发,保留最近 14 天)
|
||||
- backups/<日期>/ **每日自动本地备份**(启动与每次注册表变更也会触发,保留最近 14 天)
|
||||
- **异地备份(推荐开启)**:在 docker-compose.yml 的 internlog-sync 服务加环境变量,备份自动上传腾讯云 COS:
|
||||
|
||||
environment:
|
||||
COS_SECRET_ID: <你的 SecretId>
|
||||
COS_SECRET_KEY: <你的 SecretKey>
|
||||
COS_BUCKET: <桶名-AppId,如 footprint-1435255581>
|
||||
COS_REGION: <地域,如 ap-guangzhou>
|
||||
|
||||
开启后每次本地备份都会同步上传(含 latest/ 覆盖版);上传状态可随时查看:
|
||||
|
||||
curl https://daywk.linyux.cn/api/health
|
||||
|
||||
返回 backup: "ok" / "failed: ..." / "local-only"(未配置)。
|
||||
- 会话安全:修改密码或管理员重置密码后,该用户**所有旧登录令牌立即失效**(各端自动用保存的凭据重新登录,用户无感知)
|
||||
- 登录防爆破:同一 IP 十分钟内密码错误满 10 次将临时拒绝(429)
|
||||
- 更新版本:cd /opt/daywork && git pull && cd server && docker compose up -d --build
|
||||
- 同步协议:POST /api/sync(Bearer token;推送差异并返回权威全量快照)
|
||||
- 实时通知:GET /api/events?token=...(SSE,同账号任意端有变更即广播 change)
|
||||
|
||||
+102
-12
@@ -12,7 +12,7 @@ const PORT = Number(process.env.PORT || 3780)
|
||||
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天
|
||||
const ITER = 150000
|
||||
|
||||
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
|
||||
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'goal', 'transaction', 'plan']
|
||||
|
||||
// 自然键:同一逻辑记录在两台设备上独立创建时的去重依据
|
||||
const NATURAL_KEYS = {
|
||||
@@ -39,6 +39,69 @@ function writeJson(file, data) {
|
||||
fs.renameSync(tmp, path.join(DATA_DIR, file))
|
||||
}
|
||||
|
||||
// ---------- 异地备份:可选,配置环境变量后每日备份与注册表变更时自动上传 COS ----------
|
||||
// COS_SECRET_ID / COS_SECRET_KEY / COS_BUCKET(形如 name-125xxxxxx) / COS_REGION(形如 ap-guangzhou)
|
||||
const COS_CFG = {
|
||||
secretId: process.env.COS_SECRET_ID || '',
|
||||
secretKey: process.env.COS_SECRET_KEY || '',
|
||||
bucket: process.env.COS_BUCKET || '',
|
||||
region: process.env.COS_REGION || ''
|
||||
}
|
||||
let lastOffsiteBackup = { state: 'disabled', at: '', detail: '' }
|
||||
function cosEncode(s) {
|
||||
return encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase())
|
||||
}
|
||||
function cosAuth(method, pathname) {
|
||||
const start = Math.floor(Date.now() / 1000) - 60
|
||||
const end = start + 600
|
||||
const keyTime = start + ';' + end
|
||||
const signKey = crypto.createHmac('sha1', COS_CFG.secretKey).update(keyTime).digest('hex')
|
||||
const lowerPath = pathname.toLowerCase()
|
||||
const httpString = method.toLowerCase() + '\n' + cosEncode(lowerPath) + '\n' + '' + '\n' + 'host=' + COS_CFG.bucket + '.cos.' + COS_CFG.region + '.myqcloud.com' + '\n'
|
||||
const stringToSign = crypto.createHash('sha1').update(httpString).digest('hex') + '\n' + signKey + '\n' + keyTime + '\n' + crypto.createHash('sha1').update('host').digest('hex')
|
||||
const signature = crypto.createHmac('sha1', signKey).update(stringToSign).digest('hex')
|
||||
return 'q-sign-algorithm=sha1&q-ak=' + COS_CFG.secretId + '&q-sign-time=' + keyTime + '&q-key-time=' + keyTime + '&q-header-list=host&q-url-param-list=&q-signature=' + signature
|
||||
}
|
||||
async function cosPut(key, filePath) {
|
||||
const body = fs.readFileSync(filePath)
|
||||
const pathname = '/' + key.split('/').map(cosEncode).join('/')
|
||||
const auth = cosAuth('put', pathname)
|
||||
const url = 'https://' + COS_CFG.bucket + '.cos.' + COS_CFG.region + '.myqcloud.com' + pathname
|
||||
const resp = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Host: COS_CFG.bucket + '.cos.' + COS_CFG.region + '.myqcloud.com',
|
||||
Authorization: auth,
|
||||
'Content-Length': String(body.length)
|
||||
},
|
||||
body
|
||||
})
|
||||
if (!resp.ok) throw new Error('COS ' + resp.status + ' ' + (await resp.text()).slice(0, 120))
|
||||
}
|
||||
async function backupOffsite() {
|
||||
if (!COS_CFG.secretId || !COS_CFG.secretKey || !COS_CFG.bucket || !COS_CFG.region) return
|
||||
try {
|
||||
const day = new Date().toISOString().slice(0, 10)
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const files = [['registry.json', 'registry.json'], ['secret.key', 'secret.key']]
|
||||
try {
|
||||
const usersDir = path.join(DATA_DIR, 'users')
|
||||
if (fs.existsSync(usersDir)) {
|
||||
for (const f of fs.readdirSync(usersDir)) if (f.endsWith('.json')) files.push(['users/' + f, 'users/' + f])
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
for (const [rel, src] of files) {
|
||||
const full = path.join(DATA_DIR, src)
|
||||
if (!fs.existsSync(full)) continue
|
||||
await cosPut('daywork-backup/' + day + '/' + stamp + '/' + rel, full)
|
||||
await cosPut('daywork-backup/latest/' + rel, full)
|
||||
}
|
||||
lastOffsiteBackup = { state: 'ok', at: new Date().toISOString(), detail: files.length + ' files' }
|
||||
} catch (e) {
|
||||
lastOffsiteBackup = { state: 'failed', at: new Date().toISOString(), detail: (e && e.message) || String(e) }
|
||||
console.error('offsite backup failed:', e && e.message)
|
||||
}
|
||||
}
|
||||
if (!fs.existsSync(path.join(DATA_DIR, 'secret.key'))) {
|
||||
writeJson('secret.key', { secret: crypto.randomBytes(32).toString('hex') })
|
||||
}
|
||||
@@ -55,7 +118,7 @@ for (const u of registry.users) {
|
||||
if (adminDirty) saveRegistry()
|
||||
function isAdminToken(token) {
|
||||
const auth = verifyToken(token)
|
||||
if (!auth) return null
|
||||
if (!auth || !tokenUsable(auth)) return null
|
||||
const rec = registry.users.find(u => u.id === auth.userId)
|
||||
if (!rec || rec.role !== 'admin') return null
|
||||
return rec
|
||||
@@ -64,7 +127,10 @@ function saveRegistry() { writeJson(registryFile, registry); try { doBackup() }
|
||||
|
||||
function userFile(userId) { return path.join(DATA_DIR, 'users', userId + '.json') }
|
||||
function loadUserData(userId) {
|
||||
return readJson('users/' + userId + '.json', { userId, cursor: 0, tables: {}, tombstones: {} })
|
||||
const data = readJson('users/' + userId + '.json', { userId, cursor: 0, tables: {}, tombstones: {} })
|
||||
// 数据纪元:用户数据文件首次创建时间。目录被误删重建后该值改变,客户端据此识别并全量重推自救
|
||||
if (!data.created_at) data.created_at = new Date().toISOString()
|
||||
return data
|
||||
}
|
||||
function saveUserData(userId, data) {
|
||||
fs.mkdirSync(path.join(DATA_DIR, 'users'), { recursive: true })
|
||||
@@ -76,7 +142,7 @@ function pbkdf2Hash(password, salt) {
|
||||
return crypto.pbkdf2Sync(password, salt, ITER, 32, 'sha256').toString('hex')
|
||||
}
|
||||
function makeToken(userId) {
|
||||
const payload = base64url(JSON.stringify({ userId, exp: Date.now() + TOKEN_TTL_MS }))
|
||||
const payload = base64url(JSON.stringify({ userId, exp: Date.now() + TOKEN_TTL_MS, iat: Date.now() }))
|
||||
const sig = crypto.createHmac('sha256', SECRET).update(payload).digest('base64url')
|
||||
return payload + '.' + sig
|
||||
}
|
||||
@@ -95,12 +161,26 @@ function verifyToken(token) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// 会话时效:改密 / 管理员重置密码后,签发时间早于 pwd_changed_at 的令牌一律失效
|
||||
function tokenUsable(payload) {
|
||||
if (!payload || !payload.userId) return false
|
||||
const rec = registry.users.find(u => u.id === payload.userId)
|
||||
if (!rec) return false
|
||||
if (rec.pwd_changed_at) {
|
||||
const since = Date.parse(rec.pwd_changed_at)
|
||||
if (!payload.iat || payload.iat < since) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function base64url(s) {
|
||||
return Buffer.from(s).toString('base64url')
|
||||
}
|
||||
|
||||
// ---------- SSE 长连接:按用户分组,数据变更时实时通知 ----------
|
||||
const sseClients = new Map()
|
||||
// 登录防爆破:IP -> { count, first }(内存态,重启即清)
|
||||
const loginFails = new Map()
|
||||
function broadcast(userId, event) {
|
||||
const set = sseClients.get(userId)
|
||||
if (!set) return
|
||||
@@ -189,12 +269,12 @@ async function handle(req, res) {
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && route === '/api/health') { json(res, 200, { ok: true }); return }
|
||||
if (req.method === 'GET' && route === '/api/health') { json(res, 200, { ok: true, backup: lastOffsiteBackup.state === 'disabled' ? 'local-only' : lastOffsiteBackup }); return }
|
||||
|
||||
// SSE 事件流:token 经查询参数传递(EventSource 无法自定义请求头)
|
||||
if (req.method === 'GET' && route === '/api/events') {
|
||||
const auth = verifyToken(url.searchParams.get('token'))
|
||||
if (!auth) { json(res, 401, { error: '未认证' }); return }
|
||||
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
@@ -213,7 +293,7 @@ async function handle(req, res) {
|
||||
// 修改登录密码(验证旧密码后重置盐与哈希)
|
||||
if (req.method === 'POST' && route === '/api/auth/password') {
|
||||
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
|
||||
if (!auth) { json(res, 401, { error: '未认证' }); return }
|
||||
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
|
||||
const body = await readBody(req)
|
||||
const rec = registry.users.find(u => u.id === auth.userId)
|
||||
if (!rec) { json(res, 404, { error: '用户不存在' }); return }
|
||||
@@ -221,7 +301,7 @@ async function handle(req, res) {
|
||||
if (String(body.newPassword || '').length < 6) { json(res, 400, { error: '新密码至少 6 位' }); return }
|
||||
rec.pwd_salt = crypto.randomBytes(16).toString('hex')
|
||||
rec.pwd_hash = pbkdf2Hash(String(body.newPassword), rec.pwd_salt)
|
||||
rec.pwd_changed_at = new Date().toISOString()
|
||||
rec.pwd_changed_at = new Date().toISOString() // 吊销该用户全部旧令牌
|
||||
saveRegistry()
|
||||
json(res, 200, { ok: true })
|
||||
return
|
||||
@@ -230,7 +310,7 @@ async function handle(req, res) {
|
||||
// 删除云端账号:注册表与全部业务数据一并清除(不可恢复,客户端已做二次确认)
|
||||
if (req.method === 'POST' && route === '/api/auth/delete') {
|
||||
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
|
||||
if (!auth) { json(res, 401, { error: '未认证' }); return }
|
||||
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
|
||||
const idx = registry.users.findIndex(u => u.id === auth.userId)
|
||||
if (idx >= 0) { registry.users.splice(idx, 1); saveRegistry() }
|
||||
try { fs.rmSync(userFile(auth.userId), { force: true }) } catch { /* 忽略 */ }
|
||||
@@ -256,18 +336,27 @@ async function handle(req, res) {
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && route === '/api/auth/login') {
|
||||
const loginIp = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || 'unknown'
|
||||
const nowMs = Date.now()
|
||||
let st = loginFails.get(loginIp)
|
||||
if (st && nowMs - st.first < 10 * 60 * 1000 && st.count >= 10) { json(res, 429, { error: '尝试次数过多,请 10 分钟后再试' }); return }
|
||||
const body = await readBody(req)
|
||||
const username = String(body.username || '').trim()
|
||||
const rec = registry.users.find(u => u.username === username)
|
||||
if (!rec) { json(res, 401, { error: '用户不存在' }); return }
|
||||
if (pbkdf2Hash(String(body.password || ''), rec.pwd_salt) !== rec.pwd_hash) { json(res, 401, { error: '密码不正确' }); return }
|
||||
if (pbkdf2Hash(String(body.password || ''), rec.pwd_salt) !== rec.pwd_hash) {
|
||||
const s2 = loginFails.get(loginIp)
|
||||
loginFails.set(loginIp, !s2 || nowMs - s2.first >= 10 * 60 * 1000 ? { count: 1, first: nowMs } : { count: s2.count + 1, first: s2.first })
|
||||
json(res, 401, { error: '密码不正确' }); return
|
||||
}
|
||||
loginFails.delete(loginIp)
|
||||
json(res, 200, { userId: rec.id, username: rec.username, token: makeToken(rec.id) })
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && route === '/api/sync') {
|
||||
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
|
||||
if (!auth) { json(res, 401, { error: '未认证' }); return }
|
||||
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
|
||||
const body = await readBody(req)
|
||||
const userData = loadUserData(auth.userId)
|
||||
if (!userData.cursor) userData.cursor = 0
|
||||
@@ -356,7 +445,7 @@ async function handle(req, res) {
|
||||
const tombs = userData.tombstones[t] || {}
|
||||
for (const [u, deletedAt] of Object.entries(tombs)) outTombs.push({ table: t, uuid: u, deleted_at: deletedAt })
|
||||
}
|
||||
json(res, 200, { tables: outTables, tombstones: outTombs, serverTime: new Date().toISOString() })
|
||||
json(res, 200, { tables: outTables, tombstones: outTombs, serverTime: new Date().toISOString(), dataEpoch: String(userData.created_at || '') })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -444,6 +533,7 @@ function doBackup() {
|
||||
while (days.length > 14) { const old = days.shift(); try { fs.rmSync(path.join(backupsRoot, old), { recursive: true, force: true }) } catch { /* 忽略 */ } }
|
||||
} catch { /* 忽略 */ }
|
||||
} catch (e) { console.error('backup failed:', e && e.message) }
|
||||
void backupOffsite()
|
||||
}
|
||||
doBackup()
|
||||
setInterval(doBackup, 24 * 60 * 60 * 1000)
|
||||
|
||||
Reference in New Issue
Block a user