3 Commits
17 changed files with 328 additions and 89 deletions
+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-Deo5OtIz.js","./index-ynPmf8i8.js","./index-COwenkhO.css"])))=>i.map(i=>d[i]); 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-ynPmf8i8.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-Deo5OtIz.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem}; 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="icon" href="./icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" /> <link rel="apple-touch-icon" href="./icons/icon-192.png" />
<title>实习工作日志</title> <title>实习工作日志</title>
<script type="module" crossorigin src="./assets/index-ynPmf8i8.js"></script> <script type="module" crossorigin src="./assets/index-COxaR0MK.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-COwenkhO.css"> <link rel="stylesheet" crossorigin href="./assets/index-BWain5t9.css">
</head> </head>
<body> <body>
<div id="app"></div> <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'] const CORE = ['./', './index.html']
self.addEventListener('install', event => { self.addEventListener('install', event => {
@@ -22,7 +22,7 @@ self.addEventListener('fetch', event => {
// 页面导航:网络优先,离线回退缓存的应用壳 // 页面导航:网络优先,离线回退缓存的应用壳
if (req.mode === 'navigate') { if (req.mode === 'navigate') {
event.respondWith( event.respondWith(
fetch(req) fetch(req, { cache: 'no-cache' })
.then(resp => { .then(resp => {
const clone = resp.clone() const clone = resp.clone()
caches.open(CACHE).then(c => c.put('./index.html', clone)) caches.open(CACHE).then(c => c.put('./index.html', clone))
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "intern-log", "name": "intern-log",
"private": true, "private": true,
"version": "0.7.2", "version": "0.8.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2 -2
View File
@@ -1,4 +1,4 @@
const CACHE = 'intern-log-v1' const CACHE = 'intern-log-v2'
const CORE = ['./', './index.html'] const CORE = ['./', './index.html']
self.addEventListener('install', event => { self.addEventListener('install', event => {
@@ -22,7 +22,7 @@ self.addEventListener('fetch', event => {
// 页面导航:网络优先,离线回退缓存的应用壳 // 页面导航:网络优先,离线回退缓存的应用壳
if (req.mode === 'navigate') { if (req.mode === 'navigate') {
event.respondWith( event.respondWith(
fetch(req) fetch(req, { cache: 'no-cache' })
.then(resp => { .then(resp => {
const clone = resp.clone() const clone = resp.clone()
caches.open(CACHE).then(c => c.put('./index.html', clone)) caches.open(CACHE).then(c => c.put('./index.html', clone))
+5 -3
View File
@@ -19,12 +19,14 @@ import { getWorkRule, getWeeklyNote, searchLogItems, searchMemos } from './db/re
import { todayStr, weekStart } from './lib/date' import { todayStr, weekStart } from './lib/date'
import { showToast, useToasts } from './lib/toast' import { showToast, useToasts } from './lib/toast'
import { focusDate, focusMemo } from './lib/bus' import { focusDate, focusMemo } from './lib/bus'
import { categoryLabel, type LogItem, type Memo } from './db/schema' import { categoryLabel, db, type LogItem, type Memo } from './db/schema'
const toasts = useToasts() 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) { async function onLogin(s: Session) {
session.value = s session.value = s
openUserDatabase(s.userId) openUserDatabase(s.userId)
@@ -48,8 +50,8 @@ async function doLogout() {
moreOpen.value = false moreOpen.value = false
stopRealtime() stopRealtime()
if (syncTimer !== null) { window.clearInterval(syncTimer); syncTimer = null } if (syncTimer !== null) { window.clearInterval(syncTimer); syncTimer = null }
session.value = null // 先卸载视图再解绑缓存库,避免微任务间隙读到已关闭的库
await logoutUser() await logoutUser()
session.value = null
route.value = 'calendar' route.value = 'calendar'
location.hash = '#/calendar' location.hash = '#/calendar'
showToast('已退出登录') showToast('已退出登录')
@@ -117,15 +117,15 @@ function save() {
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label>金额(元,必填)</label> <label>金额(元)</label>
<input ref="amountInput" class="input" :class="{ invalid: fError }" v-model="fAmount" type="number" min="0" step="0.01" placeholder="0.00" /> <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>
<div class="field"> <div class="field">
<label>日期(必填)</label> <label>日期</label>
<input class="input" type="date" v-model="fDate" /> <input class="input" type="date" v-model="fDate" />
</div> </div>
<div class="field"> <div class="field">
<label>分类(可直接新增)</label> <label>分类</label>
<select class="select" v-model="fCategory"> <select class="select" v-model="fCategory">
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option> <option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
</select> </select>
@@ -134,7 +134,7 @@ function save() {
<button class="btn btn-xs" type="button" @click="addCategory">添加</button> <button class="btn btn-xs" type="button" @click="addCategory">添加</button>
</div> </div>
</div> </div>
<div class="field" v-if="fType === 'expense'"> <div class="field span2" v-if="fType === 'expense'">
<label>报销状态</label> <label>报销状态</label>
<div class="seg"> <div class="seg">
<button class="seg-btn" :class="{ active: !fReimbursable }" @click="fReimbursable = false; fReimbursed = false">无需报销</button> <button class="seg-btn" :class="{ active: !fReimbursable }" @click="fReimbursable = false; fReimbursed = false">无需报销</button>
@@ -162,6 +162,10 @@ function save() {
<style scoped> <style scoped>
.dialog { width: 480px; } .dialog { width: 480px; }
.add-cat { display: flex; gap: 6px; margin-top: 6px; } .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; } .add-cat-input { flex: 1; height: 28px; font-size: 12px; }
@media (max-width: 720px) { @media (max-width: 720px) {
.add-cat-input { height: 34px; } .add-cat-input { height: 34px; }
+239 -41
View File
@@ -10,7 +10,7 @@ import {
setTransactionReimbursed, updateTransaction, type Transaction setTransactionReimbursed, updateTransaction, type Transaction
} from '../db/repo' } from '../db/repo'
import { getLedgerCategories } from '../db/categories' import { getLedgerCategories } from '../db/categories'
import { addDays, 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 { cycleEndOf, cycleIndexOf, cycleStartOf, fmtDayRef, fmtRange, type CycleSel } from '../lib/period'
import { showToast } from '../lib/toast' import { showToast } from '../lib/toast'
@@ -24,6 +24,17 @@ const LS_CSTART = 'dsh_ledger_cstart'
const LS_CEND = 'dsh_ledger_cend' const LS_CEND = 'dsh_ledger_cend'
const EARLIEST = '0000-01-01' 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 mode = ref<PeriodMode>('month')
const monthRef = ref(todayStr()) const monthRef = ref(todayStr())
const cycleSel = ref<CycleSel>(1) const cycleSel = ref<CycleSel>(1)
@@ -52,13 +63,12 @@ function monthEndOf(ym: string): string {
return ym + '-' + (last < 10 ? '0' + last : String(last)) return ym + '-' + (last < 10 ? '0' + last : String(last))
} }
// 今天所在的期数(未设起点时按 1 处理)
const currentCycle = computed(() => (anchor.value ? Math.max(1, cycleIndexOf(anchor.value, todayStr())) : 1)) const currentCycle = computed(() => (anchor.value ? Math.max(1, cycleIndexOf(anchor.value, todayStr())) : 1))
const rangeStart = computed(() => { const rangeStart = computed(() => {
if (mode.value === 'month') return monthRef.value.slice(0, 7) + '-01' if (mode.value === 'month') return monthRef.value.slice(0, 7) + '-01'
if (mode.value === 'custom') return customStart.value if (mode.value === 'custom') return customStart.value
if (!anchor.value) return monthRef.value.slice(0, 7) + '-01' // 未设起点时按月兜底 if (!anchor.value) return monthRef.value.slice(0, 7) + '-01'
return cycleSel.value === 'before' ? EARLIEST : cycleStartOf(anchor.value, cycleSel.value) return cycleSel.value === 'before' ? EARLIEST : cycleStartOf(anchor.value, cycleSel.value)
}) })
const rangeEnd = computed(() => { const rangeEnd = computed(() => {
@@ -110,7 +120,6 @@ function step(dir: -1 | 1) {
cycleSel.value = n cycleSel.value = n
} }
// 只有偏离当前周期时才出现「回到本期」,平时不占工具栏
const offCurrent = computed(() => { const offCurrent = computed(() => {
if (mode.value === 'month') return monthRef.value.slice(0, 7) !== todayStr().slice(0, 7) 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 if (mode.value === 'cycle') return !!anchor.value && cycleSel.value !== currentCycle.value
@@ -149,7 +158,7 @@ onMounted(async () => {
await load() await load()
}) })
watch([rangeStart, rangeEnd], () => { watch([rangeStart, rangeEnd], () => {
catFilter.value = 'all' // 切换周期时重置分类筛选(分类集合可能不同) selectedCats.value = [] // 切换周期重置分类选择
void load() void load()
}) })
watch(mode, v => localStorage.setItem(LS_MODE, v)) watch(mode, v => localStorage.setItem(LS_MODE, v))
@@ -199,9 +208,8 @@ const summary = computed(() => {
let expense = 0 let expense = 0
let pending = 0 let pending = 0
for (const r of rows.value) { for (const r of rows.value) {
if (r.type === 'income') { if (r.type === 'income') income += r.amount
income += r.amount else {
} else {
expense += r.amount expense += r.amount
if (r.reimbursable === 1 && r.reimbursed !== 1) pending += r.amount if (r.reimbursable === 1 && r.reimbursed !== 1) pending += r.amount
} }
@@ -209,41 +217,143 @@ const summary = computed(() => {
return { income, expense, pending, balance: income - expense } 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 typeFilter = ref<'all' | TxType>('all')
const reimbFilter = ref<'all' | 'pending' | 'done'>('all') const reimbFilter = ref<'all' | 'pending' | 'done'>('all')
const catFilter = ref<string>('all')
const usedCategories = computed(() =>
Array.from(new Set(rows.value.map(r => r.category))).sort((a, b) => a.localeCompare(b, 'zh'))
)
const filtered = computed(() => const filtered = computed(() =>
sorted.value.filter(r => { sorted.value.filter(r => {
if (typeFilter.value !== 'all' && r.type !== typeFilter.value) return false 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 === 'pending' && !(r.type === 'expense' && r.reimbursable === 1 && r.reimbursed !== 1)) return false
if (reimbFilter.value === 'done' && !(r.reimbursable === 1 && r.reimbursed === 1)) return false if (reimbFilter.value === 'done' && !(r.reimbursable === 1 && r.reimbursed === 1)) return false
return true 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 { function yuan(cents: number): string {
return (cents / 100).toFixed(2) return fmtMoney(cents)
} }
function amountText(r: Transaction): string { function amountText(r: Transaction): string {
return (r.type === 'income' ? '+' : '-') + yuan(r.amount) return (r.type === 'income' ? '+' : '-') + fmtMoney(r.amount)
} }
async function markReimbursed(row: Transaction) { async function markReimbursed(row: Transaction) {
@@ -257,7 +367,7 @@ function openEdit(row: Transaction) {
dialogInitial.value = { dialogInitial.value = {
type: row.type, type: row.type,
category: row.category, category: row.category,
amountYuan: yuan(row.amount), amountYuan: yuan(row.amount).replace(/,/g, ''),
date: row.date, date: row.date,
note: row.note || '', note: row.note || '',
reimbursable: row.reimbursable, reimbursable: row.reimbursable,
@@ -312,10 +422,6 @@ async function confirmDelete() {
<option value="expense">仅支出</option> <option value="expense">仅支出</option>
<option value="income">仅收入</option> <option value="income">仅收入</option>
</select> </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"> <select class="select w-filter" v-model="reimbFilter">
<option value="all">全部报销</option> <option value="all">全部报销</option>
<option value="pending">待报销</option> <option value="pending">待报销</option>
@@ -331,12 +437,57 @@ async function confirmDelete() {
<div class="card stat-card stat-pending"><div class="muted">待报销(元)</div><div class="stat-num num amt-warn">{{ yuan(summary.pending) }}</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>
<div class="card sec" v-if="categoryStats.length"> <div class="card sec chart-sec">
<div class="sec-title">本期支出分类</div> <div class="sec-head">
<div v-for="c in categoryStats" :key="c.name" class="bar-row"> <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-label">{{ c.name }}</span>
<span class="bar-track"><span class="bar-fill" :style="{ width: c.percent + '%' }"></span></span> <span class="bar-track"><span class="bar-fill" :style="{ width: c.percent + '%', background: c.color }"></span></span>
<span class="muted num bar-num">{{ yuan(c.amount) }} 元 · {{ c.percent }}%</span> <span class="bar-amt num">{{ yuan(c.amount) }}</span>
<span class="bar-pct num muted">{{ c.percent }}%</span>
</div> </div>
</div> </div>
@@ -439,8 +590,50 @@ async function confirmDelete() {
.stat-pending { border-left-color: var(--warn); } .stat-pending { border-left-color: var(--warn); }
.card.sec, .card.section { margin-top: 12px; } .card.sec, .card.section { margin-top: 12px; }
.bar-label { width: 56px; } .sec-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
.bar-num { width: 118px; } .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); } .td-note { color: var(--text-2); }
.tx-pending { color: var(--warn); font-size: 12px; } .tx-pending { color: var(--warn); font-size: 12px; }
@@ -456,5 +649,10 @@ async function confirmDelete() {
.period-title { flex: 1; justify-content: flex-start; padding-left: 0; } .period-title { flex: 1; justify-content: flex-start; padding-left: 0; }
.w-filter { flex: 1 1 calc(50% - 4px); min-width: 0; width: auto; } .w-filter { flex: 1 1 calc(50% - 4px); min-width: 0; width: auto; }
.ledger-toolbar .btn-primary { flex: 1 1 100%; order: 9; } .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> </style>
+4 -2
View File
@@ -331,16 +331,18 @@ async function handle(req, res) {
if (rel === '/' || rel === '') rel = '/index.html' if (rel === '/' || rel === '') rel = '/index.html'
const file = path.normalize(path.join(DIST_DIR, rel)) const file = path.normalize(path.join(DIST_DIR, rel))
if (!file.startsWith(DIST_DIR)) { json(res, 403, { error: 'forbidden' }); return } 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) => { fs.readFile(file, (err, data) => {
if (err) { if (err) {
fs.readFile(path.join(DIST_DIR, 'index.html'), (e2, d2) => { fs.readFile(path.join(DIST_DIR, 'index.html'), (e2, d2) => {
if (e2) { json(res, 404, { error: 'not found' }); return } 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) res.end(d2)
}) })
return 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) res.end(data)
}) })
return return