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:
Generated
-1432
File diff suppressed because it is too large
Load Diff
@@ -15,3 +15,26 @@ export function getCategoryStatistics(params: { year: number; month: number }) {
|
||||
export function getTrendStatistics(params: { year: number; month: number }) {
|
||||
return request.get('/statistics/trend', { params })
|
||||
}
|
||||
|
||||
/** 统计过滤条件,供概览与分组统计复用 */
|
||||
export interface StatisticsFilterParams {
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
types?: string[]
|
||||
categoryIds?: number[]
|
||||
uncategorized?: boolean
|
||||
paymentMethods?: string[]
|
||||
minAmount?: number
|
||||
maxAmount?: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
/** 获取指定时间范围的统计概览(收支汇总、日均、最大单笔、平均每笔) */
|
||||
export function getStatisticsSummary(params: StatisticsFilterParams) {
|
||||
return request.get('/statistics/summary', { params })
|
||||
}
|
||||
|
||||
/** 按维度获取分组统计数据(dimension:day/week/month/quarter/year/paymentMethod/category/type) */
|
||||
export function getStatisticsGroup(params: StatisticsFilterParams & { dimension: string }) {
|
||||
return request.get('/statistics/group', { params })
|
||||
}
|
||||
|
||||
@@ -3,16 +3,20 @@ import request from './request'
|
||||
import axios from 'axios'
|
||||
import { useUserStore } from '../store/user'
|
||||
|
||||
/** 分页获取交易记录列表,支持按类型、分类、支付方式、时间范围、计入统计多选筛选 */
|
||||
/** 分页获取交易记录列表,支持按类型、分类、支付方式、时间范围、计入统计、金额区间多选筛选 */
|
||||
export function getTransactionPage(params: {
|
||||
page?: number
|
||||
size?: number
|
||||
types?: string[]
|
||||
categoryIds?: number[]
|
||||
uncategorized?: boolean
|
||||
paymentMethods?: string[]
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
includeInStats?: boolean
|
||||
minAmount?: number
|
||||
maxAmount?: number
|
||||
remark?: string
|
||||
}) {
|
||||
return request.get('/transaction/page', { params })
|
||||
}
|
||||
@@ -53,14 +57,82 @@ export function toggleTransactionStats(id: number, includeInStats: boolean) {
|
||||
return request.put(`/transaction/${id}/stats`, { includeInStats })
|
||||
}
|
||||
|
||||
/** 切换指定交易记录的备注隐藏标记 */
|
||||
export function toggleRemarkHidden(id: number, remarkHidden: boolean) {
|
||||
return request.put(`/transaction/${id}/remark-hidden`, { remarkHidden })
|
||||
}
|
||||
|
||||
/** 支持的账单类型,当前仅支付宝 */
|
||||
export type BillType = 'ALIPAY'
|
||||
|
||||
/** 账单解析结果行 */
|
||||
export interface ParsedBillRow {
|
||||
rowNo: number
|
||||
externalNo: string
|
||||
transactionTime: string
|
||||
type: string
|
||||
amount: number
|
||||
counterparty: string
|
||||
product: string
|
||||
tradeStatus: string
|
||||
direction: string
|
||||
includeInStats: boolean
|
||||
importable: boolean
|
||||
selected: boolean
|
||||
duplicate: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** 待导入账单行 */
|
||||
export interface BillImportItem {
|
||||
externalNo: string
|
||||
transactionTime: string
|
||||
type: string
|
||||
amount: number
|
||||
counterparty: string
|
||||
product: string
|
||||
includeInStats: boolean
|
||||
}
|
||||
|
||||
/** 解析账单文件(不落库),返回预览数据 */
|
||||
export function parseBill(file: File, billType: BillType) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('billType', billType)
|
||||
return request.post('/transaction/import/parse', formData)
|
||||
}
|
||||
|
||||
/** 确认导入用户勾选的账单行 */
|
||||
export function confirmImport(data: {
|
||||
billType: BillType
|
||||
expenseCategoryId?: number | null
|
||||
incomeCategoryId?: number | null
|
||||
items: BillImportItem[]
|
||||
}) {
|
||||
return request.post('/transaction/import/confirm', data)
|
||||
}
|
||||
|
||||
/** 批量设置交易记录的分类(按类型分别应用支出/收入分类) */
|
||||
export function batchUpdateCategory(data: {
|
||||
ids: number[]
|
||||
expenseCategoryId?: number | null
|
||||
incomeCategoryId?: number | null
|
||||
}) {
|
||||
return request.put('/transaction/batch-category', data)
|
||||
}
|
||||
|
||||
/** 导出交易记录为 Excel 文件,支持按条件筛选 */
|
||||
export function exportTransaction(params: {
|
||||
types?: string[]
|
||||
categoryIds?: number[]
|
||||
uncategorized?: boolean
|
||||
paymentMethods?: string[]
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
includeInStats?: boolean
|
||||
minAmount?: number
|
||||
maxAmount?: number
|
||||
remark?: string
|
||||
}) {
|
||||
const userStore = useUserStore()
|
||||
return axios.get('/api/transaction/export', {
|
||||
|
||||
@@ -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>
|
||||
@@ -1,34 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
// 统计面板组件:展示月度收支汇总、分类占比饼图、每日收支趋势柱状图
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect } from 'naive-ui'
|
||||
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与支付方式分布
|
||||
import { ref, computed, onMounted, watch, nextTick, h } from 'vue'
|
||||
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton } from 'naive-ui'
|
||||
import VChart from 'vue-echarts'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart, PieChart, BarChart } from 'echarts/charts'
|
||||
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
|
||||
import { getMonthlyStatistics, getCategoryStatistics, getTrendStatistics } from '../api/statistics'
|
||||
import { getStatisticsSummary, getStatisticsGroup } from '../api/statistics'
|
||||
import { getCategoryList } from '../api/category'
|
||||
|
||||
// 注册 ECharts 所需组件
|
||||
use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
|
||||
|
||||
const now = new Date()
|
||||
const selectedYear = ref(now.getFullYear()) // 选中的年份
|
||||
const selectedMonth = ref(now.getMonth() + 1) // 选中的月份
|
||||
// 时间范围快捷选项
|
||||
const rangeOptions = [
|
||||
{ label: '本月', value: 'month' },
|
||||
{ label: '近3月', value: '3month' },
|
||||
{ label: '近12月', value: '12month' },
|
||||
{ label: '自定义', value: 'custom' },
|
||||
]
|
||||
// 趋势图时间粒度选项
|
||||
const granularityOptions = [
|
||||
{ label: '按日', value: 'day' },
|
||||
{ label: '按周', value: 'week' },
|
||||
{ label: '按月', value: 'month' },
|
||||
{ label: '按季', value: 'quarter' },
|
||||
{ label: '按年', value: 'year' },
|
||||
]
|
||||
|
||||
// 月度汇总数据(总收入、总支出、结余、交易笔数)
|
||||
const monthly = ref({ totalIncome: 0, totalExpense: 0, balance: 0, transactionCount: 0 })
|
||||
const categoryData = ref<any[]>([]) // 分类统计数据
|
||||
const trendData = ref<any[]>([]) // 每日趋势数据
|
||||
const rangeType = ref<'month' | '3month' | '12month' | 'custom'>('month') // 时间范围类型
|
||||
const customRange = ref<[number, number] | null>(null) // 自定义起止时间戳
|
||||
const granularity = ref<'day' | 'week' | 'month' | 'quarter' | 'year'>('day') // 趋势粒度
|
||||
|
||||
// 年份和月份下拉选项
|
||||
const yearOptions = Array.from({ length: 10 }, (_, i) => ({ label: `${2020 + i}年`, value: 2020 + i }))
|
||||
const monthOptions = Array.from({ length: 12 }, (_, i) => ({ label: `${i + 1}月`, value: i + 1 }))
|
||||
// 过滤条件
|
||||
const filterType = ref<string[]>([]) // 类型(多选)
|
||||
const filterCategory = ref<number[]>([]) // 分类(多选,0 表示未分类)
|
||||
const filterPaymentMethod = ref<string[]>([]) // 支付方式(多选)
|
||||
const filterMinAmount = ref<number | null>(null) // 最小金额
|
||||
const filterMaxAmount = ref<number | null>(null) // 最大金额
|
||||
const filterRemark = ref('') // 备注关键字
|
||||
|
||||
const pieOption = ref({}) // 饼图配置
|
||||
const trendOption = ref({}) // 趋势图配置
|
||||
// 类型与支付方式选项
|
||||
const typeOptions = [
|
||||
{ label: '支出', value: 'EXPENSE' },
|
||||
{ label: '收入', value: 'INCOME' },
|
||||
]
|
||||
const paymentOptions = [
|
||||
{ label: '微信', value: 'WECHAT' },
|
||||
{ label: '支付宝', value: 'ALIPAY' },
|
||||
{ label: '现金', value: 'CASH' },
|
||||
{ label: '银行卡', value: 'BANK' },
|
||||
]
|
||||
|
||||
/** 更新支出分类饼图配置 */
|
||||
/** 分类选项数据结构 */
|
||||
interface CategoryOption {
|
||||
label: string
|
||||
value: number
|
||||
type: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const categories = ref<CategoryOption[]>([]) // 分类选项列表
|
||||
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
|
||||
const categoryFilterOptions = computed(() => [
|
||||
{ label: '未分类', value: 0, type: '' },
|
||||
...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
|
||||
])
|
||||
|
||||
/** 分类下拉项渲染:图标 + 名称 */
|
||||
function renderCategoryLabel(option: any) {
|
||||
return h('span', null, [
|
||||
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
|
||||
option.label,
|
||||
])
|
||||
}
|
||||
|
||||
/** 将分类筛选拆分为分类ID与未分类标记 */
|
||||
function buildCategoryFilter() {
|
||||
const ids = filterCategory.value.filter((v) => v !== 0)
|
||||
return {
|
||||
categoryIds: ids.length > 0 ? ids : undefined,
|
||||
uncategorized: filterCategory.value.includes(0) ? true : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** 统计接口的过滤条件 */
|
||||
const filterParams = computed(() => ({
|
||||
types: filterType.value.length > 0 ? filterType.value : undefined,
|
||||
...buildCategoryFilter(),
|
||||
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined,
|
||||
minAmount: filterMinAmount.value ?? undefined,
|
||||
maxAmount: filterMaxAmount.value ?? undefined,
|
||||
remark: filterRemark.value.trim() || undefined,
|
||||
}))
|
||||
|
||||
// 统计结果
|
||||
const summary = ref<any>({
|
||||
totalIncome: 0, totalExpense: 0, balance: 0, transactionCount: 0,
|
||||
dayCount: 0, avgDailyIncome: 0, avgDailyExpense: 0,
|
||||
maxExpenseAmount: 0, maxExpenseTime: null, maxExpenseRemark: '', avgExpenseAmount: 0,
|
||||
})
|
||||
const trendData = ref<any[]>([]) // 趋势分组数据
|
||||
const categoryData = ref<any[]>([]) // 分类分组数据
|
||||
const paymentData = ref<any[]>([]) // 支付方式分组数据
|
||||
|
||||
const pieOption = ref({}) // 支出分类饼图
|
||||
const paymentOption = ref({}) // 支付方式饼图
|
||||
const trendOption = ref({}) // 趋势柱状图
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
/** 格式化日期为查询用字符串 */
|
||||
function fmt(d: Date, end = false): string {
|
||||
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
return end ? `${date}T23:59:59` : `${date}T00:00:00`
|
||||
}
|
||||
|
||||
/** 根据快捷选项与自定义区间计算查询起止时间 */
|
||||
const range = computed(() => {
|
||||
if (rangeType.value === 'custom' && customRange.value) {
|
||||
return {
|
||||
startTime: fmt(new Date(customRange.value[0])),
|
||||
endTime: fmt(new Date(customRange.value[1]), true),
|
||||
}
|
||||
}
|
||||
const now = new Date()
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0) // 本月最后一天
|
||||
let start: Date
|
||||
if (rangeType.value === '12month') {
|
||||
start = new Date(now.getFullYear(), now.getMonth() - 11, 1)
|
||||
} else if (rangeType.value === '3month') {
|
||||
start = new Date(now.getFullYear(), now.getMonth() - 2, 1)
|
||||
} else {
|
||||
start = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
}
|
||||
return { startTime: fmt(start), endTime: fmt(end, true) }
|
||||
})
|
||||
|
||||
/** 计算 ISO 周标签,与后端 %x-W%v 对齐 */
|
||||
function isoWeekKey(d: Date): string {
|
||||
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
|
||||
const dayNum = date.getUTCDay() || 7
|
||||
date.setUTCDate(date.getUTCDate() + 4 - dayNum)
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
|
||||
const weekNo = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
|
||||
return `${date.getUTCFullYear()}-W${pad(weekNo)}`
|
||||
}
|
||||
|
||||
/** 按粒度生成区间内完整的分组键序列(用于补全无数据的点) */
|
||||
function buildKeys(start: Date, end: Date, gran: string): string[] {
|
||||
const keys: string[] = []
|
||||
if (gran === 'day') {
|
||||
for (const d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
keys.push(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`)
|
||||
}
|
||||
} else if (gran === 'week') {
|
||||
const seen = new Set<string>()
|
||||
for (const d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
const k = isoWeekKey(d)
|
||||
if (!seen.has(k)) { seen.add(k); keys.push(k) }
|
||||
}
|
||||
} else if (gran === 'month') {
|
||||
for (const d = new Date(start.getFullYear(), start.getMonth(), 1); d <= end; d.setMonth(d.getMonth() + 1)) {
|
||||
keys.push(`${d.getFullYear()}-${pad(d.getMonth() + 1)}`)
|
||||
}
|
||||
} else if (gran === 'quarter') {
|
||||
const seen = new Set<string>()
|
||||
for (const d = new Date(start.getFullYear(), start.getMonth(), 1); d <= end; d.setMonth(d.getMonth() + 1)) {
|
||||
const k = `${d.getFullYear()}-Q${Math.floor(d.getMonth() / 3) + 1}`
|
||||
if (!seen.has(k)) { seen.add(k); keys.push(k) }
|
||||
}
|
||||
} else {
|
||||
for (let y = start.getFullYear(); y <= end.getFullYear(); y++) keys.push(String(y))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/** 更新支出分类饼图 */
|
||||
function updatePieChart() {
|
||||
pieOption.value = {
|
||||
title: { text: '支出分类占比', left: 'center' },
|
||||
@@ -36,98 +185,308 @@ function updatePieChart() {
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'], // 环形图内外半径
|
||||
radius: ['40%', '70%'],
|
||||
data: categoryData.value
|
||||
.filter((c: any) => c.type === 'EXPENSE') // 只展示支出类
|
||||
.map((c: any) => ({ name: c.categoryName, value: c.amount })),
|
||||
.filter((c: any) => c.type === 'EXPENSE')
|
||||
.map((c: any) => ({ name: c.name, value: Number(c.total) })),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新每日收支趋势柱状图配置 */
|
||||
/** 更新支付方式分布饼图 */
|
||||
function updatePaymentChart() {
|
||||
paymentOption.value = {
|
||||
title: { text: '支付方式分布', left: 'center' },
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
data: paymentData.value.map((p: any) => ({ name: p.name, value: Number(p.total) })),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新每日/周/月收支趋势柱状图(补全缺失点) */
|
||||
function updateTrendChart() {
|
||||
const { startTime, endTime } = range.value
|
||||
const start = new Date(startTime.replace('T', ' ').replace(/-/g, '/'))
|
||||
const end = new Date(endTime.replace('T', ' ').replace(/-/g, '/'))
|
||||
const keys = buildKeys(start, end, granularity.value)
|
||||
const map = new Map<string, any>(trendData.value.map((d: any) => [d.key, d]))
|
||||
const income = keys.map((k) => Number(map.get(k)?.income ?? 0))
|
||||
const expense = keys.map((k) => Number(map.get(k)?.expense ?? 0))
|
||||
const labels = keys.map((k) => (granularity.value === 'day' ? k.substring(5) : k))
|
||||
|
||||
trendOption.value = {
|
||||
title: { text: '每日收支趋势' },
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['收入', '支出'], bottom: 0 },
|
||||
grid: { left: 60, right: 20, top: 40, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: trendData.value.map((d: any) => d.date.substring(5)), // 只显示 MM-DD
|
||||
},
|
||||
xAxis: { type: 'category', data: labels },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: '收入', type: 'bar', data: trendData.value.map((d: any) => d.income), color: '#18a058' },
|
||||
{ name: '支出', type: 'bar', data: trendData.value.map((d: any) => d.expense), color: '#d03050' },
|
||||
{ name: '收入', type: 'bar', data: income, color: '#18a058' },
|
||||
{ name: '支出', type: 'bar', data: expense, color: '#d03050' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 并行请求所有统计数据并更新图表 */
|
||||
async function fetchData() {
|
||||
const params = { year: selectedYear.value, month: selectedMonth.value }
|
||||
const { startTime, endTime } = range.value
|
||||
const filters = filterParams.value
|
||||
try {
|
||||
const [monthlyRes, categoryRes, trendRes]: any[] = await Promise.all([
|
||||
getMonthlyStatistics(params),
|
||||
getCategoryStatistics(params),
|
||||
getTrendStatistics(params),
|
||||
const [summaryRes, trendRes, categoryRes, paymentRes]: any[] = await Promise.all([
|
||||
getStatisticsSummary({ startTime, endTime, ...filters }),
|
||||
getStatisticsGroup({ dimension: granularity.value, startTime, endTime, ...filters }),
|
||||
getStatisticsGroup({ dimension: 'category', startTime, endTime, ...filters }),
|
||||
getStatisticsGroup({ dimension: 'paymentMethod', startTime, endTime, ...filters }),
|
||||
])
|
||||
monthly.value = monthlyRes.data
|
||||
categoryData.value = categoryRes.data
|
||||
summary.value = summaryRes.data
|
||||
trendData.value = trendRes.data
|
||||
categoryData.value = categoryRes.data
|
||||
paymentData.value = paymentRes.data
|
||||
updatePieChart()
|
||||
updatePaymentChart()
|
||||
updateTrendChart()
|
||||
} catch (e: any) {
|
||||
console.error('加载统计数据失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听年月变化,自动重新加载数据
|
||||
watch([selectedYear, selectedMonth], fetchData)
|
||||
onMounted(fetchData) // 组件挂载时加载数据
|
||||
/** 面板偏好存储键 */
|
||||
const DASHBOARD_PREFS_KEY = 'finance.dashboard.prefs'
|
||||
let suppressRangeWatch = false // 还原偏好时跳过 rangeType 对粒度的默认覆盖
|
||||
|
||||
/** 读取面板偏好并还原(时间范围、粒度、过滤条件) */
|
||||
function loadPrefs() {
|
||||
try {
|
||||
const raw = localStorage.getItem(DASHBOARD_PREFS_KEY)
|
||||
if (!raw) return
|
||||
const p = JSON.parse(raw)
|
||||
suppressRangeWatch = true
|
||||
if (['month', '3month', '12month', 'custom'].includes(p.rangeType)) rangeType.value = p.rangeType
|
||||
if (Array.isArray(p.customRange) && p.customRange.length === 2) customRange.value = p.customRange
|
||||
if (Array.isArray(p.filterType)) filterType.value = p.filterType
|
||||
if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory
|
||||
if (Array.isArray(p.filterPaymentMethod)) filterPaymentMethod.value = p.filterPaymentMethod
|
||||
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
|
||||
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
|
||||
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
|
||||
if (['day', 'week', 'month', 'quarter', 'year'].includes(p.granularity)) granularity.value = p.granularity
|
||||
} catch (e) {
|
||||
// 忽略损坏的偏好数据
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存面板偏好,下次进入自动还原 */
|
||||
function savePrefs() {
|
||||
try {
|
||||
localStorage.setItem(DASHBOARD_PREFS_KEY, JSON.stringify({
|
||||
rangeType: rangeType.value,
|
||||
customRange: customRange.value,
|
||||
granularity: granularity.value,
|
||||
filterType: filterType.value,
|
||||
filterCategory: filterCategory.value,
|
||||
filterPaymentMethod: filterPaymentMethod.value,
|
||||
filterMinAmount: filterMinAmount.value,
|
||||
filterMaxAmount: filterMaxAmount.value,
|
||||
filterRemark: filterRemark.value,
|
||||
}))
|
||||
} catch (e) {
|
||||
// 忽略写入失败
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载分类选项 */
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res: any = await getCategoryList()
|
||||
categories.value = res.data.map((c: any) => ({ label: c.name, value: c.id, type: c.type, icon: c.icon || '' }))
|
||||
} catch (e) {
|
||||
// 静默处理
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置所有过滤条件(保留时间范围与粒度) */
|
||||
function handleResetFilters() {
|
||||
filterType.value = []
|
||||
filterCategory.value = []
|
||||
filterPaymentMethod.value = []
|
||||
filterMinAmount.value = null
|
||||
filterMaxAmount.value = null
|
||||
filterRemark.value = ''
|
||||
}
|
||||
|
||||
// 切换时间范围时给出默认粒度(还原偏好时不覆盖)
|
||||
watch(rangeType, (v) => {
|
||||
if (suppressRangeWatch) {
|
||||
suppressRangeWatch = false
|
||||
return
|
||||
}
|
||||
granularity.value = v === '12month' ? 'month' : v === '3month' ? 'week' : 'day'
|
||||
})
|
||||
// 范围、粒度或过滤条件变化时重新加载
|
||||
watch([() => range.value.startTime, () => range.value.endTime, granularity, filterParams], fetchData)
|
||||
// 任一偏好变化时自动保存
|
||||
watch([rangeType, customRange, granularity, filterType, filterCategory, filterPaymentMethod, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
|
||||
|
||||
onMounted(async () => {
|
||||
loadPrefs()
|
||||
fetchCategories()
|
||||
await nextTick()
|
||||
suppressRangeWatch = false
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 顶部:标题和年月选择器 -->
|
||||
<NSpace justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<!-- 顶部:标题与时间范围选择 -->
|
||||
<NSpace justify="space-between" align="center" style="margin-bottom: 20px;" :wrap="true">
|
||||
<h2 style="margin: 0;">统计面板</h2>
|
||||
<NSpace>
|
||||
<NSelect v-model:value="selectedYear" :options="yearOptions" style="width: 100px;" />
|
||||
<NSelect v-model:value="selectedMonth" :options="monthOptions" style="width: 80px;" />
|
||||
<NSpace align="center" :wrap="true">
|
||||
<NSelect v-model:value="rangeType" :options="rangeOptions" style="width: 110px;" />
|
||||
<NDatePicker
|
||||
v-if="rangeType === 'custom'"
|
||||
v-model:value="customRange"
|
||||
type="daterange"
|
||||
clearable
|
||||
style="width: 260px;"
|
||||
/>
|
||||
<span style="color: #999; font-size: 12px;">{{ range.startTime.substring(0, 10) }} ~ {{ range.endTime.substring(0, 10) }}</span>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<!-- 统计卡片:总收入、总支出、结余、交易笔数 -->
|
||||
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 20px;">
|
||||
<!-- 过滤条件(偏好自动记忆) -->
|
||||
<NCard :bordered="false" style="margin-bottom: 16px;">
|
||||
<NSpace align="center" :wrap="true" :size="[12, 12]">
|
||||
<NSelect
|
||||
v-model:value="filterType"
|
||||
:options="typeOptions"
|
||||
multiple
|
||||
clearable
|
||||
:max-tag-count="2"
|
||||
placeholder="类型"
|
||||
style="width: 160px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterCategory"
|
||||
:options="categoryFilterOptions"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:max-tag-count="2"
|
||||
:render-label="renderCategoryLabel"
|
||||
placeholder="分类"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterPaymentMethod"
|
||||
:options="paymentOptions"
|
||||
multiple
|
||||
clearable
|
||||
:max-tag-count="2"
|
||||
placeholder="支付方式"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NInputNumber
|
||||
v-model:value="filterMinAmount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
clearable
|
||||
placeholder="最小金额"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<span style="color: #999;">~</span>
|
||||
<NInputNumber
|
||||
v-model:value="filterMaxAmount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
clearable
|
||||
placeholder="最大金额"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="filterRemark"
|
||||
clearable
|
||||
placeholder="备注搜索"
|
||||
style="width: 180px;"
|
||||
/>
|
||||
<NButton @click="handleResetFilters">重置筛选</NButton>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<!-- 汇总卡片 -->
|
||||
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 16px;">
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<NStatistic label="总收入" :value="Number(monthly.totalIncome).toFixed(2)" style="color: #18a058;">
|
||||
<NStatistic label="总收入" :value="Number(summary.totalIncome).toFixed(2)" style="color: #18a058;">
|
||||
<template #prefix>+</template>
|
||||
</NStatistic>
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<NStatistic label="总支出" :value="Number(monthly.totalExpense).toFixed(2)" style="color: #d03050;">
|
||||
<NStatistic label="总支出" :value="Number(summary.totalExpense).toFixed(2)" style="color: #d03050;">
|
||||
<template #prefix>-</template>
|
||||
</NStatistic>
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<NStatistic label="结余" :value="Number(monthly.balance).toFixed(2)" />
|
||||
<NStatistic label="结余" :value="Number(summary.balance).toFixed(2)" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<NStatistic label="交易笔数" :value="monthly.transactionCount" />
|
||||
<NStatistic label="交易笔数" :value="summary.transactionCount" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
<!-- 图表区域:饼图 + 趋势柱状图 -->
|
||||
<!-- 概览指标 -->
|
||||
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 20px;">
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<NStatistic label="日均收入" :value="Number(summary.avgDailyIncome).toFixed(2)" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<NStatistic label="日均支出" :value="Number(summary.avgDailyExpense).toFixed(2)" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<NStatistic label="最大单笔支出" :value="Number(summary.maxExpenseAmount).toFixed(2)">
|
||||
<template #suffix>
|
||||
<span style="font-size: 12px; color: #999;">{{ (summary.maxExpenseRemark || '').slice(0, 12) }}</span>
|
||||
</template>
|
||||
</NStatistic>
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<NStatistic label="平均每笔支出" :value="Number(summary.avgExpenseAmount).toFixed(2)" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
<!-- 趋势图 -->
|
||||
<NCard style="margin-bottom: 16px;">
|
||||
<template #header>
|
||||
<NSpace justify="space-between" align="center">
|
||||
<span>收支趋势</span>
|
||||
<NSelect v-model:value="granularity" :options="granularityOptions" style="width: 110px;" size="small" />
|
||||
</NSpace>
|
||||
</template>
|
||||
<VChart :option="trendOption" style="height: 350px;" autoresize />
|
||||
</NCard>
|
||||
|
||||
<!-- 饼图区域:支出分类 + 支付方式 -->
|
||||
<NGrid :cols="2" :x-gap="16" :y-gap="16">
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
@@ -136,7 +495,7 @@ onMounted(fetchData) // 组件挂载时加载数据
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="trendOption" style="height: 350px;" autoresize />
|
||||
<VChart :option="paymentOption" style="height: 350px;" autoresize />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选
|
||||
import { ref, onMounted, h } from 'vue'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch } from 'naive-ui'
|
||||
import { ref, onMounted, computed, h, watch } from 'vue'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon } from 'naive-ui'
|
||||
import { EyeOutline, EyeOffOutline } from '@vicons/ionicons5'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats } from '../api/transaction'
|
||||
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory } from '../api/transaction'
|
||||
import { getCategoryList } from '../api/category'
|
||||
import BillImportModal from './BillImportModal.vue'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
const dialog = useDialog() // 对话框
|
||||
@@ -19,6 +21,7 @@ interface TransactionItem {
|
||||
amount: number
|
||||
paymentMethod: string
|
||||
remark: string
|
||||
remarkHidden: boolean
|
||||
transactionTime: string
|
||||
includeInStats: boolean
|
||||
createdAt: string
|
||||
@@ -29,6 +32,7 @@ interface CategoryOption {
|
||||
label: string
|
||||
value: number
|
||||
type: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
// 列表数据和分页状态
|
||||
@@ -36,10 +40,29 @@ const transactions = ref<TransactionItem[]>([]) // 交易记录列表
|
||||
const total = ref(0) // 总记录数
|
||||
const page = ref(1) // 当前页码
|
||||
const pageSize = ref(20) // 每页条数
|
||||
const pageSizes = [10, 20, 50, 100, 200, 500] // 可选的每页条数
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) // 总页数
|
||||
// 当前页金额汇总:支出、收入与结余(含本页所有展示记录)
|
||||
const pageSummary = computed(() => {
|
||||
let income = 0
|
||||
let expense = 0
|
||||
for (const t of transactions.value) {
|
||||
const amount = Number(t.amount) || 0
|
||||
if (t.type === 'INCOME') income += amount
|
||||
else expense += amount
|
||||
}
|
||||
return { income, expense, balance: income - expense }
|
||||
})
|
||||
const loading = ref(false) // 列表加载状态
|
||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||
const showImportModal = ref(false) // 账单导入弹窗显示状态
|
||||
const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
|
||||
const batchExpenseCategoryId = ref<number | null>(null) // 批量设置的支出分类
|
||||
const batchIncomeCategoryId = ref<number | null>(null) // 批量设置的收入分类
|
||||
const batching = ref(false) // 批量设置提交状态
|
||||
const editingId = ref<number | null>(null) // 当前编辑的记录 ID(null 表示新增)
|
||||
const categories = ref<CategoryOption[]>([]) // 分类选项列表
|
||||
const revealedRemarkIds = ref<number[]>([]) // 临时展开查看原文的备注行 ID(不持久,刷新/翻页后重置)
|
||||
|
||||
// 表单数据(新增/编辑共用)
|
||||
const form = ref({
|
||||
@@ -72,6 +95,24 @@ const filterCategory = ref<number[]>([]) // 分类筛选(多选)
|
||||
const filterPaymentMethod = ref<string[]>([]) // 支付方式筛选(多选)
|
||||
const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选(起止时间戳)
|
||||
const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:null-全部,'true'-计入,'false'-不计入
|
||||
const filterRemark = ref('') // 备注关键字搜索
|
||||
const filterMinAmount = ref<number | null>(null) // 最小金额筛选
|
||||
const filterMaxAmount = ref<number | null>(null) // 最大金额筛选
|
||||
|
||||
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
|
||||
const categoryFilterOptions = computed(() => [
|
||||
{ label: '未分类', value: 0, type: '' },
|
||||
...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
|
||||
])
|
||||
|
||||
/** 将分类筛选拆分为分类ID与未分类标记,供查询与导出复用 */
|
||||
function buildCategoryFilter() {
|
||||
const ids = filterCategory.value.filter((v) => v !== 0)
|
||||
return {
|
||||
categoryIds: ids.length > 0 ? ids : undefined,
|
||||
uncategorized: filterCategory.value.includes(0) ? true : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 计入统计筛选下拉选项
|
||||
const includeInStatsOptions = [
|
||||
@@ -79,6 +120,55 @@ const includeInStatsOptions = [
|
||||
{ label: '不计入', value: 'false' },
|
||||
]
|
||||
|
||||
/** 筛选条件偏好存储键 */
|
||||
const FILTER_PREFS_KEY = 'finance.transaction.filterPrefs'
|
||||
|
||||
/** 从 localStorage 读取上次的筛选条件偏好并还原 */
|
||||
function loadFilterPrefs() {
|
||||
try {
|
||||
const raw = localStorage.getItem(FILTER_PREFS_KEY)
|
||||
if (!raw) return
|
||||
const p = JSON.parse(raw)
|
||||
if (Array.isArray(p.filterType)) filterType.value = p.filterType
|
||||
if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory
|
||||
if (Array.isArray(p.filterPaymentMethod)) filterPaymentMethod.value = p.filterPaymentMethod
|
||||
if (Array.isArray(p.filterDateRange) && p.filterDateRange.length === 2) filterDateRange.value = p.filterDateRange
|
||||
if (p.filterIncludeInStats === 'true' || p.filterIncludeInStats === 'false') filterIncludeInStats.value = p.filterIncludeInStats
|
||||
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
|
||||
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
|
||||
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
|
||||
if (typeof p.pageSize === 'number' && pageSizes.includes(p.pageSize)) pageSize.value = p.pageSize
|
||||
} catch (e) {
|
||||
// 忽略损坏的偏好数据
|
||||
}
|
||||
}
|
||||
|
||||
/** 将当前筛选条件写入 localStorage,下次进入自动还原 */
|
||||
function saveFilterPrefs() {
|
||||
try {
|
||||
localStorage.setItem(FILTER_PREFS_KEY, JSON.stringify({
|
||||
filterType: filterType.value,
|
||||
filterCategory: filterCategory.value,
|
||||
filterPaymentMethod: filterPaymentMethod.value,
|
||||
filterDateRange: filterDateRange.value,
|
||||
filterIncludeInStats: filterIncludeInStats.value,
|
||||
filterRemark: filterRemark.value,
|
||||
filterMinAmount: filterMinAmount.value,
|
||||
filterMaxAmount: filterMaxAmount.value,
|
||||
pageSize: pageSize.value,
|
||||
}))
|
||||
} catch (e) {
|
||||
// 忽略写入失败
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选条件变化时自动保存用户偏好
|
||||
watch(
|
||||
[filterType, filterCategory, filterPaymentMethod, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize],
|
||||
saveFilterPrefs,
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
/** 将时间戳格式化为 ISO 格式的日期时间字符串(YYYY-MM-DDTHH:mm:ss) */
|
||||
function formatTimestamp(ts: number, isEnd = false): string {
|
||||
const d = new Date(ts)
|
||||
@@ -94,9 +184,64 @@ function getPaymentLabel(val: string) {
|
||||
return map[val] || val || '-'
|
||||
}
|
||||
|
||||
/** 分类下拉项渲染:图标 + 名称(下拉选项与已选项均生效) */
|
||||
function renderCategoryLabel(option: any) {
|
||||
return h('span', null, [
|
||||
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
|
||||
option.label,
|
||||
])
|
||||
}
|
||||
|
||||
/** 渲染备注单元格内的小图标按钮 */
|
||||
function renderRemarkAction(label: string, icon: any, onClick: () => void) {
|
||||
return h(NButton, {
|
||||
size: 'tiny',
|
||||
quaternary: true,
|
||||
style: { marginLeft: '6px', flexShrink: 0 },
|
||||
onClick,
|
||||
}, {
|
||||
icon: () => h(NIcon, { size: 14 }, { default: () => h(icon) }),
|
||||
default: () => label,
|
||||
})
|
||||
}
|
||||
|
||||
/** 临时展开某行的备注原文(仅本格,不改变隐藏标记) */
|
||||
function revealRemark(id: number) {
|
||||
if (!revealedRemarkIds.value.includes(id)) {
|
||||
revealedRemarkIds.value = [...revealedRemarkIds.value, id]
|
||||
}
|
||||
}
|
||||
|
||||
/** 收起某行临时展开的备注 */
|
||||
function concealRemark(id: number) {
|
||||
revealedRemarkIds.value = revealedRemarkIds.value.filter((v) => v !== id)
|
||||
}
|
||||
|
||||
/** 持久切换某行备注的隐藏标记 */
|
||||
async function setRemarkHidden(row: TransactionItem, hidden: boolean) {
|
||||
try {
|
||||
await toggleRemarkHidden(row.id, hidden)
|
||||
row.remarkHidden = hidden
|
||||
concealRemark(row.id)
|
||||
message.success(hidden ? '已隐藏备注' : '已取消隐藏')
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建表格列配置 */
|
||||
function createColumns(): DataTableColumns<TransactionItem> {
|
||||
return [
|
||||
{
|
||||
title: '交易时间', key: 'transactionTime', width: 170,
|
||||
sorter: (a, b) => (a.transactionTime || '').localeCompare(b.transactionTime || ''),
|
||||
defaultSortOrder: 'descend',
|
||||
render(row) {
|
||||
const time = row.transactionTime?.replace('T', ' ').substring(0, 16) || '-'
|
||||
const created = row.createdAt?.replace('T', ' ').substring(0, 16)
|
||||
return h('span', { title: `创建时间:${created || '-'}` }, time)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '类型', key: 'type', width: 90,
|
||||
render(row) {
|
||||
@@ -106,9 +251,16 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
||||
return h(NTag, { type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' }, { default: () => row.type === 'INCOME' ? '收入' : '支出' })
|
||||
},
|
||||
},
|
||||
{ title: '分类', key: 'categoryName', width: 100 },
|
||||
{
|
||||
title: '分类', key: 'categoryName', width: 120,
|
||||
render(row) {
|
||||
const name = row.categoryName || '未分类'
|
||||
return row.categoryIcon ? `${row.categoryIcon} ${name}` : name
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '金额', key: 'amount', width: 120,
|
||||
sorter: (a, b) => a.amount - b.amount,
|
||||
render(row) {
|
||||
if (!row.includeInStats) {
|
||||
return h('span', { style: { color: '#999', fontWeight: 'bold' } }, `${row.amount.toFixed(2)}`)
|
||||
@@ -121,14 +273,28 @@ function createColumns(): DataTableColumns<TransactionItem> {
|
||||
title: '支付方式', key: 'paymentMethod', width: 100,
|
||||
render(row) { return getPaymentLabel(row.paymentMethod) },
|
||||
},
|
||||
{ title: '备注', key: 'remark', ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '交易时间', key: 'transactionTime', width: 170,
|
||||
render(row) { return row.transactionTime?.replace('T', ' ').substring(0, 16) },
|
||||
},
|
||||
{
|
||||
title: '创建时间', key: 'createdAt', width: 170,
|
||||
render(row) { return row.createdAt?.replace('T', ' ').substring(0, 16) },
|
||||
title: '备注', key: 'remark', minWidth: 220,
|
||||
render(row) {
|
||||
const hidden = !!row.remarkHidden
|
||||
const collapsed = hidden && !revealedRemarkIds.value.includes(row.id)
|
||||
const nodes: any[] = [
|
||||
h('span', {
|
||||
class: 'remark-text',
|
||||
title: collapsed ? '备注已隐藏' : (row.remark || ''),
|
||||
}, collapsed ? '******' : (row.remark || '-')),
|
||||
]
|
||||
if (collapsed) {
|
||||
nodes.push(renderRemarkAction('查看', EyeOutline, () => revealRemark(row.id)))
|
||||
nodes.push(renderRemarkAction('取消隐藏', EyeOffOutline, () => setRemarkHidden(row, false)))
|
||||
} else if (hidden) {
|
||||
nodes.push(renderRemarkAction('收起', EyeOffOutline, () => concealRemark(row.id)))
|
||||
nodes.push(renderRemarkAction('取消隐藏', EyeOutline, () => setRemarkHidden(row, false)))
|
||||
} else {
|
||||
nodes.push(renderRemarkAction('隐藏', EyeOffOutline, () => setRemarkHidden(row, true)))
|
||||
}
|
||||
return h('div', { class: 'remark-cell' }, nodes)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '计入统计', key: 'includeInStats', width: 100, align: 'center',
|
||||
@@ -159,7 +325,7 @@ const columns = createColumns() // 表格列配置
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res: any = await getCategoryList()
|
||||
categories.value = res.data.map((c: any) => ({ label: c.name, value: c.id, type: c.type }))
|
||||
categories.value = res.data.map((c: any) => ({ label: c.name, value: c.id, type: c.type, icon: c.icon || '' }))
|
||||
} catch (e) {
|
||||
// 静默处理
|
||||
}
|
||||
@@ -173,14 +339,18 @@ async function fetchData() {
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
types: filterType.value.length > 0 ? filterType.value : undefined,
|
||||
categoryIds: filterCategory.value.length > 0 ? filterCategory.value : undefined,
|
||||
...buildCategoryFilter(),
|
||||
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined,
|
||||
startTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[0]) : undefined,
|
||||
endTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[1], true) : undefined,
|
||||
includeInStats: filterIncludeInStats.value === null ? undefined : filterIncludeInStats.value === 'true',
|
||||
minAmount: filterMinAmount.value ?? undefined,
|
||||
maxAmount: filterMaxAmount.value ?? undefined,
|
||||
remark: filterRemark.value.trim() || undefined,
|
||||
})
|
||||
transactions.value = res.data.records
|
||||
total.value = res.data.total
|
||||
revealedRemarkIds.value = []
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败')
|
||||
} finally {
|
||||
@@ -269,6 +439,13 @@ function handlePageChange(p: number) {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
/** 每页条数变化处理:重置到第一页并重新加载 */
|
||||
function handlePageSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
/** 执行查询:重置页码并按当前筛选条件加载数据 */
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
@@ -282,6 +459,9 @@ function handleReset() {
|
||||
filterPaymentMethod.value = []
|
||||
filterDateRange.value = null
|
||||
filterIncludeInStats.value = null
|
||||
filterRemark.value = ''
|
||||
filterMinAmount.value = null
|
||||
filterMaxAmount.value = null
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
@@ -297,16 +477,53 @@ async function handleToggleStats(row: TransactionItem, value: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开批量设置分类弹窗 */
|
||||
function handleBatchOpen() {
|
||||
if (transactions.value.length === 0) {
|
||||
message.warning('当前表格没有可设置的记录')
|
||||
return
|
||||
}
|
||||
batchExpenseCategoryId.value = null
|
||||
batchIncomeCategoryId.value = null
|
||||
showBatchModal.value = true
|
||||
}
|
||||
|
||||
/** 对当前表格展示的记录按类型批量设置分类 */
|
||||
async function handleBatchSubmit() {
|
||||
if (!batchExpenseCategoryId.value && !batchIncomeCategoryId.value) {
|
||||
message.warning('请至少选择一个分类')
|
||||
return
|
||||
}
|
||||
batching.value = true
|
||||
try {
|
||||
const res: any = await batchUpdateCategory({
|
||||
ids: transactions.value.map((t) => t.id),
|
||||
expenseCategoryId: batchExpenseCategoryId.value,
|
||||
incomeCategoryId: batchIncomeCategoryId.value,
|
||||
})
|
||||
message.success(`已更新 ${res.data} 条记录`)
|
||||
showBatchModal.value = false
|
||||
await fetchData()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '操作失败')
|
||||
} finally {
|
||||
batching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出交易记录为 Excel 文件 */
|
||||
async function handleExport() {
|
||||
try {
|
||||
const res = await exportTransaction({
|
||||
types: filterType.value.length > 0 ? filterType.value : undefined,
|
||||
categoryIds: filterCategory.value.length > 0 ? filterCategory.value : undefined,
|
||||
...buildCategoryFilter(),
|
||||
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined,
|
||||
startTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[0]) : undefined,
|
||||
endTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[1], true) : undefined,
|
||||
includeInStats: filterIncludeInStats.value === null ? undefined : filterIncludeInStats.value === 'true',
|
||||
minAmount: filterMinAmount.value ?? undefined,
|
||||
maxAmount: filterMaxAmount.value ?? undefined,
|
||||
remark: filterRemark.value.trim() || undefined,
|
||||
})
|
||||
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
@@ -325,6 +542,7 @@ async function handleExport() {
|
||||
|
||||
// 组件挂载时加载分类和交易记录
|
||||
onMounted(() => {
|
||||
loadFilterPrefs()
|
||||
fetchCategories()
|
||||
fetchData()
|
||||
})
|
||||
@@ -336,6 +554,8 @@ onMounted(() => {
|
||||
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
||||
<h2 style="margin: 0;">收支记录</h2>
|
||||
<NSpace>
|
||||
<NButton @click="handleBatchOpen">一键设置分类</NButton>
|
||||
<NButton @click="showImportModal = true">导入账单</NButton>
|
||||
<NButton @click="handleExport">导出 Excel</NButton>
|
||||
<NButton type="primary" @click="handleAdd">新增记录</NButton>
|
||||
</NSpace>
|
||||
@@ -355,7 +575,7 @@ onMounted(() => {
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterCategory"
|
||||
:options="categories.filter(c => filterType.length === 0 || filterType.includes(c.type))"
|
||||
:options="categoryFilterOptions"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
@@ -385,17 +605,64 @@ onMounted(() => {
|
||||
placeholder="计入统计"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<NInputNumber
|
||||
v-model:value="filterMinAmount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
clearable
|
||||
placeholder="最小金额"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<span style="color: #999;">~</span>
|
||||
<NInputNumber
|
||||
v-model:value="filterMaxAmount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
clearable
|
||||
placeholder="最大金额"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="filterRemark"
|
||||
clearable
|
||||
placeholder="备注搜索"
|
||||
style="width: 180px;"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</NSpace>
|
||||
<NSpace justify="end" style="margin-top: 12px;">
|
||||
<NButton type="primary" @click="handleSearch">查询</NButton>
|
||||
<NButton @click="handleReset">重置</NButton>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<!-- 表格信息栏(右上角) -->
|
||||
<NSpace justify="end" align="center" :size="12" style="margin-bottom: 8px;">
|
||||
<span style="color: #999; font-size: 12px;">
|
||||
总计 {{ total }} 条 · 第 {{ page }} / {{ totalPages }} 页 · 每页 {{ pageSize }} 条
|
||||
</span>
|
||||
<span style="font-size: 12px; color: #999;">
|
||||
本页支出 <span style="color: #d03050;">{{ pageSummary.expense.toFixed(2) }}</span>
|
||||
· 本页收入 <span style="color: #18a058;">{{ pageSummary.income.toFixed(2) }}</span>
|
||||
· 本页结余 <span :style="{ color: pageSummary.balance < 0 ? '#d03050' : '#18a058' }">{{ pageSummary.balance.toFixed(2) }}</span>
|
||||
</span>
|
||||
</NSpace>
|
||||
|
||||
<!-- 交易记录数据表格 -->
|
||||
<NDataTable :columns="columns" :data="transactions" :loading="loading" :bordered="false" />
|
||||
|
||||
<!-- 分页控件 -->
|
||||
<NSpace justify="center" style="margin-top: 16px;">
|
||||
<NPagination v-model:page="page" :page-count="Math.ceil(total / pageSize)" @update:page="handlePageChange" />
|
||||
<NPagination
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-count="Math.ceil(total / pageSize)"
|
||||
:page-sizes="pageSizes"
|
||||
show-size-picker
|
||||
show-quick-jumper
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
@@ -405,7 +672,7 @@ onMounted(() => {
|
||||
<NSelect v-model:value="form.type" :options="typeOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem label="分类">
|
||||
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" placeholder="请选择分类" filterable />
|
||||
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" :render-label="renderCategoryLabel" placeholder="请选择分类" filterable />
|
||||
</NFormItem>
|
||||
<NFormItem label="金额">
|
||||
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" placeholder="请输入金额" />
|
||||
@@ -426,5 +693,54 @@ onMounted(() => {
|
||||
<NButton type="primary" block @click="handleSubmit">确定</NButton>
|
||||
</NForm>
|
||||
</NModal>
|
||||
|
||||
<!-- 账单导入弹窗 -->
|
||||
<BillImportModal v-model:show="showImportModal" @imported="fetchData" />
|
||||
|
||||
<!-- 一键设置分类弹窗(作用于当前表格展示的记录) -->
|
||||
<NModal v-model:show="showBatchModal" preset="card" title="一键设置分类" style="width: 480px;">
|
||||
<NForm>
|
||||
<NFormItem label="作用范围">
|
||||
<span style="color: #999; font-size: 12px;">
|
||||
将对当前表格展示的 {{ transactions.length }} 条记录按类型设置分类
|
||||
</span>
|
||||
</NFormItem>
|
||||
<NFormItem label="支出分类">
|
||||
<NSelect
|
||||
v-model:value="batchExpenseCategoryId"
|
||||
:options="categories.filter(c => c.type === 'EXPENSE')"
|
||||
:render-label="renderCategoryLabel"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="不设置支出分类"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="收入分类">
|
||||
<NSelect
|
||||
v-model:value="batchIncomeCategoryId"
|
||||
:options="categories.filter(c => c.type === 'INCOME')"
|
||||
:render-label="renderCategoryLabel"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="不设置收入分类"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NButton type="primary" block :loading="batching" @click="handleBatchSubmit">确定</NButton>
|
||||
</NForm>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.remark-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.remark-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { defineConfig } from 'vite'
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
Reference in New Issue
Block a user