feat: 新增标签体系、微信账单导入与收支记录体验优化

- 标签:新增 t_tag / t_transaction_tag 表与标签 CRUD,支持交易打标签、按标签筛选、批量添加/移除标签、导入账单默认标签,新增标签管理页
- 账单导入:新增微信支付 xlsx 解析器(WechatBillParser),支持支付宝 / 微信按类型导入;解析行保存原始完整数据(raw_data)并可在列表查看
- 收支记录:排序移至顶部并改为服务端排序(交易时间 / 金额,记忆偏好);筛选条件收纳为「更多筛选」弹层;分类筛选显示图标;一键设置支持「计入统计」;导入预览支持关键字 / 类型 / 状态 / 金额筛选
- 数据库:init.sql 新增 raw_data 字段与标签表,新增 init_tags.sql 默认标签种子
- 其他:更新 README 测试账号
This commit is contained in:
2026-09-17 16:47:26 +08:00
parent 1df6e23510
commit 65c0d8c550
33 changed files with 1854 additions and 100 deletions
+180 -16
View File
@@ -3,13 +3,14 @@
import { ref, computed, h, watch } from 'vue'
import {
useMessage, NModal, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable,
NCheckbox, NTag, NAlert, NText, NIcon,
NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber,
} 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'
import { getTagList } from '../api/tag'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{
@@ -19,9 +20,47 @@ const emit = defineEmits<{
const message = useMessage()
// 账单类型选项,当前仅支持支付宝
const billType = ref<BillType>('ALIPAY')
const billTypeOptions = [{ label: '支付宝', value: 'ALIPAY' }]
// 账单类型偏好存储键
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
// 账单类型选项,当前支持支付宝与微信
const billTypeOptions = [
{ label: '支付宝', value: 'ALIPAY' },
{ label: '微信', value: 'WECHAT' },
]
/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */
function loadBillTypePref(): BillType {
const saved = localStorage.getItem(BILL_TYPE_PREFS_KEY)
return saved === 'ALIPAY' || saved === 'WECHAT' ? saved : 'ALIPAY'
}
// 当前账单类型,初始值取上次选择(个人偏好)
const billType = ref<BillType>(loadBillTypePref())
// 各账单类型允许上传的文件扩展名
const allowedExtensions: Record<BillType, string[]> = {
ALIPAY: ['.csv'],
WECHAT: ['.xlsx'],
}
// 各账单类型的账单导出路径提示
const exportHints: Record<BillType, string> = {
ALIPAY: '导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。',
WECHAT: '导出路径:微信 App → 我 → 服务 → 钱包 → 账单 → 常见问题 → 下载账单 → 用于个人对账,选择 xlsx 格式。',
}
// 当前账单类型允许的扩展名,用于上传组件 accept 属性
const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).join(','))
// 当前账单类型的文件格式提示
const fileHint = computed(() => {
const label = billTypeOptions.find((o) => o.value === billType.value)?.label || ''
return `仅支持${label}导出的账单文件(${(allowedExtensions[billType.value] || []).join('、')})`
})
// 当前账单类型的导出路径提示
const exportHint = computed(() => exportHints[billType.value])
// 弹窗阶段:选择文件 -> 预览勾选 -> 导入结果
const stage = ref<'select' | 'preview' | 'result'>('select')
@@ -35,25 +74,83 @@ const importing = ref(false)
const rows = ref<ParsedBillRow[]>([])
const checkedRowNos = ref<number[]>([])
// 预览筛选条件
const filterKeyword = ref('')
const filterType = ref<string | null>(null)
const filterStatus = ref<string | null>(null)
const filterMinAmount = ref<number | null>(null)
const filterMaxAmount = ref<number | null>(null)
// 类型筛选选项
const typeFilterOptions = [
{ label: '收入', value: 'INCOME' },
{ label: '支出', value: 'EXPENSE' },
{ label: '不计入', value: 'NEUTRAL' },
]
// 状态筛选选项
const statusFilterOptions = [
{ label: '可导入', value: 'importable' },
{ label: '已存在', value: 'duplicate' },
{ label: '不可导入', value: 'error' },
]
/** 重置预览筛选条件 */
function resetFilters() {
filterKeyword.value = ''
filterType.value = null
filterStatus.value = null
filterMinAmount.value = null
filterMaxAmount.value = null
}
// 默认分类
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 allTags = ref<{ label: string; value: number }[]>([])
const importTagIds = ref<number[]>([])
// 导入结果
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 filteredRows = computed(() => {
const kw = filterKeyword.value.trim().toLowerCase()
return rows.value.filter((r) => {
if (kw) {
const text = `${r.counterparty || ''} ${r.product || ''} ${r.externalNo || ''}`.toLowerCase()
if (!text.includes(kw)) return false
}
if (filterType.value === 'NEUTRAL') {
if (r.includeInStats) return false
} else if (filterType.value && r.type !== filterType.value) {
return false
}
if (filterStatus.value === 'importable' && !r.importable) return false
if (filterStatus.value === 'duplicate' && !r.duplicate) return false
if (filterStatus.value === 'error' && !r.error) return false
if (filterMinAmount.value != null && Number(r.amount) < filterMinAmount.value) return false
if (filterMaxAmount.value != null && Number(r.amount) > filterMaxAmount.value) return false
return true
})
})
/** 可导入的行(受筛选影响) */
const selectableRows = computed(() => filteredRows.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 someChecked = computed(() => {
const checkedVisible = selectableRows.value.filter((r) => checkedRowNos.value.includes(r.rowNo))
return checkedVisible.length > 0 && !allChecked.value
})
/** 表格列定义 */
const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
@@ -136,10 +233,34 @@ async function loadCategories() {
}
}
/** 文件选择变化(支持点击或拖拽) */
/** 加载标签选项 */
async function loadTags() {
try {
const res: any = await getTagList()
allTags.value = (res.data || []).map((t: any) => ({ label: t.name, value: t.id }))
} catch (e: any) {
message.error(e.message || '加载标签失败')
}
}
/** 判断文件名是否符合当前账单类型允许的扩展名 */
function isAllowedFile(name: string) {
const exts = allowedExtensions[billType.value] || []
const lower = name.toLowerCase()
return exts.length === 0 || exts.some((ext) => lower.endsWith(ext))
}
/** 文件选择变化(支持点击或拖拽),按账单类型校验扩展名,非法文件直接拒绝 */
function handleFileChange(options: { fileList: UploadFileInfo[] }) {
uploadFileList.value = options.fileList
const last = options.fileList[options.fileList.length - 1]
if (last?.file && !isAllowedFile(last.name)) {
const label = billTypeOptions.find((o) => o.value === billType.value)?.label || billType.value
message.error(`账单类型「${label}」仅支持 ${allowedExtensions[billType.value].join('、')} 格式的文件`)
uploadFileList.value = []
file.value = null
return
}
uploadFileList.value = options.fileList
file.value = last?.file ?? null
}
@@ -155,6 +276,7 @@ async function handleParse() {
rows.value = res.data.rows || []
// 默认勾选解析器建议勾选的行
checkedRowNos.value = rows.value.filter((r) => r.selected).map((r) => r.rowNo)
resetFilters()
stage.value = 'preview'
} catch (e: any) {
message.error(e.message || '解析失败')
@@ -193,6 +315,7 @@ async function handleConfirm() {
counterparty: r.counterparty,
product: r.product,
includeInStats: r.includeInStats,
rawData: r.rawData,
}))
importing.value = true
try {
@@ -200,6 +323,7 @@ async function handleConfirm() {
billType: billType.value,
expenseCategoryId: expenseCategoryId.value,
incomeCategoryId: incomeCategoryId.value,
tagIds: importTagIds.value,
items,
})
result.value = res.data
@@ -222,6 +346,8 @@ function reset() {
result.value = null
expenseCategoryId.value = null
incomeCategoryId.value = null
importTagIds.value = []
resetFilters()
}
/** 关闭弹窗 */
@@ -229,6 +355,13 @@ function handleClose() {
emit('update:show', false)
}
// 切换账单类型时清空已选文件、记录偏好,避免文件格式与类型不匹配
watch(billType, (value) => {
uploadFileList.value = []
file.value = null
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
})
// 打开弹窗时重置并加载分类
watch(
() => props.show,
@@ -236,6 +369,7 @@ watch(
if (visible) {
reset()
loadCategories()
loadTags()
}
},
)
@@ -264,7 +398,7 @@ watch(
:file-list="uploadFileList"
:default-upload="false"
:max="1"
accept=".csv"
:accept="acceptTypes"
style="margin-top: 8px;"
@change="handleFileChange"
>
@@ -275,7 +409,7 @@ watch(
</NIcon>
<div style="font-size: 15px; margin-top: 8px;">点击或拖拽文件到此处</div>
<div style="font-size: 12px; color: #999; margin-top: 4px;">
仅支持支付宝导出的交易记录明细 .csv 文件
{{ fileHint }}
</div>
</div>
</NUploadDragger>
@@ -283,7 +417,7 @@ watch(
</div>
<NAlert type="info" :show-icon="true">
导出路径:支付宝 App → 我的 → 账单 → 开具交易流水证明 / 账单导出,选择 CSV 格式。
{{ exportHint }}
</NAlert>
<NSpace justify="end">
@@ -315,12 +449,42 @@ watch(
placeholder="未选则不计分类"
style="width: 180px;"
/>
<NText depth="3">共 {{ rows.length }} 条,已勾选 {{ checkedRows.length }} 条</NText>
<NText>默认标签(可不选):</NText>
<NSelect
v-model:value="importTagIds"
:options="allTags"
multiple
clearable
filterable
:max-tag-count="2"
placeholder="未选则不打标签"
style="width: 200px;"
/>
<NCheckbox
:checked="allChecked"
:indeterminate="someChecked"
:disabled="selectableRows.length === 0"
@update:checked="toggleAll"
>
全选
</NCheckbox>
<NText depth="3">共 {{ rows.length }} 条,筛选后 {{ filteredRows.length }} 条,已勾选 {{ checkedRows.length }} 条</NText>
</NSpace>
<!-- 筛选条件 -->
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NInput v-model:value="filterKeyword" clearable placeholder="搜索交易对方/商品/单号" style="width: 220px;" />
<NSelect v-model:value="filterType" :options="typeFilterOptions" clearable placeholder="类型" style="width: 120px;" />
<NSelect v-model:value="filterStatus" :options="statusFilterOptions" clearable placeholder="状态" style="width: 130px;" />
<NInputNumber v-model:value="filterMinAmount" :min="0" :precision="2" clearable placeholder="最小金额" style="width: 120px;" />
<span style="color: #999;">~</span>
<NInputNumber v-model:value="filterMaxAmount" :min="0" :precision="2" clearable placeholder="最大金额" style="width: 120px;" />
<NButton size="small" @click="resetFilters">重置</NButton>
</NSpace>
<NDataTable
:columns="columns"
:data="rows"
:data="filteredRows"
:max-height="420"
:row-key="(row) => row.rowNo"
size="small"