feat: update finance manager and fix some issue
This commit is contained in:
@@ -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'
|
||||||
@@ -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('初始化完成!'))
|
||||||
@@ -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
|
||||||
@@ -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__'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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)),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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/')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '爱特工作室物品管理及财务管理系统',
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user