5 Commits
22 changed files with 820 additions and 271 deletions
+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 与服务端改动;所有统计跟随统计周期(按月 / 按期 / 自定义)。
+9
View File
@@ -0,0 +1,9 @@
# v0.8.1 修复启动竞态崩溃
## 修复
- 修复「Cannot read properties of undefined (reading 'attendance')」启动崩溃:当本机存有登录会话但静默登录失败时(典型场景:服务器重启/重建容器的窗口期打开应用),旧版会按已登录渲染但本地缓存库未绑定,视图挂载即崩溃。
- 现在会话恢复会校验本地缓存库已绑定;未绑定则回到登录页,重新登录即可(服务器恢复后一次点击进入)。
- 退出登录改为先卸载视图再解绑缓存库,消除微任务间隙的同类崩溃窗口。
## 说明
- 该问题为潜伏缺陷,服务器重建窗口期触发;v0.8.0 功能本身不受影响。
+8
View File
@@ -0,0 +1,8 @@
# v0.8.2 部署缓存确定性
## 优化
- 服务端静态托管补齐缓存策略:带内容哈希的 /assets/ 产物永久缓存(immutable),index.html 与 sw.js 改为 no-cache 每次校验——部署后任何浏览器/缓存层都会立刻拿到新包。
- PWA Service Worker 页面导航请求强制重新校验(cache: no-cache),缓存存储升级至 v2 自动清理旧条目。
## 说明
- 配合 v0.8.1 的启动竞态修复使用;部署命令不变,部署后普通刷新即可拿到新包(无需强制刷新)。
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-Cn9aGqjN.js","./index-vpp14W9X.js","./index-D9ZLjO2x.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-vpp14W9X.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-Cn9aGqjN.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-BQsFOvf-.js","./index-COxaR0MK.js","./index-BWain5t9.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-COxaR0MK.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-BQsFOvf-.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
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-vpp14W9X.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D9ZLjO2x.css">
<script type="module" crossorigin src="./assets/index-COxaR0MK.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BWain5t9.css">
</head>
<body>
<div id="app"></div>
+2 -2
View File
@@ -1,4 +1,4 @@
const CACHE = 'intern-log-v1'
const CACHE = 'intern-log-v2'
const CORE = ['./', './index.html']
self.addEventListener('install', event => {
@@ -22,7 +22,7 @@ self.addEventListener('fetch', event => {
// 页面导航:网络优先,离线回退缓存的应用壳
if (req.mode === 'navigate') {
event.respondWith(
fetch(req)
fetch(req, { cache: 'no-cache' })
.then(resp => {
const clone = resp.clone()
caches.open(CACHE).then(c => c.put('./index.html', clone))
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "intern-log",
"private": true,
"version": "0.7.0",
"version": "0.8.2",
"type": "module",
"scripts": {
"dev": "vite",
+2 -2
View File
@@ -1,4 +1,4 @@
const CACHE = 'intern-log-v1'
const CACHE = 'intern-log-v2'
const CORE = ['./', './index.html']
self.addEventListener('install', event => {
@@ -22,7 +22,7 @@ self.addEventListener('fetch', event => {
// 页面导航:网络优先,离线回退缓存的应用壳
if (req.mode === 'navigate') {
event.respondWith(
fetch(req)
fetch(req, { cache: 'no-cache' })
.then(resp => {
const clone = resp.clone()
caches.open(CACHE).then(c => c.put('./index.html', clone))
+23 -100
View File
@@ -15,23 +15,24 @@ 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, db, type LogItem, type Memo } from './db/schema'
const toasts = useToasts()
// ---- 用户会话(持久化登录,退出走顶栏按钮)----
const session = ref<Session | null>(getSession())
// 会话恢复必须确认本地缓存库已绑定:静默登录失败(如服务器重启窗口期)时
// db 仍为 undefined,此时退回登录页,避免视图挂载即读库崩溃
const session = ref<Session | null>(getSession() && db ? getSession() : null)
async function onLogin(s: Session) {
session.value = s
openUserDatabase(s.userId)
location.hash = '#/calendar'
route.value = 'calendar'
await refreshClock()
await refreshTip()
await checkWeeklyReminder()
startSyncTimer()
try {
await ensureCloudBinding()
@@ -49,8 +50,8 @@ async function doLogout() {
moreOpen.value = false
stopRealtime()
if (syncTimer !== null) { window.clearInterval(syncTimer); syncTimer = null }
session.value = null // 先卸载视图再解绑缓存库,避免微任务间隙读到已关闭的库
await logoutUser()
session.value = null
route.value = 'calendar'
location.hash = '#/calendar'
showToast('已退出登录')
@@ -161,72 +162,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 }
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
}
// ---- 周记提醒:周五至周日打开应用时 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) 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 +240,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">
@@ -507,8 +450,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 {
@@ -554,21 +495,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; }
@@ -752,10 +678,7 @@ 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 12px; height: 34px; }
.topbar-actions .btn .num { font-size: 12px; }
.mob-search-btn { margin-left: auto; }
.tabbar { display: flex; }
.tab-btn { height: 56px; font-size: 11px; }
+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>
@@ -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,6 +162,10 @@ 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; }
+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)
}
+426 -126
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,96 +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 catFilter = ref<string>('all')
// 切换月份时重置分类筛选(新月份的分类集合可能不同)
watch(monthRef, () => { catFilter.value = 'all' })
const usedCategories = computed(() =>
Array.from(new Set(rows.value.map(r => r.category))).sort((a, b) => a.localeCompare(b, 'zh'))
)
const filtered = computed(() =>
sorted.value.filter(r => {
if (typeFilter.value !== 'all' && r.type !== typeFilter.value) return false
if (catFilter.value !== 'all' && r.category !== catFilter.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) {
@@ -187,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,
@@ -221,23 +401,27 @@ 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>
<option value="expense">仅支出</option>
<option value="income">仅收入</option>
</select>
<select class="select w-filter" v-model="catFilter">
<option value="all">全部分类</option>
<option v-for="c in usedCategories" :key="c" :value="c">{{ c }}</option>
</select>
<select class="select w-filter" v-model="reimbFilter">
<option value="all">全部报销</option>
<option value="pending">待报销</option>
@@ -247,18 +431,63 @@ async function confirmDelete() {
</div>
<div class="cards">
<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-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>
@@ -297,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"
@@ -313,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="删除确认"
@@ -326,8 +571,16 @@ 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); }
@@ -337,8 +590,50 @@ async function confirmDelete() {
.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; }
@@ -351,8 +646,13 @@ async function confirmDelete() {
@media (max-width: 720px) {
.ledger-toolbar { gap: 8px; }
.ledger-toolbar .flex1 { display: none; }
.month-title { min-width: 0; flex: 1; text-align: left; }
.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>
+4 -2
View File
@@ -331,16 +331,18 @@ async function handle(req, res) {
if (rel === '/' || rel === '') rel = '/index.html'
const file = path.normalize(path.join(DIST_DIR, rel))
if (!file.startsWith(DIST_DIR)) { json(res, 403, { error: 'forbidden' }); return }
// 带内容哈希的构建产物可永久缓存;入口与 SW 每次校验,保证部署后立刻拿到新包
const cacheControl = rel.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache'
fs.readFile(file, (err, data) => {
if (err) {
fs.readFile(path.join(DIST_DIR, 'index.html'), (e2, d2) => {
if (e2) { json(res, 404, { error: 'not found' }); return }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' })
res.end(d2)
})
return
}
res.writeHead(200, { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream' })
res.writeHead(200, { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream', 'Cache-Control': cacheControl })
res.end(data)
})
return