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>
+24 -1
View File
@@ -1,6 +1,6 @@
# 实习工作日志系统 设计方案
- 版本:v1.10(Electron 桌面应用)
- 版本:v1.11(记账系统)
- 范围:个人版全量功能;团队版仅作扩展预留
## 1. 产品定位
@@ -154,6 +154,15 @@ AI 润色为可选进阶:将统计、成果、周记交给大模型按四段
| 账号管理 | 设置页可修改登录密码、删除用户(连同其全部数据,二次确认) |
| 旧数据迁移 | 首次创建用户时可勾选迁移升级前单机数据,迁移仅复制、原库保留兜底 |
### 3.12 记账系统
| 项 | 说明 |
|---|---|
| 录入 | 支出/收入两类型;金额以「分」为整数存储避免浮点误差;日期、分类(支出:餐饮/交通/住房/通讯/学习/娱乐/其他;收入:工资/报销/其他,固定枚举)、备注 |
| 报销跟踪 | 支出可标记「需要报销」并跟踪待报销/已报销状态,面向出差垫付场景;汇总卡片显示待报销金额 |
| 汇总 | 按月展示收入、支出、结余、待报销四卡片;支出分类分布条形图 |
| 明细 | 月度流水表格,类型与报销状态筛选,支持弹窗编辑、删除确认、标记已报销 |
## 4. 数据模型(SQLite)
### 4.1 建表语句
@@ -232,6 +241,19 @@ CREATE TABLE credential (
updated_at TEXT NOT NULL
);
-- 记账流水(amount 单位为分)
CREATE TABLE transaction (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
category TEXT NOT NULL,
amount INTEGER NOT NULL,
date TEXT NOT NULL,
note TEXT,
reimbursable INTEGER NOT NULL DEFAULT 0,
reimbursed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
-- 设置
CREATE TABLE setting (
key TEXT PRIMARY KEY,
@@ -482,3 +504,4 @@ src/
- v1.8:新增用户系统——每位用户独立 IndexedDB 数据库物理隔离数据;登录密码仅存 PBKDF2 哈希;会话存 sessionStorage 并支持 30 分钟无操作自动退出;设置页支持修改登录密码与删除用户;首次创建用户可一键迁移升级前单机数据(原库保留兜底)
- v1.9:PWA 应用化——新增 Web 应用清单、Service Worker 离线缓存与自绘应用图标;在 Edge/Chrome 中可一键安装为独立窗口应用(桌面图标、开始菜单入口),离线可用,数据随浏览器配置文件存储
- v1.10:Electron 桌面应用——pnpm dist 一键产出 NSIS 安装包与免安装目录;主进程加载本地构建产物,数据存于系统用户目录;窗口禁用菜单栏、外部链接交由系统浏览器打开
- v1.11:新增记账系统——支出/收入录入(金额以分存储)、固定分类枚举、报销跟踪(待报销/已报销与待报销金额汇总)、月度汇总卡片与支出分类分布、月度流水筛选与弹窗编辑;备份格式升级至 v3 并向下兼容