3 Commits
38 changed files with 1024 additions and 776 deletions
+29
View File
@@ -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 及之前版本可直接升级。
+18
View File
@@ -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 安装包。
+18
View File
@@ -0,0 +1,18 @@
# v0.6.2 发布说明(2026-10-26)
## 数据安全:补上「首次升级即遇灾」的恢复漏洞(关键)
- v0.6.1 的数据纪元保护只对「已存过纪元」的设备生效;所有设备第一次跑新版时检测不到服务器数据被重建。
- 本版本增加**二道防线**(不依赖任何历史状态):若本地镜像认为服务器应有 N 行,而服务器实际行数更少——即服务器数据发生过丢失/重建——立即作废本地镜像,把本机全量数据重推回服务器,再拉取快照,本地与服务器同时恢复。
- 已通过灾难恢复冒烟测试(7/7):删除服务器用户数据文件后,保留缓存的设备下一次同步自动重推全部数据(备忘录、密码库密文、主密码盐与校验值、考勤规则),全新设备随后完整拉回。
## 与数据丢失相关的说明
- 密码库:密文行与主密码盐/校验值均随同步存储于服务器;只要任一快照存活即可整体恢复,主密码不变。
- 备忘录:明文行随同步存储,恢复机制相同。
- 若所有设备都已在数据丢失后打开过应用(本地缓存同样被旧版清空),且服务器 data 目录被删、COS 未配置,则丢失部分无法找回;建议尽快配置 COS 异地备份。
## 升级
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(切勿删除 server/data)
- 桌面 / 安卓:安装 0.6.2;网页端刷新页面即为新版。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-e9jKyg3_.js","./index-DLDMtYxl.js","./index-ieoBMDLY.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-DLDMtYxl.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-e9jKyg3_.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-B-qBD-4O.js","./index-DFZaRaeX.js","./index-OQmtzryQ.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-DFZaRaeX.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-B-qBD-4O.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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-DLDMtYxl.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-ieoBMDLY.css">
<script type="module" crossorigin src="./assets/index-DFZaRaeX.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-OQmtzryQ.css">
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "intern-log",
"private": true,
"version": "0.5.0",
"version": "0.6.2",
"type": "module",
"scripts": {
"dev": "vite",
+82
View File
@@ -0,0 +1,82 @@
import 'fake-indexeddb/auto'
import { bindDatabase, WorklogDB } from '../src/db/schema'
// node 环境的 localStorage 垫片(引擎的纪元/idMap 存取需要)
const lsMap = new Map<string, string>()
;(globalThis as any).localStorage = {
getItem: (k: string) => (lsMap.has(k) ? lsMap.get(k)! : null),
setItem: (k: string, v: string) => { lsMap.set(k, String(v)) },
removeItem: (k: string) => { lsMap.delete(k) }
}
const BASE = 'http://127.0.0.1:3780'
let pass = 0
let fail = 0
function check(name: string, ok: boolean, detail?: string): void {
if (ok) { pass++; console.log(' PASS ' + name) }
else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
}
async function main() {
await fetch(BASE + '/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'restore', password: 'dev123456' })
}).catch(() => { /* 已存在忽略 */ })
const engine = await import('../src/sync/engine')
const CONF = { baseUrl: BASE, username: 'restore', password: 'dev123456' }
// ===== 设备 A:备忘录 x3 + 密码库密文 x1 + 设置 =====
bindDatabase(new WorklogDB('restore_a'))
const a = (await import('../src/db/schema')).db
const now = '2026-10-26T10:00:00'
await a.memo.bulkAdd([
{ title: '备忘一', content: '内容一', created_at: now, updated_at: now },
{ title: '备忘二', content: '内容二', created_at: now, updated_at: now },
{ title: '备忘三', content: '内容三', created_at: now, updated_at: now }
])
await a.credential.bulkAdd([
{ system: '企业邮箱', account: 'dev@x.com', password: 'enc:v1:FAKECIPHER', remark: '', created_at: now, updated_at: now }
])
await a.setting.bulkAdd([
{ key: 'work_start', value: '09:00' },
{ key: 'pwd_salt', value: 'SALTSALT' },
{ key: 'pwd_verifier', value: 'enc:v1:VERIFIER' }
])
const rA = await engine.runSync(CONF)
console.log('设备A 首次推送:', JSON.stringify({ p: rA.pushed, e: rA.errors }))
// ===== 灾难:服务器用户数据文件被删(模拟 rm -rf data / 目录重建)=====
const fsx = await import('fs')
const usersDir = 'server/data/users'
for (const f of fsx.readdirSync(usersDir)) fsx.rmSync(usersDir + '/' + f)
console.log('已删除服务器用户数据文件')
// ===== 设备 A 再次同步:应触发二道防线,自动全量重推自救 =====
// (设备 A 此前从未存过纪元 —— 恰好覆盖「首次升级即遇到灾难」的窗口)
const rA2 = await engine.runSync(CONF)
console.log('设备A 自救同步:', JSON.stringify({ p: rA2.pushed, e: rA2.errors }))
check('自救时自动重推了全部本地数据(pushed>=7)', rA2.pushed >= 7, 'pushed=' + rA2.pushed)
const aMemo = await a.memo.toArray()
const aCred = await a.credential.toArray()
check('设备A 本地数据未被清空(备忘录仍 3 条)', aMemo.length === 3, 'len=' + aMemo.length)
check('设备A 本地密码库未被清空(仍 1 条)', aCred.length === 1, 'len=' + aCred.length)
// ===== 全新设备 C:拉取验证服务器已被 A 恢复 =====
bindDatabase(new WorklogDB('restore_c'))
const c = (await import('../src/db/schema')).db
const rC = await engine.runSync(CONF)
console.log('设备C 拉取:', JSON.stringify({ p: rC.pushed, e: rC.errors }))
const cMemo = await c.memo.toArray()
const cCred = await c.credential.toArray()
const cSet = await c.setting.toArray()
check('设备C 拉到 3 条备忘录', cMemo.length === 3, 'len=' + cMemo.length)
check('设备C 拉到密码库密文', cCred.length === 1 && cCred[0].system === '企业邮箱', 'len=' + cCred.length)
check('设备C 拉到主密码盐与校验值(密码库可解锁前提)', cSet.some(s => s.key === 'pwd_salt' && s.value === 'SALTSALT') && cSet.some(s => s.key === 'pwd_verifier'))
check('设备C 拉到考勤规则', cSet.some(s => s.key === 'work_start' && s.value === '09:00'))
console.log('RESULT: ' + (fail === 0 ? 'ALL PASS ' + pass : 'FAIL ' + fail + '/' + (pass + fail)))
if (fail > 0) process.exit(1)
}
main().catch(e => { console.error('SMOKE ERROR', e); process.exit(1) })
+135 -90
View File
@@ -10,6 +10,7 @@ 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'
@@ -230,20 +231,31 @@ onMounted(async () => {
}
})
</script>
<template>
<LoginView v-if="!session" @login="onLogin" />
<div v-else class="app">
<!-- 桌面侧栏:品牌 + 分组导航 + 当前用户 -->
<nav class="side">
<template v-for="(v, i) in shownViews" :key="v.key">
<div v-if="i > 0 && v.group !== shownViews[i - 1].group" class="nav-sep"></div>
<button
class="nav-btn" :class="{ active: route === v.key }"
:data-tip="v.name" @click="nav(v.key)"
>
<Icon :name="v.icon" :size="18" />
<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>
</template>
</div>
</nav>
<div class="main">
<header class="topbar">
@@ -281,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>
@@ -370,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>
@@ -377,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);
@@ -423,40 +468,57 @@ onMounted(async () => {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
z-index: 40;
}
.nav-btn:hover::after { opacity: 1; }
.nav-sep {
width: 26px;
height: 1px;
.side-foot {
flex: none;
background: var(--border);
margin: 5px 0;
display: flex; align-items: center; gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--border);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
.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;
}
/* ---------- 主区 ---------- */
.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);
@@ -473,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;
}
@@ -490,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;
@@ -504,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;
@@ -537,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);
@@ -550,8 +607,6 @@ onMounted(async () => {
align-items: center;
justify-content: center;
}
.mobile-hide { display: initial; }
.tabbar {
display: none;
position: fixed;
@@ -565,7 +620,7 @@ onMounted(async () => {
}
.tab-btn {
flex: 1;
height: calc(52px - 6px);
height: 50px;
display: flex;
flex-direction: column;
align-items: center;
@@ -577,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;
@@ -591,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;
@@ -623,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;
@@ -668,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; }
@@ -685,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;
+2 -24
View File
@@ -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>
+1 -38
View File
@@ -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>
+154
View File
@@ -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>
@@ -48,8 +48,10 @@ 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 = ref<string[]>([])
@@ -158,57 +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 { width: 480px; }
.add-cat { display: flex; gap: 6px; margin-top: 6px; }
.add-cat-input { flex: 1; height: 28px; font-size: 12px; }
.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; }
</style>
+21 -20
View File
@@ -163,12 +163,16 @@ async function saveMakeupForm() {
// ---- 条目操作 ----
// 弹窗开关与编辑目标分离:添加模式 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
@@ -196,6 +200,7 @@ async function onEditSave(payload: {
showToast('记录已添加')
}
editTarget.value = null
dialogOpen.value = false
await load()
}
async function toggleMilestone(it: LogItem) {
@@ -398,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>
@@ -412,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; }
@@ -447,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>
@@ -113,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')">
@@ -173,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>
@@ -182,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>
+9
View File
@@ -2,6 +2,9 @@
// 约定:每个写函数修改本地缓存后立即调用 commit() —— 推送差异给服务器,
// 并以服务器返回的权威快照重建缓存;断网时抛出错误(纯在线模式)。
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'
@@ -376,6 +379,12 @@ export async function listPlansByDate(date: string): Promise<PlanItem[]> {
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({
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string
+6
View File
@@ -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++
}
+20 -13
View File
@@ -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; }
}
+209 -42
View File
@@ -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; }
+24 -1
View File
@@ -300,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()
@@ -358,9 +358,31 @@ 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 || []
// 二道防线(不依赖纪元存储,首次升级即可生效):
// 本地镜像认为服务器应有 N 行,但服务器实际行数更少 —— 说明服务器数据发生过丢失/重建。
// 此时若照常拉快照,会把本地缓存也清空。处置:作废镜像并以本地全量数据重推自救。
const serverCount = TABLES.reduce((s, tn) => s + ((serverTables[tn] || []).length), 0)
const expectedMin = mirrorRows.length - tombstones.length
if (!epochRetry && serverCount < expectedMin) {
await db.sync_mirror.clear()
return doRunSync(config, true)
}
// 3. 用服务器快照全量重建本地缓存(本地不再有映射逻辑,服务器永远是对的)
const applyResult = await applySnapshot(serverTables, serverTombstones)
@@ -368,6 +390,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 }
}
+35 -43
View File
@@ -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);
+6 -56
View File
@@ -214,7 +214,6 @@ async function confirmDelete() {
showToast('账目已删除')
}
</script>
<template>
<div class="ledger-page">
<div class="ledger-toolbar">
@@ -293,7 +292,7 @@ async function confirmDelete() {
</table>
</div>
<div class="muted vault-note num">本月 {{ rows.length }} 条 · 当前筛选 {{ filtered.length }} 条</div>
<div class="muted table-note num">本月 {{ rows.length }} 条 · 当前筛选 {{ filtered.length }} 条</div>
<TransactionDialog
:open="dialogOpen"
@@ -320,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>
+9 -31
View File
@@ -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>
+4 -4
View File
@@ -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;
+97 -79
View File
@@ -8,7 +8,7 @@ import {
addGoal, updateGoal, deleteGoal, listGoals, getGoalProgress, type Goal
} from '../db/repo'
import { addDays, parseDateStr, todayStr, toDateStr, formatDateCN } from '../lib/date'
import { attendanceChanged } from '../lib/bus'
import { attendanceChanged, notifyPlanTimer } from '../lib/bus'
import { showToast } from '../lib/toast'
// ---- 长期计划 ----
@@ -108,12 +108,13 @@ const summary = computed(() => {
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) actualMin + elapsedMinutes(rp)
if (rp) liveExtra = liveElapsed(rp)
}
return { total, done, plannedMin, actualMin }
return { total, done, plannedMin, actualMin: actualMin + liveExtra }
})
function spanMinutes(p: PlanItem): number {
const [sh, sm] = p.start_time.split(':').map(Number)
@@ -143,6 +144,7 @@ async function toggleTimer(p: PlanItem) {
await startPlanTimer(p.id as number)
showToast('开始计时:' + p.title)
}
notifyPlanTimer()
await load()
}
@@ -211,62 +213,65 @@ function weekday(s: string): string {
return wdNames[parseDateStr(s).getDay()]
}
</script>
<template>
<div class="plan-page">
<div class="card sec plan-head">
<div class="nav-row">
<button class="icon-btn" @click="prevDay"><Icon name="chevron-left" :size="16" /></button>
<div class="date-box">
<span class="date-main num">{{ date.slice(5) }} {{ weekday(date) }}</span>
<span class="muted date-sub num">{{ date }}</span>
<!-- 长期计划 -->
<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>
<button class="icon-btn" @click="nextDay"><Icon name="chevron-right" :size="16" /></button>
<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>
<span class="flex1"></span>
<button class="btn btn-primary btn-sm" @click="openCreate"><Icon name="plus" :size="14" /> 添加计划</button>
</div>
<div class="muted sum-line">
共 {{ summary.total }} 项 · 完成 {{ summary.done }} 项 · 计划 {{ fmtMin(summary.plannedMin) }} · 实际投入 {{ fmtMin(summary.actualMin) }}
<template v-if="running"> · <span class="timer-live">计时中</span></template>
</div>
</div>
<div class="goals-sec">
<div class="goals-head">
<span class="goals-title">长期计划</span>
<button class="btn btn-sm" @click="openGoalCreate"><Icon name="plus" :size="14" /> 新建长期计划</button>
</div>
<div v-if="!goals.length" class="muted goals-empty">暂无长期计划。可以创建一个跨越多天的目标(如「产品文档学习」),系统会自动生成每天的打卡任务。</div>
<div v-for="g in goals" :key="g.id" class="card sec 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>
<div class="plan-list">
<div v-if="!loading && !items.length" class="card sec empty muted">
今天还没有计划,点右上角「添加计划」开始规划:学习、健身、自律……
</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="card sec plan-item"
class="plan-item"
:class="{ done: p.status === 'done', skip: p.status === 'skip', running: !!p.timer_started_at }"
>
<div class="pi-time num">
@@ -304,7 +309,7 @@ function weekday(s: string): string {
</div>
<teleport to="body">
<div v-if="dialogOpen" class="fixed-mask" @click.self="dialogOpen = false">
<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>
@@ -345,7 +350,7 @@ function weekday(s: string): string {
</div>
</teleport>
<teleport to="body">
<div v-if="goalOpen" class="fixed-mask" @click.self="goalOpen = false">
<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>
@@ -397,52 +402,65 @@ function weekday(s: string): string {
/>
</div>
</template>
<style scoped>
.plan-page { display: flex; flex-direction: column; gap: 10px; max-width: 760px; }
.plan-head { padding: 12px 16px; }
.nav-row { display: flex; align-items: center; gap: 8px; }
.date-box { min-width: 150px; text-align: center; }
.date-main { font-weight: 600; font-size: 14px; }
.date-sub { font-size: 11px; margin-left: 6px; }
.sum-line { font-size: 12px; margin-top: 8px; }
.timer-live { color: var(--accent); font-weight: 600; }
.flex1 { flex: 1; }
.plan-page { display: flex; flex-direction: column; }
.plan-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; }
.plan-item.done { opacity: 0.62; }
.plan-item.skip { opacity: 0.5; }
.plan-item.running { border-left: 3px solid var(--accent); }
.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, #2e9e5b); font-size: 12px; }
.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 { text-align: center; padding: 26px 16px; }
.empty { padding: 28px 16px; }
.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; }
.dialog { width: 420px; }
.form-row { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
.form-row label { font-size: 12px; color: var(--text-2); }
.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>
+3 -14
View File
@@ -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; }
+7 -12
View File
@@ -7,7 +7,7 @@ import {
listAttendanceBetween, listLogItemsBetween, listProjects,
renameProject, saveLocationPresets, setProjectArchived, setSetting, type WorkRule
} from '../db/repo'
import { type Project } from '../db/schema'
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'
@@ -494,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; }
@@ -513,22 +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; }
.cat-block { margin-top: 10px; }
.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); }
.exp-dates { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.exp-dates .input { width: 150px; }
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
.seg-btn { border: none; background: var(--bg-page); color: var(--text-2); cursor: pointer; font-size: 12px; padding: 5px 12px; }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
.import-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
.backup-note { font-size: 12px; margin-top: 10px; }
</style>
+11 -15
View File
@@ -180,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>
+38 -39
View File
@@ -1,5 +1,5 @@
<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 { listLogItemsBetween, listAttendanceBetween, listProjects, getWorkRule } from '../db/repo'
@@ -23,7 +23,8 @@ 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: expStart.value, end: expStart.value, label: expStart.value }
// 「指定日」导出:导的是详情页当前浏览的那一天,而不是今天
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) }
@@ -87,59 +88,57 @@ 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>
<div class="export-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 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>
<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>
<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; }
.export-row {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-bottom: 12px; padding: 8px 12px;
border: 1px solid var(--border); border-radius: var(--radius-md);
background: var(--bg-page);
}
.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; }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
.seg-btn { border: none; background: var(--bg-page); color: var(--text-2); cursor: pointer; font-size: 12px; padding: 4px 10px; }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
.w-exp { width: 140px; }
.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; }
}
+27 -45
View File
@@ -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>
+6 -56
View File
@@ -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>
+3 -2
View File
@@ -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 可用容器名访问)。
+5 -2
View File
@@ -127,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 })
@@ -442,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
}