359 lines
13 KiB
Vue
359 lines
13 KiB
Vue
<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 { getLedgerCategories } from '../db/categories'
|
||
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('')
|
||
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 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
|
||
try {
|
||
rows.value = await listTransactionsBetween(monthStart.value, monthEnd.value)
|
||
loadError.value = ''
|
||
} catch (e) {
|
||
loadError.value = '账目加载失败:' + (e instanceof Error ? (e.stack || e.message) : String(e))
|
||
showToast('账目加载失败')
|
||
}
|
||
loading.value = false
|
||
}
|
||
onMounted(load)
|
||
watch(monthRef, load)
|
||
|
||
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)))
|
||
)
|
||
|
||
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 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 (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 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 = ''
|
||
}
|
||
|
||
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
|
||
}
|
||
function openCreate() {
|
||
editingId.value = null
|
||
dialogInitial.value = { type: 'expense', category: (expenseCats.value[0] || ''), amountYuan: '', date: todayStr(), note: '', reimbursable: 0, reimbursed: 0 }
|
||
dialogOpen.value = true
|
||
}
|
||
|
||
async function onDialogSave(payload: { type: string; category: string; amount: number; date: string; note: string; reimbursable: number; reimbursed: number }) {
|
||
if (editingId.value) {
|
||
await updateTransaction(editingId.value, payload)
|
||
dialogOpen.value = false
|
||
await load()
|
||
showToast('账目已更新')
|
||
return
|
||
}
|
||
await addTransaction({ type: payload.type, category: payload.category, amount: payload.amount, date: payload.date, note: payload.note, reimbursable: payload.reimbursable, reimbursed: payload.reimbursed })
|
||
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="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>
|
||
<option value="done">已报销</option>
|
||
</select>
|
||
<button class="btn btn-primary btn-sm" @click="openCreate"><Icon name="plus" :size="14" /> 记一笔</button>
|
||
</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-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">
|
||
<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 v-if="loadError" class="card section">
|
||
<div class="form-error">{{ loadError }}</div>
|
||
<div class="muted loc-note">请将上方完整错误文字发送给开发者</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" data-label="日期">{{ r.date }}</td>
|
||
<td data-label="分类"><span class="tag">{{ r.category }}</span></td>
|
||
<td class="num" data-label="金额" :class="r.type === 'income' ? 'amt-income' : 'amt-expense'">{{ amountText(r) }}</td>
|
||
<td class="td-note" data-label="备注">{{ r.note || '--' }}</td>
|
||
<td data-label="报销">
|
||
<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 class="ops-cell">
|
||
<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 table-note num">本月 {{ rows.length }} 条 · 当前筛选 {{ filtered.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 { border-left: 3px solid var(--border); }
|
||
.stat-income { border-left-color: var(--ok); }
|
||
.stat-expense { border-left-color: var(--text-3); }
|
||
.stat-balance { border-left-color: var(--accent); }
|
||
.stat-pending { border-left-color: var(--warn); }
|
||
|
||
.card.sec, .card.section { margin-top: 12px; }
|
||
.bar-label { width: 56px; }
|
||
.bar-num { width: 118px; }
|
||
|
||
.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; }
|
||
.table-card { overflow-x: auto; }
|
||
.table-note { margin-top: 10px; font-size: 12px; text-align: right; }
|
||
|
||
@media (max-width: 720px) {
|
||
.ledger-toolbar { gap: 8px; }
|
||
.ledger-toolbar .flex1 { display: none; }
|
||
.month-title { min-width: 0; flex: 1; text-align: left; }
|
||
.w-filter { flex: 1 1 calc(50% - 4px); min-width: 0; width: auto; }
|
||
.ledger-toolbar .btn-primary { flex: 1 1 100%; order: 9; }
|
||
}
|
||
</style>
|