Files
daywork/intern-log/src/views/LedgerView.vue
T

359 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>