v0.8.0 记账系统升级:分类多选统计(合计/笔数/占比联动表格)+ 每日收支柱状图(长区间按月聚合)+ 记账弹窗审计修复(报销状态整行/金额强调)+ 金额千分位

This commit is contained in:
root
2026-11-01 20:07:14 +08:00
parent 971853560a
commit 4092b0830a
11 changed files with 298 additions and 80 deletions
+239 -41
View File
@@ -10,7 +10,7 @@ import {
setTransactionReimbursed, updateTransaction, type Transaction
} from '../db/repo'
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 { showToast } from '../lib/toast'
@@ -24,6 +24,17 @@ 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)
@@ -52,13 +63,12 @@ function monthEndOf(ym: string): string {
return ym + '-' + (last < 10 ? '0' + last : String(last))
}
// 今天所在的期数(未设起点时按 1 处理)
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' // 未设起点时按月兜底
if (!anchor.value) return monthRef.value.slice(0, 7) + '-01'
return cycleSel.value === 'before' ? EARLIEST : cycleStartOf(anchor.value, cycleSel.value)
})
const rangeEnd = computed(() => {
@@ -110,7 +120,6 @@ function step(dir: -1 | 1) {
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
@@ -149,7 +158,7 @@ onMounted(async () => {
await load()
})
watch([rangeStart, rangeEnd], () => {
catFilter.value = 'all' // 切换周期时重置分类筛选(分类集合可能不同)
selectedCats.value = [] // 切换周期重置分类选择
void load()
})
watch(mode, v => localStorage.setItem(LS_MODE, v))
@@ -199,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
}
@@ -209,41 +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')
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)
return (r.type === 'income' ? '+' : '-') + fmtMoney(r.amount)
}
async function markReimbursed(row: Transaction) {
@@ -257,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,
@@ -312,10 +422,6 @@ async function confirmDelete() {
<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>
@@ -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>
<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>
@@ -439,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; }
@@ -456,5 +649,10 @@ async function confirmDelete() {
.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>