10 Commits
Author SHA1 Message Date
root 4092b0830a v0.8.0 记账系统升级:分类多选统计(合计/笔数/占比联动表格)+ 每日收支柱状图(长区间按月聚合)+ 记账弹窗审计修复(报销状态整行/金额强调)+ 金额千分位 2026-11-01 20:07:14 +08:00
root 971853560a v0.7.2 顶栏精简:移除常驻打卡与周记提示按钮,周记提醒改为周五至周日 toast 一次性提示 2026-11-01 19:58:44 +08:00
root d300fd9b96 v0.7.1 记账周期统计:按月/按期/自定义三种周期,账期起点多端同步;工具栏精简(回到本期条件显示、自定义隐藏翻页箭头、清除快速记账死代码) 2026-11-01 16:59:48 +08:00
root 1e3dc1a170 feat: 记账新增分类筛选(本月实际分类动态选项,与类型/报销筛选叠加,切月重置) 2026-10-27 12:57:11 +08:00
root 3bbc68b63e ui: 分类管理去原生弹窗(自绘输入弹窗带内联校验 + 危险确认框) + 通用InputDialog组件 2026-10-26 23:25:57 +08:00
root d55c768f16 ui: 确认弹窗重做(警告徽标+分区留白,修复内容贴边) + warning自绘图标 2026-10-26 21:53:08 +08:00
root 5a6ab2b322 ui: 全组件审核打磨(按下态/细滚动条/tooltip修复/统计卡语义色条/进度条完成态/危险确认警示/耗时chips/日历今日高亮/密码等宽/登录页质感) 2026-10-26 21:48:33 +08:00
root 3b48a27921 fix: 服务端表结构自愈(数组/非法行治愈为uuid键对象,根因修复500与重启丢数据) + 客户端快照防御 2026-10-26 21:34:03 +08:00
root 793a41ab99 fix: 写后同步失败不再弹全屏报错(降级为状态记录+自动续传) / 服务端500异常写入容器日志留痕 2026-10-26 21:21:25 +08:00
root 7d11a48756 ui: 移动端触控基线与板块重排(按钮32-36/输入38/弹窗按钮40;出勤2x2;计划操作等宽;记账工具栏分区;导出铺满;底栏56) 2026-10-26 21:07:50 +08:00
44 changed files with 1391 additions and 354 deletions
+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 安装包
+13
View File
@@ -0,0 +1,13 @@
# v0.6.8 发布说明(2026-10-26)
## 确认弹窗重做
- 修复内容贴边(上一轮组件基座统一时确认框未获得内边距,标题/正文/按钮挤作一团)
- 重做为标准企业级危险确认:红色警告徽标 + 标题/正文分区 + 22px 内边距 + 右对齐操作区
- 危险操作:红色三角警告徽标 + 红色标题 + 红色实心确认按钮;普通确认保持蓝白风格
- 移动端按钮等宽 40px;Esc/点击遮罩关闭行为不变(全部 10+ 处调用点零改动)
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
- 桌面 / 安卓:安装 0.6.8 安装包
+13
View File
@@ -0,0 +1,13 @@
# v0.6.9 发布说明(2026-10-26)
## 分类管理去原生弹窗,全部自绘
- **添加 / 修改分类**:不再使用浏览器原生 prompt,改为自绘「输入弹窗」——自动聚焦全选、回车提交、**内联校验**(空值 / 重名直接在弹窗内红字提示,不再 toast 打断)
- **删除分类**:不再使用原生 confirm,改为统一的红色警告确认框(含保留提示:历史记录不受影响)
- 新增通用 InputDialog 组件(输入类弹窗基座),后续项目改名等场景可复用
- 全应用已无任何浏览器原生弹窗
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
- 桌面 / 安卓:安装 0.6.9 安装包
+12
View File
@@ -0,0 +1,12 @@
# v0.7.0 发布说明(2026-10-26)
## 记账:分类筛选
- 工具栏新增「分类」下拉:选项为本月**实际出现的分类**(自动去重排序),支持「全部分类」
- 与类型筛选(支出/收入)、报销筛选可叠加使用;底部「当前筛选 N 条」实时联动
- 切换月份自动重置分类筛选,避免跨月无效选项
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
- 桌面 / 安卓:安装 0.7.0 安装包
+19
View File
@@ -0,0 +1,19 @@
# v0.7.1 记账周期统计
## 新增
- 记账支持按统计周期查看:按月 / 按期 / 自定义三种模式,统计卡、支出分类、表格与筛选全部跟随所选周期。
- 按期模式:设置「账期起点」(如实习开始日 10-14)后自动切分第 1、2、3…期,第 N 期 = 起点 + (N-1) 个月,到起点 + N 个月的前一天(如 10.14 开启,第 1 期为 10.14 – 11.13)。
- 账期起点存于设置表,随账号多端同步;设置起点后自动切换为按期视图。
- 起点之前如有账目,期列表中提供「起点前」入口,避免数据「不见」的疑惑。
- 自定义模式:任意起止日期区间(如 10.14 – 10.31)。
- 新增「统计周期」选择弹窗:月份列表(仅有账目的月份)、期列表(带日期范围)、自定义区间三个页签。
## 优化
- 工具栏月份标题改为可点击的周期选择按钮,‹ › 按当前模式逐周期移动。
- 「回到本月」按钮仅在偏离当前周期时出现,减少常驻按钮。
- 自定义模式下隐藏前后翻页箭头(无操作对象)。
- 清除页面遗留的快速记账表单死代码(约 90 行未使用的脚本)。
- 文案「本月」统一为「本期」(统计卡、分类、空态、计数)。
## 说明
- 纯客户端功能,无 schema 变更、无服务端改动;部署后刷新网页即生效。
+7
View File
@@ -0,0 +1,7 @@
# v0.7.2 顶栏精简
## 优化
- 移除右上角常驻的「上班打卡」按钮:打卡在「详情」页当日面板与日历当日抽屉中操作,顶栏不再重复。
- 移除右上角常驻的「填写本周周记」提示按钮:改为周五至周日打开应用时 toast 提示一次,不占用常驻界面;设置页「周记提醒」开关继续有效。
- 顶栏仅保留页面标题与搜索,移动端搜索按钮右对齐。
- 清除相关死代码约 80 行(打卡三态、周记提示样式)。
+16
View File
@@ -0,0 +1,16 @@
# v0.8.0 记账系统升级:多选分类统计 + 数据可视化
## 新增
- **分类统计(多选)**:支出 / 收入分类以可多选的胶囊呈现,任选几类即实时计算「合计金额 · 笔数 · 占本期比例」,同时联动下方明细表过滤;分类条按所选高亮、未选淡显。
- **每日收支柱状图**:本周期内每日收入(绿)/ 支出(灰)双柱对比,悬浮显示当日金额;自定义区间超过 92 天自动按月聚合,超过 120 桶仅展示最近 120 个并提示。
- 分类条新增彩色标识(8 色低饱和配色,按分类名稳定取色)与独立占比列。
## 优化
- 原工具栏「全部分类」下拉移除,由分类统计区多选胶囊承担(可多选、可看合计,能力超集)。
- 金额全面改为千分位格式(统计卡、分类条、合计、表格)。
- 分类条布局重排:色标 + 名称 + 进度条 + 金额右对齐 + 占比,长分类名截断不再挤压。
- 记一笔弹窗:报销状态三段选择器由半栏改为整行(修复溢出),金额输入放大加粗、数字等宽,标签精简,移动端按钮加高至触控基线。
- 移动端:图表横向可滑动、柱宽自适应,分类胶囊自动换行。
## 说明
- 纯客户端升级,无 schema 与服务端改动;所有统计跟随统计周期(按月 / 按期 / 自定义)。
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
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-DmHJbQPa.js","./index-BytpYHOU.js","./index-OQmtzryQ.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-BytpYHOU.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-DmHJbQPa.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-oDK3lWem.js","./index-BI5aMIlx.js","./index-CwfY7_G6.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-BI5aMIlx.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-oDK3lWem.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
+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-BytpYHOU.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-OQmtzryQ.css">
<script type="module" crossorigin src="./assets/index-BI5aMIlx.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CwfY7_G6.css">
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "intern-log",
"private": true,
"version": "0.6.3",
"version": "0.8.0",
"type": "module",
"scripts": {
"dev": "vite",
+33 -99
View File
@@ -15,11 +15,11 @@ import UserAdminView from './views/UserAdminView.vue'
import LoginView from './views/LoginView.vue'
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth'
import { autoSyncNow, enableRealtimeSync, ensureCloudBinding, stopRealtime } from './sync/engine'
import { clockIn, getAttendance, getWorkRule, getWeeklyNote, searchLogItems, searchMemos, smartClockOut, type WorkRule } from './db/repo'
import { addDays, hhmm, todayStr, weekStart } from './lib/date'
import { getWorkRule, getWeeklyNote, searchLogItems, searchMemos } from './db/repo'
import { todayStr, weekStart } from './lib/date'
import { showToast, useToasts } from './lib/toast'
import { attendanceChanged, focusDate, focusMemo, notifyAttendanceChanged } from './lib/bus'
import { categoryLabel, type Attendance, type LogItem, type Memo } from './db/schema'
import { focusDate, focusMemo } from './lib/bus'
import { categoryLabel, type LogItem, type Memo } from './db/schema'
const toasts = useToasts()
@@ -30,8 +30,7 @@ async function onLogin(s: Session) {
openUserDatabase(s.userId)
location.hash = '#/calendar'
route.value = 'calendar'
await refreshClock()
await refreshTip()
await checkWeeklyReminder()
startSyncTimer()
try {
await ensureCloudBinding()
@@ -161,72 +160,24 @@ function pickLog(item: LogItem & { date: string }) { openLog(item); mobileSearch
function pickMemo(m: Memo) { openMemo(m); mobileSearch.value = false }
// ---- 工具栏打卡 ----
const today = ref(todayStr())
const att = ref<Attendance | undefined>(undefined)
const yesterdayPending = ref(false)
async function refreshClock() {
att.value = await getAttendance(today.value)
yesterdayPending.value = false
const now = new Date()
if (now.getHours() < 4) {
const yRec = await getAttendance(addDays(today.value, -1))
yesterdayPending.value = !!(yRec && yRec.clock_in_at && !yRec.clock_out_at)
}
}
const hasActiveSession = computed(() => {
const a = att.value
if (a && a.clock_in_at && !a.clock_out_at) return true
return yesterdayPending.value && !(a && a.clock_in_at)
})
const clockDone = computed(() => {
const a = att.value
return !!(a && a.clock_in_at && a.clock_out_at)
})
const clockLabel = computed(() => {
const a = att.value
if (hasActiveSession.value) return '下班打卡'
if (clockDone.value) return '已打卡 ' + hhmm(a?.clock_in_at) + ' - ' + hhmm(a?.clock_out_at)
return '上班打卡'
})
async function doClock() {
if (hasActiveSession.value) {
try { await smartClockOut() } catch { /* 没有进行中的打卡 */ }
} else {
await clockIn(today.value)
}
await refreshClock()
notifyAttendanceChanged()
}
// 任何视图发生的考勤变更(含今日面板、日历抽屉)都实时同步到顶栏
watch(attendanceChanged, () => {
if (session.value) refreshClock()
})
// ---- 周记提醒 ----
const weekTip = ref(false)
async function refreshTip() {
const rule: WorkRule = await getWorkRule()
if (rule.remind_weekly === 'off') { weekTip.value = false; return }
// ---- 周记提醒:周五至周日打开应用时 toast 提示一次,不占用常驻界面 ----
async function checkWeeklyReminder() {
try {
const rule = await getWorkRule()
if (rule.remind_weekly === 'off') return
const wd = new Date().getDay()
if (wd === 5 || wd === 6 || wd === 0) {
const note = await getWeeklyNote(weekStart(today.value))
weekTip.value = !note || (!note.highlights && !note.challenges && !note.plan_next)
} else {
weekTip.value = false
}
if (wd !== 5 && wd !== 6 && wd !== 0) return
const note = await getWeeklyNote(weekStart(todayStr()))
if (note && (note.highlights || note.challenges || note.plan_next)) return
showToast('本周周记还没写,可在「总结」页填写')
} catch { /* 提醒检查失败不影响使用 */ }
}
onMounted(async () => {
window.addEventListener('keydown', onGlobalKey)
if (session.value) {
openUserDatabase(session.value.userId)
await refreshClock()
await refreshTip()
await checkWeeklyReminder()
startSyncTimer()
}
})
@@ -287,17 +238,7 @@ onMounted(async () => {
<div v-else class="gs-empty muted">无匹配结果</div>
</div>
</div>
<span class="flex1"></span>
<div class="topbar-actions">
<button v-if="weekTip" class="week-tip" @click="nav('report')">
<span class="dot" style="background: var(--warn)"></span>
填写本周周记
</button>
<button class="btn btn-primary" :disabled="clockDone" @click="doClock">
<Icon :name="clockDone ? 'check-circle' : 'clock'" :size="14" />
<span class="num">{{ clockLabel }}</span>
</button>
</div>
</header>
<main class="content">
<div class="content-inner">
@@ -333,6 +274,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 +394,7 @@ onMounted(async () => {
background: var(--accent);
}
.nav-label { white-space: nowrap; }
/* tooltip 仅在折叠态(图标-only)显示;宽侧栏文字可见,不需要 */
.nav-btn::after {
content: attr(data-tip);
position: absolute;
@@ -505,8 +448,6 @@ onMounted(async () => {
}
.title { font-size: 15px; font-weight: 600; white-space: nowrap; }
.topbar-actions { display: flex; align-items: center; gap: 8px; }
.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 {
@@ -552,21 +493,6 @@ onMounted(async () => {
.gs-date { font-size: 12px; flex: none; }
.gs-empty { padding: 10px; font-size: 12px; text-align: center; }
.week-tip {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--warn);
color: var(--warn);
background: var(--bg-page);
border-radius: 999px;
height: 28px;
padding: 0 10px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.week-tip:hover { background: #FDF4EC; }
.content { flex: 1; overflow: auto; }
.content-inner { max-width: 1280px; margin: 0 auto; padding: 20px 24px 48px; }
@@ -647,10 +573,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 +655,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; }
}
@@ -741,16 +676,15 @@ onMounted(async () => {
.gsearch { display: none; }
.gsearch .gs-panel { display: none; }
.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 .num { font-size: 12px; }
.mob-search-btn { margin-left: auto; }
.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 {
+44 -9
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onUnmounted, watch } from 'vue'
import Icon from './Icon.vue'
const props = withDefaults(defineProps<{
open: boolean
@@ -27,12 +28,19 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
<teleport to="body">
<transition name="modal">
<div v-if="open" class="mask" @click.self="emit('cancel')">
<div class="dialog" role="dialog" :aria-label="title">
<div class="dialog-title">{{ title }}</div>
<div class="dialog-msg">{{ message }}</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('cancel')">{{ cancelText }}</button>
<button class="btn btn-sm" :class="danger ? 'btn-danger-solid' : 'btn-primary'" @click="emit('confirm')">{{ confirmText }}</button>
<div class="dialog confirm-dialog" role="dialog" :aria-label="title">
<div class="confirm-body">
<div v-if="danger" class="confirm-badge">
<Icon name="warning" :size="22" />
</div>
<div class="confirm-text">
<div class="confirm-title" :class="{ 'is-danger': danger }">{{ title }}</div>
<div class="confirm-msg">{{ message }}</div>
</div>
</div>
<div class="confirm-btns">
<button class="btn" @click="emit('cancel')">{{ cancelText }}</button>
<button class="btn" :class="danger ? 'btn-danger-solid' : 'btn-primary'" @click="emit('confirm')">{{ confirmText }}</button>
</div>
</div>
</div>
@@ -41,7 +49,34 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
</template>
<style scoped>
.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; }
.confirm-dialog {
width: 400px;
padding: 22px 22px 18px;
}
.confirm-body { display: flex; gap: 14px; align-items: flex-start; }
.confirm-badge {
width: 40px;
height: 40px;
flex: none;
border-radius: 50%;
background: #FDEEEE;
color: var(--danger);
display: flex;
align-items: center;
justify-content: center;
}
.confirm-text { flex: 1; min-width: 0; }
.confirm-title { font-size: 15px; font-weight: 600; color: var(--text-1); line-height: 1.4; }
.confirm-title.is-danger { color: var(--danger); }
.confirm-msg { margin-top: 6px; color: var(--text-2); font-size: 13px; line-height: 1.7; word-break: break-all; }
.confirm-btns { margin-top: 18px; display: flex; justify-content: flex-end; gap: 8px; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
@media (max-width: 720px) {
.confirm-dialog { width: calc(100vw - 16px) !important; max-width: calc(100vw - 16px) !important; }
.confirm-btns .btn { flex: 1; height: 40px; }
.confirm-btns { margin-top: 16px; }
}
</style>
+89
View File
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { nextTick, onUnmounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
const props = withDefaults(defineProps<{
open: boolean
title: string
label?: string
placeholder?: string
initial?: string
confirmText?: string
validate?: (v: string) => string | null
}>(), { label: '', placeholder: '', initial: '', confirmText: '确定' })
const emit = defineEmits<{ (e: 'cancel'): void; (e: 'confirm', value: string): void }>()
const value = ref('')
const err = ref('')
const input = ref<HTMLInputElement | null>(null)
function onKey(e: KeyboardEvent) {
if (!props.open) return
if (e.key === 'Escape') emit('cancel')
}
watch(() => props.open, async v => {
if (v) {
value.value = props.initial
err.value = ''
window.addEventListener('keydown', onKey)
await nextTick()
input.value?.focus()
input.value?.select()
} else {
window.removeEventListener('keydown', onKey)
}
})
onUnmounted(() => window.removeEventListener('keydown', onKey))
function confirm() {
const v = value.value.trim()
if (!v) { err.value = '请输入内容'; return }
const e = props.validate ? props.validate(v) : null
if (e) { err.value = e; return }
emit('confirm', v)
}
</script>
<template>
<teleport to="body">
<transition name="modal">
<div v-if="open" class="mask" @click.self="emit('cancel')">
<div class="dialog input-dialog" role="dialog" :aria-label="title">
<div class="dialog-head">
<span class="dialog-title">{{ title }}</span>
<button class="icon-btn" title="关闭" @click="emit('cancel')">
<Icon name="close" :size="16" />
</button>
</div>
<div class="dialog-body">
<div class="field">
<label v-if="label">{{ label }}</label>
<input
ref="input" class="input" :class="{ invalid: err }"
v-model="value" :placeholder="placeholder" maxlength="20"
@keydown.enter="confirm"
/>
<div v-if="err" class="form-error">{{ err }}</div>
</div>
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('cancel')">取消</button>
<button class="btn btn-primary btn-sm" @click="confirm">{{ confirmText }}</button>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<style scoped>
.input-dialog { width: 400px; }
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
@media (max-width: 720px) {
.input-dialog { width: calc(100vw - 16px) !important; max-width: calc(100vw - 16px) !important; }
.dialog-btns .btn { flex: 1; height: 40px; }
}
</style>
+207
View File
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { addDays, todayStr } from '../lib/date'
import { cycleStartOf, cycleEndOf, fmtDayRef, fmtRange } from '../lib/period'
const props = withDefaults(defineProps<{
open: boolean
mode: 'month' | 'cycle' | 'custom'
anchor: string
month: string
cycleSel: number | 'before'
customStart: string
customEnd: string
cycleCount: number
hasBefore: boolean
months: string[]
}>(), { anchor: '', months: () => [] })
const emit = defineEmits<{
(e: 'close'): void
(e: 'apply', payload: { mode: 'month' | 'cycle' | 'custom'; month?: string; cycle?: number | 'before'; customStart?: string; customEnd?: string }): void
(e: 'saveAnchor', value: string): void
}>()
const dMode = ref(props.mode)
const dMonth = ref(props.month.slice(0, 7))
const dCycle = ref<number | 'before'>(props.cycleSel)
const dStart = ref(props.customStart)
const dEnd = ref(props.customEnd)
const dAnchor = ref(props.anchor)
const customErr = ref('')
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
watch(() => props.open, v => {
if (v) {
dMode.value = props.mode
dMonth.value = props.month.slice(0, 7)
dCycle.value = props.cycleSel
dStart.value = props.customStart
dEnd.value = props.customEnd
dAnchor.value = props.anchor
customErr.value = ''
window.addEventListener('keydown', onKey)
} else {
window.removeEventListener('keydown', onKey)
}
})
watch(() => [props.anchor, props.cycleSel] as const, () => {
dAnchor.value = props.anchor
dCycle.value = props.cycleSel
})
onUnmounted(() => window.removeEventListener('keydown', onKey))
// 有账目的月份 ∪ 当前选中月 ∪ 本月,倒序
const monthOptions = computed(() => {
const set = new Set(props.months)
set.add(dMonth.value)
set.add(todayStr().slice(0, 7))
return Array.from(set).sort((a, b) => (a < b ? 1 : -1))
})
const beforeEndLabel = computed(() =>
props.anchor ? fmtDayRef(addDays(props.anchor, -1), props.anchor) : ''
)
function monthLabel(m: string): string {
return m.slice(0, 4) + ' 年 ' + Number(m.slice(5, 7)) + ' 月'
}
function pickMonth(m: string) {
dMonth.value = m
emit('apply', { mode: 'month', month: m })
emit('close')
}
function pickCycle(c: number | 'before') {
dCycle.value = c
emit('apply', { mode: 'cycle', cycle: c })
emit('close')
}
function saveAnchor() {
if (!dAnchor.value) return
emit('saveAnchor', dAnchor.value)
}
function applyCustom() {
if (!dStart.value || !dEnd.value) { customErr.value = '请选择开始和结束日期'; return }
if (dStart.value > dEnd.value) { customErr.value = '开始日期不能晚于结束日期'; return }
customErr.value = ''
emit('apply', { mode: 'custom', customStart: dStart.value, customEnd: dEnd.value })
emit('close')
}
</script>
<template>
<teleport to="body">
<transition name="modal">
<div v-if="open" class="mask" @click.self="emit('close')">
<div class="dialog period-dialog" role="dialog" aria-label="统计周期">
<div class="dialog-head">
<span class="dialog-title">统计周期</span>
<button class="icon-btn" title="关闭" @click="emit('close')">
<Icon name="close" :size="16" />
</button>
</div>
<div class="dialog-body">
<div class="seg seg-row">
<button class="seg-btn" :class="{ active: dMode === 'month' }" @click="dMode = 'month'">按月</button>
<button class="seg-btn" :class="{ active: dMode === 'cycle' }" @click="dMode = 'cycle'">按期</button>
<button class="seg-btn" :class="{ active: dMode === 'custom' }" @click="dMode = 'custom'">自定义</button>
</div>
<!-- 按月:有账目的月份列表 -->
<div v-if="dMode === 'month'" class="pane">
<button
v-for="m in monthOptions" :key="m"
class="opt" :class="{ active: m === dMonth }"
@click="pickMonth(m)"
>
<span>{{ monthLabel(m) }}</span>
<Icon v-if="m === dMonth" name="check-circle" :size="15" />
</button>
<div v-if="!monthOptions.length" class="hint">还没有账目记录</div>
</div>
<!-- 按期:账期起点 + 期列表 -->
<div v-else-if="dMode === 'cycle'" class="pane">
<div class="anchor-row">
<span class="anchor-label">账期起点</span>
<input class="input" type="date" v-model="dAnchor" />
<button class="btn btn-sm" :disabled="!dAnchor || dAnchor === anchor" @click="saveAnchor">保存</button>
</div>
<div v-if="!dAnchor" class="hint">填写实习开始日期并保存,自动切分为第 1、2、3…期(每期到下月同日前一天)</div>
<div v-else-if="dAnchor !== anchor" class="hint">起点已修改,保存后按新起点重新切分</div>
<template v-if="anchor">
<button
v-if="hasBefore" class="opt" :class="{ active: dCycle === 'before' }"
@click="pickCycle('before')"
>
<span>起点前</span>
<span class="muted num">~ {{ beforeEndLabel }}</span>
</button>
<button
v-for="n in cycleCount" :key="n"
class="opt" :class="{ active: dCycle === n }"
@click="pickCycle(n)"
>
<span>第 {{ n }} 期</span>
<span class="muted num">{{ fmtRange(cycleStartOf(anchor, n), cycleEndOf(anchor, n)) }}</span>
</button>
</template>
</div>
<!-- 自定义:任意起止区间 -->
<div v-else class="pane">
<div class="custom-grid">
<div class="field">
<label>开始日期</label>
<input class="input" type="date" v-model="dStart" />
</div>
<div class="field">
<label>结束日期</label>
<input class="input" type="date" v-model="dEnd" />
</div>
</div>
<div v-if="customErr" class="form-error">{{ customErr }}</div>
<button class="btn btn-primary apply-btn" @click="applyCustom">应用区间</button>
</div>
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('close')">关闭</button>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<style scoped>
.period-dialog { width: 400px; }
.seg-row { display: flex; width: 100%; }
.seg-row .seg-btn { flex: 1; }
.pane { margin-top: 12px; max-height: 46vh; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
.opt {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
border: none; background: transparent; cursor: pointer;
padding: 8px 10px; border-radius: var(--radius-sm);
font-size: 13px; color: var(--text-1); text-align: left;
transition: background 0.12s;
}
.opt:hover { background: var(--bg-hover); }
.opt.active { background: rgba(51, 112, 255, 0.08); color: var(--accent); font-weight: 600; }
.opt.active .muted { color: var(--accent); opacity: 0.75; }
.anchor-row { display: flex; align-items: center; gap: 8px; }
.anchor-label { font-size: 12px; color: var(--text-2); flex: none; }
.anchor-row .input { flex: 1; min-width: 0; }
.hint { font-size: 12px; color: var(--text-3); padding: 4px 2px; }
.custom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.apply-btn { width: 100%; margin-top: 6px; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
@media (max-width: 720px) {
.period-dialog { width: calc(100vw - 16px) !important; max-width: calc(100vw - 16px) !important; }
.dialog-btns .btn { flex: 1; height: 40px; }
.pane { max-height: 48vh; }
}
</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;
}
}
@@ -117,15 +117,15 @@ function save() {
</div>
</div>
<div class="field">
<label>金额(元,必填)</label>
<input ref="amountInput" class="input" :class="{ invalid: fError }" v-model="fAmount" type="number" min="0" step="0.01" placeholder="0.00" />
<label>金额(元)</label>
<input ref="amountInput" class="input amt-input" :class="{ invalid: fError }" v-model="fAmount" type="number" min="0" step="0.01" placeholder="0.00" inputmode="decimal" />
</div>
<div class="field">
<label>日期(必填)</label>
<label>日期</label>
<input class="input" type="date" v-model="fDate" />
</div>
<div class="field">
<label>分类(可直接新增)</label>
<label>分类</label>
<select class="select" v-model="fCategory">
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
</select>
@@ -134,7 +134,7 @@ function save() {
<button class="btn btn-xs" type="button" @click="addCategory">添加</button>
</div>
</div>
<div class="field" v-if="fType === 'expense'">
<div class="field span2" v-if="fType === 'expense'">
<label>报销状态</label>
<div class="seg">
<button class="seg-btn" :class="{ active: !fReimbursable }" @click="fReimbursable = false; fReimbursed = false">无需报销</button>
@@ -162,5 +162,13 @@ function save() {
<style scoped>
.dialog { width: 480px; }
.add-cat { display: flex; gap: 6px; margin-top: 6px; }
.amt-input { height: 38px; font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }
@media (max-width: 720px) {
.dialog-btns .btn { flex: 1; height: 40px; }
}
.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>
+1
View File
@@ -25,5 +25,6 @@ export const ICONS: Record<string, string> = {
wallet: svg('<rect x="3.5" y="6" width="17" height="12.5" rx="2"/><path d="M3.5 10h17"/><circle cx="16.4" cy="14.2" r="1.1"/>'),
more: svg('<circle cx="5.4" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.6" cy="12" r="1.7"/>'),
shield: svg('<path d="M12 3.5l7.2 2.7v5.1c0 4.7-3.1 7.8-7.2 9.4-4.1-1.6-7.2-4.7-7.2-9.4V6.2z"/><path d="M9.3 12.2l1.9 1.9 3.6-4"/>'),
warning: svg('<path d="M12 4.2L21 19.2H3L12 4.2z"/><path d="M12 10.2v3.6M12 16.4h.01"/>'),
plan: svg('<rect x="4.5" y="4" width="15" height="16.5" rx="2"/><path d="M8.5 9.5l1.4 1.4 2.6-2.9M8.5 15l1.4 1.4 2.6-2.9M15 9.8h.01M15 15.3h.01"/>')
}
+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>
+13
View File
@@ -634,6 +634,19 @@ export async function setTransactionReimbursed(id: number, done: boolean): Promi
await commit()
}
// 有账目的月份列表(YYYY-MM,倒序),供周期选择弹窗使用
export async function listTransactionMonths(): Promise<string[]> {
const all = await db.table('transaction').toArray()
const set = new Set<string>()
for (const r of all) if (r && r.date) set.add(r.date.slice(0, 7))
return Array.from(set).sort((a, b) => (a < b ? 1 : -1))
}
// 某日期之前的账目条数(判断「起点前」是否有数据)
export async function countTransactionsBefore(date: string): Promise<number> {
return db.table('transaction').where('date').below(date).count()
}
// ---------- 备份 ----------
export async function exportAll(): Promise<string> {
+41
View File
@@ -0,0 +1,41 @@
// 账期(统计周期)推算:全部基于 YYYY-MM-DD 字符串与整数运算,不涉及时区。
// 第 n 期 = [起点 + (n-1) 个月, 起点 + n 个月 - 1 天];月加法沿用 date.ts 的自然月末收敛
//(如起点 01-31,下一期从 02-28 起)。
import { addDays, addMonths } from './date'
export type CycleSel = number | 'before'
// 第 n 期的开始日期(n 从 1 开始)
export function cycleStartOf(anchor: string, n: number): string {
return addMonths(anchor, n - 1)
}
// 第 n 期的结束日期
export function cycleEndOf(anchor: string, n: number): string {
return addDays(addMonths(anchor, n), -1)
}
// 某日期落在第几期;早于起点返回 0(即「起点前」)
export function cycleIndexOf(anchor: string, date: string): number {
if (!anchor || date < anchor) return 0
let n = (Number(date.slice(0, 4)) - Number(anchor.slice(0, 4))) * 12
+ (Number(date.slice(5, 7)) - Number(anchor.slice(5, 7))) + 1
if (n < 1) n = 1
if (n > 1 && date < cycleStartOf(anchor, n)) n--
return n
}
// 10.14 形式的短日期
export function fmtDay(d: string): string {
return Number(d.slice(5, 7)) + '.' + Number(d.slice(8, 10))
}
// 与参照日期同年则省略年份,跨年则带完整年月日
export function fmtDayRef(d: string, ref: string): string {
return d.slice(0, 4) === ref.slice(0, 4) ? fmtDay(d) : d.split('-').join('.')
}
// 「10.14 – 11.13」,跨年时两侧均带年份
export function fmtRange(start: string, end: string): string {
return fmtDayRef(start, end) + ' – ' + fmtDayRef(end, start)
}
+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>
+437 -117
View File
@@ -3,49 +3,139 @@ import { computed, onMounted, ref, watch } from 'vue'
import Icon from '../components/Icon.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import TransactionDialog from '../components/TransactionDialog.vue'
import PeriodDialog from '../components/PeriodDialog.vue'
import {
addTransaction, deleteTransaction, listTransactionsBetween,
addTransaction, countTransactionsBefore, deleteTransaction, getSetting,
listTransactionMonths, listTransactionsBetween, setSetting,
setTransactionReimbursed, updateTransaction, type Transaction
} from '../db/repo'
import { getLedgerCategories } from '../db/categories'
import { addMonths, todayStr } from '../lib/date'
import { addDays, addMonths, parseDateStr, todayStr } from '../lib/date'
import { cycleEndOf, cycleIndexOf, cycleStartOf, fmtDayRef, fmtRange, type CycleSel } from '../lib/period'
import { showToast } from '../lib/toast'
type TxType = 'expense' | 'income'
type PeriodMode = 'month' | 'cycle' | 'custom'
// 周期偏好为设备本地习惯;账期起点存 setting 表,随账号多端同步
const LS_MODE = 'dsh_ledger_mode'
const LS_CYCLE = 'dsh_ledger_cycle'
const LS_CSTART = 'dsh_ledger_cstart'
const LS_CEND = 'dsh_ledger_cend'
const EARLIEST = '0000-01-01'
// 分类可视化配色(高级白低饱和,按分类名稳定取色)
const PALETTE = ['#3370FF', '#23A757', '#7A5AF8', '#0EA5A0', '#E8842C', '#D96D5A', '#5B7A99', '#B56CC6']
function catColor(name: string): string {
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
return PALETTE[h % PALETTE.length]
}
function fmtMoney(cents: number): string {
return (cents / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const mode = ref<PeriodMode>('month')
const monthRef = ref(todayStr())
const cycleSel = ref<CycleSel>(1)
const customStart = ref(todayStr().slice(0, 7) + '-01')
const customEnd = ref(todayStr())
const anchor = ref('')
const hasBefore = ref(false)
const txMonths = ref<string[]>([])
const rows = ref<Transaction[]>([])
const loading = ref(true)
// ---- 新增表单 ----
const fType = ref<TxType>('expense')
const fAmount = ref('')
const fCategory = ref('')
const fDate = ref(todayStr())
const fNote = ref('')
const fReimb = ref(false)
const fError = ref('')
const loadError = ref('')
const submitting = ref(false)
const dialogOpen = ref(false)
const editingId = ref<number | null>(null)
const dialogInitial = ref({ type: 'expense', category: '', amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 })
const deleteTarget = ref<Transaction | null>(null)
const periodOpen = ref(false)
const monthStart = computed(() => monthRef.value.slice(0, 7) + '-01')
const monthEnd = computed(() => {
const y = Number(monthRef.value.slice(0, 4))
const m0 = Number(monthRef.value.slice(5, 7)) - 1
// ---- 统计周期 ----
function monthEndOf(ym: string): string {
const y = Number(ym.slice(0, 4))
const m0 = Number(ym.slice(5, 7)) - 1
const last = new Date(y, m0 + 1, 0).getDate()
return monthRef.value.slice(0, 7) + '-' + (last < 10 ? '0' + last : String(last))
return ym + '-' + (last < 10 ? '0' + last : String(last))
}
const currentCycle = computed(() => (anchor.value ? Math.max(1, cycleIndexOf(anchor.value, todayStr())) : 1))
const rangeStart = computed(() => {
if (mode.value === 'month') return monthRef.value.slice(0, 7) + '-01'
if (mode.value === 'custom') return customStart.value
if (!anchor.value) return monthRef.value.slice(0, 7) + '-01'
return cycleSel.value === 'before' ? EARLIEST : cycleStartOf(anchor.value, cycleSel.value)
})
const title = computed(() => Number(monthRef.value.slice(0, 4)) + ' 年 ' + Number(monthRef.value.slice(5, 7)) + ' 月')
const rangeEnd = computed(() => {
if (mode.value === 'month') return monthEndOf(monthRef.value.slice(0, 7))
if (mode.value === 'custom') return customEnd.value
if (!anchor.value) return monthEndOf(monthRef.value.slice(0, 7))
return cycleSel.value === 'before' ? addDays(anchor.value, -1) : cycleEndOf(anchor.value, cycleSel.value)
})
const title = computed(() => {
if (mode.value === 'month') return Number(monthRef.value.slice(0, 4)) + ' 年 ' + Number(monthRef.value.slice(5, 7)) + ' 月'
if (mode.value === 'custom') return fmtRange(rangeStart.value, rangeEnd.value)
if (!anchor.value) return '按期统计 · 设置起点'
if (cycleSel.value === 'before') return '起点前 · ~ ' + fmtDayRef(addDays(anchor.value, -1), anchor.value)
return '第 ' + cycleSel.value + ' 期 · ' + fmtRange(rangeStart.value, rangeEnd.value)
})
const canPrev = computed(() => {
if (mode.value === 'month') return true
if (mode.value === 'cycle' && anchor.value) {
if (cycleSel.value === 'before') return false
return cycleSel.value > 1 || hasBefore.value
}
return false
})
const canNext = computed(() => {
if (mode.value === 'month') return true
if (mode.value === 'cycle' && anchor.value) {
return cycleSel.value === 'before' ? true : cycleSel.value < currentCycle.value
}
return false
})
function step(dir: -1 | 1) {
if (mode.value === 'month') {
monthRef.value = addMonths(monthRef.value, dir)
return
}
if (mode.value !== 'cycle' || !anchor.value) return
if (cycleSel.value === 'before') {
if (dir === 1) cycleSel.value = 1
return
}
const n = Number(cycleSel.value) + dir
if (n > currentCycle.value) return
if (n < 1) {
if (hasBefore.value) cycleSel.value = 'before'
return
}
cycleSel.value = n
}
const offCurrent = computed(() => {
if (mode.value === 'month') return monthRef.value.slice(0, 7) !== todayStr().slice(0, 7)
if (mode.value === 'cycle') return !!anchor.value && cycleSel.value !== currentCycle.value
return false
})
function goCurrent() {
if (mode.value === 'cycle') cycleSel.value = currentCycle.value
else monthRef.value = todayStr()
}
// ---- 数据加载:全部统计共用 [rangeStart, rangeEnd] ----
async function load() {
loading.value = true
try {
rows.value = await listTransactionsBetween(monthStart.value, monthEnd.value)
rows.value = await listTransactionsBetween(rangeStart.value, rangeEnd.value)
loadError.value = ''
} catch (e) {
loadError.value = '账目加载失败:' + (e instanceof Error ? (e.stack || e.message) : String(e))
@@ -53,18 +143,62 @@ async function load() {
}
loading.value = false
}
onMounted(load)
watch(monthRef, load)
onMounted(async () => {
const savedMode = localStorage.getItem(LS_MODE)
if (savedMode === 'month' || savedMode === 'cycle' || savedMode === 'custom') mode.value = savedMode
const cs = localStorage.getItem(LS_CSTART)
const ce = localStorage.getItem(LS_CEND)
if (cs && ce && cs <= ce) { customStart.value = cs; customEnd.value = ce }
const cyc = localStorage.getItem(LS_CYCLE)
if (cyc === 'before') cycleSel.value = 'before'
else if (cyc && Number(cyc) >= 1) cycleSel.value = Number(cyc)
anchor.value = await getSetting('ledger.anchor', '')
if (cycleSel.value !== 'before' && cycleSel.value > currentCycle.value) cycleSel.value = currentCycle.value
await load()
})
watch([rangeStart, rangeEnd], () => {
selectedCats.value = [] // 切换周期重置分类选择
void load()
})
watch(mode, v => localStorage.setItem(LS_MODE, v))
watch(cycleSel, v => localStorage.setItem(LS_CYCLE, String(v)))
watch([customStart, customEnd], ([s, e]) => {
localStorage.setItem(LS_CSTART, s)
localStorage.setItem(LS_CEND, e)
})
async function openPeriodDialog() {
try {
txMonths.value = await listTransactionMonths()
hasBefore.value = anchor.value ? (await countTransactionsBefore(anchor.value)) > 0 : false
} catch { /* 统计失败不阻塞打开弹窗 */ }
periodOpen.value = true
}
function onApply(p: { mode: PeriodMode; month?: string; cycle?: number | 'before'; customStart?: string; customEnd?: string }) {
mode.value = p.mode
if (p.month) monthRef.value = p.month + '-01'
if (p.cycle !== undefined) cycleSel.value = p.cycle
if (p.customStart) customStart.value = p.customStart
if (p.customEnd) customEnd.value = p.customEnd
}
async function onSaveAnchor(v: string) {
anchor.value = v
await setSetting('ledger.anchor', v)
mode.value = 'cycle'
cycleSel.value = Math.max(1, cycleIndexOf(v, todayStr()))
try {
hasBefore.value = (await countTransactionsBefore(v)) > 0
} catch { /* 忽略统计失败 */ }
showToast('账期起点已保存,已按新起点切分期数')
}
const expenseCats = ref<string[]>([])
const incomeCats = ref<string[]>([])
async function loadCats() {
expenseCats.value = await getLedgerCategories('expense')
incomeCats.value = await getLedgerCategories('income')
}
loadCats()
const sorted = computed(() =>
rows.value.slice().sort((a, b) => (a.date === b.date ? (b.id as number) - (a.id as number) : (a.date < b.date ? 1 : -1)))
)
@@ -74,9 +208,8 @@ const summary = computed(() => {
let expense = 0
let pending = 0
for (const r of rows.value) {
if (r.type === 'income') {
income += r.amount
} else {
if (r.type === 'income') income += r.amount
else {
expense += r.amount
if (r.reimbursable === 1 && r.reimbursed !== 1) pending += r.amount
}
@@ -84,89 +217,143 @@ const summary = computed(() => {
return { income, expense, pending, balance: income - expense }
})
// ---- 每日收支(自定义长区间自动按月聚合) ----
interface Bucket { label: string; tip: string; income: number; expense: number; showLabel: boolean }
const bucketHint = ref('')
const buckets = computed<Bucket[]>(() => {
bucketHint.value = ''
const list: Bucket[] = []
const start = rangeStart.value
const end = rangeEnd.value
if (!start || !end || start > end) return list
const days = Math.round((parseDateStr(end).getTime() - parseDateStr(start).getTime()) / 86400000) + 1
// 收集:日/月两个粒度各一份,按需取用
const daily = new Map<string, { i: number; e: number }>()
const monthly = new Map<string, { i: number; e: number }>()
for (const r of rows.value) {
const d = daily.get(r.date) || { i: 0, e: 0 }
if (r.type === 'income') d.i += r.amount; else d.e += r.amount
daily.set(r.date, d)
const m = r.date.slice(0, 7)
const mo = monthly.get(m) || { i: 0, e: 0 }
if (r.type === 'income') mo.i += r.amount; else mo.e += r.amount
monthly.set(m, mo)
}
let keys: string[] = []
let gran: 'day' | 'month' = 'day'
if (days <= 92) {
let cur = start
let guard = 0
while (cur <= end && guard < 4000) { keys.push(cur); cur = addDays(cur, 1); guard++ }
} else {
gran = 'month'
let ym = start.slice(0, 7)
const endYm = end.slice(0, 7)
let guard = 0
while (ym <= endYm && guard < 600) {
keys.push(ym)
const y = Number(ym.slice(0, 4)), m0 = Number(ym.slice(5, 7)) - 1
const next = new Date(y, m0 + 1, 1)
ym = next.getFullYear() + '-' + (next.getMonth() + 1 < 10 ? '0' + (next.getMonth() + 1) : String(next.getMonth() + 1))
guard++
}
}
if (!keys.length) return list
if (keys.length > 120) {
if (gran === 'day') bucketHint.value = '时间跨度较长,仅展示最近 120 天'
else bucketHint.value = '时间跨度较长,仅展示最近 120 个月'
keys = keys.slice(-120)
}
const src = gran === 'day' ? daily : monthly
const labelEvery = Math.max(1, Math.ceil(keys.length / 8))
keys.forEach((k, idx) => {
const v = src.get(k) || { i: 0, e: 0 }
let label = ''
let tip = ''
if (gran === 'day') {
label = Number(k.slice(5, 7)) + '.' + Number(k.slice(8, 10))
tip = k + ' · 支出 ' + fmtMoney(v.e) + ' · 收入 ' + fmtMoney(v.i)
} else {
label = k.slice(2, 4) + '.' + Number(k.slice(5, 7))
tip = Number(k.slice(0, 4)) + ' 年 ' + Number(k.slice(5, 7)) + ' 月 · 支出 ' + fmtMoney(v.e) + ' · 收入 ' + fmtMoney(v.i)
}
list.push({ label, tip, income: v.i, expense: v.e, showLabel: idx % labelEvery === 0 || idx === keys.length - 1 })
})
return list
})
const bucketMax = computed(() => {
let m = 0
for (const b of buckets.value) m = Math.max(m, b.income, b.expense)
return m || 1
})
// ---- 分类统计(多选) ----
const chartType = ref<TxType>('expense')
const selectedCats = ref<string[]>([])
watch(chartType, () => { selectedCats.value = [] })
function toggleCat(name: string) {
const i = selectedCats.value.indexOf(name)
if (i >= 0) selectedCats.value.splice(i, 1)
else selectedCats.value = [...selectedCats.value, name]
}
const activeCats = computed(() => {
const map = new Map<string, { name: string; amount: number; count: number }>()
for (const r of rows.value) {
if (r.type !== chartType.value) continue
const c = map.get(r.category) || { name: r.category, amount: 0, count: 0 }
c.amount += r.amount
c.count++
map.set(r.category, c)
}
return Array.from(map.values()).sort((a, b) => b.amount - a.amount)
})
const activeTypeTotal = computed(() => activeCats.value.reduce((s, c) => s + c.amount, 0))
const catRows = computed(() =>
activeCats.value.map(c => ({
...c,
color: catColor(c.name),
percent: activeTypeTotal.value ? Math.round((c.amount / activeTypeTotal.value) * 100) : 0,
dim: selectedCats.value.length > 0 && !selectedCats.value.includes(c.name)
}))
)
const selSummary = computed(() => {
if (!selectedCats.value.length) return null
let amount = 0
let count = 0
for (const r of rows.value) {
if (r.type !== chartType.value || !selectedCats.value.includes(r.category)) continue
amount += r.amount
count++
}
return {
cats: selectedCats.value.length,
count,
amount,
percent: activeTypeTotal.value ? Math.round((amount / activeTypeTotal.value) * 100) : 0
}
})
const typeFilter = ref<'all' | TxType>('all')
const reimbFilter = ref<'all' | 'pending' | 'done'>('all')
const filtered = computed(() =>
sorted.value.filter(r => {
if (typeFilter.value !== 'all' && r.type !== typeFilter.value) return false
if (selectedCats.value.length && !selectedCats.value.includes(r.category)) return false
if (reimbFilter.value === 'pending' && !(r.type === 'expense' && r.reimbursable === 1 && r.reimbursed !== 1)) return false
if (reimbFilter.value === 'done' && !(r.reimbursable === 1 && r.reimbursed === 1)) return false
return true
})
)
const categoryStats = computed(() => {
const map = new Map<string, number>()
let total = 0
for (const r of rows.value) {
if (r.type !== 'expense') continue
map.set(r.category, (map.get(r.category) || 0) + r.amount)
total += r.amount
}
const base = total || 1
return Array.from(map.entries())
.map(pair => ({ name: pair[0], amount: pair[1], percent: Math.round((pair[1] / base) * 100) }))
.sort((a, b) => b.amount - a.amount)
})
function yuan(cents: number): string {
return (cents / 100).toFixed(2)
return fmtMoney(cents)
}
function amountText(r: Transaction): string {
return (r.type === 'income' ? '+' : '-') + yuan(r.amount)
}
function prevMonth() { monthRef.value = addMonths(monthRef.value, -1) }
function nextMonth() { monthRef.value = addMonths(monthRef.value, 1) }
function goThisMonth() { monthRef.value = todayStr() }
watch(fAmount, () => {
if (fError.value) fError.value = ''
})
async function submit() {
const amountText = String(fAmount.value ?? '').trim()
const v = Number(amountText)
if (!amountText || !isFinite(v) || v <= 0) {
fError.value = '请填写大于 0 的金额'
return
}
if (!fDate.value) {
fError.value = '请选择日期'
return
}
if (submitting.value) return
submitting.value = true
try {
await addTransaction({
type: fType.value,
category: fCategory.value,
amount: Math.round(v * 100),
date: fDate.value,
note: String(fNote.value ?? '').trim(),
reimbursable: fType.value === 'expense' && fReimb.value ? 1 : 0,
reimbursed: 0
})
} catch (e) {
showToast('添加失败:' + (e instanceof Error ? e.message : String(e)))
submitting.value = false
return
}
fAmount.value = ''
fNote.value = ''
fReimb.value = false
fError.value = ''
await load()
showToast('记账已添加')
submitting.value = false
}
const formDirty = computed(() => String(fAmount.value ?? '').trim() !== '' || String(fNote.value ?? '').trim() !== '')
function resetForm() {
fAmount.value = ''
fNote.value = ''
fReimb.value = false
fError.value = ''
return (r.type === 'income' ? '+' : '-') + fmtMoney(r.amount)
}
async function markReimbursed(row: Transaction) {
@@ -180,7 +367,7 @@ function openEdit(row: Transaction) {
dialogInitial.value = {
type: row.type,
category: row.category,
amountYuan: yuan(row.amount),
amountYuan: yuan(row.amount).replace(/,/g, ''),
date: row.date,
note: row.note || '',
reimbursable: row.reimbursable,
@@ -214,13 +401,21 @@ async function confirmDelete() {
showToast('账目已删除')
}
</script>
<template>
<div class="ledger-page">
<div class="ledger-toolbar">
<button class="icon-btn" @click="prevMonth"><Icon name="chevron-left" :size="16" /></button>
<span class="month-title num">{{ title }}</span>
<button class="icon-btn" @click="nextMonth"><Icon name="chevron-right" :size="16" /></button>
<button class="btn btn-sm" @click="goThisMonth">回到本月</button>
<button v-if="mode !== 'custom'" class="icon-btn" :disabled="!canPrev" title="上一周期" @click="step(-1)">
<Icon name="chevron-left" :size="16" />
</button>
<button class="period-title" title="选择统计周期" @click="openPeriodDialog">
<span class="num">{{ title }}</span>
<Icon name="chevron-down" :size="14" />
</button>
<button v-if="mode !== 'custom'" class="icon-btn" :disabled="!canNext" title="下一周期" @click="step(1)">
<Icon name="chevron-right" :size="16" />
</button>
<button v-if="offCurrent" class="btn btn-sm" @click="goCurrent">{{ mode === 'cycle' ? '回到本期' : '回到本月' }}</button>
<span class="flex1"></span>
<select class="select w-filter" v-model="typeFilter">
<option value="all">全部类型</option>
@@ -236,18 +431,63 @@ 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">
<div class="sec-title">本月支出分类</div>
<div v-for="c in categoryStats" :key="c.name" class="bar-row">
<div class="card sec chart-sec">
<div class="sec-head">
<span class="sec-title">每日收支</span>
<span class="legend muted">
<i class="lg" style="background: var(--ok)"></i>收入
<i class="lg" style="background: var(--text-3)"></i>支出
</span>
</div>
<div v-if="bucketHint" class="chart-hint muted">{{ bucketHint }}</div>
<div v-if="buckets.length" class="chart-wrap">
<div class="chart">
<div v-for="(b, i) in buckets" :key="i" class="chart-col" :title="b.tip">
<div class="chart-bars">
<div class="cbar cbar-income" :style="{ height: Math.round(b.income / bucketMax * 100) + '%' }"></div>
<div class="cbar cbar-expense" :style="{ height: Math.round(b.expense / bucketMax * 100) + '%' }"></div>
</div>
</div>
</div>
<div class="chart-axis">
<span v-for="(b, i) in buckets" :key="i" class="chart-label num">{{ b.showLabel ? b.label : '' }}</span>
</div>
</div>
<div v-else class="empty muted">本周期暂无账目</div>
</div>
<div class="card sec" v-if="activeCats.length">
<div class="sec-head">
<span class="sec-title">分类统计</span>
<div class="seg">
<button class="seg-btn" :class="{ active: chartType === 'expense' }" @click="chartType = 'expense'">支出</button>
<button class="seg-btn" :class="{ active: chartType === 'income' }" @click="chartType = 'income'">收入</button>
</div>
</div>
<div class="chip-row">
<button class="chip" :class="{ active: !selectedCats.length }" @click="selectedCats = []">全部</button>
<button
v-for="c in activeCats" :key="c.name"
class="chip" :class="{ active: selectedCats.includes(c.name) }"
@click="toggleCat(c.name)"
>{{ c.name }}</button>
</div>
<div v-if="selSummary" class="sel-strip">
<span>已选 {{ selSummary.cats }} 类 · {{ selSummary.count }} 笔</span>
<span class="num">{{ yuan(selSummary.amount) }} 元 · 占本期{{ chartType === 'expense' ? '支出' : '收入' }} {{ selSummary.percent }}%</span>
</div>
<div v-for="c in catRows" :key="c.name" class="bar-row" :class="{ dim: c.dim }" :title="c.count + ' 笔'">
<i class="cat-dot" :style="{ background: c.color }"></i>
<span class="bar-label">{{ c.name }}</span>
<span class="bar-track"><span class="bar-fill" :style="{ width: c.percent + '%' }"></span></span>
<span class="muted num bar-num">{{ yuan(c.amount) }} 元 · {{ c.percent }}%</span>
<span class="bar-track"><span class="bar-fill" :style="{ width: c.percent + '%', background: c.color }"></span></span>
<span class="bar-amt num">{{ yuan(c.amount) }}</span>
<span class="bar-pct num muted">{{ c.percent }}%</span>
</div>
</div>
@@ -286,13 +526,13 @@ async function confirmDelete() {
</td>
</tr>
<tr v-if="!filtered.length && !loading">
<td colspan="6" class="empty muted">本月暂无账目</td>
<td colspan="6" class="empty muted">该周期暂无账目</td>
</tr>
</tbody>
</table>
</div>
<div class="muted table-note num">本月 {{ rows.length }} 条 · 当前筛选 {{ filtered.length }} 条</div>
<div class="muted table-note num">本期 {{ rows.length }} 条 · 当前筛选 {{ filtered.length }} 条</div>
<TransactionDialog
:open="dialogOpen"
@@ -302,6 +542,22 @@ async function confirmDelete() {
@save="onDialogSave"
/>
<PeriodDialog
:open="periodOpen"
:mode="mode"
:anchor="anchor"
:month="monthRef"
:cycle-sel="cycleSel"
:custom-start="customStart"
:custom-end="customEnd"
:cycle-count="currentCycle"
:has-before="hasBefore"
:months="txMonths"
@close="periodOpen = false"
@apply="onApply"
@save-anchor="onSaveAnchor"
/>
<ConfirmDialog
:open="!!deleteTarget"
title="删除确认"
@@ -315,14 +571,69 @@ async function confirmDelete() {
<style scoped>
.ledger-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.month-title { font-size: 14px; font-weight: 600; min-width: 88px; text-align: center; }
.period-title {
display: inline-flex; align-items: center; justify-content: center; gap: 5px;
border: none; background: transparent; cursor: pointer;
font-size: 14px; font-weight: 600; color: var(--text-1);
padding: 5px 10px; border-radius: var(--radius-sm);
transition: background 0.12s;
}
.period-title:hover { background: var(--bg-hover); }
.w-filter { width: 110px; flex: none; }
.icon-btn:disabled { opacity: 0.35; cursor: default; }
.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; }
.bar-num { width: 118px; }
.sec-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
.sec-head .sec-title { margin-bottom: 0; }
.legend { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; }
.legend .lg { width: 8px; height: 8px; border-radius: 2px; display: inline-block; margin-left: 8px; }
.legend .lg:first-child { margin-left: 0; }
/* 每日收支柱状图 */
.chart-wrap { min-width: 0; }
.chart { display: flex; align-items: stretch; gap: 3px; height: 110px; }
.chart-col { flex: 1; min-width: 0; display: flex; }
.chart-bars { flex: 1; display: flex; align-items: flex-end; justify-content: center; gap: 2px; }
.cbar { width: 6px; max-width: 12px; border-radius: 2px 2px 0 0; min-height: 0; }
.cbar-income { background: var(--ok); opacity: 0.85; }
.cbar-expense { background: var(--text-3); opacity: 0.8; }
.chart-axis { display: flex; gap: 3px; border-top: 1px solid var(--border); margin-top: 0; }
.chart-label { flex: 1; min-width: 0; text-align: center; font-size: 10px; color: var(--text-3); line-height: 18px; height: 18px; overflow: hidden; white-space: nowrap; }
.chart-hint { font-size: 12px; margin-bottom: 6px; }
.chart-col:hover .cbar-income { opacity: 1; }
.chart-col:hover .cbar-expense { opacity: 1; }
/* 分类统计 */
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.chip {
height: 24px; padding: 0 10px;
border: 1px solid var(--border-strong); border-radius: 999px;
background: var(--bg-page); color: var(--text-2);
font-size: 12px; cursor: pointer; white-space: nowrap;
transition: all 0.12s;
}
.chip:hover { background: var(--bg-hover); }
.chip.active { background: var(--accent-bg); border-color: var(--accent); color: var(--accent); font-weight: 500; }
.sel-strip {
display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap;
background: var(--accent-bg); border-radius: var(--radius-sm);
padding: 7px 10px; font-size: 12px; color: var(--accent);
margin-bottom: 10px;
}
.bar-row { display: flex; align-items: center; gap: 8px; margin: 8px 0; transition: opacity 0.12s; }
.bar-row.dim { opacity: 0.4; }
.cat-dot { width: 8px; height: 8px; border-radius: 2px; flex: none; }
.bar-label { flex: none; width: 72px; color: var(--text-2); font-size: 12px; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bar-track { flex: 1; height: 8px; border-radius: 4px; background: var(--bg-subtle); overflow: hidden; }
.bar-amt { flex: none; width: 88px; text-align: right; font-size: 12px; }
.bar-pct { flex: none; width: 38px; text-align: right; font-size: 12px; }
.td-note { color: var(--text-2); }
.tx-pending { color: var(--warn); font-size: 12px; }
@@ -332,7 +643,16 @@ 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; }
.period-title { flex: 1; justify-content: flex-start; padding-left: 0; }
.w-filter { flex: 1 1 calc(50% - 4px); min-width: 0; width: auto; }
.ledger-toolbar .btn-primary { flex: 1 1 100%; order: 9; }
.chart { overflow-x: auto; }
.chart-col { min-width: 11px; }
.cbar { width: 4px; }
.bar-label { width: 60px; }
.bar-amt { width: 78px; }
}
</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>
+70 -32
View File
@@ -2,6 +2,7 @@
import { onMounted, ref, watch } from 'vue'
import Icon from '../components/Icon.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import InputDialog from '../components/InputDialog.vue'
import {
addProject, exportAll, getLocationPresets, getSetting, getWorkRule, importAll,
listAttendanceBetween, listLogItemsBetween, listProjects,
@@ -39,37 +40,53 @@ async function loadCategories() {
expenseCats.value = await getLedgerCategories('expense')
incomeCats.value = await getLedgerCategories('income')
}
async function addCat(kind: 'log' | 'expense' | 'income') {
const name = window.prompt('新分类名称')
if (!name || !name.trim()) return
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
const clean = name.trim()
if (list.includes(clean)) { showToast('分类已存在'); return }
const next = [...list, clean]
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
else { incomeCats.value = next; await setLedgerCategories('income', next) }
showToast('分类已添加')
// ---- 分类编辑:自绘输入弹窗 + 危险确认框(替代浏览器原生 prompt/confirm)----
type CatKind = 'log' | 'expense' | 'income'
const catDialog = ref<{ open: boolean; kind: CatKind; index: number; initial: string }>({ open: false, kind: 'log', index: -1, initial: '' })
const delCat = ref<{ kind: CatKind; index: number; name: string } | null>(null)
function catListOf(kind: CatKind): string[] {
return kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
}
async function renameCat(kind: 'log' | 'expense' | 'income', index: number, oldName: string) {
const name = window.prompt('修改分类名称', oldName)
if (!name || !name.trim() || name.trim() === oldName) return
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
const next = list.map((x, i) => (i === index ? name.trim() : x))
async function writeCats(kind: CatKind, next: string[]) {
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
else { incomeCats.value = next; await setLedgerCategories('income', next) }
}
function openCatAdd(kind: CatKind) {
catDialog.value = { open: true, kind, index: -1, initial: '' }
}
function openCatRename(kind: CatKind, index: number, oldName: string) {
catDialog.value = { open: true, kind, index, initial: oldName }
}
function validateCat(v: string): string | null {
const { kind, index } = catDialog.value
const list = catListOf(kind)
if (list.includes(v) && !(index >= 0 && list[index] === v)) return '该分类已存在'
return null
}
async function onCatDialogConfirm(v: string) {
const { kind, index } = catDialog.value
if (index >= 0) {
await writeCats(kind, catListOf(kind).map((x, i) => (i === index ? v : x)))
showToast('分类已更新(历史记录自动跟随显示)')
} else {
await writeCats(kind, [...catListOf(kind), v])
showToast('分类已添加')
}
catDialog.value.open = false
}
async function removeCat(kind: 'log' | 'expense' | 'income', index: number) {
const list = kind === 'log' ? logCats.value : kind === 'expense' ? expenseCats.value : incomeCats.value
function askRemoveCat(kind: CatKind, index: number) {
const list = catListOf(kind)
if (list.length <= 1) { showToast('至少保留一个分类'); return }
if (!window.confirm('删除分类「' + list[index] + '」?历史记录不受影响。')) return
const next = list.filter((_, i) => i !== index)
if (kind === 'log') { logCats.value = next; await setLogCategories(next) }
else if (kind === 'expense') { expenseCats.value = next; await setLedgerCategories('expense', next) }
else { incomeCats.value = next; await setLedgerCategories('income', next) }
delCat.value = { kind, index, name: list[index] }
}
async function confirmRemoveCat() {
if (!delCat.value) return
const { kind, index } = delCat.value
await writeCats(kind, catListOf(kind).filter((_, i) => i !== index))
showToast('分类已删除')
delCat.value = null
}
const fileInput = ref<HTMLInputElement | null>(null)
@@ -424,32 +441,32 @@ async function doImport() {
<div class="card section">
<div class="section-title">工作分类</div>
<div class="cat-block">
<div class="cat-head"><span class="muted">工作记录分类</span><button class="btn btn-xs" @click="addCat('log')">添加</button></div>
<div class="cat-head"><span class="muted">工作记录分类</span><button class="btn btn-xs" @click="openCatAdd('log')">添加</button></div>
<div class="cat-chips">
<span v-for="(c, i) in logCats" :key="c + i" class="cat-chip">
{{ c }}
<button class="chip-btn" title="修改" @click="renameCat('log', i, c)">改</button>
<button class="chip-btn" title="删除" @click="removeCat('log', i)">删</button>
<button class="chip-btn" title="修改" @click="openCatRename('log', i, c)">改</button>
<button class="chip-btn" title="删除" @click="askRemoveCat('log', i)">删</button>
</span>
</div>
</div>
<div class="cat-block">
<div class="cat-head"><span class="muted">记账支出分类</span><button class="btn btn-xs" @click="addCat('expense')">添加</button></div>
<div class="cat-head"><span class="muted">记账支出分类</span><button class="btn btn-xs" @click="openCatAdd('expense')">添加</button></div>
<div class="cat-chips">
<span v-for="(c, i) in expenseCats" :key="c + i" class="cat-chip">
{{ c }}
<button class="chip-btn" title="修改" @click="renameCat('expense', i, c)">改</button>
<button class="chip-btn" title="删除" @click="removeCat('expense', i)">删</button>
<button class="chip-btn" title="修改" @click="openCatRename('expense', i, c)">改</button>
<button class="chip-btn" title="删除" @click="askRemoveCat('expense', i)">删</button>
</span>
</div>
</div>
<div class="cat-block">
<div class="cat-head"><span class="muted">记账收入分类</span><button class="btn btn-xs" @click="addCat('income')">添加</button></div>
<div class="cat-head"><span class="muted">记账收入分类</span><button class="btn btn-xs" @click="openCatAdd('income')">添加</button></div>
<div class="cat-chips">
<span v-for="(c, i) in incomeCats" :key="c + i" class="cat-chip">
{{ c }}
<button class="chip-btn" title="修改" @click="renameCat('income', i, c)">改</button>
<button class="chip-btn" title="删除" @click="removeCat('income', i)">删</button>
<button class="chip-btn" title="修改" @click="openCatRename('income', i, c)">改</button>
<button class="chip-btn" title="删除" @click="askRemoveCat('income', i)">删</button>
</span>
</div>
</div>
@@ -471,6 +488,27 @@ async function doImport() {
<div class="muted backup-note">数据保存在云端服务器;导出的 JSON 备份用于跨设备迁移或灾后恢复。</div>
</div>
<InputDialog
:open="catDialog.open"
:title="catDialog.index >= 0 ? '修改分类名称' : '添加分类'"
label="分类名称"
placeholder="如:制度学习 / 差旅交通"
:initial="catDialog.initial"
:validate="validateCat"
confirm-text="保存"
@cancel="catDialog.open = false"
@confirm="onCatDialogConfirm"
/>
<ConfirmDialog
:open="!!delCat"
title="删除分类"
:message="delCat ? '将删除分类「' + delCat.name + '」。历史记录不受影响,仍按原分类展示。' : ''"
confirm-text="删除" danger
@cancel="delCat = null"
@confirm="confirmRemoveCat"
/>
<ConfirmDialog
:open="!!pendingImport"
title="导入确认"
+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.
+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) })
+52 -8
View File
@@ -140,20 +140,60 @@ 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 full = userFile(userId)
if (fs.existsSync(full)) {
try {
return JSON.parse(fs.readFileSync(full, 'utf-8'))
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 emptyUserData(userId)
return { data: emptyUserData(userId), healed: false }
}
}
return emptyUserData(userId)
return { data: emptyUserData(userId), healed: false }
}
// 墓碑回收:超过保留期的墓碑删除(全量快照重建的客户端不依赖过期墓碑也能收敛)
function gcTombstones(data) {
@@ -403,8 +443,9 @@ 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)
let dirty = false
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 }
@@ -429,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 }
}
}
}
@@ -484,7 +527,7 @@ 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]
}
}
if (gcTombstones(userData) > 0) dirty = true
@@ -621,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' })
})
})