- 标签:新增 t_tag / t_transaction_tag 表与标签 CRUD,支持交易打标签、按标签筛选、批量添加/移除标签、导入账单默认标签,新增标签管理页 - 账单导入:新增微信支付 xlsx 解析器(WechatBillParser),支持支付宝 / 微信按类型导入;解析行保存原始完整数据(raw_data)并可在列表查看 - 收支记录:排序移至顶部并改为服务端排序(交易时间 / 金额,记忆偏好);筛选条件收纳为「更多筛选」弹层;分类筛选显示图标;一键设置支持「计入统计」;导入预览支持关键字 / 类型 / 状态 / 金额筛选 - 数据库:init.sql 新增 raw_data 字段与标签表,新增 init_tags.sql 默认标签种子 - 其他:更新 README 测试账号
521 lines
18 KiB
Vue
521 lines
18 KiB
Vue
<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>
|