5 Commits
35 changed files with 590 additions and 116 deletions
+26
View File
@@ -0,0 +1,26 @@
# v0.6.3 发布说明(2026-10-26)
服务端存储层加固(纯服务端变更,客户端无需重装;桌面/安卓继续使用 0.6.2 安装包)。
## 存储可靠性
- **原子落盘**:所有 JSON 写入改为「临时文件 + fsync + 原子改名」,进程崩溃/断电不会留下半截数据文件
- **损坏隔离**:用户数据文件若解析失败,自动隔离为 `.corrupt-<时间戳>` 保留原件并在日志报错——绝不静默当作空数据(旧逻辑的静默空文件正是数据事故的放大器)
- **墓碑自动回收**:删除标记保留 14 天后自动清除,数据文件不再随历史无限膨胀,也不会永久重放旧删除
- **空同步不落盘**:内容无变化的同步不再重写磁盘(顺带修复 cursor 假值导致的每轮必写)
## 备份体系
- **当日首份快照不可变**:同日再备份写入带时间戳的副本目录,当日证据链不再被覆盖(本次事故中 11:11 快照被 20:03 覆盖正是教训)
- **每周深快照**:每周一额外保留一份,保留 8 份
- **大批删除前置保险**:单次同步携带 ≥20 条墓碑时,服务端先自动快照再应用删除,可随时回滚
- `/api/health` 新增 users / lastBackup 字段
## 验证
- 服务端协议审计 36/36、用户管理审计 16/16、灾难恢复自救 7/7、存储加固冒烟 9/9 全部通过
- 修复审计中两处过时预期(墓碑 GC 后老日期墓碑不再回传属预期;当日首份快照布局变更)
## 部署
`cd /opt/daywork && git pull && cd server && docker compose up -d --build`
+23
View File
@@ -0,0 +1,23 @@
# v0.6.4 发布说明(2026-10-26)
## 移动端布局与板块尺寸优化
**触控基线**(全部界面统一):
- 常规按钮 36px / 次级 32px / 小按钮 30px(原 24-28px,触控不友好)
- 输入框与下拉 38px 高;分段选择器 34px;图标按钮 34px
- 弹窗底部主按钮 40px 并等宽铺满;弹窗宽距收窄近全屏
**板块重排**:
- 详情页:出勤信息改 2×2 紧凑网格(原手机上拉成 1 列过高);打卡/休假按钮并排等宽
- 计划页:每日任务操作按钮(计时/完成/跳过)等宽铺满、触控高度 32px;「今天 / 添加计划」等宽;长期计划卡操作角标化
- 记账页:工具栏分区——月份导航一行、两个筛选并排、「记一笔」全宽主按钮
- 详情页导出:指定日/当周/当月/自定义分段等宽铺满,日期输入全宽
- 总结页:范围分段铺满一行;草稿编辑器高度收窄
- 备忘录:列表收窄至 210px,底部按钮等宽
- 底部标签栏加高至 56px、图标 22px;悬浮计时时钟位置同步适配
- 顶栏打卡按钮加高至 34px,更易点按
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
- 桌面 / 安卓:安装 0.6.4 安装包
+11
View File
@@ -0,0 +1,11 @@
# v0.6.5 发布说明(2026-10-26)
## 修复
- **写操作不再因同步失败弹全屏报错**:此前任何一次写后同步失败(如服务端 500)都会以「应用内部错误」遮罩抛到界面,一次服务端故障会让每次点击都报错。现在写操作在本地提交成功即视为成功,同步失败仅记录状态(设置页「多端数据同步」可见原因),并由下一轮自动同步继续完成。
- **服务端 500 留痕**:接口异常现在会连同堆栈写入容器日志(`docker logs internlog-sync`),用于定位「同步服务返回 500」的真实原因。
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`
- 桌面 / 安卓:安装 0.6.5(或继续用 0.6.2+ 旧版客户端,报错遮罩问题主要影响 0.6.4 网页端)
+23
View File
@@ -0,0 +1,23 @@
# v0.6.6 发布说明(2026-10-26)
## 修复「同步服务返回 500」与二次数据丢失(根因修复)
**根因**:此前数据恢复用的合并脚本把用户数据文件中的表写成了**数组**形态,而服务端按「uuid 键对象」读写。后果:数组上的附加属性在序列化时被丢弃(**每次容器重启丢失合并后新写的数据**)、删除/半途状态产生 null 行(**自然键扫描读到 null.date → 每次同步 500**)。该脚本缺陷由开发者引入,本版彻底治愈。
## 修复内容
- **服务端加载即自愈**:读取用户数据时自动把数组形态转回 uuid 键对象、剔除 null/非法行(一次性治愈线上文件,无需任何手动操作),自愈结果立即持久化
- **自然键扫描 / 墓碑生效循环**:对非对象行防御性跳过
- **客户端快照重建**:跳过 null / 无 uuid 行,垃圾行不再进入本地缓存
- 合并后丢失的增量数据:各端升级后首次同步会触发行数比对防线,**自动把设备缓存中的数据重推回服务器**
## 验证
- 表结构自愈冒烟 8/8(数组+null 混合文件 → 同步一次 → 治愈且数据完整、快照无 null)
- 存储冒烟 9/9 · 协议审计 36/36 · 用户管理审计 16/16 · 灾难恢复自救 7/7
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`
- 桌面 / 安卓:安装 0.6.6(网页端刷新即新版)
- 部署后各端打开一次,核对数据(工作记录/记账等);若仍有缺,检查设置页「立即同步」的结果行并反馈
+27
View File
@@ -0,0 +1,27 @@
# v0.6.7 发布说明(2026-10-26)
## 全组件 UI 审核打磨(19 个界面/组件逐一过审)
**交互反馈**
- 按钮增加按下态(轻微下沉 + 主按钮阴影收起),主按钮带品牌色微阴影
- 全局细滚动条(8px 圆角,滚动区域观感统一)
- 修复侧栏 tooltip:仅折叠态(平板)显示,宽侧栏不再多余弹泡
**语义色与信息层级**
- 记账统计卡增加语义色条:收入绿 / 支出灰 / 结余蓝 / 待报销橙
- 长期计划进度条 100% 完成时变绿
- 危险确认弹窗标题红色警示
- 详情页出勤时间数字加大加粗;工作记录内容字重提升;总结页摘要数字加粗
- 密码库密码文本改等宽字体
- 记账表格金额列右对齐
**体验升级**
- 记录弹窗「耗时」新增快捷选择 chips(15/30/60/90/120 分)
- 登录页背景渐变质感、输入框加高
- 日历:今日格子淡蓝高亮、非本月格灰底、周标题字重
- 移动端「更多」弹层加拖拽指示条
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
- 桌面 / 安卓:安装 0.6.7 安装包
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-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};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-CEj0vlG8.js","./index-o_Kd7ZbB.js","./index-CsewO6wh.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-o_Kd7ZbB.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-CEj0vlG8.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-DFZaRaeX.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-OQmtzryQ.css">
<script type="module" crossorigin src="./assets/index-o_Kd7ZbB.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CsewO6wh.css">
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "intern-log",
"private": true,
"version": "0.6.2",
"version": "0.6.7",
"type": "module",
"scripts": {
"dev": "vite",
+6 -2
View File
@@ -75,7 +75,8 @@ async function main() {
check('LWW 反向: 旧 work_start 被墓碑', tombsOf(lwwR2).some(t => t.table === 'setting' && t.uuid === 'su1'))
await post('/api/sync', { tables: { memo: [row('memo', { uuid: 'mt1', title: '待删', content: 'x', updated_at: '2026-04-01T00:00:00' }), row('memo', { uuid: 'ms2', title: '更迟', content: 'y', updated_at: '2030-01-01T00:00:00' })] } }, tokA)
const delPush = await post('/api/sync', { tombstones: [{ table: 'memo', uuid: 'mt1', deleted_at: '2026-04-02T00:00:00' }] }, tokA)
// deleted_at 用当前时间:过期墓碑会被服务端 14 天 GC 自动回收(这是预期行为,不能用老日期验证回传)
const delPush = await post('/api/sync', { tombstones: [{ table: 'memo', uuid: 'mt1', deleted_at: new Date().toISOString() }] }, tokA)
check('墓碑同步后行被清除', !((rowsOf(delPush).memo || []).some(x => x.uuid === 'mt1')))
check('新于删除时间的行保留(防误删)', (rowsOf(delPush).memo || []).some(x => x.uuid === 'ms2'))
check('墓碑记录回传', tombsOf(delPush).some(t => t.table === 'memo' && t.uuid === 'mt1'))
@@ -127,7 +128,10 @@ async function main() {
const aRec = regJson.users.find(u => u.username === 'audit_a')
check('注册表仅存哈希不存明文密码', !!aRec && !JSON.stringify(aRec).includes('aapass'))
await new Promise(r => globalThis.setTimeout(r, 400))
check('启动自动备份已生成', fsMod.existsSync('server/data/backups/' + new Date().toISOString().slice(0, 10) + '/registry.json'))
// 当日首份快照不可变:registry.json 可能位于当日基准目录或其 _t 时间戳副本
const today = new Date().toISOString().slice(0, 10)
const todayDirs = fsMod.readdirSync('server/data/backups').filter(d => d.startsWith(today))
check('启动自动备份已生成', todayDirs.length > 0 && todayDirs.some(d => fsMod.existsSync('server/data/backups/' + d + '/registry.json')), todayDirs.join(','))
const hInfo = await fetch(BASE + '/api/health').then(x => x.json()).catch(() => ({}))
check('health 暴露备份状态', hInfo && hInfo.backup === 'local-only', hInfo.backup)
+16 -3
View File
@@ -333,6 +333,7 @@ onMounted(async () => {
<!-- 手机端「更多」底部弹层 -->
<div v-if="moreOpen" class="sheet-mask" @click="moreOpen = false">
<div class="more-sheet" @click.stop>
<div class="sheet-handle"></div>
<div class="sheet-user num">{{ session?.username }}</div>
<button v-for="v in moreViews" :key="v.key" class="sheet-row" @click="navFromSheet(v.key)">
<Icon :name="v.icon" :size="18" />
@@ -452,6 +453,7 @@ onMounted(async () => {
background: var(--accent);
}
.nav-label { white-space: nowrap; }
/* tooltip 仅在折叠态(图标-only)显示;宽侧栏文字可见,不需要 */
.nav-btn::after {
content: attr(data-tip);
position: absolute;
@@ -647,10 +649,18 @@ onMounted(async () => {
bottom: 0;
background: var(--bg-page);
border-radius: 16px 16px 0 0;
padding: 10px 14px calc(14px + env(safe-area-inset-bottom));
padding: 6px 14px calc(14px + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
}
.sheet-handle {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--border-strong);
margin: 4px auto 8px;
flex: none;
}
.sheet-user {
font-size: 12px;
color: var(--text-3);
@@ -721,6 +731,7 @@ onMounted(async () => {
.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; }
.nav-btn:hover::after { opacity: 1; }
.side-foot { flex-direction: column; gap: 6px; }
.foot-name { display: none; }
}
@@ -743,14 +754,16 @@ onMounted(async () => {
.mob-search-btn { display: flex; }
.topbar-actions { margin-left: auto; gap: 6px; }
.week-tip { height: 28px; max-width: 118px; padding: 0 8px; white-space: nowrap; overflow: hidden; }
.topbar-actions .btn { padding: 0 10px; height: 32px; }
.topbar-actions .btn { padding: 0 12px; height: 34px; }
.topbar-actions .btn .num { font-size: 12px; }
.tabbar { display: flex; }
.tab-btn { height: 56px; font-size: 11px; }
.tab-btn .icon { width: 22px; height: 22px; }
.content { flex: 1; min-height: 0; }
.content-inner {
max-width: none;
padding: 12px 12px calc(70px + env(safe-area-inset-bottom));
padding: 12px 12px calc(78px + env(safe-area-inset-bottom));
}
.toast-wrap {
+2 -1
View File
@@ -28,7 +28,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
<transition name="modal">
<div v-if="open" class="mask" @click.self="emit('cancel')">
<div class="dialog" role="dialog" :aria-label="title">
<div class="dialog-title">{{ title }}</div>
<div class="dialog-title" :class="{ 'is-danger': danger }">{{ title }}</div>
<div class="dialog-msg">{{ message }}</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('cancel')">{{ cancelText }}</button>
@@ -42,6 +42,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
<style scoped>
.dialog { width: 380px; }
.dialog-title.is-danger { color: var(--danger); }
.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; }
</style>
+1 -1
View File
@@ -147,7 +147,7 @@ onUnmounted(() => {
.tp {
left: 12px;
right: 12px;
bottom: calc(66px + env(safe-area-inset-bottom));
bottom: calc(64px + env(safe-area-inset-bottom));
max-width: none;
}
}
@@ -163,4 +163,8 @@ function save() {
.dialog { width: 480px; }
.add-cat { display: flex; gap: 6px; margin-top: 6px; }
.add-cat-input { flex: 1; height: 28px; font-size: 12px; }
@media (max-width: 720px) {
.add-cat-input { height: 34px; }
.add-cat .btn { height: 34px; }
}
</style>
+12 -3
View File
@@ -420,6 +420,7 @@ async function confirmDelete() {
.att-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.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; }
.att-val > .num { font-size: 14px; font-weight: 600; letter-spacing: 0.2px; }
.val-empty { color: var(--text-3); }
.edit-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.time-input { width: 104px; }
@@ -444,7 +445,7 @@ async function confirmDelete() {
}
.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-content { cursor: text; word-break: break-all; font-weight: 500; }
.item-meta { font-size: 12px; }
.item-sub {
margin-top: 6px; padding-top: 6px; border-top: 1px dashed var(--border);
@@ -452,7 +453,15 @@ async function confirmDelete() {
}
.dp-foot { margin-top: 10px; font-size: 12px; text-align: right; }
@media (max-width: 560px) {
.att-grid { grid-template-columns: 1fr; }
@media (max-width: 720px) {
.dp-head { margin-bottom: 10px; }
.att-grid { grid-template-columns: 1fr 1fr; gap: 10px 12px; }
.att-cell label { margin-bottom: 3px; }
.att-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.att-actions .btn { width: 100%; }
.edit-row { flex-wrap: wrap; }
.edit-row .time-input { width: 100%; flex: 1 1 100%; }
.edit-row .btn { flex: 1; }
.item-main { gap: 6px; }
}
</style>
@@ -146,6 +146,13 @@ function save() {
<div class="field">
<label>耗时(分钟)</label>
<input class="input" type="number" min="0" v-model.number="formMinutes" placeholder="选填" />
<div class="min-chips">
<button
v-for="m in [15, 30, 60, 90, 120]" :key="m" type="button"
class="min-chip" :class="{ on: formMinutes === m }"
@click="formMinutes = formMinutes === m ? null : m"
>{{ m }}分</button>
</div>
</div>
<div class="field">
<label>主要收获</label>
@@ -183,4 +190,18 @@ function save() {
<style scoped>
.dialog { width: 560px; }
.min-chips { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.min-chip {
border: 1px solid var(--border-strong);
background: var(--bg-page);
border-radius: 999px;
height: 24px;
padding: 0 10px;
font-size: 12px;
color: var(--text-2);
cursor: pointer;
transition: all 0.12s;
}
.min-chip:hover { background: var(--bg-hover); }
.min-chip.on { background: var(--accent-bg); border-color: var(--accent); color: var(--accent); }
</style>
+42 -27
View File
@@ -1,11 +1,40 @@
/* ============================================================
手机端(<=720px)全局适配:作用于各视图与弹窗组件
(App.vue 的壳层样式是 scoped,跨组件规则统一放这里,便于集中管理)
手机端(<=720px)全局适配
原则:触控目标 ≥30px(按钮 32-36 / 输入 38 / 弹窗主按钮 40)、
板块两列优先避免过度堆叠、内容优先密度适中
============================================================ */
@media (max-width: 720px) {
/* 卡片在窄屏贴边、圆角略收 */
/* ---------- 触控基线 ---------- */
.btn { height: 36px; padding: 0 14px; }
.btn-sm { height: 32px; padding: 0 12px; }
.btn-xs { height: 30px; padding: 0 12px; }
.icon-btn { width: 34px; height: 34px; }
.seg-btn { height: 34px; padding: 0 13px; }
.input, .select { height: 38px; padding: 0 12px; }
.textarea { min-height: 76px; padding: 10px 12px; }
.toggle-btn { height: 38px; }
.tag { height: 24px; line-height: 22px; padding: 0 9px; }
.dot { width: 7px; height: 7px; }
/* ---------- 弹窗:近全屏 + 大按钮 ---------- */
.dialog {
width: calc(100vw - 16px) !important;
max-width: calc(100vw - 16px) !important;
max-height: 90vh;
max-height: 90dvh;
}
.dialog-head { padding: 14px 16px 10px !important; }
.dialog-body { padding: 14px 16px !important; }
.dialog-btns { padding: 10px 16px calc(12px + env(safe-area-inset-bottom)) !important; gap: 10px; }
.dialog-btns .btn { flex: 1; height: 40px; }
.form-grid { grid-template-columns: 1fr !important; }
/* ---------- 卡片与分区 ---------- */
.card { border-radius: 12px; }
.section, .sec { padding: 12px; }
.stat-num { font-size: 20px; }
.empty { padding: 22px 12px; }
input.input, select.select, textarea.textarea {
width: 100%;
@@ -13,20 +42,7 @@
}
.btn { touch-action: manipulation; }
/* ---------- 弹窗:窄屏近全屏 ---------- */
.dialog {
width: calc(100vw - 20px) !important;
max-width: calc(100vw - 20px) !important;
max-height: 88vh;
max-height: 88dvh;
}
.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 {
top: auto !important;
left: 0 !important;
@@ -42,7 +58,7 @@
.drawer-head { border-radius: 14px 14px 0 0; }
.drawer-body { padding: 12px !important; }
/* ---------- 记账表格:卡片行(带行内标签),手机上不横滚 ---------- */
/* ---------- 记账表格:卡片行(带行内标签) ---------- */
.tx-table { min-width: 0 !important; }
.tx-table thead { display: none !important; }
.tx-table tr { display: block; padding: 10px 12px; border-bottom: 1px solid var(--border); background: transparent; }
@@ -63,11 +79,12 @@
flex: none;
}
.tx-table td.ops-cell::before { content: none; }
.tx-table td.ops-cell { justify-content: flex-end; }
.tx-table td.ops-cell { justify-content: flex-end; flex-wrap: wrap; gap: 8px; }
.tx-table td.ops-cell .btn { flex: none; }
.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; }
@@ -79,15 +96,13 @@
/* ---------- 统计卡两列 ---------- */
.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; }
/* ---------- 日历格子 ---------- */
.cal-toolbar { margin-bottom: 10px; }
.cal-cell { min-height: 58px !important; padding: 5px 6px !important; }
.cal-marks { margin-top: 4px !important; height: 14px !important; gap: 4px !important; }
}
+13 -1
View File
@@ -78,9 +78,11 @@ button, input, select, textarea { font: inherit; color: inherit; }
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.btn:hover { background: var(--bg-hover); border-color: var(--border-strong); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #FFFFFF; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #FFFFFF; box-shadow: 0 1px 2px rgba(51, 112, 255, 0.28); }
.btn-primary:hover { background: var(--accent-deep); border-color: var(--accent-deep); }
.btn-primary:active { box-shadow: none; }
.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; }
@@ -279,6 +281,16 @@ button, input, select, textarea { font: inherit; color: inherit; }
.bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; }
.bar-num { font-size: 12px; flex: none; color: var(--text-3); }
/* ---------- 滚动条(桌面细滚动条,移动端系统默认) ---------- */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: #D3D7DE; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #B9BEC7; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-corner { background: transparent; }
/* 记账表格:金额列右对齐(数字位对齐更利扫读) */
.tx-table th:nth-child(3), .tx-table td:nth-child(3) { text-align: right; }
/* ---------- 图标 ---------- */
.icon { display: inline-flex; align-items: center; justify-content: center; flex: none; }
.icon svg { width: 100%; height: 100%; display: block; }
+6 -3
View File
@@ -159,14 +159,15 @@ export async function syncWithStoredConfig(): Promise<SyncResult> {
return runSync({ baseUrl: await baseOf(), username: creds.username, password: creds.password })
}
// 界面写操作完成后调用:立即推送差异并以服务器快照重建缓存(失败会把缓存回退到服务器真值)
// 界面写操作完成后调用:立即推送差异并以服务器快照重建缓存
// 注意:绝不向界面抛出——写操作本身已在本地提交成功,同步失败只记录状态
// (设置页可见 + 下一轮自动同步续传),否则一次服务端故障会把每次点击都变成全屏报错
export async function flushAfterWrite(): Promise<void> {
try {
await syncWithStoredConfig()
} catch (e) {
syncLastError.value = e instanceof Error ? e.message : String(e)
try { await syncWithStoredConfig() } catch { /* 服务器不可达:本地暂存修改,下次自动推送或全量刷新时对齐 */ }
throw e
}
}
@@ -437,7 +438,9 @@ async function applySnapshot(
const prevById = new Map(prevRows.map(r => [pkOf(t, r), r]))
const prevByUuid = new Map(prevRows.filter(r => r.uuid).map(r => [r.uuid as string, r]))
const live: AnyRow[] = (serverTables[t] || []).filter(r => !dead.has(t + '|' + r.uuid))
const live: AnyRow[] = (serverTables[t] || [])
.filter(r => r && typeof r === 'object' && r.uuid)
.filter(r => !dead.has(t + '|' + r.uuid))
// 设置表的临时键:不写回本地缓存;顺手在服务端也清掉(下一轮推送墓碑)
const filtered = live.filter(r => !(t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key)))
const tmpKeys = live.filter(r => t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key))
+4 -4
View File
@@ -104,7 +104,7 @@ watch(focusDate, d => {
<button
v-for="(c, idx) in cells" :key="idx"
class="cal-cell"
:class="{ blank: !c.inMonth, weekend: c.weekend, selected: selected === c.date }"
:class="{ blank: !c.inMonth, weekend: c.weekend, selected: selected === c.date, 'is-today': c.date === t }"
:title="c.inMonth ? cellTitle(c.date) : undefined"
@click="selectDay(c.date, c.inMonth)"
>
@@ -149,6 +149,7 @@ watch(focusDate, d => {
text-align: center;
padding: 7px 0;
font-size: 12px;
font-weight: 500;
color: var(--text-3);
}
.cal-cell {
@@ -163,7 +164,8 @@ watch(focusDate, d => {
transition: background 0.12s;
}
.cal-cell:hover { background: var(--bg-subtle); }
.cal-cell.blank { cursor: default; }
.cal-cell.blank { cursor: default; background: var(--bg-subtle); }
.cal-cell.is-today { background: #F5F9FF; }
.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); }
@@ -200,9 +202,7 @@ watch(focusDate, d => {
.drawer-enter-from, .drawer-leave-to { opacity: 0; }
@media (max-width: 720px) {
.cal-cell { min-height: 60px; padding: 4px 5px; }
.cal-title { min-width: 76px; font-size: 13px; }
.cal-marks { gap: 3px; margin-top: 4px; height: 12px; }
.drawer { width: 100%; }
}
</style>
+15 -6
View File
@@ -236,10 +236,10 @@ async function confirmDelete() {
</div>
<div class="cards">
<div class="card stat-card"><div class="muted">本月收入(元)</div><div class="stat-num num amt-income">{{ yuan(summary.income) }}</div></div>
<div class="card stat-card"><div class="muted">本月支出(元)</div><div class="stat-num num">{{ yuan(summary.expense) }}</div></div>
<div class="card stat-card"><div class="muted">本月结余(元)</div><div class="stat-num num" :class="{ 'amt-income': summary.balance >= 0, 'amt-expense': summary.balance < 0 }">{{ yuan(summary.balance) }}</div></div>
<div class="card stat-card"><div class="muted">待报销(元)</div><div class="stat-num num amt-warn">{{ yuan(summary.pending) }}</div></div>
<div class="card stat-card stat-income"><div class="muted">本月收入(元)</div><div class="stat-num num amt-income">{{ yuan(summary.income) }}</div></div>
<div class="card stat-card stat-expense"><div class="muted">本月支出(元)</div><div class="stat-num num">{{ yuan(summary.expense) }}</div></div>
<div class="card stat-card stat-balance"><div class="muted">本月结余(元)</div><div class="stat-num num" :class="{ 'amt-income': summary.balance >= 0, 'amt-expense': summary.balance < 0 }">{{ yuan(summary.balance) }}</div></div>
<div class="card stat-card stat-pending"><div class="muted">待报销(元)</div><div class="stat-num num amt-warn">{{ yuan(summary.pending) }}</div></div>
</div>
<div class="card sec" v-if="categoryStats.length">
@@ -319,6 +319,11 @@ 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 { border-left: 3px solid var(--border); }
.stat-income { border-left-color: var(--ok); }
.stat-expense { border-left-color: var(--text-3); }
.stat-balance { border-left-color: var(--accent); }
.stat-pending { border-left-color: var(--warn); }
.card.sec, .card.section { margin-top: 12px; }
.bar-label { width: 56px; }
@@ -332,7 +337,11 @@ async function confirmDelete() {
.table-card { overflow-x: auto; }
.table-note { margin-top: 10px; font-size: 12px; text-align: right; }
@media (max-width: 560px) {
.ledger-toolbar .w-filter { flex: 1; min-width: 0; width: auto; }
@media (max-width: 720px) {
.ledger-toolbar { gap: 8px; }
.ledger-toolbar .flex1 { display: none; }
.month-title { min-width: 0; flex: 1; text-align: left; }
.w-filter { flex: 1 1 calc(50% - 4px); min-width: 0; width: auto; }
.ledger-toolbar .btn-primary { flex: 1 1 100%; order: 9; }
}
</style>
+2 -1
View File
@@ -78,12 +78,13 @@ async function submit() {
.login-page {
min-height: 100vh;
min-height: 100dvh;
background: var(--bg-app);
background: linear-gradient(180deg, #EBF0F8 0%, var(--bg-app) 46%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-card .input { height: 38px; }
.login-card {
width: 400px;
max-width: 100%;
+6 -3
View File
@@ -248,8 +248,11 @@ function fmtTime(s: string): string {
}
@media (max-width: 800px) {
.memo-page { grid-template-columns: 1fr; }
.memo-list { max-height: 240px; }
.content-textarea { min-height: 240px; }
.memo-page { grid-template-columns: 1fr; gap: 10px; }
.memo-list { max-height: 210px; }
.content-textarea { min-height: 260px; }
.title-input { height: 38px; }
.editor-foot { flex-wrap: wrap; }
.editor-foot .btn { flex: 1; min-height: 34px; }
}
</style>
+17 -3
View File
@@ -236,7 +236,10 @@ function weekday(s: string): string {
<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) + '%' }"
:style="{
width: (goalProg[String(g.id)] ? Math.round(goalProg[String(g.id)].done / Math.max(1, goalProg[String(g.id)].total) * 100) : 0) + '%',
background: goalProg[String(g.id)] && goalProg[String(g.id)].done >= goalProg[String(g.id)].total && goalProg[String(g.id)].total > 0 ? 'var(--ok)' : 'var(--accent)'
}"
></span>
</div>
<div class="muted num goal-meta">
@@ -459,8 +462,19 @@ function weekday(s: string): string {
.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; }
.blk-head { margin: 0 0 10px; }
.day-head { margin-top: 16px; }
.day-ops { width: 100%; }
.day-ops .btn { flex: 1; min-width: 0; }
.day-ops .btn-primary { flex: 1.5; }
.date-chip { min-width: 64px; }
.plan-item { flex-wrap: wrap; padding: 12px; gap: 10px; }
.pi-time { min-width: 0; padding-top: 0; }
.pi-ops { width: 100%; justify-content: flex-start; gap: 8px; }
.pi-ops .btn-xs { flex: 1; min-height: 32px; }
.goal-card { position: relative; flex-direction: column; gap: 8px; }
.goal-main { padding-right: 0; }
.goal-line { padding-right: 72px; }
.goal-ops { position: absolute; top: 10px; right: 8px; }
}
</style>
+8
View File
@@ -221,6 +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; }
.sum-row span:last-child { font-weight: 500; }
.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; }
@@ -228,4 +229,11 @@ function undoPolish() {
@media (max-width: 960px) {
.report-grid { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.report-toolbar { gap: 8px; }
.report-toolbar .seg { flex: 1 1 100%; display: flex; }
.report-toolbar .seg-btn { flex: 1; }
.w-date { flex: 1; min-width: 0; width: auto; }
.md-editor { min-height: 300px; }
}
</style>
+6 -1
View File
@@ -138,8 +138,13 @@ watch(date, d => { expStart.value = d; if (expRange.value === 'custom') expEnd.v
@media (max-width: 720px) {
.tool-sep { display: none; }
.nav-row { flex: 1; }
.toolbar { padding: 10px 12px; gap: 10px; }
.nav-row { flex: 1 1 100%; }
.date-title { min-width: 0; flex: 1; text-align: left; }
.w-date { flex: 1; min-width: 120px; width: auto; }
.exp-row { width: 100%; }
.exp-row .seg { flex: 1; display: flex; }
.exp-row .seg-btn { flex: 1; padding: 0 6px; }
.w-exp { flex: 1; min-width: 0; width: auto; }
}
</style>
+1 -1
View File
@@ -375,7 +375,7 @@ async function doChangePwd() {
.td-system { font-weight: 500; }
.th-ops { width: 150px; }
.pwd-cell { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.pwd-text { letter-spacing: 1px; }
.pwd-text { letter-spacing: 1px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.copyable { cursor: pointer; }
.copyable:hover { color: var(--accent); }
.ops { display: flex; gap: 4px; flex-wrap: wrap; }
BIN
View File
Binary file not shown.
+7 -2
View File
@@ -62,8 +62,13 @@ Caddy 会自动为 daywk.linyux.cn 申请并续期 HTTPS 证书(需 DNS 已生
- 数据目录:/opt/daywork/server/data
- registry.json 用户注册表(用户名 / 角色 / PBKDF2 哈希)
- users/<id>.json 每用户九张表数据 + 墓碑
- backups/<日期>/ **每日自动本地备份**(启动与每次注册表变更也会触发,保留最近 14 天)
- users/<id>.json 每用户全部业务表数据 + 墓碑
- backups/<日期>/ 每日快照。**当日首份不可变**(完整证据链),同日再备份写入 `<日期>_t<时分秒>` 副本
- backups/weekly/<日期>/ 每周一额外深快照,保留 8 份
- 保留策略:每日快照 14 天;写入均为「临时文件 + fsync + 原子改名」,断电不会留下半截文件
- 用户数据文件若解析失败会被自动隔离为 `<文件>.corrupt-<时间戳>` 并在日志中大声报错,绝不静默当作空数据
- 墓碑自动回收:删除标记保留 14 天后自动清除(足够覆盖离线设备),文件不会无限膨胀
- 单次同步携带 ≥20 条墓碑(大批删除)时,服务端会**先自动快照再应用删除**,可随时回滚
- **异地备份(推荐开启)**:在 docker-compose.yml 的 internlog-sync 服务加环境变量,备份自动上传腾讯云 COS:
environment:
+52
View File
@@ -0,0 +1,52 @@
// 表结构自愈冒烟:数组形态 + null 行 + 合法行混合 → 同步一次 → 治愈为 uuid 键对象且数据完整
const BASE = 'http://127.0.0.1:3780'
let pass = 0, fail = 0
function check(name, ok, detail) {
if (ok) { pass++; console.log(' PASS ' + name) } else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
}
async function j(path, body, token) {
const r = await fetch(BASE + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, body: body ? JSON.stringify(body) : undefined })
return { status: r.status, json: await r.json().catch(() => ({})) }
}
async function main() {
const fsp = await import('fs')
const reg = await j('/api/auth/register', { username: 'healtest', password: 'test123456' })
const tok = reg.json.token
const file = 'server/data/users/' + reg.json.userId + '.json'
// 构造坏文件:work_log 是数组 + 含 null 槽位;attendance 数组含 null;memo 正常对象但混入 null 行
const bad = {
userId: reg.json.userId, cursor: 0,
created_at: '2026-10-26T12:00:00.000Z',
tables: {
work_log: [ { uuid: 'wl1', date: '2026-10-20', created_at: '2026-10-20T00:00:00', updated_at: '2026-10-20T00:00:00' }, null, { uuid: 'wl2', date: '2026-10-21', created_at: '2026-10-21T00:00:00', updated_at: '2026-10-21T00:00:00' } ],
attendance: [ null, { uuid: 'at1', date: '2026-10-20', created_at: '2026-10-20T00:00:00', updated_at: '2026-10-20T00:00:00' } ],
memo: { m1: { uuid: 'm1', title: '幸存备忘', content: '', created_at: '2026-10-20T00:00:00', updated_at: '2026-10-20T00:00:00' }, bad: null },
plan: []
},
tombstones: { memo: { g1: null }, work_log: {} }
}
fsp.mkdirSync('server/data/users', { recursive: true })
fsp.writeFileSync(file, JSON.stringify(bad))
// 推送一行 work_log(自然键 date 扫描会遍历表)→ 修复前这里 500,修复后应 200
const r1 = await j('/api/sync', { device: 't', tables: { work_log: [ { uuid: 'wl3', date: '2026-10-22', created_at: '2026-10-22T00:00:00', updated_at: '2026-10-22T00:00:00' } ] }, tombstones: [] }, tok)
check('同步 200(不再读 null.date)', r1.status === 200, 'status=' + r1.status)
const healed = JSON.parse(fsp.readFileSync(file, 'utf8'))
check('work_log 已转 uuid 键对象', !Array.isArray(healed.tables.work_log) && !!healed.tables.work_log.wl1 && !!healed.tables.work_log.wl3)
check('null 行已剔除', !('1' in (healed.tables.work_log || {})) && !healed.tables.attendance['0'])
check('attendance 幸存行保留', healed.tables.attendance && healed.tables.attendance.at1)
check('memo 正常对象保留', healed.tables.memo && healed.tables.memo.m1)
check('null 墓碑已清理', !healed.tombstones.memo || !healed.tombstones.memo.g1)
check('全表均为对象形态', Object.values(healed.tables).every(x => x && !Array.isArray(x) && typeof x === 'object'))
// 推送 m1 更新(memo 无自然键)→ 再拉全量,快照中不得出现 null
await j('/api/sync', { device: 't', tables: { memo: [ { uuid: 'm2', title: '新备忘', content: '', created_at: '2026-10-26T00:00:00', updated_at: '2026-10-26T00:00:00' } ] }, tombstones: [] }, tok)
const r3 = await j('/api/sync', { device: 't', tables: {}, tombstones: [] }, tok)
const flat = Object.values(r3.json.tables || {}).flat()
check('权威快照不含 null/无uuid行', r3.status === 200 && flat.every(x => x && typeof x === 'object' && x.uuid), JSON.stringify(flat.filter(x => !x || !x.uuid)).slice(0, 100))
console.log('RESULT: ' + (fail === 0 ? 'ALL PASS ' + pass : 'FAIL ' + fail + '/' + (pass + fail)))
if (fail) process.exit(1)
}
main().catch(e => { console.error('SMOKE ERROR', e); process.exit(1) })
+157 -33
View File
@@ -11,6 +11,11 @@ const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data')
const PORT = Number(process.env.PORT || 3780)
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天
const ITER = 150000
const BACKUP_DAYS = 14 // 每日快照保留天数
const WEEKLY_KEEP = 8 // 每周深快照保留份数
const TOMBSTONE_TTL_MS = 14 * 24 * 60 * 60 * 1000 // 墓碑保留 14 天:足够覆盖离线设备,防止无限膨胀与重放
const LARGE_TOMBSTONE_BACKUP = 20 // 单次同步墓碑达到该数量时,先做数据快照再应用(大批删除的前置保险)
let lastBackupAt = ''
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'goal', 'transaction', 'plan']
@@ -35,7 +40,13 @@ function readJson(file, fallback) {
}
function writeJson(file, data) {
const tmp = path.join(DATA_DIR, file + '.tmp')
fs.writeFileSync(tmp, JSON.stringify(data))
const fd = fs.openSync(tmp, 'w')
try {
fs.writeSync(fd, JSON.stringify(data))
fs.fsyncSync(fd) // 落盘后再原子改名:进程崩溃/断电不会留下半截文件
} finally {
fs.closeSync(fd)
}
fs.renameSync(tmp, path.join(DATA_DIR, file))
}
@@ -126,11 +137,77 @@ function isAdminToken(token) {
function saveRegistry() { writeJson(registryFile, registry); try { doBackup() } catch { /* 忽略 */ } }
function userFile(userId) { return path.join(DATA_DIR, 'users', userId + '.json') }
function emptyUserData(userId) {
return { userId, cursor: 0, tables: {}, tombstones: {}, created_at: '' }
}
// 表结构自愈:tables[t] 必须是「uuid -> 行对象」;
// 兼容历史误存为数组的形态(并剔除 null / 非法行),避免自然键扫描在 null 上崩溃、避免序列化丢弃行
function sanitizeTables(data) {
let healed = false
const src = data.tables && typeof data.tables === 'object' && !Array.isArray(data.tables) ? data.tables : null
if (!src) { data.tables = {}; return true }
const out = {}
for (const t of Object.keys(src)) {
const container = src[t]
if (Array.isArray(container)) {
healed = true
const m = {}
for (const row of container) {
if (row && typeof row === 'object' && row.uuid) m[row.uuid] = row
}
out[t] = m
continue
}
if (!container || typeof container !== 'object') { healed = true; out[t] = {}; continue }
const m = {}
for (const k of Object.keys(container)) {
const row = container[k]
if (!row || typeof row !== 'object' || Array.isArray(row) || !row.uuid) { healed = true; continue }
m[k] = row
}
out[t] = m
}
data.tables = out
if (!data.tombstones || typeof data.tombstones !== 'object' || Array.isArray(data.tombstones)) { data.tombstones = {}; healed = true }
for (const t of Object.keys(data.tombstones)) {
const bucket = data.tombstones[t]
if (!bucket || typeof bucket !== 'object' || Array.isArray(bucket)) { delete data.tombstones[t]; healed = true; continue }
for (const k of Object.keys(bucket)) {
if (!bucket[k]) { delete bucket[k]; healed = true }
}
}
return healed
}
function loadUserData(userId) {
const data = readJson('users/' + userId + '.json', { userId, cursor: 0, tables: {}, tombstones: {} })
// 数据纪元:用户数据文件首次创建时间。目录被误删重建后该值改变,客户端据此识别并全量重推自救
if (!data.created_at) data.created_at = new Date().toISOString()
return data
const full = userFile(userId)
if (fs.existsSync(full)) {
try {
const data = JSON.parse(fs.readFileSync(full, 'utf-8'))
const healed = sanitizeTables(data)
return { data, healed }
} catch (e) {
// 数据文件损坏:隔离保留原件并大声报错,绝不静默当作空数据(否则客户端同步会引发连锁覆盖)
const quarantine = full + '.corrupt-' + new Date().toISOString().replace(/[:.]/g, '-')
try { fs.renameSync(full, quarantine) } catch { /* 忽略 */ }
console.error('[存储] 用户数据文件解析失败,已隔离:' + quarantine + '(' + (e && e.message) + ')')
return { data: emptyUserData(userId), healed: false }
}
}
return { data: emptyUserData(userId), healed: false }
}
// 墓碑回收:超过保留期的墓碑删除(全量快照重建的客户端不依赖过期墓碑也能收敛)
function gcTombstones(data) {
const cutoff = Date.now() - TOMBSTONE_TTL_MS
let pruned = 0
for (const t of Object.keys(data.tombstones || {})) {
const bucket = data.tombstones[t]
for (const u of Object.keys(bucket)) {
const ts = Date.parse(bucket[u])
if (Number.isFinite(ts) && ts < cutoff) { delete bucket[u]; pruned++ }
}
if (!Object.keys(bucket).length) delete data.tombstones[t]
}
return pruned
}
function saveUserData(userId, data) {
fs.mkdirSync(path.join(DATA_DIR, 'users'), { recursive: true })
@@ -269,7 +346,15 @@ async function handle(req, res) {
return
}
if (req.method === 'GET' && route === '/api/health') { json(res, 200, { ok: true, backup: lastOffsiteBackup.state === 'disabled' ? 'local-only' : lastOffsiteBackup }); return }
if (req.method === 'GET' && route === '/api/health') {
json(res, 200, {
ok: true,
backup: lastOffsiteBackup.state === 'disabled' ? 'local-only' : lastOffsiteBackup,
users: registry.users.length,
lastBackup: lastBackupAt
})
return
}
// SSE 事件流:token 经查询参数传递(EventSource 无法自定义请求头)
if (req.method === 'GET' && route === '/api/events') {
@@ -358,10 +443,13 @@ async function handle(req, res) {
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
const body = await readBody(req)
const userData = loadUserData(auth.userId)
if (!userData.cursor) userData.cursor = 0
if (!userData.tables) userData.tables = {}
if (!userData.tombstones) userData.tombstones = {}
const loaded = loadUserData(auth.userId)
const userData = loaded.data
let dirty = loaded.healed
if (userData.cursor == null) { userData.cursor = 0; dirty = true }
if (!userData.tables) { userData.tables = {}; dirty = true }
if (!userData.tombstones) { userData.tombstones = {}; dirty = true }
if (!userData.created_at) { userData.created_at = new Date().toISOString(); dirty = true } // 数据纪元(客户端灾难自检依据)
// 应用推送的变更(新者胜 + 自然键去重)
const incomingTables = body.tables || {}
@@ -382,7 +470,9 @@ async function handle(req, res) {
if (nkVal) {
for (const u2 of Object.keys(userData.tables[t])) {
if (u2 === row.uuid) continue
if (String(userData.tables[t][u2][nk] || '') === nkVal) { conflictUuid = u2; break }
const other = userData.tables[t][u2]
if (!other || typeof other !== 'object') continue
if (String(other[nk] || '') === nkVal) { conflictUuid = u2; break }
}
}
}
@@ -411,12 +501,18 @@ async function handle(req, res) {
userData.tombstones[t][conflictUuid] = now
changed++
}
// 内容与已存行完全一致:不计变更、不触发保存(空推送不落盘)
if (!conflictUuid && existing && JSON.stringify(existing) === JSON.stringify(clean)) continue
userData.tables[t][row.uuid] = clean
changed++
}
}
// 应用墓碑
const incomingTombs = body.tombstones || []
if (incomingTombs.length >= LARGE_TOMBSTONE_BACKUP) {
// 大批删除的前置保险:先把当前数据快照留存,再应用删除(可追溯、可回滚)
try { doBackup(); console.log('[存储] 检测到大批删除(' + incomingTombs.length + ' 条墓碑),已先行快照') } catch { /* 忽略 */ }
}
for (const tb of incomingTombs) {
if (!TABLES.includes(tb.table) || !tb.uuid) continue
userData.tombstones[tb.table] = userData.tombstones[tb.table] || {}
@@ -431,10 +527,11 @@ async function handle(req, res) {
const tombs = userData.tombstones[t] || {}
for (const [u, deletedAt] of Object.entries(tombs)) {
const row = userData.tables[t][u]
if (row && String(row.updated_at || '') <= String(deletedAt)) delete userData.tables[t][u]
if (row && typeof row === 'object' && String(row.updated_at || '') <= String(deletedAt)) delete userData.tables[t][u]
}
}
saveUserData(auth.userId, userData)
if (gcTombstones(userData) > 0) dirty = true
if (dirty || changed > 0) saveUserData(auth.userId, userData)
if (changed) broadcast(auth.userId, 'change')
// 返回权威全量
@@ -511,27 +608,53 @@ async function handle(req, res) {
}
// ---------- 自动备份:服务器是唯一数据源,必须自带兜底 ----------
function snapshotTo(dir) {
fs.mkdirSync(path.join(dir, 'users'), { recursive: true })
try { fs.copyFileSync(path.join(DATA_DIR, registryFile), path.join(dir, 'registry.json')) } catch { /* 忽略 */ }
try { fs.copyFileSync(path.join(DATA_DIR, 'secret.key'), path.join(dir, 'secret.key')) } catch { /* 忽略 */ }
try {
const usersDir = path.join(DATA_DIR, 'users')
if (fs.existsSync(usersDir)) {
for (const f of fs.readdirSync(usersDir)) {
if (f.endsWith('.json') && !f.includes('.corrupt-')) fs.copyFileSync(path.join(usersDir, f), path.join(dir, 'users', f))
}
}
} catch { /* 忽略 */ }
}
function pruneBackups() {
try {
const root = path.join(DATA_DIR, 'backups')
const entries = fs.readdirSync(root).filter(d => /^\d{4}-\d{2}-\d{2}/.test(d))
const days = Array.from(new Set(entries.map(d => d.slice(0, 10)))).sort()
while (days.length > BACKUP_DAYS) {
const dead = days.shift()
for (const d of entries) {
if (d.startsWith(dead)) { try { fs.rmSync(path.join(root, d), { recursive: true, force: true }) } catch { /* 忽略 */ } }
}
}
} catch { /* 忽略 */ }
}
function doBackup() {
try {
const day = new Date().toISOString().slice(0, 10)
const dir = path.join(DATA_DIR, 'backups', day)
fs.mkdirSync(path.join(dir, 'users'), { recursive: true })
try { fs.copyFileSync(path.join(DATA_DIR, registryFile), path.join(dir, 'registry.json')) } catch { /* 忽略 */ }
try { fs.copyFileSync(path.join(DATA_DIR, 'secret.key'), path.join(dir, 'secret.key')) } catch { /* 忽略 */ }
try {
const usersDir = path.join(DATA_DIR, 'users')
if (fs.existsSync(usersDir)) {
for (const f of fs.readdirSync(usersDir)) {
if (f.endsWith('.json')) fs.copyFileSync(path.join(usersDir, f), path.join(dir, 'users', f))
}
}
} catch { /* 忽略 */ }
// 保留最近 14 天
try {
const backupsRoot = path.join(DATA_DIR, 'backups')
const days = fs.readdirSync(backupsRoot).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort()
while (days.length > 14) { const old = days.shift(); try { fs.rmSync(path.join(backupsRoot, old), { recursive: true, force: true }) } catch { /* 忽略 */ } }
} catch { /* 忽略 */ }
const now = new Date()
const day = now.toISOString().slice(0, 10)
const root = path.join(DATA_DIR, 'backups')
let dir = path.join(root, day)
// 当日首份快照不可变(当日完整证据链);之后的同日备份落时间戳副本
if (fs.existsSync(dir)) dir = path.join(root, day + '_t' + now.toTimeString().slice(0, 8).replace(/:/g, ''))
snapshotTo(dir)
// 每周一额外做周快照(更深恢复点),保留 8 份
if (now.getDay() === 1) {
const wdir = path.join(root, 'weekly', day)
if (!fs.existsSync(wdir)) snapshotTo(wdir)
try {
const wroot = path.join(root, 'weekly')
const ws = fs.readdirSync(wroot).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort()
while (ws.length > WEEKLY_KEEP) { const old = ws.shift(); try { fs.rmSync(path.join(wroot, old), { recursive: true, force: true }) } catch { /* 忽略 */ } }
} catch { /* 忽略 */ }
}
pruneBackups()
lastBackupAt = new Date().toISOString()
} catch (e) { console.error('backup failed:', e && e.message) }
void backupOffsite()
}
@@ -541,7 +664,8 @@ setInterval(doBackup, 24 * 60 * 60 * 1000)
// ---------- HTTP 服务 ----------
const server = http.createServer((req, res) => {
handle(req, res).catch(err => {
json(res, 500, { error: err.message || 'server error' })
console.error('[api] ' + req.method + ' ' + req.url + ' -> 500: ' + ((err && (err.stack || err.message)) || String(err)))
json(res, 500, { error: (err && err.message) || 'server error' })
})
})
+61
View File
@@ -0,0 +1,61 @@
// 服务端存储加固冒烟:空同步不落盘 / 墓碑GC / 大批删除前置快照 / 损坏隔离
const BASE = 'http://127.0.0.1:3780'
let pass = 0, fail = 0
function check(name, ok, detail) {
if (ok) { pass++; console.log(' PASS ' + name) } else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
}
const sleep = ms => new Promise(r => setTimeout(r, ms))
async function j(path, body, token) {
const r = await fetch(BASE + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, body: body ? JSON.stringify(body) : undefined })
return { status: r.status, json: await r.json().catch(() => ({})) }
}
async function main() {
const fsp = await import('fs')
let reg = await j('/api/auth/register', { username: 'storagetest', password: 'test123456' })
if (reg.status === 409) reg = await j('/api/auth/login', { username: 'storagetest', password: 'test123456' })
console.log('auth status:', reg.status)
const tok = reg.json.token
const file = 'server/data/users/' + reg.json.userId + '.json'
const row = { uuid: 'm1', title: 't', content: 'c', created_at: '2026-10-26T00:00:00', updated_at: '2026-10-26T00:00:00' }
const push = tables => j('/api/sync', { device: 't', tables, tombstones: [] }, tok)
const p1 = await push({ memo: [row] })
console.log('push1 status:', p1.status)
check('首次同步落盘(200+文件存在)', p1.status === 200 && fsp.existsSync(file))
const m1 = fsp.statSync(file).mtimeMs
await sleep(30)
const p2 = await push({ memo: [row] })
await sleep(30)
const m2 = fsp.statSync(file).mtimeMs
check('空推送不重写文件(脏检查)', p2.status === 200 && m2 === m1, 'mtime ' + m1 + ' -> ' + m2)
await j('/api/sync', { device: 't', tables: {}, tombstones: [{ table: 'memo', uuid: 'old1', deleted_at: '2026-09-01T00:00:00' }] }, tok)
const d = JSON.parse(fsp.readFileSync(file, 'utf8'))
check('过期墓碑(>14天)自动回收', !d.tombstones.memo || !d.tombstones.memo.old1, JSON.stringify(d.tombstones))
const dirBefore = fsp.readdirSync('server/data/backups').filter(x => /^\d{4}-\d{2}-\d{2}/.test(x))
const tombs = Array.from({ length: 20 }, (_, i) => ({ table: 'memo', uuid: 'ghost' + i, deleted_at: '2026-10-26T23:59:00' }))
tombs.push({ table: 'memo', uuid: 'm1', deleted_at: '2026-10-26T23:59:00' }) // m1 本尊:应被删除
await sleep(1100) // 错开秒级时间戳,避免 _t 目录同名覆盖
const bigResp = await j('/api/sync', { device: 't', tables: {}, tombstones: tombs }, tok)
console.log('big-tombstone status:', bigResp.status)
const dirAfter = fsp.readdirSync('server/data/backups').filter(x => /^\d{4}-\d{2}-\d{2}/.test(x))
console.log('backup dirs:', dirAfter.join(' | '))
check('大批删除触发前置快照', dirAfter.length > dirBefore.length, dirBefore.length + '->' + dirAfter.length)
const d2 = JSON.parse(fsp.readFileSync(file, 'utf8'))
check('墓碑应用后行已删除', !d2.tables.memo || !d2.tables.memo.m1, JSON.stringify(Object.keys(d2.tables.memo || {})))
check('墓碑已记录(待客户端收敛)', Object.keys(d2.tombstones.memo || {}).length >= 21, JSON.stringify(d2.tombstones.memo || {}))
fsp.writeFileSync(file, '{ this is not json !!!')
const r5 = await push({ memo: [{ uuid: 'm2', title: 'x', content: '', created_at: '2026-10-26T00:00:00', updated_at: '2026-10-26T00:00:00' }] })
check('损坏文件不再静默当空(同步仍200)', r5.status === 200, 'status=' + r5.status)
const quarantined = fsp.readdirSync('server/data/users').some(f => f.includes('.corrupt-'))
check('损坏文件已隔离保留原件', quarantined, fsp.readdirSync('server/data/users').join(','))
const d3 = JSON.parse(fsp.readFileSync('server/data/users/' + reg.json.userId + '.json', 'utf8'))
check('新纪元已写入(created_at 非空)', !!d3.created_at)
console.log('RESULT: ' + (fail === 0 ? 'ALL PASS ' + pass : 'FAIL ' + fail + '/' + (pass + fail)))
if (fail) process.exit(1)
}
main().catch(e => { console.error('SMOKE ERROR', e); process.exit(1) })