v0.7.1 记账周期统计:按月/按期/自定义三种周期,账期起点多端同步;工具栏精简(回到本期条件显示、自定义隐藏翻页箭头、清除快速记账死代码)
This commit is contained in:
@@ -3,49 +3,130 @@ 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 PeriodDialog from '../components/PeriodDialog.vue'
|
||||
import {
|
||||
addTransaction, deleteTransaction, listTransactionsBetween,
|
||||
addTransaction, countTransactionsBefore, deleteTransaction, getSetting,
|
||||
listTransactionMonths, listTransactionsBetween, setSetting,
|
||||
setTransactionReimbursed, updateTransaction, type Transaction
|
||||
} from '../db/repo'
|
||||
import { getLedgerCategories } from '../db/categories'
|
||||
import { addMonths, todayStr } from '../lib/date'
|
||||
import { addDays, addMonths, todayStr } from '../lib/date'
|
||||
import { cycleEndOf, cycleIndexOf, cycleStartOf, fmtDayRef, fmtRange, type CycleSel } from '../lib/period'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
||||
type TxType = 'expense' | 'income'
|
||||
type PeriodMode = 'month' | 'cycle' | 'custom'
|
||||
|
||||
// 周期偏好为设备本地习惯;账期起点存 setting 表,随账号多端同步
|
||||
const LS_MODE = 'dsh_ledger_mode'
|
||||
const LS_CYCLE = 'dsh_ledger_cycle'
|
||||
const LS_CSTART = 'dsh_ledger_cstart'
|
||||
const LS_CEND = 'dsh_ledger_cend'
|
||||
const EARLIEST = '0000-01-01'
|
||||
|
||||
const mode = ref<PeriodMode>('month')
|
||||
const monthRef = ref(todayStr())
|
||||
const cycleSel = ref<CycleSel>(1)
|
||||
const customStart = ref(todayStr().slice(0, 7) + '-01')
|
||||
const customEnd = ref(todayStr())
|
||||
const anchor = ref('')
|
||||
const hasBefore = ref(false)
|
||||
const txMonths = ref<string[]>([])
|
||||
|
||||
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 periodOpen = ref(false)
|
||||
|
||||
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
|
||||
// ---- 统计周期 ----
|
||||
|
||||
function monthEndOf(ym: string): string {
|
||||
const y = Number(ym.slice(0, 4))
|
||||
const m0 = Number(ym.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))
|
||||
return ym + '-' + (last < 10 ? '0' + last : String(last))
|
||||
}
|
||||
|
||||
// 今天所在的期数(未设起点时按 1 处理)
|
||||
const currentCycle = computed(() => (anchor.value ? Math.max(1, cycleIndexOf(anchor.value, todayStr())) : 1))
|
||||
|
||||
const rangeStart = computed(() => {
|
||||
if (mode.value === 'month') return monthRef.value.slice(0, 7) + '-01'
|
||||
if (mode.value === 'custom') return customStart.value
|
||||
if (!anchor.value) return monthRef.value.slice(0, 7) + '-01' // 未设起点时按月兜底
|
||||
return cycleSel.value === 'before' ? EARLIEST : cycleStartOf(anchor.value, cycleSel.value)
|
||||
})
|
||||
const title = computed(() => Number(monthRef.value.slice(0, 4)) + ' 年 ' + Number(monthRef.value.slice(5, 7)) + ' 月')
|
||||
const rangeEnd = computed(() => {
|
||||
if (mode.value === 'month') return monthEndOf(monthRef.value.slice(0, 7))
|
||||
if (mode.value === 'custom') return customEnd.value
|
||||
if (!anchor.value) return monthEndOf(monthRef.value.slice(0, 7))
|
||||
return cycleSel.value === 'before' ? addDays(anchor.value, -1) : cycleEndOf(anchor.value, cycleSel.value)
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
if (mode.value === 'month') return Number(monthRef.value.slice(0, 4)) + ' 年 ' + Number(monthRef.value.slice(5, 7)) + ' 月'
|
||||
if (mode.value === 'custom') return fmtRange(rangeStart.value, rangeEnd.value)
|
||||
if (!anchor.value) return '按期统计 · 设置起点'
|
||||
if (cycleSel.value === 'before') return '起点前 · ~ ' + fmtDayRef(addDays(anchor.value, -1), anchor.value)
|
||||
return '第 ' + cycleSel.value + ' 期 · ' + fmtRange(rangeStart.value, rangeEnd.value)
|
||||
})
|
||||
|
||||
const canPrev = computed(() => {
|
||||
if (mode.value === 'month') return true
|
||||
if (mode.value === 'cycle' && anchor.value) {
|
||||
if (cycleSel.value === 'before') return false
|
||||
return cycleSel.value > 1 || hasBefore.value
|
||||
}
|
||||
return false
|
||||
})
|
||||
const canNext = computed(() => {
|
||||
if (mode.value === 'month') return true
|
||||
if (mode.value === 'cycle' && anchor.value) {
|
||||
return cycleSel.value === 'before' ? true : cycleSel.value < currentCycle.value
|
||||
}
|
||||
return false
|
||||
})
|
||||
function step(dir: -1 | 1) {
|
||||
if (mode.value === 'month') {
|
||||
monthRef.value = addMonths(monthRef.value, dir)
|
||||
return
|
||||
}
|
||||
if (mode.value !== 'cycle' || !anchor.value) return
|
||||
if (cycleSel.value === 'before') {
|
||||
if (dir === 1) cycleSel.value = 1
|
||||
return
|
||||
}
|
||||
const n = Number(cycleSel.value) + dir
|
||||
if (n > currentCycle.value) return
|
||||
if (n < 1) {
|
||||
if (hasBefore.value) cycleSel.value = 'before'
|
||||
return
|
||||
}
|
||||
cycleSel.value = n
|
||||
}
|
||||
|
||||
// 只有偏离当前周期时才出现「回到本期」,平时不占工具栏
|
||||
const offCurrent = computed(() => {
|
||||
if (mode.value === 'month') return monthRef.value.slice(0, 7) !== todayStr().slice(0, 7)
|
||||
if (mode.value === 'cycle') return !!anchor.value && cycleSel.value !== currentCycle.value
|
||||
return false
|
||||
})
|
||||
function goCurrent() {
|
||||
if (mode.value === 'cycle') cycleSel.value = currentCycle.value
|
||||
else monthRef.value = todayStr()
|
||||
}
|
||||
|
||||
// ---- 数据加载:全部统计共用 [rangeStart, rangeEnd] ----
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
rows.value = await listTransactionsBetween(monthStart.value, monthEnd.value)
|
||||
rows.value = await listTransactionsBetween(rangeStart.value, rangeEnd.value)
|
||||
loadError.value = ''
|
||||
} catch (e) {
|
||||
loadError.value = '账目加载失败:' + (e instanceof Error ? (e.stack || e.message) : String(e))
|
||||
@@ -53,18 +134,62 @@ async function load() {
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
onMounted(load)
|
||||
watch(monthRef, load)
|
||||
|
||||
onMounted(async () => {
|
||||
const savedMode = localStorage.getItem(LS_MODE)
|
||||
if (savedMode === 'month' || savedMode === 'cycle' || savedMode === 'custom') mode.value = savedMode
|
||||
const cs = localStorage.getItem(LS_CSTART)
|
||||
const ce = localStorage.getItem(LS_CEND)
|
||||
if (cs && ce && cs <= ce) { customStart.value = cs; customEnd.value = ce }
|
||||
const cyc = localStorage.getItem(LS_CYCLE)
|
||||
if (cyc === 'before') cycleSel.value = 'before'
|
||||
else if (cyc && Number(cyc) >= 1) cycleSel.value = Number(cyc)
|
||||
anchor.value = await getSetting('ledger.anchor', '')
|
||||
if (cycleSel.value !== 'before' && cycleSel.value > currentCycle.value) cycleSel.value = currentCycle.value
|
||||
await load()
|
||||
})
|
||||
watch([rangeStart, rangeEnd], () => {
|
||||
catFilter.value = 'all' // 切换周期时重置分类筛选(分类集合可能不同)
|
||||
void load()
|
||||
})
|
||||
watch(mode, v => localStorage.setItem(LS_MODE, v))
|
||||
watch(cycleSel, v => localStorage.setItem(LS_CYCLE, String(v)))
|
||||
watch([customStart, customEnd], ([s, e]) => {
|
||||
localStorage.setItem(LS_CSTART, s)
|
||||
localStorage.setItem(LS_CEND, e)
|
||||
})
|
||||
|
||||
async function openPeriodDialog() {
|
||||
try {
|
||||
txMonths.value = await listTransactionMonths()
|
||||
hasBefore.value = anchor.value ? (await countTransactionsBefore(anchor.value)) > 0 : false
|
||||
} catch { /* 统计失败不阻塞打开弹窗 */ }
|
||||
periodOpen.value = true
|
||||
}
|
||||
function onApply(p: { mode: PeriodMode; month?: string; cycle?: number | 'before'; customStart?: string; customEnd?: string }) {
|
||||
mode.value = p.mode
|
||||
if (p.month) monthRef.value = p.month + '-01'
|
||||
if (p.cycle !== undefined) cycleSel.value = p.cycle
|
||||
if (p.customStart) customStart.value = p.customStart
|
||||
if (p.customEnd) customEnd.value = p.customEnd
|
||||
}
|
||||
async function onSaveAnchor(v: string) {
|
||||
anchor.value = v
|
||||
await setSetting('ledger.anchor', v)
|
||||
mode.value = 'cycle'
|
||||
cycleSel.value = Math.max(1, cycleIndexOf(v, todayStr()))
|
||||
try {
|
||||
hasBefore.value = (await countTransactionsBefore(v)) > 0
|
||||
} catch { /* 忽略统计失败 */ }
|
||||
showToast('账期起点已保存,已按新起点切分期数')
|
||||
}
|
||||
|
||||
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)))
|
||||
)
|
||||
@@ -87,8 +212,6 @@ const summary = computed(() => {
|
||||
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'))
|
||||
)
|
||||
@@ -123,59 +246,6 @@ 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()
|
||||
@@ -221,13 +291,21 @@ async function confirmDelete() {
|
||||
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>
|
||||
<button v-if="mode !== 'custom'" class="icon-btn" :disabled="!canPrev" title="上一周期" @click="step(-1)">
|
||||
<Icon name="chevron-left" :size="16" />
|
||||
</button>
|
||||
<button class="period-title" title="选择统计周期" @click="openPeriodDialog">
|
||||
<span class="num">{{ title }}</span>
|
||||
<Icon name="chevron-down" :size="14" />
|
||||
</button>
|
||||
<button v-if="mode !== 'custom'" class="icon-btn" :disabled="!canNext" title="下一周期" @click="step(1)">
|
||||
<Icon name="chevron-right" :size="16" />
|
||||
</button>
|
||||
<button v-if="offCurrent" class="btn btn-sm" @click="goCurrent">{{ mode === 'cycle' ? '回到本期' : '回到本月' }}</button>
|
||||
<span class="flex1"></span>
|
||||
<select class="select w-filter" v-model="typeFilter">
|
||||
<option value="all">全部类型</option>
|
||||
@@ -247,14 +325,14 @@ async function confirmDelete() {
|
||||
</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-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 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>
|
||||
@@ -297,13 +375,13 @@ async function confirmDelete() {
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!filtered.length && !loading">
|
||||
<td colspan="6" class="empty muted">本月暂无账目</td>
|
||||
<td colspan="6" class="empty muted">该周期暂无账目</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="muted table-note num">本月 {{ rows.length }} 条 · 当前筛选 {{ filtered.length }} 条</div>
|
||||
<div class="muted table-note num">本期 {{ rows.length }} 条 · 当前筛选 {{ filtered.length }} 条</div>
|
||||
|
||||
<TransactionDialog
|
||||
:open="dialogOpen"
|
||||
@@ -313,6 +391,22 @@ async function confirmDelete() {
|
||||
@save="onDialogSave"
|
||||
/>
|
||||
|
||||
<PeriodDialog
|
||||
:open="periodOpen"
|
||||
:mode="mode"
|
||||
:anchor="anchor"
|
||||
:month="monthRef"
|
||||
:cycle-sel="cycleSel"
|
||||
:custom-start="customStart"
|
||||
:custom-end="customEnd"
|
||||
:cycle-count="currentCycle"
|
||||
:has-before="hasBefore"
|
||||
:months="txMonths"
|
||||
@close="periodOpen = false"
|
||||
@apply="onApply"
|
||||
@save-anchor="onSaveAnchor"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="!!deleteTarget"
|
||||
title="删除确认"
|
||||
@@ -326,8 +420,16 @@ async function confirmDelete() {
|
||||
|
||||
<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; }
|
||||
.period-title {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 5px;
|
||||
border: none; background: transparent; cursor: pointer;
|
||||
font-size: 14px; font-weight: 600; color: var(--text-1);
|
||||
padding: 5px 10px; border-radius: var(--radius-sm);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.period-title:hover { background: var(--bg-hover); }
|
||||
.w-filter { width: 110px; flex: none; }
|
||||
.icon-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
|
||||
.stat-card { border-left: 3px solid var(--border); }
|
||||
@@ -351,7 +453,7 @@ async function confirmDelete() {
|
||||
@media (max-width: 720px) {
|
||||
.ledger-toolbar { gap: 8px; }
|
||||
.ledger-toolbar .flex1 { display: none; }
|
||||
.month-title { min-width: 0; flex: 1; text-align: left; }
|
||||
.period-title { flex: 1; justify-content: flex-start; padding-left: 0; }
|
||||
.w-filter { flex: 1 1 calc(50% - 4px); min-width: 0; width: auto; }
|
||||
.ledger-toolbar .btn-primary { flex: 1 1 100%; order: 9; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user