feat: 新增记账系统(收支记录、报销跟踪、月度汇总)

This commit is contained in:
root
2026-10-16 10:40:25 +08:00
parent 337b005d52
commit 838753a058
8 changed files with 672 additions and 9 deletions
+1
View File
@@ -23,6 +23,7 @@
- 备忘录:双栏列表与编辑,未保存修改有守卫
- 密码库:企业账号密码管理,主密码 PBKDF2 派生 AES-256-GCM 加密,支持解锁、锁定、搜索、复制、随机密码生成、修改主密码;备份文件中密码保持密文
- 用户系统:每位用户独立 IndexedDB 数据库隔离存储;登录密码仅存 PBKDF2 哈希;30 分钟无操作自动退出;创建用户时可迁移升级前数据
- 记账系统:支出/收入记录、报销跟踪(面向出差垫付)、月度汇总与分类分布
- PWA:在 Edge/Chrome 中打开后,地址栏右侧点「安装」或菜单选择「将此站点作为应用安装」,即可获得独立窗口与桌面图标,离线可用
- Electron 桌面版:pnpm dist 产出 release/InternLog-Setup-<版本>.exe(NSIS 安装包)与 release/win-unpacked/InternLog.exe(免安装版);数据存于系统用户目录,与浏览器数据互不影响
- 全局搜索:Ctrl+K 唤起,覆盖工作记录与备忘录,点击结果跳转定位
+3
View File
@@ -5,6 +5,7 @@ import CalendarView from './views/CalendarView.vue'
import TodayView from './views/TodayView.vue'
import StatsView from './views/StatsView.vue'
import ReportView from './views/ReportView.vue'
import LedgerView from './views/LedgerView.vue'
import MemoView from './views/MemoView.vue'
import VaultView from './views/VaultView.vue'
import SettingsView from './views/SettingsView.vue'
@@ -104,6 +105,7 @@ const VIEWS = [
{ key: 'today', name: '今日', icon: 'edit' },
{ key: 'stats', name: '统计', icon: 'chart' },
{ key: 'report', name: '总结', icon: 'export' },
{ key: 'ledger', name: '记账', icon: 'wallet' },
{ key: 'memo', name: '备忘录', icon: 'memo' },
{ key: 'vault', name: '密码库', icon: 'lock' },
{ key: 'settings', name: '设置', icon: 'settings' }
@@ -247,6 +249,7 @@ onMounted(async () => {
<TodayView v-else-if="route === 'today'" />
<StatsView v-else-if="route === 'stats'" />
<ReportView v-else-if="route === 'report'" />
<LedgerView v-else-if="route === 'ledger'" />
<MemoView v-else-if="route === 'memo'" />
<VaultView v-else-if="route === 'vault'" />
<SettingsView v-else />
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '../db/schema'
const props = defineProps<{
open: boolean
title: string
initial: { type: string; category: string; amountYuan: string; date: string; note: string; reimbursable: number; reimbursed: number }
}>()
const emit = defineEmits<{
(e: 'cancel'): void
(e: 'save', payload: { type: string; category: string; amount: number; date: string; note: string; reimbursable: number; reimbursed: number }): void
}>()
const fType = ref<'expense' | 'income'>('expense')
const fCategory = ref('餐饮')
const fAmount = ref('')
const fDate = ref('')
const fNote = ref('')
const fReimbursable = ref(false)
const fReimbursed = ref(false)
const fError = ref('')
const amountInput = ref<HTMLInputElement | null>(null)
function onKey(e: KeyboardEvent) {
if (!props.open) return
if (e.key === 'Escape') emit('cancel')
}
watch(() => props.open, async v => {
if (v) {
fType.value = (props.initial.type === 'income' ? 'income' : 'expense')
fCategory.value = props.initial.category
fAmount.value = props.initial.amountYuan
fDate.value = props.initial.date
fNote.value = props.initial.note
fReimbursable.value = props.initial.reimbursable === 1
fReimbursed.value = props.initial.reimbursed === 1
fError.value = ''
window.addEventListener('keydown', onKey)
await nextTick()
amountInput.value?.focus()
} else {
window.removeEventListener('keydown', onKey)
}
})
onUnmounted(() => window.removeEventListener('keydown', onKey))
watch(fType, t => {
fCategory.value = t === 'expense' ? EXPENSE_CATEGORIES[0] : INCOME_CATEGORIES[0]
})
const categoryOptions = computed(() => (fType.value === 'expense' ? EXPENSE_CATEGORIES : INCOME_CATEGORIES))
function computedCategory() {
return (fCategory.value && categoryOptions.value.includes(fCategory.value)) ? fCategory.value : categoryOptions.value[0]
}
function save() {
const v = Number(fAmount.value)
if (!fAmount.value.trim() || !isFinite(v) || v <= 0) {
fError.value = '请填写大于 0 的金额'
return
}
if (!fDate.value) {
fError.value = '请选择日期'
return
}
const reimbursable = fType.value === 'expense' && fReimbursable.value ? 1 : 0
emit('save', {
type: fType.value,
category: computedCategory(),
amount: Math.round(v * 100),
date: fDate.value,
note: fNote.value.trim(),
reimbursable,
reimbursed: reimbursable && fReimbursed.value ? 1 : 0
})
}
</script>
<template>
<teleport to="body">
<transition name="modal">
<div v-if="open" class="mask" @click.self="emit('cancel')">
<div class="dialog" role="dialog" :aria-label="title">
<div class="dialog-head">
<span class="dialog-title">{{ title }}</span>
<button class="icon-btn" title="关闭" @click="emit('cancel')">
<Icon name="close" :size="16" />
</button>
</div>
<div class="dialog-body">
<div class="form-grid">
<div class="field span2">
<label>类型</label>
<div class="seg">
<button class="seg-btn" :class="{ active: fType === 'expense' }" @click="fType = 'expense'">支出</button>
<button class="seg-btn" :class="{ active: fType === 'income' }" @click="fType = 'income'">收入</button>
</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" />
</div>
<div class="field">
<label>日期(必填)</label>
<input class="input" type="date" v-model="fDate" />
</div>
<div class="field">
<label>分类</label>
<select class="select" v-model="fCategory">
<option v-for="c in categoryOptions" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="field" v-if="fType === 'expense'">
<label>报销状态</label>
<div class="seg">
<button class="seg-btn" :class="{ active: !fReimbursable }" @click="fReimbursable = false; fReimbursed = false">无需报销</button>
<button class="seg-btn" :class="{ active: fReimbursable && !fReimbursed }" @click="fReimbursable = true; fReimbursed = false">待报销</button>
<button class="seg-btn" :class="{ active: fReimbursable && fReimbursed }" @click="fReimbursable = true; fReimbursed = true">已报销</button>
</div>
</div>
<div class="field span2">
<label>备注</label>
<textarea class="textarea" v-model="fNote" placeholder="选填"></textarea>
</div>
</div>
<div v-if="fError" class="form-error">{{ fError }}</div>
</div>
<div class="dialog-btns">
<button class="btn btn-sm" @click="emit('cancel')">取消</button>
<button class="btn btn-primary btn-sm" @click="save">保存</button>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<style scoped>
.mask {
position: fixed;
inset: 0;
background: rgba(29, 33, 41, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
}
.dialog {
width: 480px;
max-width: calc(100vw - 32px);
background: var(--bg-page);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-float);
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
}
.dialog-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
.dialog-body { padding: 16px 20px; }
.dialog-btns {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid var(--border);
}
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
.seg-btn {
height: 30px;
padding: 0 14px;
border: none;
background: var(--bg-page);
color: var(--text-2);
cursor: pointer;
font-size: 13px;
}
.seg-btn + .seg-btn { border-left: 1px solid var(--border); }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
.form-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
.input.invalid, .input.invalid:focus { border-color: var(--danger); }
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>
+2 -1
View File
@@ -21,5 +21,6 @@ export const ICONS: Record<string, string> = {
search: svg('<circle cx="11" cy="11" r="6.5"/><path d="M15.8 15.8L20 20"/>'),
settings: svg('<circle cx="12" cy="12" r="3"/><path d="M12 4.5v2.2M12 17.3v2.2M4.5 12h2.2M17.3 12h2.2M6.8 6.8l1.6 1.6M15.6 15.6l1.6 1.6M17.2 6.8l-1.6 1.6M8.4 15.6l-1.6 1.6"/>'),
memo: svg('<path d="M6.5 3.5h7.5L18.5 8v11a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-15a1 1 0 0 1 1-1z"/><path d="M14 3.5V8h4.5M9.5 12h5M9.5 15.5h5"/>'),
lock: svg('<rect x="5.5" y="10.5" width="13" height="9.5" rx="1.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>')
lock: svg('<rect x="5.5" y="10.5" width="13" height="9.5" rx="1.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>'),
wallet: svg('<rect x="3.5" y="6" width="17" height="12.5" rx="2"/><path d="M3.5 10h17"/><circle cx="16.4" cy="14.2" r="1.1"/>')
}
+41 -6
View File
@@ -1,4 +1,4 @@
import { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type Project, type WeeklyNote, type WorkLog } from './schema'
import { db, DEFAULT_LOCATIONS, SCHEMA_VERSION, type Attendance, type Credential, type LogItem, type Memo, type Project, type Transaction, type WeeklyNote, type WorkLog } from './schema'
import { addDays, nowLocalISO, toDateStr } from '../lib/date'
// ---------- 设置 ----------
@@ -347,6 +347,38 @@ export async function searchMemos(q: string, limit = 4): Promise<Memo[]> {
.slice(0, limit)
}
// ---------- 记账 ----------
export interface TransactionInput {
type: string
category: string
amount: number
date: string
note: string
reimbursable: number
reimbursed: number
}
export async function listTransactionsBetween(start: string, end: string): Promise<Transaction[]> {
return db.transaction.where('date').between(start, end, true, true).toArray()
}
export async function addTransaction(input: TransactionInput): Promise<void> {
await db.transaction.add({ ...input, created_at: nowLocalISO() })
}
export async function updateTransaction(id: number, input: TransactionInput): Promise<void> {
await db.transaction.update(id, { ...input })
}
export async function deleteTransaction(id: number): Promise<void> {
await db.transaction.delete(id)
}
export async function setTransactionReimbursed(id: number, done: boolean): Promise<void> {
await db.transaction.update(id, { reimbursed: done ? 1 : 0 })
}
// ---------- 备份 ----------
export async function exportAll(): Promise<string> {
@@ -358,7 +390,8 @@ export async function exportAll(): Promise<string> {
db.weekly_note.toArray(),
db.setting.toArray(),
db.memo.toArray(),
db.credential.toArray()
db.credential.toArray(),
db.transaction.toArray()
])
return JSON.stringify({
schema_version: SCHEMA_VERSION,
@@ -371,7 +404,8 @@ export async function exportAll(): Promise<string> {
weekly_note: tables[4],
setting: tables[5],
memo: tables[6],
credential: tables[7]
credential: tables[7],
transaction: tables[8]
}
}, null, 2)
}
@@ -383,15 +417,15 @@ export async function importAll(jsonText: string): Promise<void> {
} catch {
throw new Error('不是有效的 JSON 文件')
}
if (!parsed || (parsed.schema_version !== 1 && parsed.schema_version !== 2) || !parsed.data) {
if (!parsed || (parsed.schema_version !== 1 && parsed.schema_version !== 2 && parsed.schema_version !== 3) || !parsed.data) {
throw new Error('文件格式或 schema 版本不匹配')
}
const d = parsed.data
await db.transaction('rw', [db.project, db.work_log, db.log_item, db.attendance, db.weekly_note, db.setting, db.memo, db.credential], async () => {
await db.transaction('rw', [db.project, db.work_log, db.log_item, db.attendance, db.weekly_note, db.setting, db.memo, db.credential, db.transaction], async () => {
await Promise.all([
db.project.clear(), db.work_log.clear(), db.log_item.clear(),
db.attendance.clear(), db.weekly_note.clear(), db.setting.clear(),
db.memo.clear(), db.credential.clear()
db.memo.clear(), db.credential.clear(), db.transaction.clear()
])
if (d.project && d.project.length) await db.project.bulkAdd(d.project as never[])
if (d.work_log && d.work_log.length) await db.work_log.bulkAdd(d.work_log as never[])
@@ -401,5 +435,6 @@ export async function importAll(jsonText: string): Promise<void> {
if (d.setting && d.setting.length) await db.setting.bulkAdd(d.setting as never[])
if (d.memo && d.memo.length) await db.memo.bulkAdd(d.memo as never[])
if (d.credential && d.credential.length) await db.credential.bulkAdd(d.credential as never[])
if (d.transaction && d.transaction.length) await db.transaction.bulkAdd(d.transaction as never[])
})
}
+20 -1
View File
@@ -66,6 +66,18 @@ export interface Credential {
updated_at: string
}
export interface Transaction {
id?: number
type: string // income | expense
category: string
amount: number // 单位:分
date: string // YYYY-MM-DD
note: string
reimbursable: number // 支出是否需要报销
reimbursed: number // 是否已报销
created_at: string
}
export class WorklogDB extends Dexie {
project!: Table<Project, number>
work_log!: Table<WorkLog, number>
@@ -75,6 +87,7 @@ export class WorklogDB extends Dexie {
setting!: Table<{ key: string; value: string }, string>
memo!: Table<Memo, number>
credential!: Table<Credential, number>
transaction!: Table<Transaction, number>
constructor(dbName = 'intern_worklog') {
super(dbName)
@@ -90,6 +103,9 @@ export class WorklogDB extends Dexie {
memo: '++id, updated_at',
credential: '++id, system, updated_at'
})
this.version(3).stores({
transaction: '++id, date, type, category'
})
}
}
@@ -105,7 +121,10 @@ export function unbindDatabase(): void {
db = undefined
}
export const SCHEMA_VERSION = 2
export const SCHEMA_VERSION = 3
export const EXPENSE_CATEGORIES: string[] = ['餐饮', '交通', '住房', '通讯', '学习', '娱乐', '其他']
export const INCOME_CATEGORIES: string[] = ['工资', '报销', '其他']
export const DEFAULT_LOCATIONS: string[] = ['公司', '客户现场', '出差', '居家办公']
+388
View File
@@ -0,0 +1,388 @@
<script setup lang="ts">
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 {
addTransaction, deleteTransaction, listTransactionsBetween,
setTransactionReimbursed, updateTransaction, type Transaction
} from '../db/repo'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '../db/schema'
import { addMonths, todayStr } from '../lib/date'
import { showToast } from '../lib/toast'
type TxType = 'expense' | 'income'
const monthRef = ref(todayStr())
const rows = ref<Transaction[]>([])
const loading = ref(true)
// ---- 新增表单 ----
const fType = ref<TxType>('expense')
const fAmount = ref('')
const fCategory = ref(EXPENSE_CATEGORIES[0])
const fDate = ref(todayStr())
const fNote = ref('')
const fReimb = ref(false)
const fError = ref('')
const submitting = ref(false)
const dialogOpen = ref(false)
const editingId = ref<number | null>(null)
const dialogInitial = ref({ type: 'expense', category: EXPENSE_CATEGORIES[0], amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 })
const deleteTarget = ref<Transaction | null>(null)
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
const last = new Date(y, m0 + 1, 0).getDate()
return monthRef.value.slice(0, 7) + '-' + (last < 10 ? '0' + last : String(last))
})
const title = computed(() => Number(monthRef.value.slice(0, 4)) + ' 年 ' + Number(monthRef.value.slice(5, 7)) + ' 月')
async function load() {
loading.value = true
rows.value = await listTransactionsBetween(monthStart.value, monthEnd.value)
loading.value = false
}
onMounted(load)
watch(monthRef, load)
watch(fType, t => {
fCategory.value = t === 'expense' ? EXPENSE_CATEGORIES[0] : INCOME_CATEGORIES[0]
})
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)))
)
const summary = computed(() => {
let income = 0
let expense = 0
let pending = 0
for (const r of rows.value) {
if (r.type === 'income') {
income += r.amount
} else {
expense += r.amount
if (r.reimbursable === 1 && r.reimbursed !== 1) pending += r.amount
}
}
return { income, expense, pending, balance: income - expense }
})
const typeFilter = ref<'all' | TxType>('all')
const reimbFilter = ref<'all' | 'pending' | 'done'>('all')
const filtered = computed(() =>
sorted.value.filter(r => {
if (typeFilter.value !== 'all' && r.type !== typeFilter.value) return false
if (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)
}
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 v = Number(fAmount.value)
if (!fAmount.value.trim() || !isFinite(v) || v <= 0) {
fError.value = '请填写大于 0 的金额'
return
}
if (!fDate.value) {
fError.value = '请选择日期'
return
}
if (submitting.value) return
submitting.value = true
await addTransaction({
type: fType.value,
category: fCategory.value,
amount: Math.round(v * 100),
date: fDate.value,
note: fNote.value.trim(),
reimbursable: fType.value === 'expense' && fReimb.value ? 1 : 0,
reimbursed: 0
})
fAmount.value = ''
fNote.value = ''
fReimb.value = false
fError.value = ''
await load()
showToast('记账已添加')
submitting.value = false
}
function resetForm() {
fAmount.value = ''
fNote.value = ''
fReimb.value = false
fError.value = ''
}
async function markReimbursed(row: Transaction) {
await setTransactionReimbursed(row.id as number, true)
await load()
showToast('已标记为已报销')
}
function openEdit(row: Transaction) {
editingId.value = row.id as number
dialogInitial.value = {
type: row.type,
category: row.category,
amountYuan: yuan(row.amount),
date: row.date,
note: row.note || '',
reimbursable: row.reimbursable,
reimbursed: row.reimbursed
}
dialogOpen.value = true
}
async function onDialogSave(payload: { type: string; category: string; amount: number; date: string; note: string; reimbursable: number; reimbursed: number }) {
const id = editingId.value as number
await updateTransaction(id, payload)
dialogOpen.value = false
await load()
showToast('账目已更新')
}
async function confirmDelete() {
if (deleteTarget.value && deleteTarget.value.id) await deleteTransaction(deleteTarget.value.id)
deleteTarget.value = null
await load()
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>
<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="reimbFilter">
<option value="all">全部报销</option>
<option value="pending">待报销</option>
<option value="done">已报销</option>
</select>
</div>
<div class="cards">
<div class="card stat-card"><div class="muted">本月收入(元)</div><div class="stat-num num amt-income">{{ yuan(summary.income) }}</div></div>
<div class="card stat-card"><div class="muted">本月支出(元)</div><div class="stat-num num">{{ yuan(summary.expense) }}</div></div>
<div class="card stat-card"><div class="muted">本月结余(元)</div><div class="stat-num num" :class="{ 'amt-income': summary.balance >= 0, 'amt-expense': summary.balance < 0 }">{{ yuan(summary.balance) }}</div></div>
<div class="card stat-card"><div class="muted">待报销(元)</div><div class="stat-num num amt-warn">{{ yuan(summary.pending) }}</div></div>
</div>
<div class="card sec">
<div class="sec-head">
<span class="sec-title">新增一笔</span>
<span class="muted form-hint">金额必填,回车快速添加</span>
</div>
<div class="form-grid">
<div class="field span2">
<label>类型</label>
<div class="seg">
<button class="seg-btn" :class="{ active: fType === 'expense' }" @click="fType = 'expense'">支出</button>
<button class="seg-btn" :class="{ active: fType === 'income' }" @click="fType = 'income'">收入</button>
</div>
</div>
<div class="field">
<label>金额(元,必填)</label>
<input class="input" :class="{ invalid: fError }" v-model="fAmount" type="number" min="0" step="0.01" placeholder="0.00" @keydown.enter="submit" />
</div>
<div class="field">
<label>日期</label>
<input class="input" type="date" v-model="fDate" />
</div>
<div class="field">
<label>分类</label>
<select class="select" v-model="fCategory">
<option v-for="c in (fType === 'expense' ? EXPENSE_CATEGORIES : INCOME_CATEGORIES)" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="field" v-if="fType === 'expense'">
<label>报销</label>
<button class="toggle-btn" :class="{ on: fReimb }" @click="fReimb = !fReimb">
{{ fReimb ? '需要报销' : '无需报销' }}
</button>
</div>
<div class="field span2">
<label>备注</label>
<input class="input" v-model="fNote" placeholder="选填,如:客户现场午餐" @keydown.enter="submit" />
</div>
</div>
<div v-if="fError" class="form-error">{{ fError }}</div>
<div class="form-footer">
<button class="btn btn-sm" :disabled="!(fAmount.trim() || fNote.trim())" @click="resetForm">重置</button>
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="submit">添加记录</button>
</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">
<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>
</div>
</div>
<div class="card table-card">
<table class="tx-table">
<thead>
<tr>
<th>日期</th><th>分类</th><th>金额(元)</th><th>备注</th><th>报销</th><th class="th-ops">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="r in filtered" :key="r.id">
<td class="num">{{ r.date }}</td>
<td><span class="tag">{{ r.category }}</span></td>
<td class="num" :class="r.type === 'income' ? 'amt-income' : 'amt-expense'">{{ amountText(r) }}</td>
<td class="td-note">{{ r.note || '--' }}</td>
<td>
<span v-if="r.type !== 'expense' || r.reimbursable !== 1" class="muted">--</span>
<span v-else-if="r.reimbursed === 1" class="tx-done">已报销</span>
<span v-else class="tx-pending">待报销</span>
</td>
<td>
<div class="ops">
<button
v-if="r.type === 'expense' && r.reimbursable === 1 && r.reimbursed !== 1"
class="btn btn-xs" @click="markReimbursed(r)"
>标记已报销</button>
<button class="btn btn-xs" @click="openEdit(r)">编辑</button>
<button class="btn btn-xs" @click="deleteTarget = r">删除</button>
</div>
</td>
</tr>
<tr v-if="!filtered.length && !loading">
<td colspan="6" class="empty muted">本月暂无账目</td>
</tr>
</tbody>
</table>
</div>
<div class="muted vault-note num">筛选结果 {{ filtered.length }} 条 / 本月共 {{ rows.length }} 条</div>
<TransactionDialog
:open="dialogOpen"
:title="editingId ? '编辑账目' : '编辑账目'"
:initial="dialogInitial"
@cancel="dialogOpen = false"
@save="onDialogSave"
/>
<ConfirmDialog
:open="!!deleteTarget"
title="删除确认"
:message="deleteTarget ? '将删除 ' + deleteTarget.date + ' 的账目(' + deleteTarget.category + ' ' + yuan(deleteTarget.amount) + ' 元)。删除后不可恢复。' : ''"
confirm-text="删除" danger
@cancel="deleteTarget = null"
@confirm="confirmDelete"
/>
</div>
</template>
<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; }
.w-filter { width: 110px; flex: none; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
.stat-card { padding: 14px 16px; }
.stat-num { font-size: 20px; font-weight: 600; margin-top: 4px; }
.amt-income { color: var(--ok); }
.amt-expense { color: var(--text-1); }
.amt-warn { color: var(--warn); }
.sec { padding: 14px 16px; margin-top: 12px; }
.sec-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; }
.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
.sec-head .sec-title { margin-bottom: 0; }
.form-hint { font-size: 12px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; }
.field.span2 { grid-column: span 2; }
.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
.form-error { color: var(--danger); font-size: 12px; margin-top: 8px; }
.form-footer {
display: flex; justify-content: flex-end; gap: 8px;
margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);
}
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
.seg-btn {
height: 30px; padding: 0 14px; border: none;
background: var(--bg-page); color: var(--text-2); cursor: pointer; font-size: 13px;
}
.seg-btn + .seg-btn { border-left: 1px solid var(--border); }
.seg-btn.active { background: var(--accent-bg); color: var(--accent); }
.toggle-btn {
height: 32px; width: 100%;
border: 1px solid var(--border-strong);
background: var(--bg-page);
border-radius: var(--radius-sm);
cursor: pointer; font-size: 13px; color: var(--text-2);
}
.toggle-btn:hover { background: var(--bg-hover); }
.toggle-btn.on { color: var(--accent); border-color: var(--accent); background: var(--accent-bg); }
.bar-row { display: flex; align-items: center; gap: 10px; margin: 6px 0; }
.bar-label { width: 56px; flex: none; color: var(--text-2); font-size: 12px; text-align: right; }
.bar-track { flex: 1; height: 8px; border-radius: 4px; background: var(--bg-subtle); overflow: hidden; }
.bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; }
.bar-num { font-size: 12px; width: 110px; flex: none; }
.table-card { overflow-x: auto; }
.tx-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.tx-table th {
text-align: left; font-weight: 500; color: var(--text-2);
background: var(--bg-subtle); padding: 10px 12px;
border-bottom: 1px solid var(--border); white-space: nowrap;
}
.tx-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: top; word-break: break-all; }
.tx-table tbody tr:hover { background: var(--bg-subtle); }
.tx-table tbody tr:last-child td { border-bottom: none; }
.td-note { color: var(--text-2); }
.tx-pending { color: var(--warn); font-size: 12px; }
.tx-done { color: var(--ok); font-size: 12px; }
.th-ops { width: 220px; }
.ops { display: flex; gap: 4px; flex-wrap: wrap; }
.empty { text-align: center; padding: 24px 0; }
.vault-note { margin-top: 10px; font-size: 12px; text-align: right; }
</style>