feat: 初始化收支管理系统
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
// 根组件:提供 Naive UI 的全局配置(中文化)、消息提示和对话框上下文
|
||||
import { NMessageProvider, NDialogProvider, NConfigProvider } from 'naive-ui'
|
||||
import { zhCN, dateZhCN } from 'naive-ui' // 中文语言包和日期格式
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 全局配置:设置中文语言和日期格式 -->
|
||||
<NConfigProvider :locale="zhCN" :date-locale="dateZhCN">
|
||||
<!-- 提供全局消息提示能力 -->
|
||||
<NMessageProvider>
|
||||
<!-- 提供全局对话框能力 -->
|
||||
<NDialogProvider>
|
||||
<router-view />
|
||||
</NDialogProvider>
|
||||
</NMessageProvider>
|
||||
</NConfigProvider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
// 分类相关 API 接口:分类的增删改查
|
||||
import request from './request'
|
||||
|
||||
/** 获取分类列表,可按类型筛选 */
|
||||
export function getCategoryList(type?: string) {
|
||||
return request.get('/category/list', { params: type ? { type } : {} })
|
||||
}
|
||||
|
||||
/** 创建新分类 */
|
||||
export function createCategory(data: { name: string; type: string; icon?: string; sortOrder?: number }) {
|
||||
return request.post('/category', data)
|
||||
}
|
||||
|
||||
/** 更新指定分类 */
|
||||
export function updateCategory(id: number, data: { name: string; type: string; icon?: string; sortOrder?: number }) {
|
||||
return request.put(`/category/${id}`, data)
|
||||
}
|
||||
|
||||
/** 删除指定分类 */
|
||||
export function deleteCategory(id: number) {
|
||||
return request.delete(`/category/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// HTTP 请求封装模块:基于 axios 创建请求实例,统一处理请求拦截和响应拦截
|
||||
import axios from 'axios'
|
||||
import { useUserStore } from '../store/user'
|
||||
import router from '../router'
|
||||
|
||||
// 创建 axios 实例,设置基础路径和超时时间
|
||||
const request = axios.create({
|
||||
baseURL: '/api', // API 基础路径
|
||||
timeout: 10000, // 请求超时时间(毫秒)
|
||||
})
|
||||
|
||||
// 请求拦截器:自动携带 Authorization 请求头
|
||||
request.interceptors.request.use((config) => {
|
||||
const userStore = useUserStore()
|
||||
if (userStore.token) {
|
||||
config.headers.Authorization = `Bearer ${userStore.token}` // 附加 JWT token
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// 响应拦截器:统一处理响应数据和错误
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
const data = response.data
|
||||
// 如果业务状态码不是 200,视为请求失败
|
||||
if (data.code !== 200) {
|
||||
return Promise.reject(new Error(data.message || '请求失败'))
|
||||
}
|
||||
return data
|
||||
},
|
||||
(error) => {
|
||||
// 401 未授权:自动退出登录并跳转到登录页
|
||||
if (error.response?.status === 401) {
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
@@ -0,0 +1,17 @@
|
||||
// 统计数据相关 API 接口:月度统计、分类统计、趋势统计
|
||||
import request from './request'
|
||||
|
||||
/** 获取指定年月的收支汇总统计(总收入、总支出、结余、交易笔数) */
|
||||
export function getMonthlyStatistics(params: { year: number; month: number }) {
|
||||
return request.get('/statistics/monthly', { params })
|
||||
}
|
||||
|
||||
/** 获取指定年月的分类收支统计(用于饼图等) */
|
||||
export function getCategoryStatistics(params: { year: number; month: number }) {
|
||||
return request.get('/statistics/category', { params })
|
||||
}
|
||||
|
||||
/** 获取指定年月的每日收支趋势数据(用于折线图/柱状图) */
|
||||
export function getTrendStatistics(params: { year: number; month: number }) {
|
||||
return request.get('/statistics/trend', { params })
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// 收支记录相关 API 接口:交易记录的增删改查(分页)
|
||||
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[]
|
||||
paymentMethods?: string[]
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
includeInStats?: boolean
|
||||
}) {
|
||||
return request.get('/transaction/page', { params })
|
||||
}
|
||||
|
||||
/** 创建新的交易记录 */
|
||||
export function createTransaction(data: {
|
||||
categoryId: number
|
||||
type: string
|
||||
amount: number
|
||||
paymentMethod?: string
|
||||
remark?: string
|
||||
transactionTime: string
|
||||
includeInStats?: boolean
|
||||
}) {
|
||||
return request.post('/transaction', data)
|
||||
}
|
||||
|
||||
/** 更新指定交易记录 */
|
||||
export function updateTransaction(id: number, data: {
|
||||
categoryId: number
|
||||
type: string
|
||||
amount: number
|
||||
paymentMethod?: string
|
||||
remark?: string
|
||||
transactionTime: string
|
||||
includeInStats?: boolean
|
||||
}) {
|
||||
return request.put(`/transaction/${id}`, data)
|
||||
}
|
||||
|
||||
/** 删除指定交易记录 */
|
||||
export function deleteTransaction(id: number) {
|
||||
return request.delete(`/transaction/${id}`)
|
||||
}
|
||||
|
||||
/** 切换指定交易记录的"计入统计"标记 */
|
||||
export function toggleTransactionStats(id: number, includeInStats: boolean) {
|
||||
return request.put(`/transaction/${id}/stats`, { includeInStats })
|
||||
}
|
||||
|
||||
/** 导出交易记录为 Excel 文件,支持按条件筛选 */
|
||||
export function exportTransaction(params: {
|
||||
types?: string[]
|
||||
categoryIds?: number[]
|
||||
paymentMethods?: string[]
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
includeInStats?: boolean
|
||||
}) {
|
||||
const userStore = useUserStore()
|
||||
return axios.get('/api/transaction/export', {
|
||||
params,
|
||||
paramsSerializer: {
|
||||
indexes: null,
|
||||
},
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Authorization: `Bearer ${userStore.token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// 用户相关 API 接口:登录、注册、获取用户信息
|
||||
import request from './request'
|
||||
|
||||
/** 用户登录 */
|
||||
export function login(data: { username: string; password: string }) {
|
||||
return request.post('/user/login', data)
|
||||
}
|
||||
|
||||
/** 用户注册 */
|
||||
export function register(data: { username: string; password: string; nickname?: string }) {
|
||||
return request.post('/user/register', data)
|
||||
}
|
||||
|
||||
/** 获取当前登录用户的信息 */
|
||||
export function getUserInfo() {
|
||||
return request.get('/user/info')
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// 应用入口文件:初始化 Vue 应用,挂载全局插件(状态管理、路由、UI 组件库)
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import naive from 'naive-ui'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
|
||||
// 创建 Vue 应用实例
|
||||
const app = createApp(App)
|
||||
app.use(createPinia()) // 注册 Pinia 状态管理
|
||||
app.use(router) // 注册路由
|
||||
app.use(naive) // 全局注册 Naive UI 组件库
|
||||
app.mount('#app') // 挂载到 DOM 元素 #app
|
||||
@@ -0,0 +1,55 @@
|
||||
// 路由配置文件:定义应用的所有路由及导航守卫
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '../store/user'
|
||||
|
||||
// 创建路由实例,使用 HTML5 History 模式
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/Login.vue'), // 登录页
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('../views/Register.vue'), // 注册页
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../views/Layout.vue'), // 主布局(带侧边栏)
|
||||
redirect: '/dashboard', // 默认跳转到统计面板
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/Dashboard.vue'), // 统计面板
|
||||
},
|
||||
{
|
||||
path: 'transactions',
|
||||
name: 'Transactions',
|
||||
component: () => import('../views/TransactionList.vue'), // 收支记录
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
name: 'Categories',
|
||||
component: () => import('../views/CategoryManage.vue'), // 分类管理
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// 全局前置守卫:未登录时拦截跳转至登录页
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const userStore = useUserStore()
|
||||
// 如果目标页面不是登录/注册页,且没有 token,则重定向到登录页
|
||||
if (to.path !== '/login' && to.path !== '/register' && !userStore.token) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,43 @@
|
||||
// 用户状态管理模块:管理用户的登录状态(token、昵称、用户ID)
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getUserInfo } from '../api/user'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 从 localStorage 恢复 token,若不存在则为空字符串
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const nickname = ref('') // 用户昵称
|
||||
const userId = ref<number | null>(null) // 用户 ID
|
||||
|
||||
/** 设置 token 并持久化到 localStorage */
|
||||
function setToken(t: string) {
|
||||
token.value = t
|
||||
localStorage.setItem('token', t)
|
||||
}
|
||||
|
||||
/** 设置用户基本信息(昵称和 ID) */
|
||||
function setUserInfo(nick: string, id: number) {
|
||||
nickname.value = nick
|
||||
userId.value = id
|
||||
}
|
||||
|
||||
/** 从后端获取当前登录用户的信息 */
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
const res: any = await getUserInfo()
|
||||
setUserInfo(res.data.nickname, res.data.id)
|
||||
} catch (e) {
|
||||
console.error('获取用户信息失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 退出登录:清空状态并移除 localStorage 中的 token */
|
||||
function logout() {
|
||||
token.value = ''
|
||||
nickname.value = ''
|
||||
userId.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
return { token, nickname, userId, setToken, setUserInfo, fetchUserInfo, logout }
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
// 分类管理组件:展示收入/支出分类列表,支持新增、编辑、删除分类
|
||||
import { ref, onMounted, computed, h } from 'vue'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { getCategoryList, createCategory, updateCategory, deleteCategory } from '../api/category'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
const dialog = useDialog() // 对话框
|
||||
|
||||
/** 分类数据结构 */
|
||||
interface CategoryItem {
|
||||
id: number
|
||||
name: string
|
||||
type: string // INCOME 或 EXPENSE
|
||||
icon: string
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 列表和弹窗状态
|
||||
const categories = ref<CategoryItem[]>([]) // 全部分类列表
|
||||
const loading = ref(false) // 列表加载状态
|
||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||
const editingId = ref<number | null>(null) // 当前编辑的分类 ID(null 表示新增)
|
||||
const form = ref({ name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }) // 表单数据
|
||||
|
||||
// 类型选项
|
||||
const typeOptions = [
|
||||
{ label: '支出', value: 'EXPENSE' },
|
||||
{ label: '收入', value: 'INCOME' },
|
||||
]
|
||||
|
||||
// 按类型过滤分类列表
|
||||
const incomeList = computed(() => categories.value.filter(c => c.type === 'INCOME')) // 收入分类
|
||||
const expenseList = computed(() => categories.value.filter(c => c.type === 'EXPENSE')) // 支出分类
|
||||
|
||||
/** 创建表格列配置 */
|
||||
function createColumns(): DataTableColumns<CategoryItem> {
|
||||
return [
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: '图标', key: 'icon' },
|
||||
{ title: '排序', key: 'sortOrder', width: 80 },
|
||||
{
|
||||
title: '操作', key: 'actions', width: 180,
|
||||
render(row) {
|
||||
// 编辑和删除按钮
|
||||
return [
|
||||
h(NSpace, null, {
|
||||
default: () => [
|
||||
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => '删除' }),
|
||||
],
|
||||
}),
|
||||
]
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const columns = createColumns() // 表格列配置
|
||||
|
||||
/** 加载分类列表数据 */
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getCategoryList()
|
||||
categories.value = res.data
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开新增分类弹窗,重置表单 */
|
||||
function handleAdd() {
|
||||
editingId.value = null
|
||||
form.value = { name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
/** 打开编辑分类弹窗,填充表单数据 */
|
||||
function handleEdit(row: CategoryItem) {
|
||||
editingId.value = row.id
|
||||
form.value = { name: row.name, type: row.type, icon: row.icon || '', sortOrder: row.sortOrder }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
|
||||
async function handleSubmit() {
|
||||
if (!form.value.name) {
|
||||
message.warning('请输入分类名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (editingId.value) {
|
||||
// 更新已有分类
|
||||
await updateCategory(editingId.value, form.value)
|
||||
message.success('修改成功')
|
||||
} else {
|
||||
// 创建新分类
|
||||
await createCategory(form.value)
|
||||
message.success('创建成功')
|
||||
}
|
||||
showModal.value = false
|
||||
await fetchData() // 刷新列表
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除分类:弹出确认对话框,确认后执行删除并刷新列表 */
|
||||
function handleDelete(id: number) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个分类吗?',
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteCategory(id)
|
||||
message.success('删除成功')
|
||||
await fetchData()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 组件挂载时加载分类数据
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 顶部:标题和新增按钮 -->
|
||||
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
||||
<h2 style="margin: 0;">分类管理</h2>
|
||||
<NButton type="primary" @click="handleAdd">新增分类</NButton>
|
||||
</NSpace>
|
||||
|
||||
<!-- 支出分类列表 -->
|
||||
<h3>支出分类</h3>
|
||||
<NDataTable :columns="columns" :data="expenseList" :loading="loading" :bordered="false" />
|
||||
|
||||
<!-- 收入分类列表 -->
|
||||
<h3 style="margin-top: 24px;">收入分类</h3>
|
||||
<NDataTable :columns="columns" :data="incomeList" :loading="loading" :bordered="false" />
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<NModal v-model:show="showModal" preset="card" :title="editingId ? '编辑分类' : '新增分类'" style="width: 450px;">
|
||||
<NForm>
|
||||
<NFormItem label="分类名称">
|
||||
<NInput v-model:value="form.name" placeholder="请输入分类名称" />
|
||||
</NFormItem>
|
||||
<NFormItem label="类型">
|
||||
<NSelect v-model:value="form.type" :options="typeOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem label="图标">
|
||||
<NInput v-model:value="form.icon" placeholder="可选,如 emoji" />
|
||||
</NFormItem>
|
||||
<NFormItem label="排序">
|
||||
<NInputNumber v-model:value="form.sortOrder" :min="0" />
|
||||
</NFormItem>
|
||||
<NButton type="primary" block @click="handleSubmit">确定</NButton>
|
||||
</NForm>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
// 统计面板组件:展示月度收支汇总、分类占比饼图、每日收支趋势柱状图
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect } 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'
|
||||
|
||||
// 注册 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 monthly = ref({ totalIncome: 0, totalExpense: 0, balance: 0, transactionCount: 0 })
|
||||
const categoryData = ref<any[]>([]) // 分类统计数据
|
||||
const trendData = ref<any[]>([]) // 每日趋势数据
|
||||
|
||||
// 年份和月份下拉选项
|
||||
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 pieOption = ref({}) // 饼图配置
|
||||
const trendOption = ref({}) // 趋势图配置
|
||||
|
||||
/** 更新支出分类饼图配置 */
|
||||
function updatePieChart() {
|
||||
pieOption.value = {
|
||||
title: { text: '支出分类占比', left: 'center' },
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'], // 环形图内外半径
|
||||
data: categoryData.value
|
||||
.filter((c: any) => c.type === 'EXPENSE') // 只展示支出类
|
||||
.map((c: any) => ({ name: c.categoryName, value: c.amount })),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新每日收支趋势柱状图配置 */
|
||||
function updateTrendChart() {
|
||||
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
|
||||
},
|
||||
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' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 并行请求所有统计数据并更新图表 */
|
||||
async function fetchData() {
|
||||
const params = { year: selectedYear.value, month: selectedMonth.value }
|
||||
try {
|
||||
const [monthlyRes, categoryRes, trendRes]: any[] = await Promise.all([
|
||||
getMonthlyStatistics(params),
|
||||
getCategoryStatistics(params),
|
||||
getTrendStatistics(params),
|
||||
])
|
||||
monthly.value = monthlyRes.data
|
||||
categoryData.value = categoryRes.data
|
||||
trendData.value = trendRes.data
|
||||
updatePieChart()
|
||||
updateTrendChart()
|
||||
} catch (e: any) {
|
||||
console.error('加载统计数据失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听年月变化,自动重新加载数据
|
||||
watch([selectedYear, selectedMonth], fetchData)
|
||||
onMounted(fetchData) // 组件挂载时加载数据
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 顶部:标题和年月选择器 -->
|
||||
<NSpace justify="space-between" align="center" style="margin-bottom: 20px;">
|
||||
<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>
|
||||
</NSpace>
|
||||
|
||||
<!-- 统计卡片:总收入、总支出、结余、交易笔数 -->
|
||||
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 20px;">
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<NStatistic label="总收入" :value="Number(monthly.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;">
|
||||
<template #prefix>-</template>
|
||||
</NStatistic>
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<NStatistic label="结余" :value="Number(monthly.balance).toFixed(2)" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<NStatistic label="交易笔数" :value="monthly.transactionCount" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
<!-- 图表区域:饼图 + 趋势柱状图 -->
|
||||
<NGrid :cols="2" :x-gap="16" :y-gap="16">
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="pieOption" style="height: 350px;" autoresize />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="trendOption" style="height: 350px;" autoresize />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
// 主布局组件:包含左侧导航菜单、顶部用户信息栏和内容区域
|
||||
import { h, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NButton, NSpace, NIcon } from 'naive-ui'
|
||||
import type { MenuOption } from 'naive-ui'
|
||||
import { BarChartOutline, ListOutline, FolderOpenOutline, LogOutOutline } from '@vicons/ionicons5'
|
||||
import { useUserStore } from '../store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 根据当前路由路径计算菜单激活项
|
||||
const activeKey = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
'/dashboard': 'dashboard',
|
||||
'/transactions': 'transactions',
|
||||
'/categories': 'categories',
|
||||
}
|
||||
return map[route.path] || 'dashboard'
|
||||
})
|
||||
|
||||
/** 渲染菜单图标的高阶函数 */
|
||||
function renderIcon(icon: any) {
|
||||
return () => h(NIcon, null, { default: () => h(icon) })
|
||||
}
|
||||
|
||||
// 侧边栏菜单配置项
|
||||
const menuOptions: MenuOption[] = [
|
||||
{ label: '统计面板', key: 'dashboard', icon: renderIcon(BarChartOutline) },
|
||||
{ label: '收支记录', key: 'transactions', icon: renderIcon(ListOutline) },
|
||||
{ label: '分类管理', key: 'categories', icon: renderIcon(FolderOpenOutline) },
|
||||
]
|
||||
|
||||
/** 菜单点击事件:根据 key 导航到对应路由 */
|
||||
function handleMenuUpdate(key: string) {
|
||||
router.push(`/${key}`)
|
||||
}
|
||||
|
||||
/** 退出登录:清空用户状态并跳转到登录页 */
|
||||
function handleLogout() {
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 整体布局:左侧边栏 + 右侧内容区 -->
|
||||
<NLayout has-sider style="height: 100vh">
|
||||
<NLayoutSider
|
||||
bordered
|
||||
:width="200"
|
||||
content-style="padding: 12px;"
|
||||
>
|
||||
<!-- 系统标题 -->
|
||||
<div style="text-align: center; padding: 16px 0; font-size: 18px; font-weight: bold; color: #333;">
|
||||
收支管理
|
||||
</div>
|
||||
<!-- 导航菜单 -->
|
||||
<NMenu
|
||||
:value="activeKey"
|
||||
:options="menuOptions"
|
||||
@update:value="handleMenuUpdate"
|
||||
/>
|
||||
</NLayoutSider>
|
||||
<NLayout>
|
||||
<!-- 顶部栏:显示用户名和退出按钮 -->
|
||||
<NLayoutHeader bordered style="padding: 12px 24px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-size: 16px; font-weight: 500;">{{ userStore.nickname || '用户' }}</span>
|
||||
<NSpace>
|
||||
<NButton quaternary size="small" @click="handleLogout">
|
||||
<template #icon>
|
||||
<NIcon :component="LogOutOutline" />
|
||||
</template>
|
||||
退出
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NLayoutHeader>
|
||||
<!-- 子路由内容渲染区域 -->
|
||||
<NLayoutContent content-style="padding: 24px;" style="height: calc(100vh - 57px); overflow-y: auto;">
|
||||
<router-view />
|
||||
</NLayoutContent>
|
||||
</NLayout>
|
||||
</NLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
// 登录页面组件:提供用户名/密码表单,验证后跳转至首页
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace } from 'naive-ui'
|
||||
import { login } from '../api/user'
|
||||
import { useUserStore } from '../store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage() // 消息提示工具
|
||||
const userStore = useUserStore() // 用户状态管理
|
||||
|
||||
// 登录表单数据
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
const loading = ref(false) // 登录按钮加载状态
|
||||
|
||||
/** 处理登录操作:校验表单 -> 调用登录接口 -> 保存 token -> 跳转首页 */
|
||||
async function handleLogin() {
|
||||
if (!form.value.username || !form.value.password) {
|
||||
message.warning('请填写用户名和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await login(form.value)
|
||||
userStore.setToken(res.data.token) // 保存 token
|
||||
userStore.setUserInfo(res.data.user.nickname, res.data.user.id) // 保存用户信息
|
||||
message.success('登录成功')
|
||||
router.push('/dashboard') // 跳转到统计面板
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<NCard title="收支管理系统 - 登录" style="width: 400px" :bordered="true">
|
||||
<NForm>
|
||||
<NFormItem label="用户名">
|
||||
<NInput v-model:value="form.username" placeholder="请输入用户名" @keyup.enter="handleLogin" />
|
||||
</NFormItem>
|
||||
<NFormItem label="密码">
|
||||
<NInput v-model:value="form.password" type="password" show-password-on="click" placeholder="请输入密码" @keyup.enter="handleLogin" />
|
||||
</NFormItem>
|
||||
<NSpace vertical>
|
||||
<NButton type="primary" block :loading="loading" @click="handleLogin">登录</NButton>
|
||||
<NButton text type="info" @click="router.push('/register')">没有账号?去注册</NButton>
|
||||
</NSpace>
|
||||
</NForm>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
// 注册页面组件:提供注册表单(用户名、昵称、密码、确认密码),校验后调用注册接口
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace } from 'naive-ui'
|
||||
import { register } from '../api/user'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage() // 消息提示工具
|
||||
|
||||
// 注册表单数据
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '', // 确认密码
|
||||
nickname: '', // 昵称(可选)
|
||||
})
|
||||
const loading = ref(false) // 注册按钮加载状态
|
||||
|
||||
/** 处理注册操作:校验表单字段 -> 调用注册接口 -> 成功后跳转登录页 */
|
||||
async function handleRegister() {
|
||||
// 校验必填字段
|
||||
if (!form.value.username || !form.value.password) {
|
||||
message.warning('请填写用户名和密码')
|
||||
return
|
||||
}
|
||||
if (form.value.username.length < 3) {
|
||||
message.warning('用户名至少3个字符')
|
||||
return
|
||||
}
|
||||
if (form.value.password.length < 6) {
|
||||
message.warning('密码至少6个字符')
|
||||
return
|
||||
}
|
||||
// 校验两次密码是否一致
|
||||
if (form.value.password !== form.value.confirmPassword) {
|
||||
message.warning('两次密码不一致')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await register({
|
||||
username: form.value.username,
|
||||
password: form.value.password,
|
||||
nickname: form.value.nickname || undefined, // 昵称为空时传 undefined
|
||||
})
|
||||
message.success('注册成功,请登录')
|
||||
router.push('/login') // 跳转到登录页
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '注册失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<NCard title="收支管理系统 - 注册" style="width: 400px" :bordered="true">
|
||||
<NForm>
|
||||
<NFormItem label="用户名">
|
||||
<NInput v-model:value="form.username" placeholder="3-50个字符" />
|
||||
</NFormItem>
|
||||
<NFormItem label="昵称">
|
||||
<NInput v-model:value="form.nickname" placeholder="可选" />
|
||||
</NFormItem>
|
||||
<NFormItem label="密码">
|
||||
<NInput v-model:value="form.password" type="password" show-password-on="click" placeholder="至少6个字符" />
|
||||
</NFormItem>
|
||||
<NFormItem label="确认密码">
|
||||
<NInput v-model:value="form.confirmPassword" type="password" show-password-on="click" placeholder="再次输入密码" />
|
||||
</NFormItem>
|
||||
<NSpace vertical>
|
||||
<NButton type="primary" block :loading="loading" @click="handleRegister">注册</NButton>
|
||||
<NButton text type="info" @click="router.push('/login')">已有账号?去登录</NButton>
|
||||
</NSpace>
|
||||
</NForm>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,430 @@
|
||||
<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 type { DataTableColumns } from 'naive-ui'
|
||||
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats } from '../api/transaction'
|
||||
import { getCategoryList } from '../api/category'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
const dialog = useDialog() // 对话框
|
||||
|
||||
/** 交易记录数据结构 */
|
||||
interface TransactionItem {
|
||||
id: number
|
||||
categoryId: number
|
||||
categoryName: string
|
||||
categoryIcon: string
|
||||
type: string // INCOME 或 EXPENSE
|
||||
amount: number
|
||||
paymentMethod: string
|
||||
remark: string
|
||||
transactionTime: string
|
||||
includeInStats: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 分类选项数据结构 */
|
||||
interface CategoryOption {
|
||||
label: string
|
||||
value: number
|
||||
type: string
|
||||
}
|
||||
|
||||
// 列表数据和分页状态
|
||||
const transactions = ref<TransactionItem[]>([]) // 交易记录列表
|
||||
const total = ref(0) // 总记录数
|
||||
const page = ref(1) // 当前页码
|
||||
const pageSize = ref(20) // 每页条数
|
||||
const loading = ref(false) // 列表加载状态
|
||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||
const editingId = ref<number | null>(null) // 当前编辑的记录 ID(null 表示新增)
|
||||
const categories = ref<CategoryOption[]>([]) // 分类选项列表
|
||||
|
||||
// 表单数据(新增/编辑共用)
|
||||
const form = ref({
|
||||
categoryId: null as number | null,
|
||||
type: 'EXPENSE',
|
||||
amount: null as number | null,
|
||||
paymentMethod: '',
|
||||
remark: '',
|
||||
transactionTime: Date.now(),
|
||||
includeInStats: true,
|
||||
})
|
||||
|
||||
// 类型选项
|
||||
const typeOptions = [
|
||||
{ label: '支出', value: 'EXPENSE' },
|
||||
{ label: '收入', value: 'INCOME' },
|
||||
]
|
||||
|
||||
// 支付方式选项
|
||||
const paymentOptions = [
|
||||
{ label: '微信', value: 'WECHAT' },
|
||||
{ label: '支付宝', value: 'ALIPAY' },
|
||||
{ label: '现金', value: 'CASH' },
|
||||
{ label: '银行卡', value: 'BANK' },
|
||||
]
|
||||
|
||||
// 筛选条件状态
|
||||
const filterType = ref<string[]>([]) // 类型筛选(多选)
|
||||
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 includeInStatsOptions = [
|
||||
{ label: '计入', value: 'true' },
|
||||
{ label: '不计入', value: 'false' },
|
||||
]
|
||||
|
||||
/** 将时间戳格式化为 ISO 格式的日期时间字符串(YYYY-MM-DDTHH:mm:ss) */
|
||||
function formatTimestamp(ts: number, isEnd = false): string {
|
||||
const d = new Date(ts)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
const time = isEnd ? '23:59:59' : '00:00:00'
|
||||
return `${date}T${time}`
|
||||
}
|
||||
|
||||
/** 将支付方式编码转换为中文标签 */
|
||||
function getPaymentLabel(val: string) {
|
||||
const map: Record<string, string> = { WECHAT: '微信', ALIPAY: '支付宝', CASH: '现金', BANK: '银行卡' }
|
||||
return map[val] || val || '-'
|
||||
}
|
||||
|
||||
/** 创建表格列配置 */
|
||||
function createColumns(): DataTableColumns<TransactionItem> {
|
||||
return [
|
||||
{
|
||||
title: '类型', key: 'type', width: 90,
|
||||
render(row) {
|
||||
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: 'categoryName', width: 100 },
|
||||
{
|
||||
title: '金额', key: 'amount', width: 120,
|
||||
render(row) {
|
||||
if (!row.includeInStats) {
|
||||
return h('span', { style: { color: '#999', fontWeight: 'bold' } }, `${row.amount.toFixed(2)}`)
|
||||
}
|
||||
return h('span', { style: { color: row.type === 'INCOME' ? '#18a058' : '#d03050', fontWeight: 'bold' } },
|
||||
`${row.type === 'INCOME' ? '+' : '-'}${row.amount.toFixed(2)}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'includeInStats', width: 100, align: 'center',
|
||||
render(row) {
|
||||
return h(NSwitch, {
|
||||
value: row.includeInStats,
|
||||
onUpdateValue: (v: boolean) => handleToggleStats(row, v),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 160,
|
||||
render(row) {
|
||||
return h(NSpace, null, {
|
||||
default: () => [
|
||||
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => '删除' }),
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
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 }))
|
||||
} catch (e) {
|
||||
// 静默处理
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载交易记录列表(分页),携带所有筛选条件 */
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getTransactionPage({
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
types: filterType.value.length > 0 ? filterType.value : undefined,
|
||||
categoryIds: filterCategory.value.length > 0 ? filterCategory.value : undefined,
|
||||
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',
|
||||
})
|
||||
transactions.value = res.data.records
|
||||
total.value = res.data.total
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开新增记录弹窗,重置表单 */
|
||||
function handleAdd() {
|
||||
editingId.value = null
|
||||
form.value = { categoryId: null, type: 'EXPENSE', amount: null, paymentMethod: '', remark: '', transactionTime: Date.now(), includeInStats: true }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
/** 打开编辑记录弹窗,填充表单数据 */
|
||||
function handleEdit(row: TransactionItem) {
|
||||
editingId.value = row.id
|
||||
form.value = {
|
||||
categoryId: row.categoryId,
|
||||
type: row.type,
|
||||
amount: row.amount,
|
||||
paymentMethod: row.paymentMethod || '',
|
||||
remark: row.remark || '',
|
||||
transactionTime: new Date(row.transactionTime).getTime(),
|
||||
includeInStats: row.includeInStats ?? true,
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
|
||||
async function handleSubmit() {
|
||||
if (!form.value.categoryId) {
|
||||
message.warning('请选择分类')
|
||||
return
|
||||
}
|
||||
if (!form.value.amount || form.value.amount <= 0) {
|
||||
message.warning('请输入有效金额')
|
||||
return
|
||||
}
|
||||
const data = {
|
||||
categoryId: form.value.categoryId!,
|
||||
type: form.value.type,
|
||||
amount: form.value.amount!,
|
||||
paymentMethod: form.value.paymentMethod || undefined,
|
||||
remark: form.value.remark || undefined,
|
||||
transactionTime: new Date(form.value.transactionTime).toISOString().slice(0, 19),
|
||||
includeInStats: form.value.includeInStats,
|
||||
}
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateTransaction(editingId.value, data)
|
||||
message.success('修改成功')
|
||||
} else {
|
||||
await createTransaction(data)
|
||||
message.success('创建成功')
|
||||
}
|
||||
showModal.value = false
|
||||
await fetchData()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除记录:弹出确认对话框,确认后执行删除并刷新列表 */
|
||||
function handleDelete(id: number) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这条记录吗?',
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteTransaction(id)
|
||||
message.success('删除成功')
|
||||
await fetchData()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 翻页事件处理 */
|
||||
function handlePageChange(p: number) {
|
||||
page.value = p
|
||||
fetchData()
|
||||
}
|
||||
|
||||
/** 执行查询:重置页码并按当前筛选条件加载数据 */
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
/** 重置所有筛选条件并重新查询 */
|
||||
function handleReset() {
|
||||
filterType.value = []
|
||||
filterCategory.value = []
|
||||
filterPaymentMethod.value = []
|
||||
filterDateRange.value = null
|
||||
filterIncludeInStats.value = null
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
/** 在表格中直接切换"计入统计"开关 */
|
||||
async function handleToggleStats(row: TransactionItem, value: boolean) {
|
||||
try {
|
||||
await toggleTransactionStats(row.id, value)
|
||||
row.includeInStats = value
|
||||
message.success(value ? '已计入统计' : '已排除统计')
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出交易记录为 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,
|
||||
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',
|
||||
})
|
||||
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `交易记录_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.xlsx`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
message.success('导出成功')
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载分类和交易记录
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 顶部:标题和新增按钮 -->
|
||||
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
||||
<h2 style="margin: 0;">收支记录</h2>
|
||||
<NSpace>
|
||||
<NButton @click="handleExport">导出 Excel</NButton>
|
||||
<NButton type="primary" @click="handleAdd">新增记录</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<!-- 筛选条件区域 -->
|
||||
<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="categories.filter(c => filterType.length === 0 || filterType.includes(c.type))"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:max-tag-count="2"
|
||||
placeholder="分类"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterPaymentMethod"
|
||||
:options="paymentOptions"
|
||||
multiple
|
||||
clearable
|
||||
:max-tag-count="2"
|
||||
placeholder="支付方式"
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NDatePicker
|
||||
v-model:value="filterDateRange"
|
||||
type="daterange"
|
||||
clearable
|
||||
style="width: 260px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterIncludeInStats"
|
||||
:options="includeInStatsOptions"
|
||||
clearable
|
||||
placeholder="计入统计"
|
||||
style="width: 130px;"
|
||||
/>
|
||||
<NButton type="primary" @click="handleSearch">查询</NButton>
|
||||
<NButton @click="handleReset">重置</NButton>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<!-- 交易记录数据表格 -->
|
||||
<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" />
|
||||
</NSpace>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<NModal v-model:show="showModal" preset="card" :title="editingId ? '编辑记录' : '新增记录'" style="width: 500px;">
|
||||
<NForm>
|
||||
<NFormItem label="类型">
|
||||
<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 />
|
||||
</NFormItem>
|
||||
<NFormItem label="金额">
|
||||
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" placeholder="请输入金额" />
|
||||
</NFormItem>
|
||||
<NFormItem label="支付方式">
|
||||
<NSelect v-model:value="form.paymentMethod" :options="paymentOptions" clearable placeholder="选择支付方式" />
|
||||
</NFormItem>
|
||||
<NFormItem label="交易时间">
|
||||
<NDatePicker v-model:value="form.transactionTime" type="datetime" style="width: 100%;" />
|
||||
</NFormItem>
|
||||
<NFormItem label="备注">
|
||||
<NInput v-model:value="form.remark" type="textarea" placeholder="可选备注说明" />
|
||||
</NFormItem>
|
||||
<NFormItem label="计入统计">
|
||||
<NSwitch v-model:value="form.includeInStats" />
|
||||
<span style="color: #999; margin-left: 8px; font-size: 12px;">关闭后不纳入月度统计(如借款、转账等)</span>
|
||||
</NFormItem>
|
||||
<NButton type="primary" block @click="handleSubmit">确定</NButton>
|
||||
</NForm>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user