feat: 新增记账系统(收支记录、报销跟踪、月度汇总)
This commit is contained in:
@@ -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 唤起,覆盖工作记录与备忘录,点击结果跳转定位
|
||||
|
||||
@@ -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>
|
||||
@@ -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"/>')
|
||||
}
|
||||
|
||||
@@ -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[])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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[] = ['公司', '客户现场', '出差', '居家办公']
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user