feat: 账单导入、统计维度扩展与收支记录增强

- 账单导入:新增支付宝账单解析/导入接口,按 external_no 去重;前端拖拽上传 + 预览确认弹窗
- 分类可选:category_id 允许为空,支持「未分类」筛选,新增按类型一键批量设置分类
- 备注隐藏:新增 remark_hidden 与切换接口,列表默认显示 ****** 并可单格查看/取消隐藏,导出同步隐藏
- 统计扩展:新增 /statistics/summary、/statistics/group,支持日/周/月/季/年/支付方式/分类/类型维度与时间范围过滤
- 收支记录:金额区间过滤、金额/交易时间列排序、分页快速跳转、本页收支结余汇总、交易时间列前置并合并创建时间提示
- 用户偏好:统计面板与列表的筛选条件持久化到 localStorage,进入自动还原
- 其他:新增默认分类种子脚本(含美容美发)、commons-csv 依赖、上传大小限制 10MB、Vite 监听 0.0.0.0
This commit is contained in:
2026-09-17 13:19:59 +08:00
parent 733ed4bb86
commit 1df6e23510
35 changed files with 3016 additions and 1620 deletions
+356
View File
@@ -0,0 +1,356 @@
<script setup lang="ts">
// 账单导入弹窗:先解析、后勾选导入(默认全选),当前支持支付宝账单
import { ref, computed, h, watch } from 'vue'
import {
useMessage, NModal, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable,
NCheckbox, NTag, NAlert, NText, NIcon,
} from 'naive-ui'
import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
import { CloudUploadOutline } from '@vicons/ionicons5'
import { parseBill, confirmImport } from '../api/transaction'
import type { BillType, ParsedBillRow, BillImportItem } from '../api/transaction'
import { getCategoryList } from '../api/category'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'imported'): void
}>()
const message = useMessage()
// 账单类型选项,当前仅支持支付宝
const billType = ref<BillType>('ALIPAY')
const billTypeOptions = [{ label: '支付宝', value: 'ALIPAY' }]
// 弹窗阶段:选择文件 -> 预览勾选 -> 导入结果
const stage = ref<'select' | 'preview' | 'result'>('select')
const file = ref<File | null>(null)
const uploadFileList = ref<UploadFileInfo[]>([])
const parsing = ref(false)
const importing = ref(false)
// 解析结果与勾选状态
const rows = ref<ParsedBillRow[]>([])
const checkedRowNos = ref<number[]>([])
// 默认分类
const expenseCategories = ref<{ label: string; value: number }[]>([])
const incomeCategories = ref<{ label: string; value: number }[]>([])
const expenseCategoryId = ref<number | null>(null)
const incomeCategoryId = ref<number | null>(null)
// 导入结果
const result = ref<{ total: number; imported: number; skipped: number; failed: number; messages: string[] } | null>(null)
/** 可导入的行 */
const selectableRows = computed(() => rows.value.filter((r) => r.importable))
/** 已勾选的行 */
const checkedRows = computed(() => rows.value.filter((r) => checkedRowNos.value.includes(r.rowNo)))
/** 是否全选 */
const allChecked = computed(
() => selectableRows.value.length > 0 && selectableRows.value.every((r) => checkedRowNos.value.includes(r.rowNo)),
)
/** 是否存在部分勾选 */
const someChecked = computed(() => checkedRowNos.value.length > 0 && !allChecked.value)
/** 表格列定义 */
const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
{
title: () =>
h(NCheckbox, {
checked: allChecked.value,
indeterminate: someChecked.value,
disabled: selectableRows.value.length === 0,
onUpdateChecked: (v: boolean) => toggleAll(v),
}),
key: 'checked',
width: 50,
render: (row) =>
h(NCheckbox, {
checked: checkedRowNos.value.includes(row.rowNo),
disabled: !row.importable,
onUpdateChecked: (v: boolean) => toggleRow(row, v),
}),
},
{
title: '交易时间',
key: 'transactionTime',
width: 150,
render: (row) => (row.transactionTime ? row.transactionTime.replace('T', ' ').substring(0, 16) : '-'),
},
{
title: '类型',
key: 'type',
width: 90,
render: (row) => {
if (!row.importable) {
return h(NTag, { type: 'error', size: 'small' }, { default: () => '不可导入' })
}
if (!row.includeInStats) {
return h(NTag, { type: 'default', size: 'small' }, { default: () => '不计入' })
}
return h(
NTag,
{ type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' },
{ default: () => (row.type === 'INCOME' ? '收入' : '支出') },
)
},
},
{
title: '金额',
key: 'amount',
width: 110,
render: (row) => {
const color = row.type === 'INCOME' ? '#18a058' : '#d03050'
return h('span', { style: { color, fontWeight: 'bold' } }, `${row.type === 'INCOME' ? '+' : '-'}${Number(row.amount).toFixed(2)}`)
},
},
{ title: '交易对方', key: 'counterparty', width: 160, ellipsis: { tooltip: true } },
{ title: '商品名称', key: 'product', ellipsis: { tooltip: true } },
{
title: '状态',
key: 'tradeStatus',
width: 150,
render: (row) => {
if (row.error) {
return h(NTag, { type: 'error', size: 'small' }, { default: () => row.error })
}
if (row.duplicate) {
return h(NTag, { type: 'warning', size: 'small' }, { default: () => '已存在' })
}
return h('span', null, row.tradeStatus || '-')
},
},
])
/** 加载分类选项 */
async function loadCategories() {
try {
const [expenseRes, incomeRes]: any = await Promise.all([getCategoryList('EXPENSE'), getCategoryList('INCOME')])
expenseCategories.value = (expenseRes.data || []).map((c: any) => ({ label: c.name, value: c.id }))
incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id }))
} catch (e: any) {
message.error(e.message || '加载分类失败')
}
}
/** 文件选择变化(支持点击或拖拽) */
function handleFileChange(options: { fileList: UploadFileInfo[] }) {
uploadFileList.value = options.fileList
const last = options.fileList[options.fileList.length - 1]
file.value = last?.file ?? null
}
/** 解析账单 */
async function handleParse() {
if (!file.value) {
message.warning('请先选择账单文件')
return
}
parsing.value = true
try {
const res: any = await parseBill(file.value, billType.value)
rows.value = res.data.rows || []
// 默认勾选解析器建议勾选的行
checkedRowNos.value = rows.value.filter((r) => r.selected).map((r) => r.rowNo)
stage.value = 'preview'
} catch (e: any) {
message.error(e.message || '解析失败')
} finally {
parsing.value = false
}
}
/** 切换单行勾选 */
function toggleRow(row: ParsedBillRow, checked: boolean) {
if (checked) {
if (!checkedRowNos.value.includes(row.rowNo)) {
checkedRowNos.value = [...checkedRowNos.value, row.rowNo]
}
} else {
checkedRowNos.value = checkedRowNos.value.filter((n) => n !== row.rowNo)
}
}
/** 全选/取消全选 */
function toggleAll(checked: boolean) {
checkedRowNos.value = checked ? selectableRows.value.map((r) => r.rowNo) : []
}
/** 确认导入 */
async function handleConfirm() {
if (checkedRows.value.length === 0) {
message.warning('请至少勾选一条记录')
return
}
const items: BillImportItem[] = checkedRows.value.map((r) => ({
externalNo: r.externalNo,
transactionTime: r.transactionTime,
type: r.type,
amount: r.amount,
counterparty: r.counterparty,
product: r.product,
includeInStats: r.includeInStats,
}))
importing.value = true
try {
const res: any = await confirmImport({
billType: billType.value,
expenseCategoryId: expenseCategoryId.value,
incomeCategoryId: incomeCategoryId.value,
items,
})
result.value = res.data
stage.value = 'result'
emit('imported')
} catch (e: any) {
message.error(e.message || '导入失败')
} finally {
importing.value = false
}
}
/** 重置弹窗状态 */
function reset() {
stage.value = 'select'
file.value = null
uploadFileList.value = []
rows.value = []
checkedRowNos.value = []
result.value = null
expenseCategoryId.value = null
incomeCategoryId.value = null
}
/** 关闭弹窗 */
function handleClose() {
emit('update:show', false)
}
// 打开弹窗时重置并加载分类
watch(
() => props.show,
(visible) => {
if (visible) {
reset()
loadCategories()
}
},
)
</script>
<template>
<NModal
:show="props.show"
preset="card"
title="导入账单"
style="width: 960px; max-width: 92vw;"
:mask-closable="false"
@update:show="handleClose"
>
<!-- 第一步:选择账单类型与文件 -->
<template v-if="stage === 'select'">
<NSpace vertical :size="16">
<NSpace align="center">
<NText>账单类型:</NText>
<NSelect v-model:value="billType" :options="billTypeOptions" style="width: 160px;" />
</NSpace>
<div>
<NText>账单文件:</NText>
<NUpload
:file-list="uploadFileList"
:default-upload="false"
:max="1"
accept=".csv"
style="margin-top: 8px;"
@change="handleFileChange"
>
<NUploadDragger>
<div style="padding: 24px 0; text-align: center;">
<NIcon size="48" :depth="3">
<CloudUploadOutline />
</NIcon>
<div style="font-size: 15px; margin-top: 8px;">点击或拖拽文件到此处</div>
<div style="font-size: 12px; color: #999; margin-top: 4px;">
仅支持支付宝导出的交易记录明细 .csv 文件
</div>
</div>
</NUploadDragger>
</NUpload>
</div>
<NAlert type="info" :show-icon="true">
导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。
</NAlert>
<NSpace justify="end">
<NButton @click="handleClose">取消</NButton>
<NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse">解析</NButton>
</NSpace>
</NSpace>
</template>
<!-- 第二步:预览并勾选 -->
<template v-else-if="stage === 'preview'">
<NSpace vertical :size="12">
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NText>支出默认分类(可不选):</NText>
<NSelect
v-model:value="expenseCategoryId"
:options="expenseCategories"
filterable
clearable
placeholder="未选则不计分类"
style="width: 180px;"
/>
<NText>收入默认分类(可不选):</NText>
<NSelect
v-model:value="incomeCategoryId"
:options="incomeCategories"
filterable
clearable
placeholder="未选则不计分类"
style="width: 180px;"
/>
<NText depth="3">共 {{ rows.length }} 条,已勾选 {{ checkedRows.length }} 条</NText>
</NSpace>
<NDataTable
:columns="columns"
:data="rows"
:max-height="420"
:row-key="(row) => row.rowNo"
size="small"
:bordered="false"
/>
<NSpace justify="end">
<NButton @click="stage = 'select'">上一步</NButton>
<NButton type="primary" :loading="importing" @click="handleConfirm">
确认导入({{ checkedRows.length }})
</NButton>
</NSpace>
</NSpace>
</template>
<!-- 第三步:导入结果 -->
<template v-else>
<NSpace vertical :size="12">
<NAlert type="success" :show-icon="true">
导入完成:成功 {{ result?.imported ?? 0 }} 条,跳过 {{ result?.skipped ?? 0 }} 条,失败 {{ result?.failed ?? 0 }} 条
</NAlert>
<div v-if="result?.messages?.length" style="max-height: 320px; overflow: auto;">
<NText v-for="(msg, i) in result.messages" :key="i" depth="3" style="display: block; font-size: 12px;">
{{ msg }}
</NText>
</div>
<NSpace justify="end">
<NButton type="primary" @click="handleClose">完成</NButton>
</NSpace>
</NSpace>
</template>
</NModal>
</template>