Files
ledgerly/frontend/src/views/BillImportModal.vue
T
rosercode 65c0d8c550 feat: 新增标签体系、微信账单导入与收支记录体验优化
- 标签:新增 t_tag / t_transaction_tag 表与标签 CRUD,支持交易打标签、按标签筛选、批量添加/移除标签、导入账单默认标签,新增标签管理页
- 账单导入:新增微信支付 xlsx 解析器(WechatBillParser),支持支付宝 / 微信按类型导入;解析行保存原始完整数据(raw_data)并可在列表查看
- 收支记录:排序移至顶部并改为服务端排序(交易时间 / 金额,记忆偏好);筛选条件收纳为「更多筛选」弹层;分类筛选显示图标;一键设置支持「计入统计」;导入预览支持关键字 / 类型 / 状态 / 金额筛选
- 数据库:init.sql 新增 raw_data 字段与标签表,新增 init_tags.sql 默认标签种子
- 其他:更新 README 测试账号
2026-09-17 16:47:26 +08:00

521 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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, 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<{
(e: 'update:show', value: boolean): void
(e: 'imported'): void
}>()
const message = useMessage()
// 账单类型偏好存储键
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')
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 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 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(() => {
const checkedVisible = selectableRows.value.filter((r) => checkedRowNos.value.includes(r.rowNo))
return checkedVisible.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 || '加载分类失败')
}
}
/** 加载标签选项 */
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[] }) {
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
}
/** 解析账单 */
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)
resetFilters()
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,
rawData: r.rawData,
}))
importing.value = true
try {
const res: any = await confirmImport({
billType: billType.value,
expenseCategoryId: expenseCategoryId.value,
incomeCategoryId: incomeCategoryId.value,
tagIds: importTagIds.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
importTagIds.value = []
resetFilters()
}
/** 关闭弹窗 */
function handleClose() {
emit('update:show', false)
}
// 切换账单类型时清空已选文件、记录偏好,避免文件格式与类型不匹配
watch(billType, (value) => {
uploadFileList.value = []
file.value = null
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
})
// 打开弹窗时重置并加载分类
watch(
() => props.show,
(visible) => {
if (visible) {
reset()
loadCategories()
loadTags()
}
},
)
</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="acceptTypes"
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;">
{{ fileHint }}
</div>
</div>
</NUploadDragger>
</NUpload>
</div>
<NAlert type="info" :show-icon="true">
{{ exportHint }}
</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>默认标签(可不选):</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="filteredRows"
: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>