feat: update finance manager and fix some issue

This commit is contained in:
2025-09-19 23:10:54 +08:00
parent b36c8183f2
commit fabc00c4f3
23 changed files with 1982 additions and 22 deletions
View File
+30
View File
@@ -0,0 +1,30 @@
from django.contrib import admin
from .models import Department, Category, FinancialRecord, ProofImage
@admin.register(Department)
class DepartmentAdmin(admin.ModelAdmin):
list_display = ['name']
search_fields = ['name']
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ['name']
search_fields = ['name']
@admin.register(ProofImage)
class ProofImageAdmin(admin.ModelAdmin):
list_display = ['financial_record', 'image', 'description', 'uploaded_at']
list_filter = ['uploaded_at', 'financial_record']
search_fields = ['description', 'financial_record__title']
readonly_fields = ['uploaded_at']
@admin.register(FinancialRecord)
class FinancialRecordAdmin(admin.ModelAdmin):
list_display = ['title', 'amount', 'record_type', 'transaction_date', 'department', 'category']
list_filter = ['record_type', 'department', 'category', 'transaction_date']
search_fields = ['title', 'description']
date_hierarchy = 'transaction_date'
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class FinanceConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "finance"
@@ -0,0 +1 @@
@@ -0,0 +1,55 @@
from django.core.management.base import BaseCommand
from finance.models import Department, Category
# 创建:python manage.py init_finance_data
class Command(BaseCommand):
help = '初始化财务系统的部门和类别数据'
def handle(self, *args, **options):
# 创建部门
departments = [
'爱特工作室本部',
'程序部',
'Web部',
'游戏部',
'IOS部',
'APP部',
'UI部',
'智能应用部',
'OpenHarmony部',
'FOSS部'
]
for dept_name in departments:
dept, created = Department.objects.get_or_create(name=dept_name)
if created:
self.stdout.write(self.style.SUCCESS(f'创建部门: {dept_name}'))
else:
self.stdout.write(f'部门已存在: {dept_name}')
# 创建类别
categories = [
'办公用品',
'设备采购',
'软件授权',
'差旅费',
'会议费',
'宣传费用',
'日用品费用',
'维护费用',
'其他支出',
'项目收入',
'服务收入',
'资金交接',
'其他收入'
]
for cat_name in categories:
cat, created = Category.objects.get_or_create(name=cat_name)
if created:
self.stdout.write(self.style.SUCCESS(f'创建类别: {cat_name}'))
else:
self.stdout.write(f'类别已存在: {cat_name}')
self.stdout.write(self.style.SUCCESS('初始化完成!'))
+80
View File
@@ -0,0 +1,80 @@
from django.db import models
from django.contrib.auth.models import User
def proof_image_upload_path(instance, filename):
"""
自定义文件上传路径
格式: proofs/{记录ID}-{年月日}/{文件名}
"""
record = instance.financial_record
date_str = record.transaction_date.strftime('%Y%m%d')
folder_name = f"{record.id}-{date_str}"
return f'proofs/{folder_name}/{filename}'
class Department(models.Model):
"""部门"""
name = models.CharField(max_length=100, unique=True, verbose_name="部门名称")
def __str__(self):
return self.name
class Meta:
verbose_name = "部门"
verbose_name_plural = verbose_name
class Category(models.Model):
"""财务记录类别"""
name = models.CharField(max_length=100, unique=True, verbose_name="类别名称")
def __str__(self):
return self.name
class Meta:
verbose_name = "财务类别"
verbose_name_plural = verbose_name
class FinancialRecord(models.Model):
"""财务记录"""
RECORD_TYPE_CHOICES = [
('expense', '支出'),
('income', '收入'),
]
title = models.CharField(max_length=200, verbose_name="标题")
description = models.TextField(blank=True, null=True, verbose_name="描述")
amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="金额")
record_type = models.CharField(max_length=10, choices=RECORD_TYPE_CHOICES, verbose_name="记录类型")
transaction_date = models.DateField(verbose_name="交易日期")
department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属部门")
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="类别")
fund_manager = models.CharField(max_length=100, blank=True, null=True, verbose_name="批准人")
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="创建人")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
def __str__(self):
return f"{self.title} - {self.amount}"
class Meta:
verbose_name = "财务记录"
verbose_name_plural = verbose_name
ordering = ['-transaction_date']
class ProofImage(models.Model):
"""凭证图片"""
financial_record = models.ForeignKey(FinancialRecord, on_delete=models.CASCADE, related_name='proof_images', verbose_name="财务记录")
image = models.ImageField(upload_to=proof_image_upload_path, verbose_name="凭证图片")
description = models.CharField(max_length=200, blank=True, null=True, verbose_name="图片描述")
uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间")
def __str__(self):
return f"{self.financial_record.title} - 凭证{self.id}"
class Meta:
verbose_name = "凭证图片"
verbose_name_plural = verbose_name
+48
View File
@@ -0,0 +1,48 @@
from rest_framework import serializers
from .models import FinancialRecord, Department, Category, ProofImage
class DepartmentSerializer(serializers.ModelSerializer):
class Meta:
model = Department
fields = '__all__'
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = '__all__'
class ProofImageSerializer(serializers.ModelSerializer):
"""凭证图片序列化器"""
class Meta:
model = ProofImage
fields = ['id', 'image', 'description', 'uploaded_at']
class FinancialRecordWriteSerializer(serializers.ModelSerializer):
"""用于创建和更新财务记录的序列化器"""
class Meta:
model = FinancialRecord
fields = '__all__'
class FinancialRecordReadSerializer(serializers.ModelSerializer):
"""用于读取财务记录的序列化器"""
department = DepartmentSerializer(read_only=True)
category = CategorySerializer(read_only=True)
proof_images = ProofImageSerializer(many=True, read_only=True)
class Meta:
model = FinancialRecord
fields = '__all__'
# 保持向后兼容的默认序列化器
class FinancialRecordSerializer(serializers.ModelSerializer):
proof_images = ProofImageSerializer(many=True, read_only=True)
class Meta:
model = FinancialRecord
fields = '__all__'
+1
View File
@@ -0,0 +1 @@
+13
View File
@@ -0,0 +1,13 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import FinancialRecordViewSet, DepartmentViewSet, CategoryViewSet, ProofImageViewSet
router = DefaultRouter()
router.register(r'finance', FinancialRecordViewSet)
router.register(r'departments', DepartmentViewSet)
router.register(r'finance_categories', CategoryViewSet)
router.register(r'proof-images', ProofImageViewSet)
urlpatterns = [
path('api/', include(router.urls)),
]
+98
View File
@@ -0,0 +1,98 @@
import os
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import FinancialRecord, Department, Category, ProofImage
from .serializers import (
FinancialRecordWriteSerializer,
FinancialRecordReadSerializer,
DepartmentSerializer,
CategorySerializer,
ProofImageSerializer
)
class FinancialRecordViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows financial records to be viewed or edited.
"""
queryset = FinancialRecord.objects.all()
def get_serializer_class(self):
"""根据操作类型返回不同的序列化器"""
if self.action in ['list', 'retrieve']:
return FinancialRecordReadSerializer
return FinancialRecordWriteSerializer
@action(detail=True, methods=['post'])
def upload_images(self, request, pk=None):
"""为财务记录上传多张凭证图片"""
record = self.get_object()
files = request.FILES.getlist('images')
if not files:
return Response({'error': '没有接收到图片文件'}, status=status.HTTP_400_BAD_REQUEST)
created_images = []
for file in files:
proof_image = ProofImage.objects.create(
financial_record=record,
image=file,
description=request.data.get('description', '')
)
created_images.append(ProofImageSerializer(proof_image).data)
return Response({
'message': f'成功上传 {len(created_images)} 张图片',
'images': created_images
}, status=status.HTTP_201_CREATED)
class ProofImageViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows proof images to be viewed or edited.
"""
queryset = ProofImage.objects.all()
serializer_class = ProofImageSerializer
def destroy(self, request, *args, **kwargs):
"""重写删除方法,确保删除图片记录时同时删除物理文件"""
instance = self.get_object()
# 获取文件路径
file_path = None
if instance.image:
try:
file_path = instance.image.path
except (ValueError, AttributeError):
# 如果文件路径无效或文件不存在,只删除数据库记录
pass
# 删除数据库记录
super().destroy(request, *args, **kwargs)
# 删除物理文件
if file_path and os.path.isfile(file_path):
try:
os.remove(file_path)
print(f"成功删除文件: {file_path}")
except OSError as e:
print(f"删除文件失败: {file_path}, 错误: {e}")
# 即使文件删除失败,也不抛出异常,因为数据库记录已经删除
return Response({'message': '图片删除成功'}, status=status.HTTP_200_OK)
class DepartmentViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows departments to be viewed or edited.
"""
queryset = Department.objects.all()
serializer_class = DepartmentSerializer
class CategoryViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows categories to be viewed or edited.
"""
queryset = Category.objects.all()
serializer_class = CategorySerializer
+4
View File
@@ -40,6 +40,7 @@ INSTALLED_APPS = [
"rest_framework", "rest_framework",
"corsheaders", "corsheaders",
"items", "items",
"finance",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -141,6 +142,9 @@ USE_TZ = True
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
# 安全设置(生产环境) # 安全设置(生产环境)
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = False # 如果使用HTTPS,设置为True SECURE_SSL_REDIRECT = False # 如果使用HTTPS,设置为True
+6
View File
@@ -17,9 +17,15 @@ Including another URLconf
from django.contrib import admin from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("", include("items.urls")), path("", include("items.urls")),
path("", include("finance.urls")),
path("api-auth/", include("rest_framework.urls")), path("api-auth/", include("rest_framework.urls")),
] ]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+1 -1
View File
@@ -5,7 +5,7 @@ from . import views
router = DefaultRouter() router = DefaultRouter()
router.register(r'items', views.ItemViewSet) router.register(r'items', views.ItemViewSet)
router.register(r'usages', views.ItemUsageViewSet) router.register(r'usages', views.ItemUsageViewSet)
router.register(r'categories', views.CategoryViewSet) router.register(r'item_categories', views.CategoryViewSet)
router.register(r'users', views.UserViewSet) router.register(r'users', views.UserViewSet)
urlpatterns = [ urlpatterns = [
+30 -5
View File
@@ -1,7 +1,7 @@
<template> <template>
<el-header class="app-header"> <el-header class="app-header">
<div class="header-content"> <div class="header-content">
<h1 class="logo">爱特工作室物品管理系统</h1> <h1 class="logo">爱特工作室物品管理及财务管理系统</h1>
<el-menu <el-menu
mode="horizontal" mode="horizontal"
:default-active="$route.path" :default-active="$route.path"
@@ -10,7 +10,7 @@
> >
<el-menu-item index="/"> <el-menu-item index="/">
<el-icon><House /></el-icon> <el-icon><House /></el-icon>
首页 物品统计
</el-menu-item> </el-menu-item>
<el-menu-item index="/items"> <el-menu-item index="/items">
<el-icon><Box /></el-icon> <el-icon><Box /></el-icon>
@@ -20,14 +20,31 @@
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
使用记录 使用记录
</el-menu-item> </el-menu-item>
<el-menu-item index="/finance">
<el-icon><Money /></el-icon>
财务管理
</el-menu-item>
<el-menu-item index="/finance/records">
<el-icon><Tickets /></el-icon>
财务记录
</el-menu-item>
</el-menu> </el-menu>
</div> </div>
</el-header> </el-header>
</template> </template>
<script> <script>
import { House, Box, Document, Money, Tickets } from '@element-plus/icons-vue'
export default { export default {
name: 'AppHeader' name: 'AppHeader',
components: {
House,
Box,
Document,
Money,
Tickets
}
} }
</script> </script>
@@ -47,8 +64,8 @@ export default {
.header-content { .header-content {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
width: 100%; width: 100%;
position: relative;
} }
.logo { .logo {
@@ -56,12 +73,20 @@ export default {
font-size: 20px; font-size: 20px;
font-weight: bold; font-weight: bold;
margin: 0; margin: 0;
position: absolute;
left: 0;
z-index: 1;
} }
.nav-menu { .nav-menu {
border-bottom: none; border-bottom: none;
width: 60%;
background-color: transparent; background-color: transparent;
width: 100%;
display: flex;
justify-content: center;
position: absolute;
left: 0;
right: 0;
} }
.nav-menu .el-menu-item { .nav-menu .el-menu-item {
+19
View File
@@ -4,6 +4,9 @@ import ItemList from '../views/ItemList.vue'
import ItemDetail from '../views/ItemDetail.vue' import ItemDetail from '../views/ItemDetail.vue'
import ItemUsage from '../views/ItemUsage.vue' import ItemUsage from '../views/ItemUsage.vue'
import Dashboard from '../views/Dashboard.vue' import Dashboard from '../views/Dashboard.vue'
import FinanceDashboard from '../views/FinanceDashboard.vue'
import FinanceRecordList from '../views/FinanceRecordList.vue'
import FinanceRecordDetail from '../views/FinanceRecordDetail.vue'
const routes = [ const routes = [
{ {
@@ -31,6 +34,22 @@ const routes = [
path: '/usage', path: '/usage',
name: 'ItemUsage', name: 'ItemUsage',
component: ItemUsage component: ItemUsage
},
{
path: '/finance',
name: 'FinanceDashboard',
component: FinanceDashboard
},
{
path: '/finance/records',
name: 'FinanceRecordList',
component: FinanceRecordList
},
{
path: '/finance/records/:id',
name: 'FinanceRecordDetail',
component: FinanceRecordDetail,
props: true
} }
] ]
+64 -12
View File
@@ -1,6 +1,8 @@
import axios from 'axios' import axios from 'axios'
const API_BASE_URL = 'http://localhost:8000/api' export const API_BASE_URL = 'http://localhost:8000/api'
export const API_BASE_URL_WITHOUT_API = 'http://localhost:8000'
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
@@ -109,22 +111,22 @@ export const usageService = {
export const categoryService = { export const categoryService = {
// 获取所有类别 // 获取所有类别
getAllCategories() { getAllCategories() {
return apiClient.get('/categories/') return apiClient.get('/item_categories/')
}, },
// 创建新类别 // 创建新类别
createCategory(category) { createCategory(category) {
return apiClient.post('/categories/', category) return apiClient.post('/item_categories/', category)
}, },
// 更新类别 // 更新类别
updateCategory(id, category) { updateCategory(id, category) {
return apiClient.put(`/categories/${id}/`, category) return apiClient.put(`/item_categories/${id}/`, category)
}, },
// 删除类别 // 删除类别
deleteCategory(id) { deleteCategory(id) {
return apiClient.delete(`/categories/${id}/`) return apiClient.delete(`/item_categories/${id}/`)
} }
} }
@@ -135,11 +137,61 @@ export const userService = {
} }
} }
// 健康检查API export const financeService = {
export const healthService = { // 获取所有财务记录
checkBackendConnection() { getAllFinanceRecords() {
return apiClient.get('/items/') return apiClient.get('/finance/')
} },
}
export default apiClient // 获取单个财务记录
getFinanceRecord(id) {
return apiClient.get(`/finance/${id}/`)
},
// 创建财务记录
createFinanceRecord(record) {
return apiClient.post('/finance/', record, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// 更新财务记录
updateFinanceRecord(id, record) {
return apiClient.put(`/finance/${id}/`, record, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// 删除财务记录
deleteFinanceRecord(id) {
return apiClient.delete(`/finance/${id}/`)
},
// 为财务记录上传多张凭证图片
uploadImages(recordId, formData) {
return apiClient.post(`/finance/${recordId}/upload_images/`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// 删除凭证图片
deleteProofImage(imageId) {
return apiClient.delete(`/proof-images/${imageId}/`)
},
// 获取所有部门
getAllDepartments() {
return apiClient.get('/departments/')
},
// 获取所有类别
getAllCategories() {
return apiClient.get('/finance_categories/')
},
}
+441
View File
@@ -0,0 +1,441 @@
<template>
<div>
<AppHeader />
<div class="finance-dashboard">
<!-- 总体统计 -->
<el-card class="summary-card">
<template #header>
<span>财务概览</span>
</template>
<el-row :gutter="20">
<el-col :span="6">
<el-statistic title="总收入" :value="totalIncome" :precision="2" suffix="元">
<template #prefix>
<el-icon style="vertical-align: middle;"><TrendCharts /></el-icon>
</template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="总支出" :value="totalExpense" :precision="2" suffix="元">
<template #prefix>
<el-icon style="vertical-align: middle;"><Money /></el-icon>
</template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="总剩余资金" :value="netProfit" :precision="2" suffix="元"
:value-style="netProfit >= 0 ? 'color: #67c23a' : 'color: #f56c6c'">
<template #prefix>
<el-icon style="vertical-align: middle;"><Wallet /></el-icon>
</template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="记录总数" :value="totalRecords">
<template #prefix>
<el-icon style="vertical-align: middle;"><Document /></el-icon>
</template>
</el-statistic>
</el-col>
</el-row>
</el-card>
<!-- 本月统计 -->
<el-card class="monthly-card">
<template #header>
<span>{{ currentMonth }}月统计</span>
</template>
<el-row :gutter="20">
<el-col :span="8">
<el-statistic title="本月收入" :value="monthlyIncome" :precision="2" suffix=""
value-style="color: #67c23a">
</el-statistic>
</el-col>
<el-col :span="8">
<el-statistic title="本月支出" :value="monthlyExpense" :precision="2" suffix=""
value-style="color: #f56c6c">
</el-statistic>
</el-col>
<el-col :span="8">
<el-statistic title="流水总结" :value="monthlyNet" :precision="2" suffix=""
:value-style="monthlyNet >= 0 ? 'color: #67c23a' : 'color: #f56c6c'">
</el-statistic>
</el-col>
</el-row>
</el-card>
<!-- 部门财务统计 -->
<el-card class="department-card">
<template #header>
<span>重点部门财务统计</span>
</template>
<el-row :gutter="20">
<el-col :span="8" v-for="dept in keyDepartments" :key="dept.name">
<el-card class="dept-stat-card" :class="dept.name">
<h3>{{ dept.name }}</h3>
<div class="dept-stats">
<div class="stat-item">
<span class="label">收入:</span>
<span class="value income">+¥{{ dept.income.toFixed(2) }}</span>
</div>
<div class="stat-item">
<span class="label">支出:</span>
<span class="value expense">-¥{{ dept.expense.toFixed(2) }}</span>
</div>
<div class="stat-item">
<span class="label">余额:</span>
<span class="value" :class="dept.balance >= 0 ? 'positive' : 'negative'">
¥{{ dept.balance.toFixed(2) }}
</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
</el-card>
<!-- 收支流水图 -->
<el-card class="chart-card">
<template #header>
<span>收支流水图</span>
</template>
<div ref="chart" style="height: 400px;"></div>
</el-card>
</div>
</div>
</template>
<script>
import { financeService } from '@/services/api';
import * as echarts from 'echarts';
import { TrendCharts, Money, Wallet, Document } from '@element-plus/icons-vue';
import AppHeader from '@/components/AppHeader.vue';
export default {
name: 'FinanceDashboard',
components: {
AppHeader,
TrendCharts,
Money,
Wallet,
Document
},
data() {
return {
records: [],
chart: null,
currentMonth: new Date().getMonth() + 1,
keyDepartmentNames: ['爱特工作室本部', 'UI部', '游戏部']
};
},
computed: {
totalIncome() {
return this.records
.filter(r => r.record_type === 'income')
.reduce((sum, r) => sum + parseFloat(r.amount), 0);
},
totalExpense() {
return this.records
.filter(r => r.record_type === 'expense')
.reduce((sum, r) => sum + parseFloat(r.amount), 0);
},
netProfit() {
return this.totalIncome - this.totalExpense;
},
totalRecords() {
return this.records.length;
},
monthlyIncome() {
const currentMonth = new Date().getMonth() + 1;
const currentYear = new Date().getFullYear();
return this.records
.filter(r => {
const recordDate = new Date(r.transaction_date);
return r.record_type === 'income' &&
recordDate.getMonth() + 1 === currentMonth &&
recordDate.getFullYear() === currentYear;
})
.reduce((sum, r) => sum + parseFloat(r.amount), 0);
},
monthlyExpense() {
const currentMonth = new Date().getMonth() + 1;
const currentYear = new Date().getFullYear();
return this.records
.filter(r => {
const recordDate = new Date(r.transaction_date);
return r.record_type === 'expense' &&
recordDate.getMonth() + 1 === currentMonth &&
recordDate.getFullYear() === currentYear;
})
.reduce((sum, r) => sum + parseFloat(r.amount), 0);
},
monthlyNet() {
return this.monthlyIncome - this.monthlyExpense;
},
keyDepartments() {
return this.keyDepartmentNames.map(deptName => {
const deptRecords = this.records.filter(r =>
r.department && r.department.name === deptName
);
const income = deptRecords
.filter(r => r.record_type === 'income')
.reduce((sum, r) => sum + parseFloat(r.amount), 0);
const expense = deptRecords
.filter(r => r.record_type === 'expense')
.reduce((sum, r) => sum + parseFloat(r.amount), 0);
return {
name: deptName,
income,
expense,
balance: income - expense
};
});
}
},
async created() {
await this.fetchRecords();
this.$nextTick(() => {
this.initChart();
});
},
methods: {
async fetchRecords() {
try {
const response = await financeService.getAllFinanceRecords();
this.records = response.data;
} catch (error) {
this.$message.error('获取财务记录失败');
}
},
initChart() {
if (!this.$refs.chart) return;
this.chart = echarts.init(this.$refs.chart);
const dates = [...new Set(this.records.map(r => r.transaction_date))].sort();
const option = {
tooltip: {
trigger: 'axis',
formatter: function(params) {
let result = params[0].axisValueLabel + '<br/>';
params.forEach(param => {
result += `${param.seriesName}: ¥${param.value}<br/>`;
});
return result;
}
},
legend: {
data: ['收入', '支出']
},
xAxis: {
type: 'category',
data: dates,
},
yAxis: {
type: 'value',
axisLabel: {
formatter: '¥{value}'
}
},
series: [
{
name: '收入',
type: 'line',
smooth: true,
itemStyle: { color: '#67c23a' },
data: this.getChartData('income', dates),
label: {
show: false,
position: 'top',
formatter: '¥{c}',
color: '#67c23a',
fontWeight: 'bold'
},
emphasis: {
label: {
show: true
}
}
},
{
name: '支出',
type: 'line',
smooth: true,
itemStyle: { color: '#f56c6c' },
data: this.getChartData('expense', dates),
label: {
show: false,
position: 'top',
formatter: '¥{c}',
color: '#f56c6c',
fontWeight: 'bold'
},
emphasis: {
label: {
show: true
}
}
},
],
};
this.chart.setOption(option);
// 添加点击事件监听器
this.chart.on('click', (params) => {
const { seriesName, value, name: date, dataIndex } = params;
// 获取该日期的详细记录
const dayRecords = this.records.filter(r => r.transaction_date === date);
const typeRecords = dayRecords.filter(r =>
(seriesName === '收入' && r.record_type === 'income') ||
(seriesName === '支出' && r.record_type === 'expense')
);
// 构建详细信息
let detailInfo = `<div style="max-width: 400px;">
<h3 style="margin: 0 0 15px 0; color: #409eff;">${date} - ${seriesName}</h3>
<p style="margin: 0 0 10px 0; font-size: 18px; font-weight: bold; color: ${seriesName === '收入' ? '#67c23a' : '#f56c6c'};">
总计: ¥${value.toFixed(2)}
</p>
<div style="max-height: 300px; overflow-y: auto;">`;
if (typeRecords.length > 0) {
detailInfo += `<p style="margin: 0 0 10px 0; font-weight: bold;">具体记录 (${typeRecords.length}条):</p>`;
typeRecords.forEach((record, index) => {
detailInfo += `
<div style="padding: 8px; margin-bottom: 8px; background-color: #f5f7fa; border-radius: 4px; border-left: 3px solid ${seriesName === '收入' ? '#67c23a' : '#f56c6c'};">
<div style="font-weight: bold; color: #303133;">${record.title}</div>
<div style="display: flex; justify-content: space-between; margin-top: 4px;">
<span style="color: #606266;">¥${parseFloat(record.amount).toFixed(2)}</span>
${record.department ? `<span style="color: #909399; font-size: 12px;">${record.department.name}</span>` : ''}
</div>
${record.description ? `<div style="color: #909399; font-size: 12px; margin-top: 4px;">${record.description}</div>` : ''}
</div>`;
});
} else {
detailInfo += '<p style="color: #909399;">该日期无相关记录</p>';
}
detailInfo += '</div></div>';
// 显示详细信息对话框
this.$alert(detailInfo, '财务详情', {
dangerouslyUseHTMLString: true,
customClass: 'finance-detail-alert',
showClose: true,
closeOnClickModal: true,
closeOnPressEscape: true
});
});
// 添加鼠标悬停时显示数据标签
this.chart.on('mouseover', (params) => {
const option = this.chart.getOption();
option.series[params.seriesIndex].label.show = true;
this.chart.setOption(option);
});
this.chart.on('mouseout', (params) => {
const option = this.chart.getOption();
option.series[params.seriesIndex].label.show = false;
this.chart.setOption(option);
});
},
getChartData(type, dates) {
const data = {};
this.records
.filter(r => r.record_type === type)
.forEach(r => {
if (data[r.transaction_date]) {
data[r.transaction_date] += parseFloat(r.amount);
} else {
data[r.transaction_date] = parseFloat(r.amount);
}
});
return dates.map(date => data[date] || 0);
},
},
};
</script>
<style scoped>
.finance-dashboard {
padding: 20px;
}
.summary-card, .monthly-card, .department-card, .chart-card {
margin-bottom: 20px;
}
.dept-stat-card {
height: 180px;
text-align: center;
transition: transform 0.3s, box-shadow 0.3s;
}
.dept-stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
}
.dept-stat-card h3 {
margin: 0 0 15px 0;
font-size: 18px;
color: #409eff;
}
.dept-stats {
text-align: left;
}
.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding: 5px 0;
}
.stat-item .label {
font-weight: bold;
color: #666;
}
.stat-item .value {
font-weight: bold;
font-size: 16px;
}
.value.income {
color: #67c23a;
}
.value.expense {
color: #f56c6c;
}
.value.positive {
color: #67c23a;
}
.value.negative {
color: #f56c6c;
}
/* 部门特色样式 */
.dept-stat-card.爱特工作室本部 {
border-left: 4px solid #409eff;
}
.dept-stat-card.UI部 {
border-left: 4px solid #67c23a;
}
.dept-stat-card.游戏部 {
border-left: 4px solid #e6a23c;
}
</style>
@@ -0,0 +1,305 @@
<template>
<div>
<AppHeader />
<div class="finance-detail-container">
<div class="detail-header">
<el-button @click="$router.go(-1)" style="margin-bottom: 20px;">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
</div>
<el-card v-if="record">
<template #header>
<div class="card-header">
<span>财务记录详情</span>
<div>
<el-button @click="showEditDialog" type="primary">编辑</el-button>
</div>
</div>
</template>
<div class="record-details">
<el-row :gutter="20">
<el-col :span="12">
<div class="detail-item">
<label>标题</label>
<span>{{ record.title }}</span>
</div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<label>金额</label>
<span class="amount" :class="record.record_type">
{{ record.record_type === 'income' ? '+' : '-' }}¥{{ record.amount }}
</span>
</div>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<div class="detail-item">
<label>类型</label>
<el-tag :type="record.record_type === 'income' ? 'success' : 'danger'">
{{ record.record_type === 'income' ? '收入' : '支出' }}
</el-tag>
</div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<label>交易日期</label>
<span>{{ record.transaction_date }}</span>
</div>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<div class="detail-item">
<label>所属部门</label>
<span>{{ record.department ? record.department.name : '-' }}</span>
</div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<label>类别</label>
<span>{{ record.category ? record.category.name : '-' }}</span>
</div>
</el-col>
</el-row>
<el-row v-if="record.description">
<el-col :span="24">
<div class="detail-item">
<label>描述</label>
<p class="description">{{ record.description }}</p>
</div>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<div class="detail-item">
<label>批准人</label>
<span>{{ record.fund_manager || '-' }}</span>
</div>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<div class="detail-item">
<label>创建时间</label>
<span>{{ formatDateTime(record.created_at) }}</span>
</div>
</el-col>
</el-row>
</div>
<!-- 凭证图片区域 -->
<el-divider>交易凭证</el-divider>
<div class="proof-images-section">
<div class="images-grid" v-if="record.proof_images && record.proof_images.length > 0">
<div
v-for="image in record.proof_images"
:key="image.id"
class="image-item">
<div class="image-container">
<img
:src="getImageUrl(image.image)"
:alt="image.description || '凭证图片'"
@click="showImagePreview(image)"
class="proof-image">
</div>
<p class="image-description" v-if="image.description">{{ image.description }}</p>
</div>
</div>
<div v-else class="no-images">
<p>暂无凭证图片</p>
<p class="tip">可通过编辑记录来添加凭证图片</p>
</div>
</div>
</el-card>
<!-- 图片预览对话框 -->
<el-dialog v-model="previewVisible" title="凭证图片" width="60%">
<div class="image-preview-container">
<img :src="previewImageUrl" alt="凭证图片" class="preview-image">
</div>
</el-dialog>
<!-- 编辑对话框 -->
<el-dialog :title="'编辑财务记录'" v-model="editDialogVisible" width="60%">
<finance-record-form
:record="record"
@submit="handleEditSubmit"
@cancel="editDialogVisible = false"
></finance-record-form>
</el-dialog>
</div>
</div>
</template>
<script>
import { financeService, API_BASE_URL_WITHOUT_API } from '@/services/api';
import { Plus, Delete } from '@element-plus/icons-vue';
import AppHeader from "@/components/AppHeader.vue";
import FinanceRecordForm from './FinanceRecordForm.vue';
export default {
name: 'FinanceRecordDetail',
components: {
AppHeader,
FinanceRecordForm,
Plus,
Delete
},
data() {
return {
record: null,
previewVisible: false,
previewImageUrl: '',
editDialogVisible: false,
uploadAction: ''
};
},
async created() {
await this.fetchRecord();
},
methods: {
async fetchRecord() {
try {
const response = await financeService.getFinanceRecord(this.$route.params.id);
this.record = response.data;
} catch (error) {
this.$message.error('获取记录详情失败');
this.$router.go(-1);
}
},
getImageUrl(imagePath) {
if (!imagePath) return '';
return imagePath.startsWith('http')
? imagePath
: `${API_BASE_URL_WITHOUT_API}${imagePath}`;
},
showImagePreview(image) {
this.previewImageUrl = this.getImageUrl(image.image);
this.previewVisible = true;
},
showEditDialog() {
this.editDialogVisible = true;
},
async handleEditSubmit() {
this.editDialogVisible = false;
await this.fetchRecord();
},
formatDateTime(dateTimeStr) {
if (!dateTimeStr) return '';
return new Date(dateTimeStr).toLocaleString('zh-CN');
}
}
};
</script>
<style scoped>
.finance-detail-container {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.record-details {
margin-bottom: 20px;
}
.detail-item {
margin-bottom: 15px;
}
.detail-item label {
font-weight: bold;
margin-right: 8px;
color: #666;
}
.amount.income {
color: #67c23a;
font-weight: bold;
}
.amount.expense {
color: #f56c6c;
font-weight: bold;
}
.description {
margin: 5px 0;
line-height: 1.6;
color: #333;
}
.proof-images-section {
margin-top: 20px;
}
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.image-item {
text-align: center;
}
.image-container {
position: relative;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.proof-image {
width: 100%;
height: 150px;
object-fit: cover;
cursor: pointer;
transition: transform 0.3s;
}
.proof-image:hover {
transform: scale(1.05);
}
.image-description {
margin-top: 8px;
font-size: 12px;
color: #666;
}
.no-images {
text-align: center;
color: #999;
padding: 40px 0;
}
.image-preview-container {
text-align: center;
}
.preview-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
</style>
+432
View File
@@ -0,0 +1,432 @@
<template>
<el-form :model="form" ref="form" label-width="100px">
<el-form-item label="标题" prop="title" required>
<el-input v-model="form.title"></el-input>
</el-form-item>
<el-form-item label="金额" prop="amount" required>
<el-input-number v-model="form.amount" :precision="2" :step="1"></el-input-number>
</el-form-item>
<el-form-item label="记录类型" prop="record_type" required>
<el-radio-group v-model="form.record_type">
<el-radio label="expense">支出</el-radio>
<el-radio label="income">收入</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="日期" prop="transaction_date" required>
<el-date-picker v-model="form.transaction_date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD"></el-date-picker>
</el-form-item>
<el-form-item label="所属部门" prop="department">
<el-select v-model="form.department" placeholder="请选择部门">
<el-option v-for="dept in departments" :key="dept.id" :label="dept.name" :value="dept.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="类别" prop="category">
<el-select v-model="form.category" placeholder="请选择类别">
<el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" rows="3" placeholder="必须注明所有信息"></el-input>
</el-form-item>
<!-- 批准人字段 -->
<el-form-item label="批准人" prop="fund_manager">
<el-input v-model="form.fund_manager" placeholder="批准这条账目的人"></el-input>
</el-form-item>
<!-- 凭证图片上传区域 -->
<el-form-item label="凭证图片" v-if="record">
<div class="image-upload-section">
<el-upload
:action="uploadAction"
:http-request="handleUpload"
:show-file-list="false"
list-type="picture-card"
accept="image/*"
multiple>
<div class="upload-placeholder">
<el-icon><Plus /></el-icon>
<div class="upload-text">添加凭证</div>
</div>
</el-upload>
<!-- 已有图片展示 -->
<div class="existing-images" v-if="existingImages.length > 0">
<div
v-for="image in existingImages"
:key="image.id"
class="existing-image-item">
<div class="image-container">
<img
:src="getImageUrl(image.image)"
:alt="image.description || '凭证图片'"
@click="showImagePreview(image)"
class="proof-image">
<div class="image-overlay">
<el-button
type="danger"
size="small"
circle
@click="deleteExistingImage(image.id)"
class="delete-btn">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
<!-- 新上传图片展示 -->
<div class="new-images" v-if="newImages.length > 0">
<div
v-for="(image, index) in newImages"
:key="'new-' + index"
class="new-image-item">
<div class="image-container">
<img
:src="image.url"
:alt="'新上传图片'"
class="proof-image">
<div class="image-overlay">
<el-button
type="danger"
size="small"
circle
@click="removeNewImage(index)"
class="delete-btn">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm">提交</el-button>
<el-button @click="cancel">取消</el-button>
</el-form-item>
</el-form>
<!-- 图片预览对话框 -->
<el-dialog v-model="previewVisible" title="图片预览" width="60%">
<div class="image-preview-container">
<img :src="previewImageUrl" alt="预览图片" class="preview-image">
</div>
</el-dialog>
</template>
<script>
import { financeService, API_BASE_URL_WITHOUT_API } from '@/services/api';
import { Plus, Delete } from '@element-plus/icons-vue';
export default {
name: 'FinanceRecordForm',
components: {
Plus,
Delete
},
props: {
record: {
type: Object,
default: null,
},
},
data() {
return {
form: {
title: '',
amount: 0,
record_type: 'expense',
transaction_date: '',
department: null,
category: null,
description: '',
fund_manager: '',
},
departments: [],
categories: [],
existingImages: [], // 已有的图片
newImages: [], // 新上传的图片
previewVisible: false,
previewImageUrl: '',
uploadAction: ''
};
},
watch: {
record: {
handler(newVal) {
if (newVal) {
// 处理编辑时的数据填充
this.form = {
title: newVal.title || '',
amount: newVal.amount || 0,
record_type: newVal.record_type || 'expense',
transaction_date: newVal.transaction_date || '',
department: newVal.department ? newVal.department.id : null,
category: newVal.category ? newVal.category.id : null,
description: newVal.description || '',
fund_manager: newVal.fund_manager || '',
};
this.existingImages = newVal.proof_images || [];
this.newImages = [];
} else {
this.resetForm();
}
},
immediate: true,
},
},
async created() {
this.fetchDepartments();
this.fetchCategories();
},
methods: {
async fetchDepartments() {
try {
const response = await financeService.getAllDepartments();
this.departments = response.data;
} catch (error) {
this.$message.error('获取部门列表失败');
}
},
async fetchCategories() {
try {
const response = await financeService.getAllCategories();
this.categories = response.data;
} catch (error) {
this.$message.error('获取类别列表失败');
}
},
async submitForm() {
this.$refs.form.validate(async (valid) => {
if (valid) {
try {
let recordId;
// 首先提交基本表单数据
const formData = new FormData();
Object.keys(this.form).forEach(key => {
const value = this.form[key];
if (value !== null && value !== undefined && value !== '') {
formData.append(key, value);
}
});
if (this.record) {
// 更新记录 - 只更新基本信息,不处理图片
await financeService.updateFinanceRecord(this.record.id, formData);
recordId = this.record.id;
this.$message.success('记录更新成功');
// 如果有新图片,单独上传(只在编辑模式下且有新图片时)
if (this.newImages.length > 0) {
const imageFormData = new FormData();
this.newImages.forEach((image) => {
imageFormData.append('images', image.file);
});
await financeService.uploadImages(recordId, imageFormData);
this.$message.success('新增凭证图片上传成功');
}
} else {
// 创建新记录
const response = await financeService.createFinanceRecord(formData);
recordId = response.data.id;
this.$message.success('记录创建成功');
// 如果有新图片,单独上传
if (this.newImages.length > 0) {
const imageFormData = new FormData();
this.newImages.forEach((image) => {
imageFormData.append('images', image.file);
});
await financeService.uploadImages(recordId, imageFormData);
this.$message.success('凭证图片上传成功');
}
}
this.$emit('submit');
} catch (error) {
console.error('提交失败:', error);
this.$message.error('操作失败: ' + (error.response?.data?.detail || '未知错误'));
}
}
});
},
cancel() {
this.$emit('cancel');
},
resetForm() {
this.form = {
title: '',
amount: 0,
record_type: 'expense',
transaction_date: '',
department: null,
category: null,
description: '',
fund_manager: '',
};
this.existingImages = [];
this.newImages = [];
},
handleUpload(options) {
// 简化上传逻辑,直接保存文件对象,在提交表单时一起上传
const file = options.file;
// 创建预览URL
const previewUrl = URL.createObjectURL(file);
// 添加到新图片列表
this.newImages.push({
file: file,
url: previewUrl,
name: file.name
});
// 调用成功回调
if (options.onSuccess) {
options.onSuccess();
}
},
showImagePreview(image) {
if (image.image) {
// 已有图片
this.previewImageUrl = this.getImageUrl(image.image);
} else {
// 新上传图片
this.previewImageUrl = image.url;
}
this.previewVisible = true;
},
getImageUrl(imagePath) {
if (!imagePath) return '';
return imagePath.startsWith('http')
? imagePath
: `${API_BASE_URL_WITHOUT_API}${imagePath}`;
},
async deleteExistingImage(imageId) {
try {
await this.$confirm('确定删除这张图片吗?', '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
await financeService.deleteProofImage(imageId);
this.$message.success('删除成功');
this.existingImages = this.existingImages.filter(img => img.id !== imageId);
} catch (error) {
if (error !== 'cancel') {
this.$message.error('删除失败');
}
}
},
removeNewImage(index) {
this.newImages.splice(index, 1);
},
},
};
</script>
<style scoped>
.image-upload-section {
position: relative;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100px;
border: 2px dashed #d3d3d3;
border-radius: 4px;
cursor: pointer;
}
.upload-placeholder:hover {
border-color: #409eff;
}
.upload-placeholder .el-icon {
font-size: 28px;
color: #409eff;
}
.upload-placeholder .upload-text {
margin-top: 8px;
font-size: 14px;
color: #666;
}
.existing-images,
.new-images {
display: flex;
flex-wrap: wrap;
margin-top: 10px;
}
.existing-image-item,
.new-image-item {
position: relative;
margin-right: 10px;
margin-bottom: 10px;
}
.image-container {
position: relative;
}
.proof-image {
display: block;
max-width: 100px;
max-height: 100px;
object-fit: cover;
border-radius: 4px;
}
.image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
}
.existing-image-item:hover .image-overlay,
.new-image-item:hover .image-overlay {
opacity: 1;
}
.delete-btn {
background-color: rgba(255, 255, 255, 0.8);
border: none;
cursor: pointer;
}
.delete-btn:hover {
background-color: rgba(255, 255, 255, 1);
}
.image-preview-container {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
</style>
+337
View File
@@ -0,0 +1,337 @@
<template>
<div>
<AppHeader />
<div>
<el-card>
<template #header>
<div class="card-header">
<span>财务记录</span>
<el-button type="primary" @click="showAddDialog">添加记录</el-button>
</div>
</template>
<!-- 搜索和筛选区域 -->
<div class="filter-section">
<el-row :gutter="20">
<el-col :span="6">
<el-input
v-model="searchKeyword"
placeholder="搜索标题或描述"
clearable
@input="handleSearch">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-col>
<el-col :span="4">
<el-select v-model="typeFilter" placeholder="类型筛选" clearable @change="handleFilter">
<el-option label="全部" value="" />
<el-option label="收入" value="income" />
<el-option label="支出" value="expense" />
</el-select>
</el-col>
<el-col :span="4">
<el-select v-model="departmentFilter" placeholder="部门筛选" clearable @change="handleFilter">
<el-option label="全部部门" value="" />
<el-option
v-for="dept in departments"
:key="dept.id"
:label="dept.name"
:value="dept.id" />
</el-select>
</el-col>
<el-col :span="4">
<el-select v-model="categoryFilter" placeholder="类别筛选" clearable @change="handleFilter">
<el-option label="全部类别" value="" />
<el-option
v-for="cat in categories"
:key="cat.id"
:label="cat.name"
:value="cat.id" />
</el-select>
</el-col>
<el-col :span="6">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
@change="handleFilter">
</el-date-picker>
</el-col>
</el-row>
</div>
<el-table :data="paginatedRecords" style="width: 100%" v-loading="loading">
<el-table-column prop="title" label="标题" min-width="150"></el-table-column>
<el-table-column prop="amount" label="金额" width="120" sortable>
<template #default="scope">
<span :class="['amount', scope.row.record_type]">
{{ scope.row.record_type === 'income' ? '+' : '-' }}¥{{ scope.row.amount }}
</span>
</template>
</el-table-column>
<el-table-column prop="record_type" label="类型" width="100">
<template #default="scope">
<el-tag :type="scope.row.record_type === 'income' ? 'success' : 'danger'">
{{ scope.row.record_type === 'income' ? '收入' : '支出' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="transaction_date" label="交易日期" width="120" sortable></el-table-column>
<el-table-column prop="department" label="部门" width="120">
<template #default="scope">
{{ scope.row.department ? scope.row.department.name : '-' }}
</template>
</el-table-column>
<el-table-column prop="category" label="类别" width="120">
<template #default="scope">
{{ scope.row.category ? scope.row.category.name : '-' }}
</template>
</el-table-column>
<el-table-column prop="fund_manager" label="批准人" width="120">
<template #default="scope">
{{ scope.row.fund_manager || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="scope">
<el-button size="small" @click="showDetail(scope.row.id)">详情</el-button>
<el-button size="small" @click="showEditDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteRecord(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="filteredRecords.length"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange">
</el-pagination>
</div>
</el-card>
<el-dialog :title="dialogTitle" v-model="dialogVisible" width="70%">
<finance-record-form
:record="selectedRecord"
@submit="handleFormSubmit"
@cancel="dialogVisible = false"
></finance-record-form>
</el-dialog>
</div>
</div>
</template>
<script>
import { financeService } from '@/services/api';
import FinanceRecordForm from './FinanceRecordForm.vue';
import AppHeader from "@/components/AppHeader.vue";
import { Search } from '@element-plus/icons-vue';
export default {
name: 'FinanceRecordList',
components: {
AppHeader,
FinanceRecordForm,
Search,
},
data() {
return {
records: [],
departments: [],
categories: [],
dialogVisible: false,
dialogTitle: '',
selectedRecord: null,
loading: false,
// 搜索和筛选
searchKeyword: '',
typeFilter: '',
departmentFilter: '',
categoryFilter: '',
dateRange: null,
// 分页
currentPage: 1,
pageSize: 20,
};
},
computed: {
filteredRecords() {
let filtered = [...this.records];
// 搜索过滤
if (this.searchKeyword) {
const keyword = this.searchKeyword.toLowerCase();
filtered = filtered.filter(record =>
record.title.toLowerCase().includes(keyword) ||
(record.description && record.description.toLowerCase().includes(keyword)) ||
(record.fund_manager && record.fund_manager.toLowerCase().includes(keyword)) ||
(record.fund_receiver && record.fund_receiver.toLowerCase().includes(keyword))
);
}
// 类型筛选
if (this.typeFilter) {
filtered = filtered.filter(record => record.record_type === this.typeFilter);
}
// 部门筛选
if (this.departmentFilter) {
filtered = filtered.filter(record =>
record.department && record.department.id === this.departmentFilter
);
}
// 类别筛选
if (this.categoryFilter) {
filtered = filtered.filter(record =>
record.category && record.category.id === this.categoryFilter
);
}
// 日期范围筛选
if (this.dateRange && this.dateRange.length === 2) {
const [startDate, endDate] = this.dateRange;
filtered = filtered.filter(record => {
const recordDate = record.transaction_date;
return recordDate >= startDate && recordDate <= endDate;
});
}
return filtered;
},
paginatedRecords() {
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
return this.filteredRecords.slice(start, end);
}
},
async created() {
await this.loadData();
},
methods: {
async loadData() {
await Promise.all([
this.fetchRecords(),
this.fetchDepartments(),
this.fetchCategories()
]);
},
async fetchRecords() {
this.loading = true;
try {
const response = await financeService.getAllFinanceRecords();
this.records = response.data;
} catch (error) {
this.$message.error('获取财务记录失败');
} finally {
this.loading = false;
}
},
async fetchDepartments() {
try {
const response = await financeService.getAllDepartments();
this.departments = response.data;
} catch (error) {
console.error('获取部门列表失败:', error);
}
},
async fetchCategories() {
try {
const response = await financeService.getAllCategories();
this.categories = response.data;
} catch (error) {
console.error('获取类别列表失败:', error);
}
},
handleSearch() {
this.currentPage = 1; // 搜索时重置到第一页
},
handleFilter() {
this.currentPage = 1; // 筛选时重置到第一页
},
handleSizeChange(size) {
this.pageSize = size;
this.currentPage = 1;
},
handleCurrentChange(page) {
this.currentPage = page;
},
showAddDialog() {
this.selectedRecord = null;
this.dialogTitle = '添加财务记录';
this.dialogVisible = true;
},
showEditDialog(record) {
this.selectedRecord = { ...record };
this.dialogTitle = '编辑财务记录';
this.dialogVisible = true;
},
showDetail(recordId) {
this.$router.push(`/finance/records/${recordId}`);
},
async deleteRecord(id) {
try {
await this.$confirm('确认删除这条财务记录?', '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
await financeService.deleteFinanceRecord(id);
this.fetchRecords();
this.$message.success('删除成功');
} catch (error) {
if (error !== 'cancel') {
this.$message.error('删除失败');
}
}
},
handleFormSubmit() {
this.dialogVisible = false;
this.fetchRecords();
},
},
};
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-section {
margin-bottom: 20px;
padding: 16px;
background-color: #f5f7fa;
border-radius: 4px;
}
.amount.income {
color: #67c23a;
font-weight: bold;
}
.amount.expense {
color: #f56c6c;
font-weight: bold;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>
+2 -3
View File
@@ -2,7 +2,6 @@
<AppHeader /> <AppHeader />
<div class="item-usage"> <div class="item-usage">
<div class="toolbar"> <div class="toolbar">
<h2>使用记录</h2>
<div class="filters"> <div class="filters">
<el-select v-model="statusFilter" placeholder="状态筛选" @change="handleFilter"> <el-select v-model="statusFilter" placeholder="状态筛选" @change="handleFilter">
<el-option label="全部" value="" /> <el-option label="全部" value="" />
@@ -150,7 +149,7 @@
</template> </template>
<script> <script>
import { usageService, userService } from '../services/api' import { usageService, userService } from '@/services/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import moment from 'moment' import moment from 'moment'
import AppHeader from '../components/AppHeader.vue' import AppHeader from '../components/AppHeader.vue'
@@ -299,7 +298,7 @@ export default {
.toolbar { .toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: flex-end;
align-items: center; align-items: center;
margin-bottom: 20px; margin-bottom: 20px;
} }
+9 -1
View File
@@ -1,5 +1,13 @@
const { defineConfig } = require('@vue/cli-service') const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({ module.exports = defineConfig({
transpileDependencies: true, transpileDependencies: true,
lintOnSave:false lintOnSave: false,
pages: {
index: {
entry: 'src/main.js',
template: 'public/index.html',
filename: 'index.html',
title: '爱特工作室物品管理及财务管理系统',
}
}
}) })