chore: migrate to KT financial system
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
VITE_BASE=/
|
||||
|
||||
# 接口地址
|
||||
VITE_GLOB_API_URL=https://mock-napi.vben.pro/api
|
||||
VITE_GLOB_API_URL=http://192.168.9.149:5320/api
|
||||
|
||||
# 是否开启压缩,可以设置为 none, brotli, gzip
|
||||
VITE_COMPRESS=none
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<PageWrapper>
|
||||
<!-- 头部面包屑和操作 -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<Breadcrumb>
|
||||
<Breadcrumb.Item>
|
||||
<a @click="router.back()">报销管理</a>
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Item>报销详情</Breadcrumb.Item>
|
||||
</Breadcrumb>
|
||||
|
||||
<Space>
|
||||
<Button v-if="canEdit" type="primary" @click="handleEdit">
|
||||
<Icon icon="mdi:pencil" class="mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button v-if="canSubmit" type="primary" @click="handleSubmit">
|
||||
<Icon icon="mdi:send" class="mr-1" />
|
||||
提交审批
|
||||
</Button>
|
||||
<Button v-if="canRevoke" danger @click="handleRevoke">
|
||||
<Icon icon="mdi:undo" class="mr-1" />
|
||||
撤回
|
||||
</Button>
|
||||
<Dropdown :trigger="['click']">
|
||||
<template #overlay>
|
||||
<Menu @click="handleMenuClick">
|
||||
<Menu.Item key="export">
|
||||
<Icon icon="mdi:download" class="mr-2" />
|
||||
导出PDF
|
||||
</Menu.Item>
|
||||
<Menu.Item key="print">
|
||||
<Icon icon="mdi:printer" class="mr-2" />
|
||||
打印
|
||||
</Menu.Item>
|
||||
<Menu.Item key="copy">
|
||||
<Icon icon="mdi:content-copy" class="mr-2" />
|
||||
复制
|
||||
</Menu.Item>
|
||||
<Menu.Divider v-if="canDelete" />
|
||||
<Menu.Item v-if="canDelete" key="delete" danger>
|
||||
<Icon icon="mdi:delete" class="mr-2" />
|
||||
删除
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
<Button>
|
||||
更多操作
|
||||
<Icon icon="mdi:chevron-down" class="ml-1" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<!-- 报销单基本信息 -->
|
||||
<Card class="mb-4">
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center mb-4">
|
||||
<h2 class="text-2xl font-bold mr-4">{{ reimbursement.reimbursementNo }}</h2>
|
||||
<Tag :color="getStatusColor(reimbursement.status)" class="text-base px-3 py-1">
|
||||
<Icon :icon="getStatusIcon(reimbursement.status)" class="mr-1" />
|
||||
{{ getStatusText(reimbursement.status) }}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<Descriptions :column="3" bordered>
|
||||
<Descriptions.Item label="申请人">
|
||||
<div class="flex items-center">
|
||||
<Avatar :size="32" :style="{ backgroundColor: '#1890ff' }">
|
||||
{{ reimbursement.applicant.substring(0, 1) }}
|
||||
</Avatar>
|
||||
<div class="ml-2">
|
||||
<div class="font-semibold">{{ reimbursement.applicant }}</div>
|
||||
<div class="text-xs text-gray-400">{{ reimbursement.department }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="申请日期">{{ reimbursement.applyDate }}</Descriptions.Item>
|
||||
<Descriptions.Item label="报销金额">
|
||||
<span class="text-2xl font-bold text-red-600">¥{{ formatNumber(reimbursement.amount) }}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="费用类型" :span="2">
|
||||
<Space>
|
||||
<Tag v-for="cat in reimbursement.categories" :key="cat" color="blue">{{ cat }}</Tag>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="费用项数">{{ reimbursement.items.length }} 项</Descriptions.Item>
|
||||
<Descriptions.Item label="报销事由" :span="3">
|
||||
{{ reimbursement.reason }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注" :span="3">
|
||||
{{ reimbursement.notes || '无' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 审批进度 -->
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-semibold mb-4">审批进度</h3>
|
||||
<Steps :current="currentStep" :status="stepsStatus">
|
||||
<Steps.Step
|
||||
v-for="(step, index) in approvalSteps"
|
||||
:key="index"
|
||||
:title="step.title"
|
||||
:description="step.description"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon v-if="step.status === 'finish'" icon="mdi:check-circle" class="text-green-500" />
|
||||
<Icon v-else-if="step.status === 'error'" icon="mdi:close-circle" class="text-red-500" />
|
||||
<Icon v-else-if="step.status === 'process'" icon="mdi:clock-outline" class="text-blue-500" />
|
||||
<Icon v-else icon="mdi:circle-outline" class="text-gray-400" />
|
||||
</template>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 费用明细 -->
|
||||
<Card title="费用明细" class="mb-4">
|
||||
<Table
|
||||
:columns="itemColumns"
|
||||
:dataSource="reimbursement.items"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 1000 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.dataIndex === 'index'">
|
||||
{{ index + 1 }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'category'">
|
||||
<Tag color="blue">{{ record.category }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'amount'">
|
||||
<span class="font-semibold">¥{{ formatNumber(record.amount) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'receipt'">
|
||||
<Tag :color="record.hasReceipt ? 'success' : 'warning'">
|
||||
{{ record.hasReceipt ? '已上传' : '未上传' }}
|
||||
</Tag>
|
||||
</template>
|
||||
</template>
|
||||
<template #summary>
|
||||
<Table.Summary fixed>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell :index="0" :colSpan="5" class="text-right font-bold">
|
||||
合计金额:
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell :index="5" class="font-bold text-lg text-red-600">
|
||||
¥{{ formatNumber(totalAmount) }}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell :index="6" />
|
||||
</Table.Summary.Row>
|
||||
</Table.Summary>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<!-- 附件列表 -->
|
||||
<Card title="附件资料" class="mb-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="(attachment, index) in reimbursement.attachments"
|
||||
:key="index"
|
||||
class="border rounded-lg p-3 hover:shadow-md transition-shadow cursor-pointer"
|
||||
@click="previewAttachment(attachment)"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon
|
||||
:icon="getFileIcon(attachment.type)"
|
||||
class="text-3xl mr-3"
|
||||
:style="{ color: getFileColor(attachment.type) }"
|
||||
/>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="text-sm font-semibold truncate">{{ attachment.name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ attachment.size }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Empty v-if="reimbursement.attachments.length === 0" description="暂无附件" />
|
||||
</Card>
|
||||
|
||||
<!-- 审批记录 -->
|
||||
<Card title="审批记录" class="mb-4">
|
||||
<Timeline>
|
||||
<Timeline.Item
|
||||
v-for="(record, index) in approvalRecords"
|
||||
:key="index"
|
||||
:color="getTimelineColor(record.action)"
|
||||
>
|
||||
<template #dot>
|
||||
<Icon :icon="getActionIcon(record.action)" class="text-lg" />
|
||||
</template>
|
||||
<div class="pb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<Avatar :size="32" :style="{ backgroundColor: getRandomColor(record.operator) }">
|
||||
{{ record.operator.substring(0, 1) }}
|
||||
</Avatar>
|
||||
<div class="ml-2">
|
||||
<span class="font-semibold">{{ record.operator }}</span>
|
||||
<span class="text-gray-500 ml-2">{{ record.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-gray-400 text-sm">{{ record.time }}</span>
|
||||
</div>
|
||||
<div class="ml-10">
|
||||
<Tag :color="getActionColor(record.action)">{{ getActionText(record.action) }}</Tag>
|
||||
<div v-if="record.comment" class="mt-2 text-gray-600 bg-gray-50 p-3 rounded">
|
||||
{{ record.comment }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
</Card>
|
||||
|
||||
<!-- 审批操作区(仅审批人可见) -->
|
||||
<Card v-if="showApprovalActions" title="审批操作" class="mb-4">
|
||||
<div class="max-w-2xl">
|
||||
<Form :model="approvalForm" layout="vertical">
|
||||
<Form.Item label="审批意见">
|
||||
<Input.TextArea
|
||||
v-model:value="approvalForm.comment"
|
||||
:rows="4"
|
||||
placeholder="请输入审批意见..."
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space size="large">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="handleApprove('approved')"
|
||||
:loading="approving"
|
||||
>
|
||||
<Icon icon="mdi:check-circle" class="mr-1" />
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
size="large"
|
||||
@click="handleApprove('rejected')"
|
||||
:loading="approving"
|
||||
>
|
||||
<Icon icon="mdi:close-circle" class="mr-1" />
|
||||
拒绝
|
||||
</Button>
|
||||
<Button size="large" @click="handleApprove('transfer')">
|
||||
<Icon icon="mdi:share" class="mr-1" />
|
||||
转交
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
</PageWrapper>
|
||||
|
||||
<!-- 附件预览Modal -->
|
||||
<Modal
|
||||
v-model:open="previewVisible"
|
||||
:title="previewFile?.name"
|
||||
width="80%"
|
||||
:footer="null"
|
||||
>
|
||||
<div class="flex items-center justify-center h-96">
|
||||
<img v-if="isImage(previewFile?.type)" :src="previewFile?.url" class="max-h-full" />
|
||||
<div v-else class="text-center">
|
||||
<Icon :icon="getFileIcon(previewFile?.type)" class="text-8xl mb-4" />
|
||||
<p>{{ previewFile?.name }}</p>
|
||||
<Button type="primary" class="mt-4" @click="downloadFile(previewFile)">
|
||||
<Icon icon="mdi:download" class="mr-1" />
|
||||
下载文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { PageWrapper } from '@vben/common-ui';
|
||||
import {
|
||||
Card, Button, Space, Tag, Descriptions, Steps, Table, Timeline,
|
||||
Avatar, Breadcrumb, Dropdown, Menu, Form, Input, Modal, Empty, message
|
||||
} from 'ant-design-vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
defineOptions({ name: 'ReimbursementDetail' });
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const previewVisible = ref(false);
|
||||
const previewFile = ref<any>(null);
|
||||
const approving = ref(false);
|
||||
|
||||
// 审批表单
|
||||
const approvalForm = ref({
|
||||
comment: ''
|
||||
});
|
||||
|
||||
// 模拟报销单数据
|
||||
const reimbursement = ref({
|
||||
id: '1',
|
||||
reimbursementNo: 'RE202501001',
|
||||
applicant: '张三',
|
||||
department: '技术部',
|
||||
applyDate: '2025-01-08',
|
||||
amount: 3850.00,
|
||||
categories: ['差旅', '餐饮', '交通'],
|
||||
reason: '客户现场技术支持差旅费用报销',
|
||||
notes: '北京客户现场,为期3天的技术支持工作',
|
||||
status: 'pending',
|
||||
items: [
|
||||
{
|
||||
key: '1',
|
||||
date: '2025-01-05',
|
||||
category: '交通',
|
||||
description: '北京往返高铁票',
|
||||
amount: 1200.00,
|
||||
hasReceipt: true
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
date: '2025-01-05',
|
||||
category: '住宿',
|
||||
description: '北京希尔顿酒店 2晚',
|
||||
amount: 1800.00,
|
||||
hasReceipt: true
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
date: '2025-01-06',
|
||||
category: '餐饮',
|
||||
description: '客户商务晚餐',
|
||||
amount: 650.00,
|
||||
hasReceipt: true
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
date: '2025-01-07',
|
||||
category: '交通',
|
||||
description: '北京市内打车费用',
|
||||
amount: 200.00,
|
||||
hasReceipt: true
|
||||
}
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
name: '高铁票.jpg',
|
||||
type: 'image',
|
||||
size: '2.5MB',
|
||||
url: 'https://via.placeholder.com/800x600'
|
||||
},
|
||||
{
|
||||
name: '酒店发票.pdf',
|
||||
type: 'pdf',
|
||||
size: '1.2MB',
|
||||
url: ''
|
||||
},
|
||||
{
|
||||
name: '餐饮发票.jpg',
|
||||
type: 'image',
|
||||
size: '1.8MB',
|
||||
url: 'https://via.placeholder.com/800x600'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 审批步骤
|
||||
const approvalSteps = ref([
|
||||
{
|
||||
title: '提交申请',
|
||||
description: '张三 · 2025-01-08 09:30',
|
||||
status: 'finish'
|
||||
},
|
||||
{
|
||||
title: '部门经理审批',
|
||||
description: '李经理 · 审批中',
|
||||
status: 'process'
|
||||
},
|
||||
{
|
||||
title: '财务审核',
|
||||
description: '待审核',
|
||||
status: 'wait'
|
||||
},
|
||||
{
|
||||
title: '总经理审批',
|
||||
description: '待审批',
|
||||
status: 'wait'
|
||||
},
|
||||
{
|
||||
title: '财务支付',
|
||||
description: '待支付',
|
||||
status: 'wait'
|
||||
}
|
||||
]);
|
||||
|
||||
// 审批记录
|
||||
const approvalRecords = ref([
|
||||
{
|
||||
operator: '张三',
|
||||
role: '申请人',
|
||||
action: 'submit',
|
||||
time: '2025-01-08 09:30:00',
|
||||
comment: '提交报销申请'
|
||||
},
|
||||
{
|
||||
operator: '李经理',
|
||||
role: '部门经理',
|
||||
action: 'review',
|
||||
time: '2025-01-08 14:20:00',
|
||||
comment: '正在审核中,请补充商务晚餐的详细说明'
|
||||
}
|
||||
]);
|
||||
|
||||
// 费用明细表格列
|
||||
const itemColumns = [
|
||||
{
|
||||
title: '序号',
|
||||
dataIndex: 'index',
|
||||
key: 'index',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '费用类型',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '费用说明',
|
||||
dataIndex: 'description',
|
||||
key: 'description'
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '发票状态',
|
||||
dataIndex: 'receipt',
|
||||
key: 'receipt',
|
||||
width: 100
|
||||
}
|
||||
];
|
||||
|
||||
// 计算属性
|
||||
const totalAmount = computed(() => {
|
||||
return reimbursement.value.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
});
|
||||
|
||||
const currentStep = computed(() => {
|
||||
const finishIndex = approvalSteps.value.findIndex(step => step.status === 'process');
|
||||
return finishIndex >= 0 ? finishIndex : approvalSteps.value.length;
|
||||
});
|
||||
|
||||
const stepsStatus = computed(() => {
|
||||
if (reimbursement.value.status === 'rejected') return 'error';
|
||||
if (reimbursement.value.status === 'paid') return 'finish';
|
||||
return 'process';
|
||||
});
|
||||
|
||||
const canEdit = computed(() => {
|
||||
return reimbursement.value.status === 'draft' || reimbursement.value.status === 'rejected';
|
||||
});
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return reimbursement.value.status === 'draft';
|
||||
});
|
||||
|
||||
const canRevoke = computed(() => {
|
||||
return reimbursement.value.status === 'pending';
|
||||
});
|
||||
|
||||
const canDelete = computed(() => {
|
||||
return reimbursement.value.status === 'draft' || reimbursement.value.status === 'rejected';
|
||||
});
|
||||
|
||||
const showApprovalActions = computed(() => {
|
||||
// 检查URL参数或当前用户是否为审批人
|
||||
return route.query.action === 'approve' && reimbursement.value.status === 'pending';
|
||||
});
|
||||
|
||||
// 方法
|
||||
const formatNumber = (num: number) => {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'draft': 'default',
|
||||
'pending': 'processing',
|
||||
'approved': 'success',
|
||||
'rejected': 'error',
|
||||
'paid': 'success'
|
||||
};
|
||||
return colorMap[status] || 'default';
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
'draft': 'mdi:file-document-edit-outline',
|
||||
'pending': 'mdi:clock-outline',
|
||||
'approved': 'mdi:check-circle',
|
||||
'rejected': 'mdi:close-circle',
|
||||
'paid': 'mdi:cash-check'
|
||||
};
|
||||
return iconMap[status] || 'mdi:help-circle';
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
'draft': '草稿',
|
||||
'pending': '待审批',
|
||||
'approved': '已通过',
|
||||
'rejected': '已拒绝',
|
||||
'paid': '已支付'
|
||||
};
|
||||
return textMap[status] || status;
|
||||
};
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
'image': 'mdi:file-image',
|
||||
'pdf': 'mdi:file-pdf-box',
|
||||
'excel': 'mdi:file-excel',
|
||||
'word': 'mdi:file-word'
|
||||
};
|
||||
return iconMap[type] || 'mdi:file-document';
|
||||
};
|
||||
|
||||
const getFileColor = (type: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'image': '#52c41a',
|
||||
'pdf': '#f5222d',
|
||||
'excel': '#13c2c2',
|
||||
'word': '#1890ff'
|
||||
};
|
||||
return colorMap[type] || '#666';
|
||||
};
|
||||
|
||||
const isImage = (type?: string) => {
|
||||
return type === 'image';
|
||||
};
|
||||
|
||||
const getTimelineColor = (action: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'submit': 'blue',
|
||||
'review': 'orange',
|
||||
'approved': 'green',
|
||||
'rejected': 'red',
|
||||
'transfer': 'purple'
|
||||
};
|
||||
return colorMap[action] || 'gray';
|
||||
};
|
||||
|
||||
const getActionIcon = (action: string) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
'submit': 'mdi:send',
|
||||
'review': 'mdi:eye',
|
||||
'approved': 'mdi:check-circle',
|
||||
'rejected': 'mdi:close-circle',
|
||||
'transfer': 'mdi:share'
|
||||
};
|
||||
return iconMap[action] || 'mdi:circle';
|
||||
};
|
||||
|
||||
const getActionColor = (action: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'submit': 'blue',
|
||||
'review': 'orange',
|
||||
'approved': 'success',
|
||||
'rejected': 'error',
|
||||
'transfer': 'purple'
|
||||
};
|
||||
return colorMap[action] || 'default';
|
||||
};
|
||||
|
||||
const getActionText = (action: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
'submit': '提交申请',
|
||||
'review': '审核中',
|
||||
'approved': '已通过',
|
||||
'rejected': '已拒绝',
|
||||
'transfer': '转交'
|
||||
};
|
||||
return textMap[action] || action;
|
||||
};
|
||||
|
||||
const getRandomColor = (str: string) => {
|
||||
const colors = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1'];
|
||||
const hash = str.split('').reduce((acc, char) => char.charCodeAt(0) + acc, 0);
|
||||
return colors[hash % colors.length];
|
||||
};
|
||||
|
||||
// 事件处理
|
||||
const handleEdit = () => {
|
||||
router.push(`/reimbursement/create?id=${reimbursement.value.id}&mode=edit`);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
Modal.confirm({
|
||||
title: '确认提交审批',
|
||||
content: '提交后将无法修改,是否确认提交审批?',
|
||||
onOk: () => {
|
||||
message.success('提交成功');
|
||||
router.back();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRevoke = () => {
|
||||
Modal.confirm({
|
||||
title: '确认撤回',
|
||||
content: '是否确认撤回此报销单?',
|
||||
onOk: () => {
|
||||
message.success('撤回成功');
|
||||
router.back();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
switch (key) {
|
||||
case 'export':
|
||||
message.info('正在导出PDF...');
|
||||
break;
|
||||
case 'print':
|
||||
window.print();
|
||||
break;
|
||||
case 'copy':
|
||||
message.success('复制成功');
|
||||
break;
|
||||
case 'delete':
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '删除后无法恢复,是否确认删除?',
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
message.success('删除成功');
|
||||
router.back();
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const previewAttachment = (attachment: any) => {
|
||||
previewFile.value = attachment;
|
||||
previewVisible.value = true;
|
||||
};
|
||||
|
||||
const downloadFile = (file: any) => {
|
||||
message.info(`正在下载 ${file.name}...`);
|
||||
};
|
||||
|
||||
const handleApprove = async (action: string) => {
|
||||
if (!approvalForm.value.comment && action === 'rejected') {
|
||||
message.warning('拒绝时必须填写审批意见');
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: action === 'approved' ? '确认通过' : action === 'rejected' ? '确认拒绝' : '确认转交',
|
||||
content: `是否确认${action === 'approved' ? '通过' : action === 'rejected' ? '拒绝' : '转交'}此报销申请?`,
|
||||
onOk: async () => {
|
||||
approving.value = true;
|
||||
// 模拟API调用
|
||||
setTimeout(() => {
|
||||
approving.value = false;
|
||||
message.success('操作成功');
|
||||
router.back();
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 根据路由参数加载报销单详情
|
||||
const id = route.params.id;
|
||||
console.log('加载报销单详情:', id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
font-weight: 600;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
:deep(.ant-steps-item-description) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,780 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<PageWrapper title="报销管理" content="全面的报销单管理系统,支持创建、审批、追踪报销流程">
|
||||
<!-- 顶部操作栏 -->
|
||||
<Card class="mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Input.Search
|
||||
v-model:value="searchText"
|
||||
placeholder="搜索报销单号、申请人、事由..."
|
||||
style="width: 350px"
|
||||
@search="onSearch"
|
||||
/>
|
||||
<Select v-model:value="filterStatus" style="width: 140px" placeholder="状态筛选" @change="onFilterChange">
|
||||
<Select.Option value="">全部状态</Select.Option>
|
||||
<Select.Option value="draft">草稿</Select.Option>
|
||||
<Select.Option value="pending">待审批</Select.Option>
|
||||
<Select.Option value="approved">已通过</Select.Option>
|
||||
<Select.Option value="rejected">已拒绝</Select.Option>
|
||||
<Select.Option value="paid">已支付</Select.Option>
|
||||
<Select.Option value="cancelled">已取消</Select.Option>
|
||||
</Select>
|
||||
<Select v-model:value="filterDepartment" style="width: 150px" placeholder="部门筛选" @change="onFilterChange">
|
||||
<Select.Option value="">全部部门</Select.Option>
|
||||
<Select.Option v-for="dept in departments" :key="dept" :value="dept">{{ dept }}</Select.Option>
|
||||
</Select>
|
||||
<RangePicker v-model:value="dateFilter" placeholder="['申请日期', '结束日期']" @change="onDateChange" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button type="primary" @click="goToCreate">
|
||||
<Icon icon="mdi:plus" class="mr-1" />
|
||||
创建报销单
|
||||
</Button>
|
||||
<Dropdown :trigger="['click']">
|
||||
<template #overlay>
|
||||
<Menu @click="handleExport">
|
||||
<Menu.Item key="excel">导出Excel</Menu.Item>
|
||||
<Menu.Item key="pdf">导出PDF</Menu.Item>
|
||||
<Menu.Item key="selected">导出选中</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
<Button>
|
||||
<Icon icon="mdi:download" class="mr-1" />
|
||||
导出
|
||||
<Icon icon="mdi:chevron-down" class="ml-1" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷筛选标签 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-gray-500 text-sm">快捷筛选:</span>
|
||||
<Tag
|
||||
:color="quickFilter === '' ? 'blue' : 'default'"
|
||||
class="cursor-pointer"
|
||||
@click="quickFilter = ''; loadReimbursements()"
|
||||
>
|
||||
全部 ({{ statistics.total }})
|
||||
</Tag>
|
||||
<Tag
|
||||
:color="quickFilter === 'my' ? 'blue' : 'default'"
|
||||
class="cursor-pointer"
|
||||
@click="quickFilter = 'my'; loadReimbursements()"
|
||||
>
|
||||
我的报销 ({{ statistics.myTotal }})
|
||||
</Tag>
|
||||
<Tag
|
||||
:color="quickFilter === 'pending' ? 'orange' : 'default'"
|
||||
class="cursor-pointer"
|
||||
@click="quickFilter = 'pending'; loadReimbursements()"
|
||||
>
|
||||
待我审批 ({{ statistics.pendingApproval }})
|
||||
</Tag>
|
||||
<Tag
|
||||
:color="quickFilter === 'approved' ? 'green' : 'default'"
|
||||
class="cursor-pointer"
|
||||
@click="quickFilter = 'approved'; loadReimbursements()"
|
||||
>
|
||||
已通过 ({{ statistics.approved }})
|
||||
</Tag>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<Statistic
|
||||
title="本月报销总额"
|
||||
:value="statistics.monthTotal"
|
||||
:precision="2"
|
||||
prefix="¥"
|
||||
value-style="color: #1890ff"
|
||||
/>
|
||||
<div class="text-xs text-gray-400 mt-2">较上月 +15.2%</div>
|
||||
</Card>
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<Statistic
|
||||
title="待审批金额"
|
||||
:value="statistics.pendingAmount"
|
||||
:precision="2"
|
||||
prefix="¥"
|
||||
value-style="color: #faad14"
|
||||
/>
|
||||
<div class="text-xs text-gray-400 mt-2">{{ statistics.pendingApproval }} 笔待审</div>
|
||||
</Card>
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<Statistic
|
||||
title="已支付金额"
|
||||
:value="statistics.paidAmount"
|
||||
:precision="2"
|
||||
prefix="¥"
|
||||
value-style="color: #52c41a"
|
||||
/>
|
||||
<div class="text-xs text-gray-400 mt-2">{{ statistics.paid }} 笔已支付</div>
|
||||
</Card>
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<Statistic
|
||||
title="平均处理时长"
|
||||
:value="statistics.avgProcessTime"
|
||||
suffix="天"
|
||||
value-style="color: #722ed1"
|
||||
/>
|
||||
<div class="text-xs text-gray-400 mt-2">审批效率良好</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 报销单列表 -->
|
||||
<Card title="报销单列表">
|
||||
<template #extra>
|
||||
<Space>
|
||||
<Tooltip title="刷新数据">
|
||||
<Button @click="loadReimbursements" :loading="loading">
|
||||
<Icon icon="mdi:refresh" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="列设置">
|
||||
<Button @click="showColumnSetting = true">
|
||||
<Icon icon="mdi:cog" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</template>
|
||||
|
||||
<Table
|
||||
:columns="columns"
|
||||
:dataSource="filteredReimbursements"
|
||||
:loading="loading"
|
||||
:scroll="{ x: 1500 }"
|
||||
:pagination="{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (total, range) => `显示 ${range[0]}-${range[1]} 条,共 ${total} 条`
|
||||
}"
|
||||
:rowSelection="rowSelection"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<!-- 自定义列模板 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'reimbursementNo'">
|
||||
<a @click="viewDetail(record)" class="text-blue-600 hover:text-blue-800">
|
||||
{{ record.reimbursementNo }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'amount'">
|
||||
<span class="font-semibold text-lg" :class="getAmountColor(record.amount)">
|
||||
¥{{ formatNumber(record.amount) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<Tag :color="getStatusColor(record.status)">
|
||||
<Icon :icon="getStatusIcon(record.status)" class="mr-1" />
|
||||
{{ getStatusText(record.status) }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'applicant'">
|
||||
<div class="flex items-center">
|
||||
<Avatar :size="32" :style="{ backgroundColor: getRandomColor(record.applicant) }">
|
||||
{{ record.applicant.substring(0, 1) }}
|
||||
</Avatar>
|
||||
<div class="ml-2">
|
||||
<div>{{ record.applicant }}</div>
|
||||
<div class="text-xs text-gray-400">{{ record.department }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'items'">
|
||||
<div class="text-xs">
|
||||
<div>{{ record.itemsCount }} 项费用</div>
|
||||
<div class="text-gray-400">{{ record.categories.join(', ') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'progress'">
|
||||
<Tooltip :title="`${record.progress}% 完成`">
|
||||
<Progress
|
||||
:percent="record.progress"
|
||||
:status="record.status === 'rejected' ? 'exception' : 'normal'"
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'approver'">
|
||||
<div class="text-sm">
|
||||
<div>{{ record.currentApprover || '-' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ record.approvalStep || '-' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<Space>
|
||||
<Tooltip title="查看详情">
|
||||
<Button type="link" size="small" @click="viewDetail(record)">
|
||||
<Icon icon="mdi:eye" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-if="canEdit(record)" title="编辑">
|
||||
<Button type="link" size="small" @click="editReimbursement(record)">
|
||||
<Icon icon="mdi:pencil" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-if="canApprove(record)" title="审批">
|
||||
<Button type="link" size="small" @click="approveReimbursement(record)">
|
||||
<Icon icon="mdi:check-circle" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Dropdown :trigger="['click']">
|
||||
<template #overlay>
|
||||
<Menu @click="({ key }) => handleAction(key, record)">
|
||||
<Menu.Item v-if="canSubmit(record)" key="submit">
|
||||
<Icon icon="mdi:send" class="mr-2" />提交审批
|
||||
</Menu.Item>
|
||||
<Menu.Item v-if="canRevoke(record)" key="revoke">
|
||||
<Icon icon="mdi:undo" class="mr-2" />撤回
|
||||
</Menu.Item>
|
||||
<Menu.Item key="export">
|
||||
<Icon icon="mdi:download" class="mr-2" />导出
|
||||
</Menu.Item>
|
||||
<Menu.Item key="copy">
|
||||
<Icon icon="mdi:content-copy" class="mr-2" />复制
|
||||
</Menu.Item>
|
||||
<Menu.Divider v-if="canDelete(record)" />
|
||||
<Menu.Item v-if="canDelete(record)" key="delete" danger>
|
||||
<Icon icon="mdi:delete" class="mr-2" />删除
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
<Button type="link" size="small">
|
||||
<Icon icon="mdi:dots-vertical" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<!-- 批量操作浮动按钮 -->
|
||||
<div v-if="selectedRowKeys.length > 0" class="fixed bottom-8 right-8 z-50">
|
||||
<Card class="shadow-2xl">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm">已选择 <span class="font-bold text-blue-600">{{ selectedRowKeys.length }}</span> 项</span>
|
||||
<Button type="primary" @click="batchApprove" :disabled="!canBatchApprove">
|
||||
批量审批
|
||||
</Button>
|
||||
<Button @click="batchExport">批量导出</Button>
|
||||
<Button danger @click="batchDelete" :disabled="!canBatchDelete">批量删除</Button>
|
||||
<Button @click="selectedRowKeys = []">取消选择</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { PageWrapper } from '@vben/common-ui';
|
||||
import {
|
||||
Card, Table, Button, Input, Select, RangePicker, Space, Tag,
|
||||
Tooltip, Dropdown, Menu, Statistic, Progress, Avatar, Modal, message
|
||||
} from 'ant-design-vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
defineOptions({ name: 'ReimbursementManagement' });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// 状态管理
|
||||
const searchText = ref('');
|
||||
const filterStatus = ref('');
|
||||
const filterDepartment = ref('');
|
||||
const dateFilter = ref();
|
||||
const quickFilter = ref('');
|
||||
const loading = ref(false);
|
||||
const showColumnSetting = ref(false);
|
||||
const selectedRowKeys = ref<string[]>([]);
|
||||
|
||||
// 分页
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
});
|
||||
|
||||
// 部门列表
|
||||
const departments = ref(['技术部', '市场部', '财务部', '人事部', '行政部', '销售部']);
|
||||
|
||||
// 统计数据
|
||||
const statistics = ref({
|
||||
total: 156,
|
||||
myTotal: 23,
|
||||
pendingApproval: 8,
|
||||
approved: 98,
|
||||
monthTotal: 285690.50,
|
||||
pendingAmount: 45230.00,
|
||||
paidAmount: 240460.50,
|
||||
paid: 98,
|
||||
avgProcessTime: 2.5
|
||||
});
|
||||
|
||||
// 报销单数据
|
||||
const reimbursements = ref([
|
||||
{
|
||||
key: '1',
|
||||
reimbursementNo: 'RE202501001',
|
||||
applicant: '张三',
|
||||
department: '技术部',
|
||||
applyDate: '2025-01-08',
|
||||
amount: 3850.00,
|
||||
itemsCount: 5,
|
||||
categories: ['差旅', '餐饮'],
|
||||
reason: '客户现场技术支持差旅费',
|
||||
status: 'pending',
|
||||
progress: 50,
|
||||
currentApprover: '李经理',
|
||||
approvalStep: '部门经理审批',
|
||||
attachments: 3,
|
||||
createTime: '2025-01-08 09:30:00',
|
||||
updateTime: '2025-01-08 14:20:00'
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
reimbursementNo: 'RE202501002',
|
||||
applicant: '李四',
|
||||
department: '市场部',
|
||||
applyDate: '2025-01-07',
|
||||
amount: 12600.00,
|
||||
itemsCount: 8,
|
||||
categories: ['市场活动', '餐饮', '交通'],
|
||||
reason: '产品发布会活动费用',
|
||||
status: 'approved',
|
||||
progress: 100,
|
||||
currentApprover: '-',
|
||||
approvalStep: '已完成',
|
||||
attachments: 15,
|
||||
createTime: '2025-01-07 10:15:00',
|
||||
updateTime: '2025-01-08 16:40:00'
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
reimbursementNo: 'RE202501003',
|
||||
applicant: '王五',
|
||||
department: '技术部',
|
||||
applyDate: '2025-01-09',
|
||||
amount: 5200.00,
|
||||
itemsCount: 3,
|
||||
categories: ['办公用品', '设备'],
|
||||
reason: '团队办公设备采购',
|
||||
status: 'draft',
|
||||
progress: 0,
|
||||
currentApprover: '-',
|
||||
approvalStep: '未提交',
|
||||
attachments: 2,
|
||||
createTime: '2025-01-09 11:00:00',
|
||||
updateTime: '2025-01-09 11:00:00'
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
reimbursementNo: 'RE202501004',
|
||||
applicant: '赵六',
|
||||
department: '销售部',
|
||||
applyDate: '2025-01-06',
|
||||
amount: 8900.00,
|
||||
itemsCount: 6,
|
||||
categories: ['差旅', '住宿', '交通'],
|
||||
reason: '客户拜访及商务洽谈',
|
||||
status: 'paid',
|
||||
progress: 100,
|
||||
currentApprover: '-',
|
||||
approvalStep: '已支付',
|
||||
attachments: 8,
|
||||
createTime: '2025-01-06 08:45:00',
|
||||
updateTime: '2025-01-07 10:30:00'
|
||||
},
|
||||
{
|
||||
key: '5',
|
||||
reimbursementNo: 'RE202501005',
|
||||
applicant: '陈七',
|
||||
department: '市场部',
|
||||
applyDate: '2025-01-05',
|
||||
amount: 2800.00,
|
||||
itemsCount: 4,
|
||||
categories: ['餐饮', '礼品'],
|
||||
reason: '客户接待费用',
|
||||
status: 'rejected',
|
||||
progress: 30,
|
||||
currentApprover: '王总监',
|
||||
approvalStep: '财务审批已拒绝',
|
||||
attachments: 4,
|
||||
createTime: '2025-01-05 14:20:00',
|
||||
updateTime: '2025-01-05 17:10:00'
|
||||
}
|
||||
]);
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: '报销单号',
|
||||
dataIndex: 'reimbursementNo',
|
||||
key: 'reimbursementNo',
|
||||
width: 140,
|
||||
fixed: 'left'
|
||||
},
|
||||
{
|
||||
title: '申请人',
|
||||
dataIndex: 'applicant',
|
||||
key: 'applicant',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '申请日期',
|
||||
dataIndex: 'applyDate',
|
||||
key: 'applyDate',
|
||||
width: 110,
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '报销金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
width: 130,
|
||||
sorter: true
|
||||
},
|
||||
{
|
||||
title: '费用明细',
|
||||
dataIndex: 'items',
|
||||
key: 'items',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '事由',
|
||||
dataIndex: 'reason',
|
||||
key: 'reason',
|
||||
ellipsis: true,
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 110,
|
||||
filters: [
|
||||
{ text: '草稿', value: 'draft' },
|
||||
{ text: '待审批', value: 'pending' },
|
||||
{ text: '已通过', value: 'approved' },
|
||||
{ text: '已拒绝', value: 'rejected' },
|
||||
{ text: '已支付', value: 'paid' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '当前审批人',
|
||||
dataIndex: 'approver',
|
||||
key: 'approver',
|
||||
width: 130
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right'
|
||||
}
|
||||
];
|
||||
|
||||
// 过滤后的数据
|
||||
const filteredReimbursements = computed(() => {
|
||||
let filtered = reimbursements.value;
|
||||
|
||||
// 搜索过滤
|
||||
if (searchText.value) {
|
||||
const search = searchText.value.toLowerCase();
|
||||
filtered = filtered.filter(r =>
|
||||
r.reimbursementNo.toLowerCase().includes(search) ||
|
||||
r.applicant.toLowerCase().includes(search) ||
|
||||
r.reason.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (filterStatus.value) {
|
||||
filtered = filtered.filter(r => r.status === filterStatus.value);
|
||||
}
|
||||
|
||||
// 部门过滤
|
||||
if (filterDepartment.value) {
|
||||
filtered = filtered.filter(r => r.department === filterDepartment.value);
|
||||
}
|
||||
|
||||
// 快捷过滤
|
||||
if (quickFilter.value === 'my') {
|
||||
// 模拟:只显示当前用户的报销单
|
||||
filtered = filtered.filter(r => r.applicant === '张三');
|
||||
} else if (quickFilter.value === 'pending') {
|
||||
filtered = filtered.filter(r => r.status === 'pending');
|
||||
} else if (quickFilter.value === 'approved') {
|
||||
filtered = filtered.filter(r => r.status === 'approved');
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// 行选择配置
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedRowKeys,
|
||||
onChange: (keys: string[]) => {
|
||||
selectedRowKeys.value = keys;
|
||||
}
|
||||
};
|
||||
|
||||
// 权限判断
|
||||
const canEdit = (record: any) => {
|
||||
return record.status === 'draft' || record.status === 'rejected';
|
||||
};
|
||||
|
||||
const canApprove = (record: any) => {
|
||||
return record.status === 'pending';
|
||||
};
|
||||
|
||||
const canSubmit = (record: any) => {
|
||||
return record.status === 'draft';
|
||||
};
|
||||
|
||||
const canRevoke = (record: any) => {
|
||||
return record.status === 'pending';
|
||||
};
|
||||
|
||||
const canDelete = (record: any) => {
|
||||
return record.status === 'draft' || record.status === 'rejected';
|
||||
};
|
||||
|
||||
const canBatchApprove = computed(() => {
|
||||
return selectedRowKeys.value.some(key => {
|
||||
const record = reimbursements.value.find(r => r.key === key);
|
||||
return record && record.status === 'pending';
|
||||
});
|
||||
});
|
||||
|
||||
const canBatchDelete = computed(() => {
|
||||
return selectedRowKeys.value.every(key => {
|
||||
const record = reimbursements.value.find(r => r.key === key);
|
||||
return record && (record.status === 'draft' || record.status === 'rejected');
|
||||
});
|
||||
});
|
||||
|
||||
// 方法实现
|
||||
const formatNumber = (num: number) => {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
const getAmountColor = (amount: number) => {
|
||||
if (amount > 10000) return 'text-red-600';
|
||||
if (amount > 5000) return 'text-orange-600';
|
||||
return 'text-green-600';
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'draft': 'default',
|
||||
'pending': 'processing',
|
||||
'approved': 'success',
|
||||
'rejected': 'error',
|
||||
'paid': 'success',
|
||||
'cancelled': 'default'
|
||||
};
|
||||
return colorMap[status] || 'default';
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
'draft': 'mdi:file-document-edit-outline',
|
||||
'pending': 'mdi:clock-outline',
|
||||
'approved': 'mdi:check-circle',
|
||||
'rejected': 'mdi:close-circle',
|
||||
'paid': 'mdi:cash-check',
|
||||
'cancelled': 'mdi:cancel'
|
||||
};
|
||||
return iconMap[status] || 'mdi:help-circle';
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
'draft': '草稿',
|
||||
'pending': '待审批',
|
||||
'approved': '已通过',
|
||||
'rejected': '已拒绝',
|
||||
'paid': '已支付',
|
||||
'cancelled': '已取消'
|
||||
};
|
||||
return textMap[status] || status;
|
||||
};
|
||||
|
||||
const getRandomColor = (str: string) => {
|
||||
const colors = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2'];
|
||||
const hash = str.split('').reduce((acc, char) => char.charCodeAt(0) + acc, 0);
|
||||
return colors[hash % colors.length];
|
||||
};
|
||||
|
||||
// 事件处理
|
||||
const onSearch = () => {
|
||||
pagination.value.current = 1;
|
||||
loadReimbursements();
|
||||
};
|
||||
|
||||
const onFilterChange = () => {
|
||||
pagination.value.current = 1;
|
||||
loadReimbursements();
|
||||
};
|
||||
|
||||
const onDateChange = () => {
|
||||
pagination.value.current = 1;
|
||||
loadReimbursements();
|
||||
};
|
||||
|
||||
const handleTableChange = (pag: any, filters: any, sorter: any) => {
|
||||
pagination.value = pag;
|
||||
console.log('表格变化:', pag, filters, sorter);
|
||||
};
|
||||
|
||||
const loadReimbursements = () => {
|
||||
loading.value = true;
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
pagination.value.total = filteredReimbursements.value.length;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const goToCreate = () => {
|
||||
router.push('/reimbursement/create');
|
||||
};
|
||||
|
||||
const viewDetail = (record: any) => {
|
||||
router.push(`/reimbursement/detail/${record.key}`);
|
||||
};
|
||||
|
||||
const editReimbursement = (record: any) => {
|
||||
router.push(`/reimbursement/create?id=${record.key}&mode=edit`);
|
||||
};
|
||||
|
||||
const approveReimbursement = (record: any) => {
|
||||
router.push(`/reimbursement/detail/${record.key}?action=approve`);
|
||||
};
|
||||
|
||||
const handleAction = (key: string, record: any) => {
|
||||
switch (key) {
|
||||
case 'submit':
|
||||
Modal.confirm({
|
||||
title: '确认提交审批',
|
||||
content: `是否确认提交报销单 ${record.reimbursementNo} 进行审批?`,
|
||||
onOk: () => {
|
||||
message.success('提交成功');
|
||||
loadReimbursements();
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'revoke':
|
||||
Modal.confirm({
|
||||
title: '确认撤回',
|
||||
content: `是否确认撤回报销单 ${record.reimbursementNo}?`,
|
||||
onOk: () => {
|
||||
message.success('撤回成功');
|
||||
loadReimbursements();
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'export':
|
||||
message.info('正在导出...');
|
||||
break;
|
||||
case 'copy':
|
||||
message.success('复制成功');
|
||||
break;
|
||||
case 'delete':
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `是否确认删除报销单 ${record.reimbursementNo}?此操作不可恢复。`,
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
message.success('删除成功');
|
||||
loadReimbursements();
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = ({ key }: { key: string }) => {
|
||||
message.info(`正在导出${key}格式...`);
|
||||
};
|
||||
|
||||
const batchApprove = () => {
|
||||
Modal.confirm({
|
||||
title: '批量审批',
|
||||
content: `是否确认批量审批选中的 ${selectedRowKeys.value.length} 个报销单?`,
|
||||
onOk: () => {
|
||||
message.success('批量审批成功');
|
||||
selectedRowKeys.value = [];
|
||||
loadReimbursements();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const batchExport = () => {
|
||||
message.info(`正在导出选中的 ${selectedRowKeys.value.length} 个报销单...`);
|
||||
};
|
||||
|
||||
const batchDelete = () => {
|
||||
Modal.confirm({
|
||||
title: '批量删除',
|
||||
content: `是否确认删除选中的 ${selectedRowKeys.value.length} 个报销单?此操作不可恢复。`,
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
message.success('批量删除成功');
|
||||
selectedRowKeys.value = [];
|
||||
loadReimbursements();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadReimbursements();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background-color: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.ant-statistic-content-value) {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,13 @@
|
||||
import { requestClient } from '../request';
|
||||
|
||||
export namespace FinanceApi {
|
||||
export type TransactionStatus =
|
||||
| 'draft'
|
||||
| 'pending'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'paid';
|
||||
|
||||
// 货币类型
|
||||
export interface Currency {
|
||||
code: string;
|
||||
@@ -71,6 +78,38 @@ export namespace FinanceApi {
|
||||
createdAt: string;
|
||||
isDeleted?: boolean;
|
||||
deletedAt?: string;
|
||||
status: TransactionStatus;
|
||||
statusUpdatedAt?: string;
|
||||
reimbursementBatch?: string;
|
||||
reviewNotes?: string;
|
||||
submittedBy?: string;
|
||||
approvedBy?: string;
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
export interface MediaMessage {
|
||||
id: number;
|
||||
chatId: number;
|
||||
messageId: number;
|
||||
userId: number;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
fileType: string;
|
||||
fileId: string;
|
||||
fileUniqueId?: string;
|
||||
caption?: string;
|
||||
fileName?: string;
|
||||
filePath: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
duration?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
forwardedTo?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
available: boolean;
|
||||
downloadUrl: string | null;
|
||||
}
|
||||
|
||||
// 创建交易的参数
|
||||
@@ -85,6 +124,34 @@ export namespace FinanceApi {
|
||||
project?: string;
|
||||
memo?: string;
|
||||
createdAt?: string;
|
||||
status?: TransactionStatus;
|
||||
reimbursementBatch?: string | null;
|
||||
reviewNotes?: string | null;
|
||||
submittedBy?: string | null;
|
||||
approvedBy?: string | null;
|
||||
approvedAt?: string | null;
|
||||
statusUpdatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateReimbursementParams {
|
||||
type?: 'expense' | 'income' | 'transfer';
|
||||
amount: number;
|
||||
currency?: string;
|
||||
categoryId?: number;
|
||||
accountId?: number;
|
||||
transactionDate: string;
|
||||
description?: string;
|
||||
project?: string;
|
||||
memo?: string;
|
||||
createdAt?: string;
|
||||
status?: TransactionStatus;
|
||||
reimbursementBatch?: string | null;
|
||||
reviewNotes?: string | null;
|
||||
submittedBy?: string | null;
|
||||
approvedBy?: string | null;
|
||||
approvedAt?: string | null;
|
||||
statusUpdatedAt?: string;
|
||||
requester?: string | null;
|
||||
}
|
||||
|
||||
// 预算
|
||||
@@ -210,9 +277,21 @@ export namespace FinanceApi {
|
||||
*/
|
||||
export async function getTransactions(params?: {
|
||||
type?: 'expense' | 'income' | 'transfer';
|
||||
statuses?: TransactionStatus[];
|
||||
includeDeleted?: boolean;
|
||||
}) {
|
||||
const query: Record<string, any> = {};
|
||||
if (params?.type) {
|
||||
query.type = params.type;
|
||||
}
|
||||
if (params?.statuses && params.statuses.length > 0) {
|
||||
query.statuses = params.statuses.join(',');
|
||||
}
|
||||
if (params?.includeDeleted !== undefined) {
|
||||
query.includeDeleted = params.includeDeleted;
|
||||
}
|
||||
return requestClient.get<Transaction[]>('/finance/transactions', {
|
||||
params,
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -233,6 +312,66 @@ export namespace FinanceApi {
|
||||
return requestClient.put<Transaction>(`/finance/transactions/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取报销申请
|
||||
*/
|
||||
export async function getReimbursements(params?: {
|
||||
type?: 'expense' | 'income' | 'transfer';
|
||||
statuses?: TransactionStatus[];
|
||||
includeDeleted?: boolean;
|
||||
}) {
|
||||
const query: Record<string, any> = {};
|
||||
if (params?.type) {
|
||||
query.type = params.type;
|
||||
}
|
||||
if (params?.statuses && params.statuses.length > 0) {
|
||||
query.statuses = params.statuses.join(',');
|
||||
}
|
||||
if (params?.includeDeleted !== undefined) {
|
||||
query.includeDeleted = params.includeDeleted;
|
||||
}
|
||||
return requestClient.get<Transaction[]>('/finance/reimbursements', {
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建报销申请
|
||||
*/
|
||||
export async function createReimbursement(
|
||||
data: CreateReimbursementParams,
|
||||
) {
|
||||
return requestClient.post<Transaction>('/finance/reimbursements', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新报销申请
|
||||
*/
|
||||
export async function updateReimbursement(
|
||||
id: number,
|
||||
data: Partial<CreateReimbursementParams>,
|
||||
) {
|
||||
return requestClient.put<Transaction>(`/finance/reimbursements/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除报销申请
|
||||
*/
|
||||
export async function deleteReimbursement(id: number) {
|
||||
return requestClient.delete<{ message: string }>(
|
||||
`/finance/transactions/${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复报销申请
|
||||
*/
|
||||
export async function restoreReimbursement(id: number) {
|
||||
return requestClient.put<Transaction>(`/finance/reimbursements/${id}`, {
|
||||
isDeleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删除交易
|
||||
*/
|
||||
@@ -290,4 +429,30 @@ export namespace FinanceApi {
|
||||
isDeleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取媒体消息
|
||||
*/
|
||||
export async function getMediaMessages(params?: {
|
||||
limit?: number;
|
||||
fileTypes?: string[];
|
||||
}) {
|
||||
const query: Record<string, any> = {};
|
||||
if (params?.limit) {
|
||||
query.limit = params.limit;
|
||||
}
|
||||
if (params?.fileTypes && params.fileTypes.length > 0) {
|
||||
query.types = params.fileTypes.join(',');
|
||||
}
|
||||
return requestClient.get<MediaMessage[]>('/finance/media', {
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单条媒体消息详情
|
||||
*/
|
||||
export async function getMediaMessage(id: number) {
|
||||
return requestClient.get<MediaMessage>(`/finance/media/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,8 +49,7 @@ const flattenFinWiseProMenu = () => {
|
||||
if (!childrenUL || !parentMenu) return;
|
||||
|
||||
// Check if already processed
|
||||
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true')
|
||||
return;
|
||||
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') return;
|
||||
|
||||
// Move all children to the parent menu
|
||||
const children = [...childrenUL.children];
|
||||
|
||||
@@ -52,8 +52,7 @@ function flattenFinWiseProMenu() {
|
||||
if (!childrenUL || !parentMenu) return;
|
||||
|
||||
// Check if already processed
|
||||
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true')
|
||||
return;
|
||||
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') return;
|
||||
|
||||
// Move all children to the parent menu
|
||||
const children = [...childrenUL.children];
|
||||
|
||||
@@ -55,10 +55,7 @@ router.afterEach(() => {
|
||||
if (!childrenUL || !parentMenu) return;
|
||||
|
||||
// Check if already processed
|
||||
if (
|
||||
(finwiseMenu as HTMLElement).dataset.hideFinwise === 'true'
|
||||
)
|
||||
return;
|
||||
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true') return;
|
||||
|
||||
// Move all children to the parent menu
|
||||
const children = [...childrenUL.children];
|
||||
|
||||
@@ -79,6 +79,57 @@ const routes: RouteRecordRaw[] = [
|
||||
title: '📈 报表分析',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'FinanceReimbursement',
|
||||
path: '/reimbursement',
|
||||
alias: ['/finance/reimbursement'],
|
||||
component: () => import('#/views/finance/reimbursement/index.vue'),
|
||||
meta: {
|
||||
icon: 'mdi:file-document-outline',
|
||||
order: 8,
|
||||
title: '💼 报销管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ReimbursementDetail',
|
||||
path: '/reimbursement/detail/:id',
|
||||
component: () => import('#/views/finance/reimbursement/detail.vue'),
|
||||
meta: {
|
||||
hideInMenu: true,
|
||||
icon: 'mdi:file-document',
|
||||
title: '报销详情',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ReimbursementCreate',
|
||||
path: '/reimbursement/create',
|
||||
component: () => import('#/views/finance/reimbursement/create.vue'),
|
||||
meta: {
|
||||
hideInMenu: true,
|
||||
icon: 'mdi:plus-circle',
|
||||
title: '创建报销单',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ReimbursementApproval',
|
||||
path: '/reimbursement/approval',
|
||||
component: () => import('#/views/finance/reimbursement/approval.vue'),
|
||||
meta: {
|
||||
icon: 'mdi:checkbox-marked-circle-outline',
|
||||
order: 9,
|
||||
title: '📋 待审批',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ReimbursementStatistics',
|
||||
path: '/reimbursement/statistics',
|
||||
component: () => import('#/views/finance/reimbursement/statistics.vue'),
|
||||
meta: {
|
||||
icon: 'mdi:chart-bar',
|
||||
order: 10,
|
||||
title: '📊 报销统计',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'FinanceTools',
|
||||
path: '/tools',
|
||||
@@ -86,10 +137,21 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('#/views/finance/tools/index.vue'),
|
||||
meta: {
|
||||
icon: 'mdi:tools',
|
||||
order: 8,
|
||||
order: 11,
|
||||
title: '🛠️ 财务工具',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'FinanceMedia',
|
||||
path: '/media',
|
||||
alias: ['/finance/media'],
|
||||
component: () => import('#/views/finance/media/index.vue'),
|
||||
meta: {
|
||||
icon: 'mdi:folder-multiple-image',
|
||||
order: 12,
|
||||
title: '🖼️ 媒体中心',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'FinanceSettings',
|
||||
path: '/fin-settings',
|
||||
@@ -97,7 +159,7 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('#/views/finance/settings/index.vue'),
|
||||
meta: {
|
||||
icon: 'mdi:cog',
|
||||
order: 9,
|
||||
order: 13,
|
||||
title: '⚙️ 系统设置',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,6 +13,8 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
const exchangeRates = ref<FinanceApi.ExchangeRate[]>([]);
|
||||
const transactions = ref<FinanceApi.Transaction[]>([]);
|
||||
const budgets = ref<FinanceApi.Budget[]>([]);
|
||||
const reimbursements = ref<FinanceApi.Transaction[]>([]);
|
||||
const mediaMessages = ref<FinanceApi.MediaMessage[]>([]);
|
||||
|
||||
// 加载状态
|
||||
const loading = ref({
|
||||
@@ -22,6 +24,8 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
exchangeRates: false,
|
||||
transactions: false,
|
||||
budgets: false,
|
||||
reimbursements: false,
|
||||
mediaMessages: false,
|
||||
});
|
||||
|
||||
// 获取货币列表
|
||||
@@ -131,15 +135,32 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
}
|
||||
|
||||
// 获取交易列表
|
||||
async function fetchTransactions() {
|
||||
async function fetchTransactions(params?: {
|
||||
statuses?: FinanceApi.TransactionStatus[];
|
||||
includeDeleted?: boolean;
|
||||
type?: 'expense' | 'income' | 'transfer';
|
||||
}) {
|
||||
loading.value.transactions = true;
|
||||
try {
|
||||
transactions.value = await FinanceApi.getTransactions();
|
||||
transactions.value = await FinanceApi.getTransactions(params);
|
||||
} finally {
|
||||
loading.value.transactions = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取媒体消息
|
||||
async function fetchMediaMessages(params?: {
|
||||
limit?: number;
|
||||
fileTypes?: string[];
|
||||
}) {
|
||||
loading.value.mediaMessages = true;
|
||||
try {
|
||||
mediaMessages.value = await FinanceApi.getMediaMessages(params);
|
||||
} finally {
|
||||
loading.value.mediaMessages = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建交易
|
||||
async function createTransaction(data: FinanceApi.CreateTransactionParams) {
|
||||
const transaction = await FinanceApi.createTransaction(data);
|
||||
@@ -195,6 +216,80 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
return transaction;
|
||||
}
|
||||
|
||||
// 获取报销申请
|
||||
async function fetchReimbursements(params?: {
|
||||
statuses?: FinanceApi.TransactionStatus[];
|
||||
includeDeleted?: boolean;
|
||||
type?: 'expense' | 'income' | 'transfer';
|
||||
}) {
|
||||
loading.value.reimbursements = true;
|
||||
try {
|
||||
reimbursements.value = await FinanceApi.getReimbursements(params);
|
||||
} finally {
|
||||
loading.value.reimbursements = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建报销申请
|
||||
async function createReimbursement(
|
||||
data: FinanceApi.CreateReimbursementParams,
|
||||
) {
|
||||
const payload = {
|
||||
...data,
|
||||
type: data.type ?? 'expense',
|
||||
currency: data.currency ?? 'CNY',
|
||||
status: data.status ?? 'pending',
|
||||
submittedBy: data.submittedBy ?? data.requester ?? null,
|
||||
};
|
||||
const reimbursement = await FinanceApi.createReimbursement(payload);
|
||||
reimbursements.value.unshift(reimbursement);
|
||||
return reimbursement;
|
||||
}
|
||||
|
||||
// 更新报销申请
|
||||
async function updateReimbursement(
|
||||
id: number,
|
||||
data: Partial<FinanceApi.CreateReimbursementParams>,
|
||||
) {
|
||||
const reimbursement = await FinanceApi.updateReimbursement(id, data);
|
||||
const index = reimbursements.value.findIndex((item) => item.id === id);
|
||||
if (index !== -1) {
|
||||
reimbursements.value[index] = reimbursement;
|
||||
} else {
|
||||
reimbursements.value.unshift(reimbursement);
|
||||
}
|
||||
if (reimbursement.status === 'approved' || reimbursement.status === 'paid') {
|
||||
await Promise.all([fetchTransactions(), fetchAccounts()]);
|
||||
}
|
||||
return reimbursement;
|
||||
}
|
||||
|
||||
// 删除报销申请
|
||||
async function deleteReimbursement(id: number) {
|
||||
await FinanceApi.deleteReimbursement(id);
|
||||
const index = reimbursements.value.findIndex((item) => item.id === id);
|
||||
if (index !== -1) {
|
||||
const current = reimbursements.value[index];
|
||||
reimbursements.value[index] = {
|
||||
...current,
|
||||
isDeleted: true,
|
||||
deletedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复报销申请
|
||||
async function restoreReimbursement(id: number) {
|
||||
const reimbursement = await FinanceApi.restoreReimbursement(id);
|
||||
const index = reimbursements.value.findIndex((item) => item.id === id);
|
||||
if (index !== -1) {
|
||||
reimbursements.value[index] = reimbursement;
|
||||
} else {
|
||||
reimbursements.value.unshift(reimbursement);
|
||||
}
|
||||
return reimbursement;
|
||||
}
|
||||
|
||||
// 根据货币代码获取货币信息
|
||||
function getCurrencyByCode(code: string) {
|
||||
return currencies.value.find((c) => c.code === code);
|
||||
@@ -296,6 +391,8 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
exchangeRates,
|
||||
transactions,
|
||||
budgets,
|
||||
reimbursements,
|
||||
mediaMessages,
|
||||
loading,
|
||||
|
||||
// 方法
|
||||
@@ -307,10 +404,16 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
fetchAccounts,
|
||||
fetchExchangeRates,
|
||||
fetchTransactions,
|
||||
fetchMediaMessages,
|
||||
createTransaction,
|
||||
updateTransaction,
|
||||
softDeleteTransaction,
|
||||
restoreTransaction,
|
||||
fetchReimbursements,
|
||||
createReimbursement,
|
||||
updateReimbursement,
|
||||
deleteReimbursement,
|
||||
restoreReimbursement,
|
||||
fetchBudgets,
|
||||
createBudget,
|
||||
updateBudget,
|
||||
|
||||
@@ -268,11 +268,11 @@ const resetForm = () => {
|
||||
|
||||
const getCurrencySymbol = (currency: string) => {
|
||||
const symbolMap: Record<string, string> = {
|
||||
CNY: '¥',
|
||||
CNY: '$',
|
||||
THB: '฿',
|
||||
USD: '$',
|
||||
EUR: '€',
|
||||
JPY: '¥',
|
||||
JPY: '$',
|
||||
GBP: '£',
|
||||
HKD: 'HK$',
|
||||
KRW: '₩',
|
||||
|
||||
@@ -1,25 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Button, Card, InputNumber, Switch } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'BillReminders' });
|
||||
|
||||
const showAddBill = ref(false);
|
||||
|
||||
// 今日账单(空数据)
|
||||
const todayBills = ref([]);
|
||||
|
||||
// 所有账单(空数据)
|
||||
const allBills = ref([]);
|
||||
|
||||
// 提醒设置
|
||||
const reminderSettings = ref({
|
||||
daysBefore: 3,
|
||||
smsEnabled: true,
|
||||
emailEnabled: false,
|
||||
pushEnabled: true,
|
||||
});
|
||||
|
||||
const saveReminderSettings = () => {
|
||||
console.log('保存提醒设置:', reminderSettings.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">🔔 账单提醒</h1>
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">🔔 账单提醒</h1>
|
||||
<p class="text-gray-600">智能账单管理,从此不错过任何缴费</p>
|
||||
</div>
|
||||
|
||||
<!-- 今日提醒 -->
|
||||
<Card class="mb-6" title="📅 今日待缴账单">
|
||||
<div v-if="todayBills.length === 0" class="text-center py-8">
|
||||
<div class="text-6xl mb-4">✅</div>
|
||||
<p class="text-green-600 font-medium">今天没有待缴账单</p>
|
||||
<div v-if="todayBills.length === 0" class="py-8 text-center">
|
||||
<div class="mb-4 text-6xl">✅</div>
|
||||
<p class="font-medium text-green-600">今天没有待缴账单</p>
|
||||
<p class="text-sm text-gray-500">享受无忧的一天</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="bill in todayBills" :key="bill.id" class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div
|
||||
v-for="bill in todayBills"
|
||||
:key="bill.id"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-2xl">{{ bill.emoji }}</span>
|
||||
<div>
|
||||
<p class="font-medium text-red-800">{{ bill.name }}</p>
|
||||
<p class="text-sm text-red-600">今天到期 · ¥{{ bill.amount.toLocaleString() }}</p>
|
||||
<p class="text-sm text-red-600">
|
||||
今天到期 · ${{ bill.amount.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
@@ -37,27 +71,33 @@
|
||||
<Button type="primary" @click="showAddBill = true">➕ 添加账单</Button>
|
||||
</template>
|
||||
|
||||
<div v-if="allBills.length === 0" class="text-center py-12">
|
||||
<div class="text-8xl mb-6">📱</div>
|
||||
<h3 class="text-xl font-medium text-gray-800 mb-2">暂无账单记录</h3>
|
||||
<p class="text-gray-500 mb-6">添加您的常用账单,系统将自动提醒</p>
|
||||
<div v-if="allBills.length === 0" class="py-12 text-center">
|
||||
<div class="mb-6 text-8xl">📱</div>
|
||||
<h3 class="mb-2 text-xl font-medium text-gray-800">暂无账单记录</h3>
|
||||
<p class="mb-6 text-gray-500">添加您的常用账单,系统将自动提醒</p>
|
||||
<Button type="primary" size="large" @click="showAddBill = true">
|
||||
➕ 添加第一个账单
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="bill in allBills" :key="bill.id" class="p-4 border border-gray-200 rounded-lg">
|
||||
<div
|
||||
v-for="bill in allBills"
|
||||
:key="bill.id"
|
||||
class="rounded-lg border border-gray-200 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-2xl">{{ bill.emoji }}</span>
|
||||
<div>
|
||||
<p class="font-medium">{{ bill.name }}</p>
|
||||
<p class="text-sm text-gray-500">{{ bill.provider }} · 每{{ bill.cycle }}缴费</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ bill.provider }} · 每{{ bill.cycle }}缴费
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold">¥{{ bill.amount.toLocaleString() }}</p>
|
||||
<p class="font-semibold">${{ bill.amount.toLocaleString() }}</p>
|
||||
<p class="text-sm text-gray-500">下次: {{ bill.nextDue }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,11 +106,13 @@
|
||||
</Card>
|
||||
|
||||
<!-- 账单统计 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<Card title="📊 月度账单统计">
|
||||
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div
|
||||
class="flex h-64 items-center justify-center rounded-lg bg-gray-50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-2">📈</div>
|
||||
<div class="mb-2 text-4xl">📈</div>
|
||||
<p class="text-gray-600">月度账单趋势</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,56 +120,37 @@
|
||||
|
||||
<Card title="⏰ 提醒设置">
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>提前提醒天数</span>
|
||||
<InputNumber v-model:value="reminderSettings.daysBefore" :min="1" :max="30" />
|
||||
<InputNumber
|
||||
v-model:value="reminderSettings.daysBefore"
|
||||
:min="1"
|
||||
:max="30"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>短信提醒</span>
|
||||
<Switch v-model:checked="reminderSettings.smsEnabled" />
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>邮件提醒</span>
|
||||
<Switch v-model:checked="reminderSettings.emailEnabled" />
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>应用通知</span>
|
||||
<Switch v-model:checked="reminderSettings.pushEnabled" />
|
||||
</div>
|
||||
<Button type="primary" block @click="saveReminderSettings">保存设置</Button>
|
||||
<Button type="primary" block @click="saveReminderSettings">
|
||||
保存设置
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Card, Button, InputNumber, Switch } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'BillReminders' });
|
||||
|
||||
const showAddBill = ref(false);
|
||||
|
||||
// 今日账单(空数据)
|
||||
const todayBills = ref([]);
|
||||
|
||||
// 所有账单(空数据)
|
||||
const allBills = ref([]);
|
||||
|
||||
// 提醒设置
|
||||
const reminderSettings = ref({
|
||||
daysBefore: 3,
|
||||
smsEnabled: true,
|
||||
emailEnabled: false,
|
||||
pushEnabled: true
|
||||
});
|
||||
|
||||
const saveReminderSettings = () => {
|
||||
console.log('保存提醒设置:', reminderSettings.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
</style>
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -370,7 +370,7 @@ const setBudget = (category: any) => {
|
||||
<div class="rounded-lg bg-purple-50 p-3 text-center">
|
||||
<p class="text-sm text-gray-500">预算总额</p>
|
||||
<p class="text-xl font-bold text-purple-600">
|
||||
¥{{ categoryStats.budgetTotal.toLocaleString() }}
|
||||
${{ categoryStats.budgetTotal.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,7 @@ const baseCurrencySymbol = computed(() => {
|
||||
const baseCurrency = financeStore.currencies.find(
|
||||
(currency) => currency.isBase,
|
||||
);
|
||||
return baseCurrency?.symbol || '¥';
|
||||
return baseCurrency?.symbol || '$';
|
||||
});
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
@@ -203,9 +203,9 @@ const trendChartData = computed(() => {
|
||||
bucketKeys.forEach((key) => {
|
||||
const bucket = bucketMap.get(key) ?? { income: 0, expense: 0 };
|
||||
const label = useMonthlyBucket
|
||||
? (english
|
||||
? english
|
||||
? dayjs(key).format('MMM')
|
||||
: dayjs(key).format('MM月'))
|
||||
: dayjs(key).format('MM月')
|
||||
: dayjs(key).format('MM-DD');
|
||||
labels.push(label);
|
||||
const income = Number(bucket.income.toFixed(2));
|
||||
@@ -1039,7 +1039,9 @@ onMounted(async () => {
|
||||
:style="{ width: `${item.percentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="w-12 text-right text-xs text-gray-500">{{ item.percentage }}%</span>
|
||||
<span class="w-12 text-right text-xs text-gray-500"
|
||||
>{{ item.percentage }}%</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{{ item.count }} {{ isEnglish ? 'records' : '笔交易' }}
|
||||
|
||||
@@ -1,232 +1,20 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">📱 费用追踪</h1>
|
||||
<p class="text-gray-600">智能费用追踪,支持小票OCR识别和自动分类</p>
|
||||
</div>
|
||||
|
||||
<!-- 快速添加费用 -->
|
||||
<Card class="mb-6" title="⚡ 快速记录">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- 拍照记录 -->
|
||||
<div class="text-center p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 cursor-pointer" @click="openCamera">
|
||||
<div class="text-4xl mb-3">📷</div>
|
||||
<h3 class="font-medium mb-2">拍照记录</h3>
|
||||
<p class="text-sm text-gray-500">拍摄小票,自动识别金额和商家</p>
|
||||
</div>
|
||||
|
||||
<!-- 语音记录 -->
|
||||
<div class="text-center p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-green-400 cursor-pointer" @click="startVoiceRecord">
|
||||
<div class="text-4xl mb-3">🎤</div>
|
||||
<h3 class="font-medium mb-2">语音记录</h3>
|
||||
<p class="text-sm text-gray-500">说出消费内容,智能转换为记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 手动输入 -->
|
||||
<div class="text-center p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-purple-400 cursor-pointer" @click="showQuickAdd = true">
|
||||
<div class="text-4xl mb-3">✍️</div>
|
||||
<h3 class="font-medium mb-2">手动输入</h3>
|
||||
<p class="text-sm text-gray-500">快速手动输入费用信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 今日费用汇总 -->
|
||||
<Card class="mb-6" title="📅 今日费用汇总">
|
||||
<div v-if="todayExpenses.length === 0" class="text-center py-8">
|
||||
<div class="text-6xl mb-4">💸</div>
|
||||
<p class="text-gray-500 mb-4">今天还没有费用记录</p>
|
||||
<Button type="primary" @click="openCamera">开始记录第一笔费用</Button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div class="text-center p-4 bg-red-50 rounded-lg">
|
||||
<p class="text-sm text-gray-500">今日支出</p>
|
||||
<p class="text-2xl font-bold text-red-600">¥{{ todayTotal.toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-blue-50 rounded-lg">
|
||||
<p class="text-sm text-gray-500">记录笔数</p>
|
||||
<p class="text-2xl font-bold text-blue-600">{{ todayExpenses.length }}</p>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-green-50 rounded-lg">
|
||||
<p class="text-sm text-gray-500">主要类别</p>
|
||||
<p class="text-2xl font-bold text-green-600">{{ topCategory || '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 今日费用列表 -->
|
||||
<div class="space-y-3">
|
||||
<div v-for="expense in todayExpenses" :key="expense.id"
|
||||
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-2xl">{{ expense.emoji }}</span>
|
||||
<div>
|
||||
<p class="font-medium">{{ expense.merchant || '未知商家' }}</p>
|
||||
<p class="text-sm text-gray-500">{{ expense.time }} · {{ expense.method }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-bold text-red-600">¥{{ expense.amount.toLocaleString() }}</p>
|
||||
<Tag size="small" :color="getCategoryColor(expense.category)">{{ expense.category }}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 费用分析 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<Card title="📊 本周费用趋势">
|
||||
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-2">📈</div>
|
||||
<p class="text-gray-600">费用趋势分析</p>
|
||||
<p class="text-sm text-gray-500">每日费用变化图表</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="🏪 商家排行">
|
||||
<div v-if="merchantRanking.length === 0" class="text-center py-8">
|
||||
<div class="text-4xl mb-3">🏪</div>
|
||||
<p class="text-gray-500">暂无商家数据</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="(merchant, index) in merchantRanking" :key="merchant.name"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-lg font-bold text-gray-400">{{ index + 1 }}</span>
|
||||
<span class="font-medium">{{ merchant.name }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold">¥{{ merchant.total.toLocaleString() }}</p>
|
||||
<p class="text-xs text-gray-500">{{ merchant.count }}次</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 智能分析 -->
|
||||
<Card class="mb-6" title="🧠 智能分析">
|
||||
<div v-if="insights.length === 0" class="text-center py-8">
|
||||
<div class="text-4xl mb-3">🤖</div>
|
||||
<p class="text-gray-500">积累更多数据后将为您提供智能分析</p>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div v-for="insight in insights" :key="insight.id" class="p-4 border border-gray-200 rounded-lg">
|
||||
<div class="flex items-start space-x-3">
|
||||
<span class="text-2xl">{{ insight.emoji }}</span>
|
||||
<div>
|
||||
<h4 class="font-medium mb-1">{{ insight.title }}</h4>
|
||||
<p class="text-sm text-gray-600 mb-2">{{ insight.description }}</p>
|
||||
<Tag :color="insight.type === 'warning' ? 'orange' : insight.type === 'tip' ? 'blue' : 'green'">
|
||||
{{ insight.type === 'warning' ? '注意' : insight.type === 'tip' ? '建议' : '良好' }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 快速添加模态框 -->
|
||||
<Modal v-model:open="showQuickAdd" title="✍️ 快速记录费用">
|
||||
<Form :model="quickExpenseForm" layout="vertical">
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="金额" required>
|
||||
<InputNumber v-model:value="quickExpenseForm.amount" :precision="2" style="width: 100%" placeholder="0.00" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="支付方式">
|
||||
<Select v-model:value="quickExpenseForm.method">
|
||||
<Select.Option value="cash">现金</Select.Option>
|
||||
<Select.Option value="card">刷卡</Select.Option>
|
||||
<Select.Option value="mobile">手机支付</Select.Option>
|
||||
<Select.Option value="online">网上支付</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="消费类别">
|
||||
<Select v-model:value="quickExpenseForm.category" placeholder="选择或搜索类别" show-search>
|
||||
<Select.Option value="food">餐饮</Select.Option>
|
||||
<Select.Option value="transport">交通</Select.Option>
|
||||
<Select.Option value="shopping">购物</Select.Option>
|
||||
<Select.Option value="entertainment">娱乐</Select.Option>
|
||||
<Select.Option value="medical">医疗</Select.Option>
|
||||
<Select.Option value="education">教育</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="商家名称">
|
||||
<AutoComplete v-model:value="quickExpenseForm.merchant" :options="merchantSuggestions" placeholder="输入商家名称" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="消费描述">
|
||||
<Input.TextArea v-model:value="quickExpenseForm.description" :rows="2" placeholder="简单描述这笔消费..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="添加标签">
|
||||
<Select v-model:value="quickExpenseForm.tags" mode="tags" placeholder="添加标签便于分类">
|
||||
<Select.Option value="必需品">必需品</Select.Option>
|
||||
<Select.Option value="一次性">一次性</Select.Option>
|
||||
<Select.Option value="定期">定期</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="是否分期">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Switch v-model:checked="quickExpenseForm.isInstallment" />
|
||||
<span class="text-sm text-gray-500">如果是信用卡分期消费请开启</span>
|
||||
</div>
|
||||
<div v-if="quickExpenseForm.isInstallment" class="mt-3 grid grid-cols-2 gap-4">
|
||||
<Input placeholder="分期期数" />
|
||||
<InputNumber placeholder="每期金额" style="width: 100%" />
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-between">
|
||||
<Button @click="showQuickAdd = false">取消</Button>
|
||||
<Space>
|
||||
<Button @click="saveAndContinue">保存并继续</Button>
|
||||
<Button type="primary" @click="saveQuickExpense">保存</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 相机拍摄模态框 -->
|
||||
<Modal v-model:open="showCamera" title="📷 拍摄小票" width="400px">
|
||||
<div class="text-center py-8">
|
||||
<div class="mb-4">
|
||||
<video ref="videoRef" autoplay muted style="width: 100%; max-width: 300px; border-radius: 8px;"></video>
|
||||
</div>
|
||||
<canvas ref="canvasRef" style="display: none;"></canvas>
|
||||
<div class="space-x-4">
|
||||
<Button type="primary" @click="capturePhoto">📸 拍照</Button>
|
||||
<Button @click="stopCamera">取消</Button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">请将小票置于画面中心</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Card, Button, Table, Tag, Modal, Form, Row, Col, InputNumber,
|
||||
Select, AutoComplete, Input, Switch, Space
|
||||
AutoComplete,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'ExpenseTracking' });
|
||||
@@ -239,7 +27,7 @@ const canvasRef = ref();
|
||||
// 今日费用(空数据)
|
||||
const todayExpenses = ref([]);
|
||||
|
||||
// 商家排行(空数据)
|
||||
// 商家排行(空数据)
|
||||
const merchantRanking = ref([]);
|
||||
|
||||
// 智能分析(空数据)
|
||||
@@ -249,17 +37,20 @@ const insights = ref([]);
|
||||
const merchantSuggestions = ref([]);
|
||||
|
||||
// 计算属性
|
||||
const todayTotal = computed(() =>
|
||||
todayExpenses.value.reduce((sum, expense) => sum + expense.amount, 0)
|
||||
const todayTotal = computed(() =>
|
||||
todayExpenses.value.reduce((sum, expense) => sum + expense.amount, 0),
|
||||
);
|
||||
|
||||
const topCategory = computed(() => {
|
||||
if (todayExpenses.value.length === 0) return null;
|
||||
const categoryCount = {};
|
||||
todayExpenses.value.forEach(expense => {
|
||||
categoryCount[expense.category] = (categoryCount[expense.category] || 0) + 1;
|
||||
todayExpenses.value.forEach((expense) => {
|
||||
categoryCount[expense.category] =
|
||||
(categoryCount[expense.category] || 0) + 1;
|
||||
});
|
||||
return Object.keys(categoryCount).reduce((a, b) => categoryCount[a] > categoryCount[b] ? a : b);
|
||||
return Object.keys(categoryCount).reduce((a, b) =>
|
||||
categoryCount[a] > categoryCount[b] ? a : b,
|
||||
);
|
||||
});
|
||||
|
||||
// 快速费用表单
|
||||
@@ -270,14 +61,18 @@ const quickExpenseForm = ref({
|
||||
merchant: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
isInstallment: false
|
||||
isInstallment: false,
|
||||
});
|
||||
|
||||
// 方法实现
|
||||
const getCategoryColor = (category: string) => {
|
||||
const colorMap = {
|
||||
'food': 'orange', 'transport': 'blue', 'shopping': 'purple',
|
||||
'entertainment': 'pink', 'medical': 'red', 'education': 'green'
|
||||
food: 'orange',
|
||||
transport: 'blue',
|
||||
shopping: 'purple',
|
||||
entertainment: 'pink',
|
||||
medical: 'red',
|
||||
education: 'green',
|
||||
};
|
||||
return colorMap[category] || 'default';
|
||||
};
|
||||
@@ -297,24 +92,24 @@ const capturePhoto = () => {
|
||||
const canvas = canvasRef.value;
|
||||
const video = videoRef.value;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
context.drawImage(video, 0, 0);
|
||||
|
||||
|
||||
const imageData = canvas.toDataURL('image/jpeg');
|
||||
console.log('拍摄的照片数据:', imageData);
|
||||
|
||||
|
||||
// 这里可以调用OCR API识别小票
|
||||
simulateOcrRecognition(imageData);
|
||||
|
||||
|
||||
stopCamera();
|
||||
};
|
||||
|
||||
const stopCamera = () => {
|
||||
const video = videoRef.value;
|
||||
if (video.srcObject) {
|
||||
video.srcObject.getTracks().forEach(track => track.stop());
|
||||
video.srcObject.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
showCamera.value = false;
|
||||
};
|
||||
@@ -355,11 +150,322 @@ const resetQuickForm = () => {
|
||||
merchant: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
isInstallment: false
|
||||
isInstallment: false,
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">📱 费用追踪</h1>
|
||||
<p class="text-gray-600">智能费用追踪,支持小票OCR识别和自动分类</p>
|
||||
</div>
|
||||
|
||||
<!-- 快速添加费用 -->
|
||||
<Card class="mb-6" title="⚡ 快速记录">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<!-- 拍照记录 -->
|
||||
<div
|
||||
class="cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-6 text-center hover:border-blue-400"
|
||||
@click="openCamera"
|
||||
>
|
||||
<div class="mb-3 text-4xl">📷</div>
|
||||
<h3 class="mb-2 font-medium">拍照记录</h3>
|
||||
<p class="text-sm text-gray-500">拍摄小票,自动识别金额和商家</p>
|
||||
</div>
|
||||
|
||||
<!-- 语音记录 -->
|
||||
<div
|
||||
class="cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-6 text-center hover:border-green-400"
|
||||
@click="startVoiceRecord"
|
||||
>
|
||||
<div class="mb-3 text-4xl">🎤</div>
|
||||
<h3 class="mb-2 font-medium">语音记录</h3>
|
||||
<p class="text-sm text-gray-500">说出消费内容,智能转换为记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 手动输入 -->
|
||||
<div
|
||||
class="cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-6 text-center hover:border-purple-400"
|
||||
@click="showQuickAdd = true"
|
||||
>
|
||||
<div class="mb-3 text-4xl">✍️</div>
|
||||
<h3 class="mb-2 font-medium">手动输入</h3>
|
||||
<p class="text-sm text-gray-500">快速手动输入费用信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 今日费用汇总 -->
|
||||
<Card class="mb-6" title="📅 今日费用汇总">
|
||||
<div v-if="todayExpenses.length === 0" class="py-8 text-center">
|
||||
<div class="mb-4 text-6xl">💸</div>
|
||||
<p class="mb-4 text-gray-500">今天还没有费用记录</p>
|
||||
<Button type="primary" @click="openCamera">开始记录第一笔费用</Button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="rounded-lg bg-red-50 p-4 text-center">
|
||||
<p class="text-sm text-gray-500">今日支出</p>
|
||||
<p class="text-2xl font-bold text-red-600">
|
||||
${{ todayTotal.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-blue-50 p-4 text-center">
|
||||
<p class="text-sm text-gray-500">记录笔数</p>
|
||||
<p class="text-2xl font-bold text-blue-600">
|
||||
{{ todayExpenses.length }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-green-50 p-4 text-center">
|
||||
<p class="text-sm text-gray-500">主要类别</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
{{ topCategory || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 今日费用列表 -->
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="expense in todayExpenses"
|
||||
:key="expense.id"
|
||||
class="flex items-center justify-between rounded-lg bg-gray-50 p-4"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-2xl">{{ expense.emoji }}</span>
|
||||
<div>
|
||||
<p class="font-medium">{{ expense.merchant || '未知商家' }}</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ expense.time }} · {{ expense.method }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-bold text-red-600">
|
||||
${{ expense.amount.toLocaleString() }}
|
||||
</p>
|
||||
<Tag size="small" :color="getCategoryColor(expense.category)">
|
||||
{{ expense.category }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 费用分析 -->
|
||||
<div class="mb-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card title="📊 本周费用趋势">
|
||||
<div
|
||||
class="flex h-64 items-center justify-center rounded-lg bg-gray-50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="mb-2 text-4xl">📈</div>
|
||||
<p class="text-gray-600">费用趋势分析</p>
|
||||
<p class="text-sm text-gray-500">每日费用变化图表</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="🏪 商家排行">
|
||||
<div v-if="merchantRanking.length === 0" class="py-8 text-center">
|
||||
<div class="mb-3 text-4xl">🏪</div>
|
||||
<p class="text-gray-500">暂无商家数据</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="(merchant, index) in merchantRanking"
|
||||
:key="merchant.name"
|
||||
class="flex items-center justify-between rounded-lg bg-gray-50 p-3"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-lg font-bold text-gray-400">{{
|
||||
index + 1
|
||||
}}</span>
|
||||
<span class="font-medium">{{ merchant.name }}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold">
|
||||
${{ merchant.total.toLocaleString() }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">{{ merchant.count }}次</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 智能分析 -->
|
||||
<Card class="mb-6" title="🧠 智能分析">
|
||||
<div v-if="insights.length === 0" class="py-8 text-center">
|
||||
<div class="mb-3 text-4xl">🤖</div>
|
||||
<p class="text-gray-500">积累更多数据后将为您提供智能分析</p>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="insight in insights"
|
||||
:key="insight.id"
|
||||
class="rounded-lg border border-gray-200 p-4"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<span class="text-2xl">{{ insight.emoji }}</span>
|
||||
<div>
|
||||
<h4 class="mb-1 font-medium">{{ insight.title }}</h4>
|
||||
<p class="mb-2 text-sm text-gray-600">
|
||||
{{ insight.description }}
|
||||
</p>
|
||||
<Tag
|
||||
:color="
|
||||
insight.type === 'warning'
|
||||
? 'orange'
|
||||
: insight.type === 'tip'
|
||||
? 'blue'
|
||||
: 'green'
|
||||
"
|
||||
>
|
||||
{{
|
||||
insight.type === 'warning'
|
||||
? '注意'
|
||||
: insight.type === 'tip'
|
||||
? '建议'
|
||||
: '良好'
|
||||
}}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 快速添加模态框 -->
|
||||
<Modal v-model:open="showQuickAdd" title="✍️ 快速记录费用">
|
||||
<Form :model="quickExpenseForm" layout="vertical">
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="金额" required>
|
||||
<InputNumber
|
||||
v-model:value="quickExpenseForm.amount"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
placeholder="0.00"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="支付方式">
|
||||
<Select v-model:value="quickExpenseForm.method">
|
||||
<Select.Option value="cash">现金</Select.Option>
|
||||
<Select.Option value="card">刷卡</Select.Option>
|
||||
<Select.Option value="mobile">手机支付</Select.Option>
|
||||
<Select.Option value="online">网上支付</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="消费类别">
|
||||
<Select
|
||||
v-model:value="quickExpenseForm.category"
|
||||
placeholder="选择或搜索类别"
|
||||
show-search
|
||||
>
|
||||
<Select.Option value="food">餐饮</Select.Option>
|
||||
<Select.Option value="transport">交通</Select.Option>
|
||||
<Select.Option value="shopping">购物</Select.Option>
|
||||
<Select.Option value="entertainment">娱乐</Select.Option>
|
||||
<Select.Option value="medical">医疗</Select.Option>
|
||||
<Select.Option value="education">教育</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="商家名称">
|
||||
<AutoComplete
|
||||
v-model:value="quickExpenseForm.merchant"
|
||||
:options="merchantSuggestions"
|
||||
placeholder="输入商家名称"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="消费描述">
|
||||
<Input.TextArea
|
||||
v-model:value="quickExpenseForm.description"
|
||||
:rows="2"
|
||||
placeholder="简单描述这笔消费..."
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="添加标签">
|
||||
<Select
|
||||
v-model:value="quickExpenseForm.tags"
|
||||
mode="tags"
|
||||
placeholder="添加标签便于分类"
|
||||
>
|
||||
<Select.Option value="必需品">必需品</Select.Option>
|
||||
<Select.Option value="一次性">一次性</Select.Option>
|
||||
<Select.Option value="定期">定期</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="是否分期">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Switch v-model:checked="quickExpenseForm.isInstallment" />
|
||||
<span class="text-sm text-gray-500"
|
||||
>如果是信用卡分期消费请开启</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="quickExpenseForm.isInstallment"
|
||||
class="mt-3 grid grid-cols-2 gap-4"
|
||||
>
|
||||
<Input placeholder="分期期数" />
|
||||
<InputNumber placeholder="每期金额" style="width: 100%" />
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-between">
|
||||
<Button @click="showQuickAdd = false">取消</Button>
|
||||
<Space>
|
||||
<Button @click="saveAndContinue">保存并继续</Button>
|
||||
<Button type="primary" @click="saveQuickExpense">保存</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 相机拍摄模态框 -->
|
||||
<Modal v-model:open="showCamera" title="📷 拍摄小票" width="400px">
|
||||
<div class="py-8 text-center">
|
||||
<div class="mb-4">
|
||||
<video
|
||||
ref="videoRef"
|
||||
autoplay
|
||||
muted
|
||||
style="width: 100%; max-width: 300px; border-radius: 8px"
|
||||
></video>
|
||||
</div>
|
||||
<canvas ref="canvasRef" style="display: none"></canvas>
|
||||
<div class="space-x-4">
|
||||
<Button type="primary" @click="capturePhoto">📸 拍照</Button>
|
||||
<Button @click="stopCamera">取消</Button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">请将小票置于画面中心</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
</style>
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,38 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
AutoComplete,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
RangePicker,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Upload,
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
defineOptions({ name: 'InvoiceManagement' });
|
||||
|
||||
const showOcrUpload = ref(false);
|
||||
const showCreateInvoice = ref(false);
|
||||
const ocrResult = ref(null);
|
||||
|
||||
// 发票统计(无虚拟数据)
|
||||
const invoiceStats = ref({
|
||||
pending: 0,
|
||||
issued: 0,
|
||||
received: 0,
|
||||
totalAmount: 0,
|
||||
});
|
||||
|
||||
// 发票列表(空数据)
|
||||
const invoices = ref([]);
|
||||
|
||||
// 发票表格列
|
||||
const invoiceColumns = [
|
||||
{
|
||||
title: '发票号码',
|
||||
dataIndex: 'invoiceNumber',
|
||||
key: 'invoiceNumber',
|
||||
width: 150,
|
||||
},
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100 },
|
||||
{ title: '客户/供应商', dataIndex: 'customer', key: 'customer' },
|
||||
{ title: '开票日期', dataIndex: 'issueDate', key: 'issueDate', width: 120 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 200 },
|
||||
];
|
||||
|
||||
// 发票明细表格列
|
||||
const invoiceItemColumns = [
|
||||
{ title: '项目名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '规格型号', dataIndex: 'specification', key: 'specification' },
|
||||
{ title: '数量', dataIndex: 'quantity', key: 'quantity', width: 100 },
|
||||
{ title: '单价', dataIndex: 'unitPrice', key: 'unitPrice', width: 100 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
];
|
||||
|
||||
// 客户选项(空数据)
|
||||
const customerOptions = ref([]);
|
||||
|
||||
// 发票表单
|
||||
const invoiceForm = ref({
|
||||
type: 'sales',
|
||||
code: '',
|
||||
customer: '',
|
||||
issueDate: dayjs(),
|
||||
taxRate: 13,
|
||||
items: [],
|
||||
notes: '',
|
||||
});
|
||||
|
||||
// 方法实现
|
||||
const getInvoiceStatusColor = (status: string) => {
|
||||
const statusMap = { pending: 'orange', issued: 'green', cancelled: 'red' };
|
||||
return statusMap[status] || 'default';
|
||||
};
|
||||
|
||||
const getInvoiceStatusText = (status: string) => {
|
||||
const textMap = { pending: '待开具', issued: '已开具', cancelled: '已作废' };
|
||||
return textMap[status] || status;
|
||||
};
|
||||
|
||||
const calculateTotal = () => {
|
||||
return invoiceForm.value.items.reduce(
|
||||
(sum, item) => sum + item.quantity * item.unitPrice,
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
const calculateTax = () => {
|
||||
return calculateTotal() * (invoiceForm.value.taxRate / 100);
|
||||
};
|
||||
|
||||
const handleOcrUpload = (info) => {
|
||||
console.log('OCR上传处理:', info);
|
||||
// 模拟OCR识别结果
|
||||
setTimeout(() => {
|
||||
ocrResult.value = {
|
||||
invoiceNumber: `INV${Date.now()}`,
|
||||
issueDate: dayjs().format('YYYY-MM-DD'),
|
||||
seller: '示例公司',
|
||||
buyer: '客户公司',
|
||||
amount: '1000.00',
|
||||
tax: '130.00',
|
||||
};
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const saveOcrInvoice = () => {
|
||||
console.log('保存OCR识别的发票:', ocrResult.value);
|
||||
showOcrUpload.value = false;
|
||||
ocrResult.value = null;
|
||||
};
|
||||
|
||||
const addInvoiceItem = () => {
|
||||
invoiceForm.value.items.push({
|
||||
name: '',
|
||||
specification: '',
|
||||
quantity: 1,
|
||||
unitPrice: 0,
|
||||
amount: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const batchImport = () => {
|
||||
console.log('批量导入发票');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">📄 发票管理</h1>
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">📄 发票管理</h1>
|
||||
<p class="text-gray-600">管理进项发票、销项发票,支持OCR识别和自动记账</p>
|
||||
</div>
|
||||
|
||||
<!-- 发票统计卡片 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<Card class="text-center transition-shadow hover:shadow-lg">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">📤</div>
|
||||
<p class="text-sm text-gray-500">待开发票</p>
|
||||
<p class="text-2xl font-bold text-orange-600">{{ invoiceStats.pending }}</p>
|
||||
<p class="text-2xl font-bold text-orange-600">
|
||||
{{ invoiceStats.pending }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<Card class="text-center transition-shadow hover:shadow-lg">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">✅</div>
|
||||
<p class="text-sm text-gray-500">已开发票</p>
|
||||
<p class="text-2xl font-bold text-green-600">{{ invoiceStats.issued }}</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
{{ invoiceStats.issued }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<Card class="text-center transition-shadow hover:shadow-lg">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">📥</div>
|
||||
<p class="text-sm text-gray-500">收到发票</p>
|
||||
<p class="text-2xl font-bold text-blue-600">{{ invoiceStats.received }}</p>
|
||||
<p class="text-2xl font-bold text-blue-600">
|
||||
{{ invoiceStats.received }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center hover:shadow-lg transition-shadow">
|
||||
<Card class="text-center transition-shadow hover:shadow-lg">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">💰</div>
|
||||
<p class="text-sm text-gray-500">发票金额</p>
|
||||
<p class="text-2xl font-bold text-purple-600">¥{{ invoiceStats.totalAmount.toLocaleString() }}</p>
|
||||
<p class="text-2xl font-bold text-purple-600">
|
||||
${{ invoiceStats.totalAmount.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -59,22 +204,18 @@
|
||||
<Button type="primary" @click="showCreateInvoice = true">
|
||||
📝 开具发票
|
||||
</Button>
|
||||
<Button @click="showOcrUpload = true">
|
||||
📷 OCR识别
|
||||
</Button>
|
||||
<Button @click="batchImport">
|
||||
📥 批量导入
|
||||
</Button>
|
||||
<Button @click="showOcrUpload = true"> 📷 OCR识别 </Button>
|
||||
<Button @click="batchImport"> 📥 批量导入 </Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 发票列表 -->
|
||||
<Card title="📋 发票清单">
|
||||
<div v-if="invoices.length === 0" class="text-center py-12">
|
||||
<div class="text-8xl mb-6">📄</div>
|
||||
<h3 class="text-xl font-medium text-gray-800 mb-2">暂无发票记录</h3>
|
||||
<p class="text-gray-500 mb-6">开始管理您的发票,支持OCR自动识别</p>
|
||||
<div v-if="invoices.length === 0" class="py-12 text-center">
|
||||
<div class="mb-6 text-8xl">📄</div>
|
||||
<h3 class="mb-2 text-xl font-medium text-gray-800">暂无发票记录</h3>
|
||||
<p class="mb-6 text-gray-500">开始管理您的发票,支持OCR自动识别</p>
|
||||
<div class="space-x-4">
|
||||
<Button type="primary" size="large" @click="showCreateInvoice = true">
|
||||
📝 开具发票
|
||||
@@ -84,11 +225,16 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table v-else :columns="invoiceColumns" :dataSource="invoices" :pagination="{ pageSize: 10 }">
|
||||
<Table
|
||||
v-else
|
||||
:columns="invoiceColumns"
|
||||
:data-source="invoices"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'amount'">
|
||||
<span class="font-semibold text-blue-600">
|
||||
¥{{ record.amount.toLocaleString() }}
|
||||
${{ record.amount.toLocaleString() }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
@@ -110,32 +256,46 @@
|
||||
|
||||
<!-- OCR上传模态框 -->
|
||||
<Modal v-model:open="showOcrUpload" title="📷 OCR发票识别" width="600px">
|
||||
<div class="text-center py-8">
|
||||
<div class="py-8 text-center">
|
||||
<Upload
|
||||
:customRequest="handleOcrUpload"
|
||||
:custom-request="handleOcrUpload"
|
||||
accept="image/*,.pdf"
|
||||
list-type="picture-card"
|
||||
:show-upload-list="false"
|
||||
:multiple="false"
|
||||
>
|
||||
<div class="p-8">
|
||||
<div class="text-6xl mb-4">📷</div>
|
||||
<p class="text-lg font-medium mb-2">上传发票图片或PDF</p>
|
||||
<div class="mb-4 text-6xl">📷</div>
|
||||
<p class="mb-2 text-lg font-medium">上传发票图片或PDF</p>
|
||||
<p class="text-sm text-gray-500">支持自动OCR识别发票信息</p>
|
||||
<p class="text-xs text-gray-400 mt-2">支持格式: JPG, PNG, PDF</p>
|
||||
<p class="mt-2 text-xs text-gray-400">支持格式: JPG, PNG, PDF</p>
|
||||
</div>
|
||||
</Upload>
|
||||
</div>
|
||||
|
||||
<div v-if="ocrResult" class="mt-6 p-4 bg-green-50 rounded-lg">
|
||||
<h4 class="font-medium text-green-800 mb-3">🎉 识别成功</h4>
|
||||
|
||||
<div v-if="ocrResult" class="mt-6 rounded-lg bg-green-50 p-4">
|
||||
<h4 class="mb-3 font-medium text-green-800">🎉 识别成功</h4>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><span class="text-gray-600">发票号码:</span> {{ ocrResult.invoiceNumber }}</div>
|
||||
<div><span class="text-gray-600">开票日期:</span> {{ ocrResult.issueDate }}</div>
|
||||
<div><span class="text-gray-600">销售方:</span> {{ ocrResult.seller }}</div>
|
||||
<div><span class="text-gray-600">购买方:</span> {{ ocrResult.buyer }}</div>
|
||||
<div><span class="text-gray-600">金额:</span> ¥{{ ocrResult.amount }}</div>
|
||||
<div><span class="text-gray-600">税额:</span> ¥{{ ocrResult.tax }}</div>
|
||||
<div>
|
||||
<span class="text-gray-600">发票号码:</span>
|
||||
{{ ocrResult.invoiceNumber }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">开票日期:</span>
|
||||
{{ ocrResult.issueDate }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">销售方:</span> {{ ocrResult.seller }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">购买方:</span> {{ ocrResult.buyer }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">金额:</span> ${{ ocrResult.amount }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600">税额:</span> ${{ ocrResult.tax }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<Button type="primary" @click="saveOcrInvoice">保存到系统</Button>
|
||||
@@ -175,14 +335,22 @@
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="开票日期" required>
|
||||
<DatePicker v-model:value="invoiceForm.issueDate" style="width: 100%" />
|
||||
<DatePicker
|
||||
v-model:value="invoiceForm.issueDate"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 发票项目明细 -->
|
||||
<Form.Item label="发票明细">
|
||||
<Table :columns="invoiceItemColumns" :dataSource="invoiceForm.items" :pagination="false" size="small">
|
||||
<Table
|
||||
:columns="invoiceItemColumns"
|
||||
:data-source="invoiceForm.items"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #footer>
|
||||
<Button type="dashed" block @click="addInvoiceItem">
|
||||
➕ 添加明细项
|
||||
@@ -206,139 +374,33 @@
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Form.Item label="金额合计">
|
||||
<Input :value="`¥${calculateTotal().toLocaleString()}`" disabled />
|
||||
<Input
|
||||
:value="`$${calculateTotal().toLocaleString()}`"
|
||||
disabled
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Form.Item label="税额">
|
||||
<Input :value="`¥${calculateTax().toLocaleString()}`" disabled />
|
||||
<Input :value="`$${calculateTax().toLocaleString()}`" disabled />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="备注">
|
||||
<Input.TextArea v-model:value="invoiceForm.notes" :rows="3" placeholder="发票备注信息..." />
|
||||
<Input.TextArea
|
||||
v-model:value="invoiceForm.notes"
|
||||
:rows="3"
|
||||
placeholder="发票备注信息..."
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
Card, Input, Select, RangePicker, Button, Table, Tag, Space, Modal,
|
||||
Upload, Form, Row, Col, DatePicker, AutoComplete
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
defineOptions({ name: 'InvoiceManagement' });
|
||||
|
||||
const showOcrUpload = ref(false);
|
||||
const showCreateInvoice = ref(false);
|
||||
const ocrResult = ref(null);
|
||||
|
||||
// 发票统计(无虚拟数据)
|
||||
const invoiceStats = ref({
|
||||
pending: 0,
|
||||
issued: 0,
|
||||
received: 0,
|
||||
totalAmount: 0
|
||||
});
|
||||
|
||||
// 发票列表(空数据)
|
||||
const invoices = ref([]);
|
||||
|
||||
// 发票表格列
|
||||
const invoiceColumns = [
|
||||
{ title: '发票号码', dataIndex: 'invoiceNumber', key: 'invoiceNumber', width: 150 },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100 },
|
||||
{ title: '客户/供应商', dataIndex: 'customer', key: 'customer' },
|
||||
{ title: '开票日期', dataIndex: 'issueDate', key: 'issueDate', width: 120 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 200 }
|
||||
];
|
||||
|
||||
// 发票明细表格列
|
||||
const invoiceItemColumns = [
|
||||
{ title: '项目名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '规格型号', dataIndex: 'specification', key: 'specification' },
|
||||
{ title: '数量', dataIndex: 'quantity', key: 'quantity', width: 100 },
|
||||
{ title: '单价', dataIndex: 'unitPrice', key: 'unitPrice', width: 100 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 80 }
|
||||
];
|
||||
|
||||
// 客户选项(空数据)
|
||||
const customerOptions = ref([]);
|
||||
|
||||
// 发票表单
|
||||
const invoiceForm = ref({
|
||||
type: 'sales',
|
||||
code: '',
|
||||
customer: '',
|
||||
issueDate: dayjs(),
|
||||
taxRate: 13,
|
||||
items: [],
|
||||
notes: ''
|
||||
});
|
||||
|
||||
// 方法实现
|
||||
const getInvoiceStatusColor = (status: string) => {
|
||||
const statusMap = { 'pending': 'orange', 'issued': 'green', 'cancelled': 'red' };
|
||||
return statusMap[status] || 'default';
|
||||
};
|
||||
|
||||
const getInvoiceStatusText = (status: string) => {
|
||||
const textMap = { 'pending': '待开具', 'issued': '已开具', 'cancelled': '已作废' };
|
||||
return textMap[status] || status;
|
||||
};
|
||||
|
||||
const calculateTotal = () => {
|
||||
return invoiceForm.value.items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0);
|
||||
};
|
||||
|
||||
const calculateTax = () => {
|
||||
return calculateTotal() * (invoiceForm.value.taxRate / 100);
|
||||
};
|
||||
|
||||
const handleOcrUpload = (info) => {
|
||||
console.log('OCR上传处理:', info);
|
||||
// 模拟OCR识别结果
|
||||
setTimeout(() => {
|
||||
ocrResult.value = {
|
||||
invoiceNumber: 'INV' + Date.now(),
|
||||
issueDate: dayjs().format('YYYY-MM-DD'),
|
||||
seller: '示例公司',
|
||||
buyer: '客户公司',
|
||||
amount: '1000.00',
|
||||
tax: '130.00'
|
||||
};
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const saveOcrInvoice = () => {
|
||||
console.log('保存OCR识别的发票:', ocrResult.value);
|
||||
showOcrUpload.value = false;
|
||||
ocrResult.value = null;
|
||||
};
|
||||
|
||||
const addInvoiceItem = () => {
|
||||
invoiceForm.value.items.push({
|
||||
name: '',
|
||||
specification: '',
|
||||
quantity: 1,
|
||||
unitPrice: 0,
|
||||
amount: 0
|
||||
});
|
||||
};
|
||||
|
||||
const batchImport = () => {
|
||||
console.log('批量导入发票');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
</style>
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
238
apps/web-antd/src/views/finance/media/index.vue
Normal file
238
apps/web-antd/src/views/finance/media/index.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Button, Card, Space, Table, Tag, Select, Tooltip, message } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
|
||||
defineOptions({ name: 'FinanceMediaCenter' });
|
||||
|
||||
const financeStore = useFinanceStore();
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
|
||||
const selectedTypes = ref<string[]>([]);
|
||||
const isRefreshing = ref(false);
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '图片', value: 'photo' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '音频', value: 'audio' },
|
||||
{ label: '语音', value: 'voice' },
|
||||
{ label: '文件', value: 'document' },
|
||||
{ label: '视频消息', value: 'video_note' },
|
||||
{ label: '动图', value: 'animation' },
|
||||
{ label: '贴纸', value: 'sticker' },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ title: '类型', dataIndex: 'fileType', key: 'fileType', width: 120 },
|
||||
{ title: '说明', dataIndex: 'caption', key: 'caption', ellipsis: true },
|
||||
{ title: '发送者', dataIndex: 'displayName', key: 'sender', width: 160 },
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username', width: 160 },
|
||||
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 200 },
|
||||
{ title: '大小', dataIndex: 'fileSize', key: 'fileSize', width: 120 },
|
||||
{ title: '状态', dataIndex: 'available', key: 'available', width: 120 },
|
||||
{ title: '操作', key: 'action', width: 160, fixed: 'right' },
|
||||
];
|
||||
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (total: number) => `共 ${total} 条媒体记录`,
|
||||
});
|
||||
|
||||
const loading = computed(
|
||||
() => financeStore.loading.mediaMessages || isRefreshing.value,
|
||||
);
|
||||
|
||||
const mediaMessages = computed(() => {
|
||||
const types = selectedTypes.value;
|
||||
if (!types || types.length === 0) {
|
||||
return financeStore.mediaMessages;
|
||||
}
|
||||
return financeStore.mediaMessages.filter((item) =>
|
||||
types.includes(item.fileType),
|
||||
);
|
||||
});
|
||||
|
||||
const typeLabelMap: Record<string, string> = {
|
||||
photo: '图片',
|
||||
video: '视频',
|
||||
audio: '音频',
|
||||
voice: '语音',
|
||||
document: '文件',
|
||||
video_note: '视频消息',
|
||||
animation: '动图',
|
||||
sticker: '贴纸',
|
||||
};
|
||||
|
||||
function formatFileSize(size?: number) {
|
||||
if (!size || size <= 0) {
|
||||
return '-';
|
||||
}
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
if (size < 1024 * 1024 * 1024) {
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
async function fetchMedia() {
|
||||
isRefreshing.value = true;
|
||||
try {
|
||||
await financeStore.fetchMediaMessages({
|
||||
limit: DEFAULT_LIMIT,
|
||||
fileTypes: selectedTypes.value.length > 0 ? selectedTypes.value : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[finance][media] fetch failed', error);
|
||||
message.error('媒体记录加载失败,请稍后重试');
|
||||
} finally {
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(record: (typeof mediaMessages.value)[number]) {
|
||||
if (!record.available || !record.downloadUrl) {
|
||||
message.warning('文件不可用或已被移除');
|
||||
return;
|
||||
}
|
||||
const apiBase = (import.meta.env.VITE_GLOB_API_URL ?? '').replace(/\/$/, '');
|
||||
const url = record.downloadUrl.startsWith('http')
|
||||
? record.downloadUrl
|
||||
: `${apiBase}${record.downloadUrl.startsWith('/') ? '' : '/'}${record.downloadUrl}`;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMedia();
|
||||
});
|
||||
|
||||
watch(selectedTypes, () => {
|
||||
// 每次筛选更新时刷新数据
|
||||
fetchMedia();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<Card
|
||||
:loading="loading"
|
||||
title="📂 多媒体中心"
|
||||
bordered
|
||||
>
|
||||
<template #extra>
|
||||
<Space :size="12">
|
||||
<Select
|
||||
v-model:value="selectedTypes"
|
||||
mode="multiple"
|
||||
:options="typeOptions"
|
||||
allow-clear
|
||||
placeholder="筛选媒体类型"
|
||||
style="min-width: 240px"
|
||||
max-tag-count="responsive"
|
||||
/>
|
||||
<Button type="primary" :loading="isRefreshing" @click="fetchMedia">
|
||||
手动刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="mediaMessages"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:scroll="{ x: 1000 }"
|
||||
row-key="id"
|
||||
bordered
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'fileType'">
|
||||
<Tag color="blue">
|
||||
{{ typeLabelMap[record.fileType] ?? record.fileType }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'caption'">
|
||||
<Tooltip>
|
||||
<template #title>
|
||||
<div class="max-w-72 whitespace-pre-wrap">
|
||||
{{ record.caption || '(无备注)' }}
|
||||
</div>
|
||||
</template>
|
||||
<span class="block max-w-56 truncate">
|
||||
{{ record.caption || '(无备注)' }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'sender'">
|
||||
{{ record.displayName || `用户 ${record.userId}` }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss') }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'fileSize'">
|
||||
{{ formatFileSize(record.fileSize) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'available'">
|
||||
<Tag :color="record.available ? 'success' : 'error'">
|
||||
{{ record.available ? '可下载' : '已缺失' }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
:disabled="!record.available"
|
||||
@click="handleDownload(record)"
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
<Tooltip title="复制文件路径">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="
|
||||
navigator.clipboard
|
||||
.writeText(record.filePath)
|
||||
.then(() => message.success('已复制文件路径'))
|
||||
.catch(() => message.error('复制失败'))
|
||||
"
|
||||
>
|
||||
复制路径
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
{{ record[column.dataIndex as keyof typeof record] ?? '-' }}
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.space-y-4 > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Form,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Steps,
|
||||
Tag,
|
||||
Timeline,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'FinancialPlanning' });
|
||||
|
||||
const currentStep = ref(0);
|
||||
const planningResult = ref(null);
|
||||
|
||||
// 规划数据
|
||||
const planningData = ref({
|
||||
monthlyIncome: null,
|
||||
monthlyExpense: null,
|
||||
cashAssets: null,
|
||||
investmentAssets: null,
|
||||
totalDebt: null,
|
||||
goals: [],
|
||||
riskAnswers: [],
|
||||
});
|
||||
|
||||
// 风险评估问题
|
||||
const riskQuestions = ref([
|
||||
{
|
||||
title: '如果您的投资在短期内出现20%的亏损,您会如何反应?',
|
||||
options: [
|
||||
'立即卖出,避免更大损失',
|
||||
'保持观望,等待市场恢复',
|
||||
'继续持有,甚至考虑加仓',
|
||||
'完全不担心,长期投资',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '您更偏好哪种投资方式?',
|
||||
options: [
|
||||
'银行定期存款,安全稳定',
|
||||
'货币基金,流动性好',
|
||||
'混合型基金,平衡风险收益',
|
||||
'股票投资,追求高回报',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '您的投资经验如何?',
|
||||
options: [
|
||||
'完全没有经验',
|
||||
'了解基本概念',
|
||||
'有一定实践经验',
|
||||
'经验丰富,熟悉各种产品',
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// 资产配置建议(空数据,根据评估生成)
|
||||
const assetAllocation = ref([]);
|
||||
|
||||
// 执行计划(空数据)
|
||||
const executionPlan = ref([]);
|
||||
|
||||
// 方法实现
|
||||
const nextStep = () => {
|
||||
if (currentStep.value < 3) {
|
||||
currentStep.value++;
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) {
|
||||
currentStep.value--;
|
||||
}
|
||||
};
|
||||
|
||||
const addGoal = () => {
|
||||
planningData.value.goals.push({
|
||||
name: '',
|
||||
amount: null,
|
||||
deadline: null,
|
||||
priority: 'medium',
|
||||
type: 'other',
|
||||
});
|
||||
};
|
||||
|
||||
const removeGoal = (index: number) => {
|
||||
planningData.value.goals.splice(index, 1);
|
||||
};
|
||||
|
||||
const generatePlan = () => {
|
||||
console.log('生成规划方案:', planningData.value);
|
||||
// 这里实现规划算法
|
||||
setTimeout(() => {
|
||||
planningResult.value = {
|
||||
riskLevel: 'moderate',
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
// 根据风险评估生成资产配置
|
||||
assetAllocation.value = [
|
||||
{
|
||||
type: 'cash',
|
||||
name: '现金类',
|
||||
percentage: 20,
|
||||
color: 'text-blue-600',
|
||||
description: '货币基金',
|
||||
},
|
||||
{
|
||||
type: 'bond',
|
||||
name: '债券类',
|
||||
percentage: 30,
|
||||
color: 'text-green-600',
|
||||
description: '债券基金',
|
||||
},
|
||||
{
|
||||
type: 'stock',
|
||||
name: '股票类',
|
||||
percentage: 40,
|
||||
color: 'text-red-600',
|
||||
description: '股票基金',
|
||||
},
|
||||
{
|
||||
type: 'alternative',
|
||||
name: '另类投资',
|
||||
percentage: 10,
|
||||
color: 'text-purple-600',
|
||||
description: 'REITs等',
|
||||
},
|
||||
];
|
||||
|
||||
// 生成执行计划
|
||||
executionPlan.value = [
|
||||
{
|
||||
title: '建立紧急基金',
|
||||
description: '准备3-6个月的生活费作为紧急基金',
|
||||
timeline: '1-2个月',
|
||||
color: 'red',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
title: '开设投资账户',
|
||||
description: '选择合适的券商开设证券账户',
|
||||
timeline: '第3个月',
|
||||
color: 'blue',
|
||||
priority: 'normal',
|
||||
},
|
||||
{
|
||||
title: '开始定投计划',
|
||||
description: '按照资产配置比例开始定期投资',
|
||||
timeline: '第4个月开始',
|
||||
color: 'green',
|
||||
priority: 'normal',
|
||||
},
|
||||
];
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const getRiskEmoji = () => {
|
||||
const score = planningData.value.riskAnswers.reduce(
|
||||
(sum, answer) => sum + (answer || 0),
|
||||
0,
|
||||
);
|
||||
if (score <= 3) return '🛡️';
|
||||
if (score <= 6) return '⚖️';
|
||||
return '🚀';
|
||||
};
|
||||
|
||||
const getRiskLevel = () => {
|
||||
const score = planningData.value.riskAnswers.reduce(
|
||||
(sum, answer) => sum + (answer || 0),
|
||||
0,
|
||||
);
|
||||
if (score <= 3) return '保守型投资者';
|
||||
if (score <= 6) return '平衡型投资者';
|
||||
return '积极型投资者';
|
||||
};
|
||||
|
||||
const getRiskDescription = () => {
|
||||
const score = planningData.value.riskAnswers.reduce(
|
||||
(sum, answer) => sum + (answer || 0),
|
||||
0,
|
||||
);
|
||||
if (score <= 3) return '偏好稳健投资,注重本金安全';
|
||||
if (score <= 6) return '平衡风险与收益,适度投资';
|
||||
return '愿意承担较高风险,追求高收益';
|
||||
};
|
||||
|
||||
const savePlan = () => {
|
||||
console.log('保存财务规划:', planningData.value, planningResult.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">🎯 财务规划</h1>
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">🎯 财务规划</h1>
|
||||
<p class="text-gray-600">智能财务规划向导,帮您制定个性化理财计划</p>
|
||||
</div>
|
||||
|
||||
@@ -16,35 +217,57 @@
|
||||
|
||||
<!-- 步骤1: 基本信息 -->
|
||||
<div v-if="currentStep === 0">
|
||||
<h3 class="text-lg font-medium mb-4">💼 收入支出信息</h3>
|
||||
<h3 class="mb-4 text-lg font-medium">💼 收入支出信息</h3>
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="月平均收入">
|
||||
<InputNumber v-model:value="planningData.monthlyIncome" :precision="0" style="width: 100%" placeholder="请输入月收入" />
|
||||
<InputNumber
|
||||
v-model:value="planningData.monthlyIncome"
|
||||
:precision="0"
|
||||
style="width: 100%"
|
||||
placeholder="请输入月收入"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="月平均支出">
|
||||
<InputNumber v-model:value="planningData.monthlyExpense" :precision="0" style="width: 100%" placeholder="请输入月支出" />
|
||||
<InputNumber
|
||||
v-model:value="planningData.monthlyExpense"
|
||||
:precision="0"
|
||||
style="width: 100%"
|
||||
placeholder="请输入月支出"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<h3 class="text-lg font-medium mb-4 mt-6">💰 资产负债情况</h3>
|
||||
<h3 class="mb-4 mt-6 text-lg font-medium">💰 资产负债情况</h3>
|
||||
<Row :gutter="16">
|
||||
<Col :span="8">
|
||||
<Form.Item label="现金及存款">
|
||||
<InputNumber v-model:value="planningData.cashAssets" :precision="0" style="width: 100%" />
|
||||
<InputNumber
|
||||
v-model:value="planningData.cashAssets"
|
||||
:precision="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Form.Item label="投资资产">
|
||||
<InputNumber v-model:value="planningData.investmentAssets" :precision="0" style="width: 100%" />
|
||||
<InputNumber
|
||||
v-model:value="planningData.investmentAssets"
|
||||
:precision="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Form.Item label="负债总额">
|
||||
<InputNumber v-model:value="planningData.totalDebt" :precision="0" style="width: 100%" />
|
||||
<InputNumber
|
||||
v-model:value="planningData.totalDebt"
|
||||
:precision="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -52,9 +275,13 @@
|
||||
|
||||
<!-- 步骤2: 目标设定 -->
|
||||
<div v-if="currentStep === 1">
|
||||
<h3 class="text-lg font-medium mb-4">🎯 理财目标设置</h3>
|
||||
<h3 class="mb-4 text-lg font-medium">🎯 理财目标设置</h3>
|
||||
<div class="space-y-6">
|
||||
<div v-for="(goal, index) in planningData.goals" :key="index" class="p-4 border border-gray-200 rounded-lg">
|
||||
<div
|
||||
v-for="(goal, index) in planningData.goals"
|
||||
:key="index"
|
||||
class="rounded-lg border border-gray-200 p-4"
|
||||
>
|
||||
<Row :gutter="16">
|
||||
<Col :span="8">
|
||||
<Form.Item label="目标名称">
|
||||
@@ -63,17 +290,26 @@
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Form.Item label="目标金额">
|
||||
<InputNumber v-model:value="goal.amount" :precision="0" style="width: 100%" />
|
||||
<InputNumber
|
||||
v-model:value="goal.amount"
|
||||
:precision="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Form.Item label="目标期限">
|
||||
<DatePicker v-model:value="goal.deadline" style="width: 100%" />
|
||||
<DatePicker
|
||||
v-model:value="goal.deadline"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="2">
|
||||
<Form.Item label=" ">
|
||||
<Button type="text" danger @click="removeGoal(index)">🗑️</Button>
|
||||
<Button type="text" danger @click="removeGoal(index)">
|
||||
🗑️
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -109,13 +345,20 @@
|
||||
|
||||
<!-- 步骤3: 风险评估 -->
|
||||
<div v-if="currentStep === 2">
|
||||
<h3 class="text-lg font-medium mb-4">⚖️ 投资风险评估</h3>
|
||||
<h3 class="mb-4 text-lg font-medium">⚖️ 投资风险评估</h3>
|
||||
<div class="space-y-6">
|
||||
<div v-for="(question, index) in riskQuestions" :key="index" class="p-4 bg-gray-50 rounded-lg">
|
||||
<h4 class="font-medium mb-3">{{ question.title }}</h4>
|
||||
<div
|
||||
v-for="(question, index) in riskQuestions"
|
||||
:key="index"
|
||||
class="rounded-lg bg-gray-50 p-4"
|
||||
>
|
||||
<h4 class="mb-3 font-medium">{{ question.title }}</h4>
|
||||
<Radio.Group v-model:value="planningData.riskAnswers[index]">
|
||||
<div class="space-y-2">
|
||||
<div v-for="(option, optIndex) in question.options" :key="optIndex">
|
||||
<div
|
||||
v-for="(option, optIndex) in question.options"
|
||||
:key="optIndex"
|
||||
>
|
||||
<Radio :value="optIndex">{{ option }}</Radio>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,15 +369,17 @@
|
||||
|
||||
<!-- 步骤4: 规划方案 -->
|
||||
<div v-if="currentStep === 3">
|
||||
<div v-if="!planningResult" class="text-center py-12">
|
||||
<div class="text-6xl mb-4">🤖</div>
|
||||
<p class="text-gray-500 mb-6">正在为您生成个性化财务规划方案...</p>
|
||||
<Button type="primary" @click="generatePlan" loading>生成规划方案</Button>
|
||||
<div v-if="!planningResult" class="py-12 text-center">
|
||||
<div class="mb-4 text-6xl">🤖</div>
|
||||
<p class="mb-6 text-gray-500">正在为您生成个性化财务规划方案...</p>
|
||||
<Button type="primary" @click="generatePlan" loading>
|
||||
生成规划方案
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<h3 class="text-lg font-medium mb-4">📋 您的专属财务规划方案</h3>
|
||||
|
||||
<h3 class="mb-4 text-lg font-medium">📋 您的专属财务规划方案</h3>
|
||||
|
||||
<!-- 风险评估结果 -->
|
||||
<Card class="mb-4" title="风险偏好分析">
|
||||
<div class="flex items-center space-x-4">
|
||||
@@ -148,11 +393,19 @@
|
||||
|
||||
<!-- 资产配置建议 -->
|
||||
<Card class="mb-4" title="资产配置建议">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div v-for="allocation in assetAllocation" :key="allocation.type" class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div
|
||||
v-for="allocation in assetAllocation"
|
||||
:key="allocation.type"
|
||||
class="rounded-lg bg-gray-50 p-4 text-center"
|
||||
>
|
||||
<p class="text-sm text-gray-500">{{ allocation.name }}</p>
|
||||
<p class="text-xl font-bold" :class="allocation.color">{{ allocation.percentage }}%</p>
|
||||
<p class="text-xs text-gray-400">{{ allocation.description }}</p>
|
||||
<p class="text-xl font-bold" :class="allocation.color">
|
||||
{{ allocation.percentage }}%
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
{{ allocation.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -160,15 +413,24 @@
|
||||
<!-- 具体执行计划 -->
|
||||
<Card title="执行计划">
|
||||
<Timeline>
|
||||
<Timeline.Item v-for="(step, index) in executionPlan" :key="index" :color="step.color">
|
||||
<Timeline.Item
|
||||
v-for="(step, index) in executionPlan"
|
||||
:key="index"
|
||||
:color="step.color"
|
||||
>
|
||||
<div class="mb-2">
|
||||
<span class="font-medium">{{ step.title }}</span>
|
||||
<Tag class="ml-2" :color="step.priority === 'high' ? 'red' : 'blue'">
|
||||
<Tag
|
||||
class="ml-2"
|
||||
:color="step.priority === 'high' ? 'red' : 'blue'"
|
||||
>
|
||||
{{ step.priority === 'high' ? '高优先级' : '普通' }}
|
||||
</Tag>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">{{ step.description }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">预期完成时间: {{ step.timeline }}</p>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
预期完成时间: {{ step.timeline }}
|
||||
</p>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
</Card>
|
||||
@@ -176,174 +438,20 @@
|
||||
</div>
|
||||
|
||||
<!-- 导航按钮 -->
|
||||
<div class="flex justify-between mt-8">
|
||||
<div class="mt-8 flex justify-between">
|
||||
<Button v-if="currentStep > 0" @click="prevStep">上一步</Button>
|
||||
<div v-else></div>
|
||||
<Button v-if="currentStep < 3" type="primary" @click="nextStep">下一步</Button>
|
||||
<Button v-if="currentStep < 3" type="primary" @click="nextStep">
|
||||
下一步
|
||||
</Button>
|
||||
<Button v-else type="primary" @click="savePlan">保存规划</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
Card, Steps, Row, Col, Form, InputNumber, Button, Select,
|
||||
DatePicker, Radio, Timeline, Tag
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
defineOptions({ name: 'FinancialPlanning' });
|
||||
|
||||
const currentStep = ref(0);
|
||||
const planningResult = ref(null);
|
||||
|
||||
// 规划数据
|
||||
const planningData = ref({
|
||||
monthlyIncome: null,
|
||||
monthlyExpense: null,
|
||||
cashAssets: null,
|
||||
investmentAssets: null,
|
||||
totalDebt: null,
|
||||
goals: [],
|
||||
riskAnswers: []
|
||||
});
|
||||
|
||||
// 风险评估问题
|
||||
const riskQuestions = ref([
|
||||
{
|
||||
title: '如果您的投资在短期内出现20%的亏损,您会如何反应?',
|
||||
options: [
|
||||
'立即卖出,避免更大损失',
|
||||
'保持观望,等待市场恢复',
|
||||
'继续持有,甚至考虑加仓',
|
||||
'完全不担心,长期投资'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '您更偏好哪种投资方式?',
|
||||
options: [
|
||||
'银行定期存款,安全稳定',
|
||||
'货币基金,流动性好',
|
||||
'混合型基金,平衡风险收益',
|
||||
'股票投资,追求高回报'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '您的投资经验如何?',
|
||||
options: [
|
||||
'完全没有经验',
|
||||
'了解基本概念',
|
||||
'有一定实践经验',
|
||||
'经验丰富,熟悉各种产品'
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
// 资产配置建议(空数据,根据评估生成)
|
||||
const assetAllocation = ref([]);
|
||||
|
||||
// 执行计划(空数据)
|
||||
const executionPlan = ref([]);
|
||||
|
||||
// 方法实现
|
||||
const nextStep = () => {
|
||||
if (currentStep.value < 3) {
|
||||
currentStep.value++;
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) {
|
||||
currentStep.value--;
|
||||
}
|
||||
};
|
||||
|
||||
const addGoal = () => {
|
||||
planningData.value.goals.push({
|
||||
name: '',
|
||||
amount: null,
|
||||
deadline: null,
|
||||
priority: 'medium',
|
||||
type: 'other'
|
||||
});
|
||||
};
|
||||
|
||||
const removeGoal = (index: number) => {
|
||||
planningData.value.goals.splice(index, 1);
|
||||
};
|
||||
|
||||
const generatePlan = () => {
|
||||
console.log('生成规划方案:', planningData.value);
|
||||
// 这里实现规划算法
|
||||
setTimeout(() => {
|
||||
planningResult.value = {
|
||||
riskLevel: 'moderate',
|
||||
recommendations: []
|
||||
};
|
||||
|
||||
// 根据风险评估生成资产配置
|
||||
assetAllocation.value = [
|
||||
{ type: 'cash', name: '现金类', percentage: 20, color: 'text-blue-600', description: '货币基金' },
|
||||
{ type: 'bond', name: '债券类', percentage: 30, color: 'text-green-600', description: '债券基金' },
|
||||
{ type: 'stock', name: '股票类', percentage: 40, color: 'text-red-600', description: '股票基金' },
|
||||
{ type: 'alternative', name: '另类投资', percentage: 10, color: 'text-purple-600', description: 'REITs等' }
|
||||
];
|
||||
|
||||
// 生成执行计划
|
||||
executionPlan.value = [
|
||||
{
|
||||
title: '建立紧急基金',
|
||||
description: '准备3-6个月的生活费作为紧急基金',
|
||||
timeline: '1-2个月',
|
||||
color: 'red',
|
||||
priority: 'high'
|
||||
},
|
||||
{
|
||||
title: '开设投资账户',
|
||||
description: '选择合适的券商开设证券账户',
|
||||
timeline: '第3个月',
|
||||
color: 'blue',
|
||||
priority: 'normal'
|
||||
},
|
||||
{
|
||||
title: '开始定投计划',
|
||||
description: '按照资产配置比例开始定期投资',
|
||||
timeline: '第4个月开始',
|
||||
color: 'green',
|
||||
priority: 'normal'
|
||||
}
|
||||
];
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const getRiskEmoji = () => {
|
||||
const score = planningData.value.riskAnswers.reduce((sum, answer) => sum + (answer || 0), 0);
|
||||
if (score <= 3) return '🛡️';
|
||||
if (score <= 6) return '⚖️';
|
||||
return '🚀';
|
||||
};
|
||||
|
||||
const getRiskLevel = () => {
|
||||
const score = planningData.value.riskAnswers.reduce((sum, answer) => sum + (answer || 0), 0);
|
||||
if (score <= 3) return '保守型投资者';
|
||||
if (score <= 6) return '平衡型投资者';
|
||||
return '积极型投资者';
|
||||
};
|
||||
|
||||
const getRiskDescription = () => {
|
||||
const score = planningData.value.riskAnswers.reduce((sum, answer) => sum + (answer || 0), 0);
|
||||
if (score <= 3) return '偏好稳健投资,注重本金安全';
|
||||
if (score <= 6) return '平衡风险与收益,适度投资';
|
||||
return '愿意承担较高风险,追求高收益';
|
||||
};
|
||||
|
||||
const savePlan = () => {
|
||||
console.log('保存财务规划:', planningData.value, planningResult.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
</style>
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,103 +1,7 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">💼 投资组合</h1>
|
||||
<p class="text-gray-600">实时跟踪投资组合表现,智能分析投资收益</p>
|
||||
</div>
|
||||
|
||||
<!-- 组合概览 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">📊</div>
|
||||
<p class="text-sm text-gray-500">总市值</p>
|
||||
<p class="text-2xl font-bold text-blue-600">¥{{ portfolioStats.totalValue.toLocaleString() }}</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">📈</div>
|
||||
<p class="text-sm text-gray-500">总收益</p>
|
||||
<p class="text-2xl font-bold" :class="portfolioStats.totalProfit >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ portfolioStats.totalProfit >= 0 ? '+' : '' }}¥{{ portfolioStats.totalProfit.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">⚡</div>
|
||||
<p class="text-sm text-gray-500">收益率</p>
|
||||
<p class="text-2xl font-bold" :class="portfolioStats.returnRate >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ portfolioStats.returnRate >= 0 ? '+' : '' }}{{ portfolioStats.returnRate.toFixed(2) }}%
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">🎯</div>
|
||||
<p class="text-sm text-gray-500">持仓数量</p>
|
||||
<p class="text-2xl font-bold text-purple-600">{{ holdings.length }}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 持仓列表 -->
|
||||
<Card title="📋 持仓明细" class="mb-6">
|
||||
<template #extra>
|
||||
<Button type="primary" @click="showAddHolding = true">➕ 添加持仓</Button>
|
||||
</template>
|
||||
|
||||
<div v-if="holdings.length === 0" class="text-center py-12">
|
||||
<div class="text-8xl mb-6">💼</div>
|
||||
<h3 class="text-xl font-medium text-gray-800 mb-2">暂无投资持仓</h3>
|
||||
<p class="text-gray-500 mb-6">开始记录您的投资组合</p>
|
||||
<Button type="primary" size="large" @click="showAddHolding = true">
|
||||
➕ 添加第一笔投资
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table v-else :columns="holdingColumns" :dataSource="holdings" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'profit'">
|
||||
<span :class="record.profit >= 0 ? 'text-green-600 font-semibold' : 'text-red-600 font-semibold'">
|
||||
{{ record.profit >= 0 ? '+' : '' }}¥{{ record.profit.toLocaleString() }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'returnRate'">
|
||||
<span :class="record.returnRate >= 0 ? 'text-green-600 font-semibold' : 'text-red-600 font-semibold'">
|
||||
{{ record.returnRate >= 0 ? '+' : '' }}{{ record.returnRate.toFixed(2) }}%
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<!-- 投资分析 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card title="📈 收益走势">
|
||||
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-2">📊</div>
|
||||
<p class="text-gray-600">投资收益趋势图</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="🥧 资产配置">
|
||||
<div class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-2">🍰</div>
|
||||
<p class="text-gray-600">资产配置分布图</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { Card, Button, Table } from 'ant-design-vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Button, Card, Table } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'InvestmentPortfolio' });
|
||||
|
||||
@@ -107,7 +11,7 @@ const showAddHolding = ref(false);
|
||||
const portfolioStats = ref({
|
||||
totalValue: 0,
|
||||
totalProfit: 0,
|
||||
returnRate: 0
|
||||
returnRate: 0,
|
||||
});
|
||||
|
||||
// 持仓列表(空数据)
|
||||
@@ -120,10 +24,154 @@ const holdingColumns = [
|
||||
{ title: '成本价', dataIndex: 'costPrice', key: 'costPrice', width: 100 },
|
||||
{ title: '现价', dataIndex: 'currentPrice', key: 'currentPrice', width: 100 },
|
||||
{ title: '盈亏', dataIndex: 'profit', key: 'profit', width: 120 },
|
||||
{ title: '收益率', dataIndex: 'returnRate', key: 'returnRate', width: 100 }
|
||||
{ title: '收益率', dataIndex: 'returnRate', key: 'returnRate', width: 100 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">💼 投资组合</h1>
|
||||
<p class="text-gray-600">实时跟踪投资组合表现,智能分析投资收益</p>
|
||||
</div>
|
||||
|
||||
<!-- 组合概览 -->
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">📊</div>
|
||||
<p class="text-sm text-gray-500">总市值</p>
|
||||
<p class="text-2xl font-bold text-blue-600">
|
||||
${{ portfolioStats.totalValue.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">📈</div>
|
||||
<p class="text-sm text-gray-500">总收益</p>
|
||||
<p
|
||||
class="text-2xl font-bold"
|
||||
:class="
|
||||
portfolioStats.totalProfit >= 0
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
"
|
||||
>
|
||||
{{ portfolioStats.totalProfit >= 0 ? '+' : '' }}${{
|
||||
portfolioStats.totalProfit.toLocaleString()
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">⚡</div>
|
||||
<p class="text-sm text-gray-500">收益率</p>
|
||||
<p
|
||||
class="text-2xl font-bold"
|
||||
:class="
|
||||
portfolioStats.returnRate >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
"
|
||||
>
|
||||
{{ portfolioStats.returnRate >= 0 ? '+' : ''
|
||||
}}{{ portfolioStats.returnRate.toFixed(2) }}%
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">🎯</div>
|
||||
<p class="text-sm text-gray-500">持仓数量</p>
|
||||
<p class="text-2xl font-bold text-purple-600">
|
||||
{{ holdings.length }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 持仓列表 -->
|
||||
<Card title="📋 持仓明细" class="mb-6">
|
||||
<template #extra>
|
||||
<Button type="primary" @click="showAddHolding = true">
|
||||
➕ 添加持仓
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div v-if="holdings.length === 0" class="py-12 text-center">
|
||||
<div class="mb-6 text-8xl">💼</div>
|
||||
<h3 class="mb-2 text-xl font-medium text-gray-800">暂无投资持仓</h3>
|
||||
<p class="mb-6 text-gray-500">开始记录您的投资组合</p>
|
||||
<Button type="primary" size="large" @click="showAddHolding = true">
|
||||
➕ 添加第一笔投资
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
v-else
|
||||
:columns="holdingColumns"
|
||||
:data-source="holdings"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'profit'">
|
||||
<span
|
||||
:class="
|
||||
record.profit >= 0
|
||||
? 'font-semibold text-green-600'
|
||||
: 'font-semibold text-red-600'
|
||||
"
|
||||
>
|
||||
{{ record.profit >= 0 ? '+' : '' }}${{
|
||||
record.profit.toLocaleString()
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'returnRate'">
|
||||
<span
|
||||
:class="
|
||||
record.returnRate >= 0
|
||||
? 'font-semibold text-green-600'
|
||||
: 'font-semibold text-red-600'
|
||||
"
|
||||
>
|
||||
{{ record.returnRate >= 0 ? '+' : ''
|
||||
}}{{ record.returnRate.toFixed(2) }}%
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<!-- 投资分析 -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card title="📈 收益走势">
|
||||
<div
|
||||
class="flex h-64 items-center justify-center rounded-lg bg-gray-50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="mb-2 text-4xl">📊</div>
|
||||
<p class="text-gray-600">投资收益趋势图</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="🥧 资产配置">
|
||||
<div
|
||||
class="flex h-64 items-center justify-center rounded-lg bg-gray-50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="mb-2 text-4xl">🍰</div>
|
||||
<p class="text-gray-600">资产配置分布图</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
</style>
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
210
apps/web-antd/src/views/finance/reimbursement/approval.vue
Normal file
210
apps/web-antd/src/views/finance/reimbursement/approval.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<script setup lang="ts">
|
||||
import type { TableColumnsType, TableRowSelection } from 'ant-design-vue';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import { computed, h, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import type { FinanceApi } from '#/api/core/finance';
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
|
||||
import { formatStatus } from './status';
|
||||
|
||||
defineOptions({ name: 'FinanceReimbursementApproval' });
|
||||
|
||||
const financeStore = useFinanceStore();
|
||||
const router = useRouter();
|
||||
|
||||
const selectedRowKeys = ref<number[]>([]);
|
||||
const loading = computed(() => financeStore.loading.reimbursements);
|
||||
const pendingStatuses: FinanceApi.TransactionStatus[] = ['draft', 'pending'];
|
||||
|
||||
const reimbursements = computed(() =>
|
||||
financeStore.reimbursements
|
||||
.filter(
|
||||
(item) =>
|
||||
!item.isDeleted && pendingStatuses.includes(item.status),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.transactionDate).getTime() -
|
||||
new Date(b.transactionDate).getTime(),
|
||||
),
|
||||
);
|
||||
|
||||
const rowSelection = computed<TableRowSelection<FinanceApi.Transaction>>(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys as number[];
|
||||
},
|
||||
}));
|
||||
|
||||
const columns: TableColumnsType<FinanceApi.Transaction> = [
|
||||
{
|
||||
title: '报销事项',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '报销日期',
|
||||
dataIndex: 'transactionDate',
|
||||
key: 'transactionDate',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
key: 'amount',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '提交人',
|
||||
dataIndex: 'submittedBy',
|
||||
key: 'submittedBy',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 220,
|
||||
fixed: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
async function refresh() {
|
||||
await financeStore.fetchReimbursements();
|
||||
}
|
||||
|
||||
async function updateStatus(
|
||||
record: FinanceApi.Transaction,
|
||||
status: FinanceApi.TransactionStatus,
|
||||
reviewNotes?: string | null,
|
||||
) {
|
||||
await financeStore.updateReimbursement(record.id, {
|
||||
status,
|
||||
reviewNotes: reviewNotes ?? record.reviewNotes ?? null,
|
||||
});
|
||||
message.success('状态已更新');
|
||||
}
|
||||
|
||||
function handleApprove(record: FinanceApi.Transaction) {
|
||||
updateStatus(record, 'approved');
|
||||
}
|
||||
|
||||
function handleReject(record: FinanceApi.Transaction) {
|
||||
let notes = record.reviewNotes ?? '';
|
||||
Modal.confirm({
|
||||
title: '驳回报销申请',
|
||||
content: () =>
|
||||
h(Input.TextArea, {
|
||||
value: notes,
|
||||
rows: 3,
|
||||
onChange(event: any) {
|
||||
notes = event.target.value;
|
||||
},
|
||||
placeholder: '请输入驳回原因',
|
||||
}),
|
||||
okText: '确认驳回',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
async onOk() {
|
||||
await updateStatus(record, 'rejected', notes);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleBulkApprove() {
|
||||
if (selectedRowKeys.value.length === 0) return;
|
||||
const list = financeStore.reimbursements.filter((item) =>
|
||||
selectedRowKeys.value.includes(item.id),
|
||||
);
|
||||
await Promise.all(
|
||||
list.map((item) =>
|
||||
financeStore.updateReimbursement(item.id, { status: 'approved' }),
|
||||
),
|
||||
);
|
||||
message.success('已批量通过审批');
|
||||
selectedRowKeys.value = [];
|
||||
}
|
||||
|
||||
function handleView(record: FinanceApi.Transaction) {
|
||||
router.push(`/reimbursement/detail/${record.id}`);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (financeStore.reimbursements.length === 0) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-4">
|
||||
<Card>
|
||||
<template #title>审批队列</template>
|
||||
<template #extra>
|
||||
<Space>
|
||||
<Button :loading="loading" @click="refresh">刷新</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:disabled="selectedRowKeys.length === 0"
|
||||
@click="handleBulkApprove"
|
||||
>
|
||||
批量通过
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="reimbursements"
|
||||
:loading="loading"
|
||||
:row-key="(record) => record.id"
|
||||
:row-selection="rowSelection"
|
||||
bordered
|
||||
:scroll="{ x: 820 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'amount'">
|
||||
<span class="font-medium">
|
||||
{{ record.amount.toFixed(2) }} {{ record.currency }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<Tag :color="formatStatus(record.status).color">
|
||||
{{ formatStatus(record.status).label }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<Space>
|
||||
<Button size="small" type="primary" @click="handleApprove(record)">
|
||||
通过
|
||||
</Button>
|
||||
<Button size="small" danger @click="handleReject(record)">
|
||||
驳回
|
||||
</Button>
|
||||
<Button size="small" type="link" @click="handleView(record)">
|
||||
查看详情
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
<template #emptyText>
|
||||
<span>当前没有待审批的报销申请</span>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
316
apps/web-antd/src/views/finance/reimbursement/create.vue
Normal file
316
apps/web-antd/src/views/finance/reimbursement/create.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import type { FormInstance } from 'ant-design-vue';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import type { FinanceApi } from '#/api/core/finance';
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
|
||||
import { STATUS_OPTIONS } from './status';
|
||||
|
||||
defineOptions({ name: 'FinanceReimbursementCreate' });
|
||||
|
||||
const financeStore = useFinanceStore();
|
||||
const router = useRouter();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const submitting = ref(false);
|
||||
|
||||
interface FormState {
|
||||
transactionDate: Dayjs | null;
|
||||
description: string;
|
||||
project?: string;
|
||||
categoryId?: number;
|
||||
accountId?: number;
|
||||
amount: number | null;
|
||||
currency: string;
|
||||
memo?: string;
|
||||
submittedBy?: string;
|
||||
reimbursementBatch?: string;
|
||||
status: FinanceApi.TransactionStatus;
|
||||
}
|
||||
|
||||
const formState = reactive<FormState>({
|
||||
transactionDate: dayjs(),
|
||||
description: '',
|
||||
project: '',
|
||||
categoryId: undefined,
|
||||
accountId: undefined,
|
||||
amount: null,
|
||||
currency: 'CNY',
|
||||
memo: '',
|
||||
submittedBy: '',
|
||||
reimbursementBatch: '',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const rules = {
|
||||
transactionDate: [{ required: true, message: '请选择报销日期' }],
|
||||
description: [{ required: true, message: '请填写报销内容' }],
|
||||
amount: [
|
||||
{ required: true, message: '请输入报销金额' },
|
||||
{
|
||||
validator(_: unknown, value: number) {
|
||||
if (value && value > 0) return Promise.resolve();
|
||||
return Promise.reject(new Error('金额必须大于 0'));
|
||||
},
|
||||
},
|
||||
],
|
||||
currency: [{ required: true, message: '请选择币种' }],
|
||||
};
|
||||
|
||||
const currencyOptions = computed(() =>
|
||||
financeStore.currencies.map((item) => ({
|
||||
label: `${item.name} (${item.code})`,
|
||||
value: item.code,
|
||||
})),
|
||||
);
|
||||
|
||||
const accountOptions = computed(() =>
|
||||
financeStore.accounts.map((item) => ({
|
||||
label: `${item.name} · ${item.currency}`,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const categoryOptions = computed(() =>
|
||||
financeStore.expenseCategories.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
|
||||
async function ensureBaseData() {
|
||||
await Promise.all([
|
||||
financeStore.currencies.length === 0
|
||||
? financeStore.fetchCurrencies()
|
||||
: Promise.resolve(),
|
||||
financeStore.accounts.length === 0
|
||||
? financeStore.fetchAccounts()
|
||||
: Promise.resolve(),
|
||||
financeStore.expenseCategories.length === 0
|
||||
? financeStore.fetchCategories()
|
||||
: Promise.resolve(),
|
||||
financeStore.reimbursements.length === 0
|
||||
? financeStore.fetchReimbursements()
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
}
|
||||
|
||||
function buildPayload(): FinanceApi.CreateReimbursementParams {
|
||||
return {
|
||||
amount: Number(formState.amount),
|
||||
transactionDate: formState.transactionDate
|
||||
? formState.transactionDate.format('YYYY-MM-DD')
|
||||
: dayjs().format('YYYY-MM-DD'),
|
||||
description: formState.description,
|
||||
project: formState.project || undefined,
|
||||
categoryId: formState.categoryId,
|
||||
accountId: formState.accountId,
|
||||
currency: formState.currency,
|
||||
memo: formState.memo || undefined,
|
||||
submittedBy: formState.submittedBy || undefined,
|
||||
reimbursementBatch: formState.reimbursementBatch || undefined,
|
||||
status: formState.status,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
const reimbursement = await financeStore.createReimbursement(payload);
|
||||
message.success('报销申请创建成功');
|
||||
router.push(`/reimbursement/detail/${reimbursement.id}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('创建报销申请失败,请稍后重试');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formRef.value?.resetFields();
|
||||
formState.transactionDate = dayjs();
|
||||
formState.description = '';
|
||||
formState.project = '';
|
||||
formState.categoryId = undefined;
|
||||
formState.accountId = undefined;
|
||||
formState.amount = null;
|
||||
formState.currency = 'CNY';
|
||||
formState.memo = '';
|
||||
formState.submittedBy = '';
|
||||
formState.reimbursementBatch = '';
|
||||
formState.status = 'pending';
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureBaseData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<Card class="max-w-4xl mx-auto">
|
||||
<template #title>创建报销申请</template>
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
autocomplete="off"
|
||||
class="space-y-4"
|
||||
>
|
||||
<Row :gutter="16">
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="报销日期" name="transactionDate">
|
||||
<DatePicker
|
||||
v-model:value="formState.transactionDate"
|
||||
class="w-full"
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="报销状态" name="status">
|
||||
<Select
|
||||
v-model:value="formState.status"
|
||||
:options="STATUS_OPTIONS"
|
||||
placeholder="选择状态"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="报销内容" name="description">
|
||||
<Input
|
||||
v-model:value="formState.description"
|
||||
placeholder="例如:办公设备采购、客户招待等"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="所属项目" name="project">
|
||||
<Input
|
||||
v-model:value="formState.project"
|
||||
placeholder="可填写项目名称或成本中心"
|
||||
allow-clear
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="费用分类" name="categoryId">
|
||||
<Select
|
||||
v-model:value="formState.categoryId"
|
||||
:options="categoryOptions"
|
||||
placeholder="请选择费用分类"
|
||||
allow-clear
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="支付账号" name="accountId">
|
||||
<Select
|
||||
v-model:value="formState.accountId"
|
||||
:options="accountOptions"
|
||||
placeholder="请选择支出账号"
|
||||
allow-clear
|
||||
show-search
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="提交人" name="submittedBy">
|
||||
<Input
|
||||
v-model:value="formState.submittedBy"
|
||||
placeholder="填写报销提交人或责任人"
|
||||
allow-clear
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="报销金额" name="amount">
|
||||
<InputNumber
|
||||
v-model:value="formState.amount"
|
||||
:precision="2"
|
||||
:min="0"
|
||||
class="w-full"
|
||||
placeholder="请输入金额"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :xs="24" :md="12">
|
||||
<Form.Item label="币种" name="currency">
|
||||
<Select
|
||||
v-model:value="formState.currency"
|
||||
:options="currencyOptions"
|
||||
placeholder="请选择币种"
|
||||
show-search
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="报销批次" name="reimbursementBatch">
|
||||
<Input
|
||||
v-model:value="formState.reimbursementBatch"
|
||||
placeholder="可填写批次号或审批编号"
|
||||
allow-clear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="备注" name="memo">
|
||||
<Input.TextArea
|
||||
v-model:value="formState.memo"
|
||||
:rows="4"
|
||||
placeholder="补充说明、附件信息等"
|
||||
allow-clear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
提交
|
||||
</Button>
|
||||
<Button :disabled="submitting" @click="resetForm">重置</Button>
|
||||
<Button :disabled="submitting" @click="handleCancel">取消</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
383
apps/web-antd/src/views/finance/reimbursement/detail.vue
Normal file
383
apps/web-antd/src/views/finance/reimbursement/detail.vue
Normal file
@@ -0,0 +1,383 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import type { FormInstance } from 'ant-design-vue';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import type { FinanceApi } from '#/api/core/finance';
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
|
||||
import { STATUS_OPTIONS, formatStatus } from './status';
|
||||
|
||||
defineOptions({ name: 'FinanceReimbursementDetail' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const financeStore = useFinanceStore();
|
||||
|
||||
const reimbursementId = Number(route.params.id);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const reimbursement = computed(() =>
|
||||
financeStore.reimbursements.find((item) => item.id === reimbursementId),
|
||||
);
|
||||
|
||||
const formState = reactive({
|
||||
description: '',
|
||||
amount: 0,
|
||||
currency: 'CNY',
|
||||
project: '',
|
||||
memo: '',
|
||||
submittedBy: '',
|
||||
approvedBy: '',
|
||||
reimbursementBatch: '',
|
||||
reviewNotes: '',
|
||||
status: 'pending' as FinanceApi.TransactionStatus,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
description: [{ required: true, message: '请填写报销内容' }],
|
||||
amount: [
|
||||
{ required: true, message: '请输入金额' },
|
||||
{
|
||||
validator(_: unknown, value: number) {
|
||||
if (value && value > 0) return Promise.resolve();
|
||||
return Promise.reject(new Error('金额需大于 0'));
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const canEdit = computed(() => !reimbursement.value?.isDeleted);
|
||||
|
||||
watch(
|
||||
reimbursement,
|
||||
(value) => {
|
||||
if (!value) return;
|
||||
formState.description = value.description;
|
||||
formState.amount = value.amount;
|
||||
formState.currency = value.currency;
|
||||
formState.project = value.project ?? '';
|
||||
formState.memo = value.memo ?? '';
|
||||
formState.submittedBy = value.submittedBy ?? '';
|
||||
formState.approvedBy = value.approvedBy ?? '';
|
||||
formState.reimbursementBatch = value.reimbursementBatch ?? '';
|
||||
formState.reviewNotes = value.reviewNotes ?? '';
|
||||
formState.status = value.status;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function ensureData() {
|
||||
if (
|
||||
financeStore.reimbursements.length === 0 ||
|
||||
!reimbursement.value
|
||||
) {
|
||||
loading.value = true;
|
||||
try {
|
||||
await financeStore.fetchReimbursements({ includeDeleted: true });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!reimbursement.value) return;
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await financeStore.updateReimbursement(reimbursement.value.id, {
|
||||
description: formState.description,
|
||||
amount: formState.amount,
|
||||
currency: formState.currency,
|
||||
project: formState.project || null,
|
||||
memo: formState.memo || null,
|
||||
submittedBy: formState.submittedBy || null,
|
||||
approvedBy: formState.approvedBy || null,
|
||||
reimbursementBatch: formState.reimbursementBatch || null,
|
||||
reviewNotes: formState.reviewNotes || null,
|
||||
status: formState.status,
|
||||
});
|
||||
message.success('信息已更新');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('更新失败,请稍后重试');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!reimbursement.value) return;
|
||||
try {
|
||||
await financeStore.updateReimbursement(reimbursement.value.id, {
|
||||
status: 'approved',
|
||||
});
|
||||
message.success('已审批通过');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('审批失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!reimbursement.value) return;
|
||||
try {
|
||||
await financeStore.updateReimbursement(reimbursement.value.id, {
|
||||
status: 'rejected',
|
||||
reviewNotes: formState.reviewNotes || reimbursement.value.reviewNotes,
|
||||
});
|
||||
message.success('已驳回');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('驳回失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkPaid() {
|
||||
if (!reimbursement.value) return;
|
||||
try {
|
||||
await financeStore.updateReimbursement(reimbursement.value.id, {
|
||||
status: 'paid',
|
||||
});
|
||||
message.success('已标记为报销完成');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('操作失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestore() {
|
||||
if (!reimbursement.value) return;
|
||||
try {
|
||||
await financeStore.restoreReimbursement(reimbursement.value.id);
|
||||
message.success('已恢复报销单');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('恢复失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!reimbursement.value) return;
|
||||
try {
|
||||
await financeStore.deleteReimbursement(reimbursement.value.id);
|
||||
message.success('已移入回收站');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('操作失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
ensureData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-4">
|
||||
<Button @click="goBack">返回</Button>
|
||||
|
||||
<Card v-if="loading">
|
||||
<span>加载中...</span>
|
||||
</Card>
|
||||
|
||||
<Card v-else-if="!reimbursement">
|
||||
<Space direction="vertical">
|
||||
<span>未找到报销单记录,可能已被删除或编号错误。</span>
|
||||
<Button type="primary" @click="goBack">返回列表</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<template v-else>
|
||||
<Card>
|
||||
<template #title>报销概览</template>
|
||||
<Space direction="vertical" class="w-full">
|
||||
<Space align="center">
|
||||
<Tag :color="formatStatus(reimbursement.status).color">
|
||||
{{ formatStatus(reimbursement.status).label }}
|
||||
</Tag>
|
||||
<span class="text-gray-500 text-sm">
|
||||
创建于 {{ dayjs(reimbursement.createdAt).format('YYYY-MM-DD HH:mm') }}
|
||||
</span>
|
||||
</Space>
|
||||
<Descriptions :column="1" size="small" bordered>
|
||||
<Descriptions.Item label="报销内容">
|
||||
{{ reimbursement.description || '未填写' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="报销日期">
|
||||
{{ dayjs(reimbursement.transactionDate).format('YYYY-MM-DD') }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
{{ reimbursement.amount.toFixed(2) }} {{ reimbursement.currency }}
|
||||
(折合 {{ reimbursement.amountInBase.toFixed(2) }} CNY)
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="所属项目">
|
||||
{{ reimbursement.project || '未指定' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提交人">
|
||||
{{ reimbursement.submittedBy || '未填写' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="审批人">
|
||||
{{ reimbursement.approvedBy || '未指定' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="审批备注">
|
||||
{{ reimbursement.reviewNotes || '暂无' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="批次号">
|
||||
{{ reimbursement.reimbursementBatch || '未设置' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{{ dayjs(reimbursement.createdAt).format('YYYY-MM-DD HH:mm') }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<template #title>更新报销信息</template>
|
||||
<template #extra>
|
||||
<Space>
|
||||
<Button type="primary" :loading="saving" :disabled="!canEdit" @click="handleSave">
|
||||
保存变更
|
||||
</Button>
|
||||
<Button :disabled="!canEdit" @click="handleApprove">
|
||||
审批通过
|
||||
</Button>
|
||||
<Button danger :disabled="!canEdit" @click="handleReject">
|
||||
驳回
|
||||
</Button>
|
||||
<Button :disabled="!canEdit" @click="handleMarkPaid">
|
||||
标记已报销
|
||||
</Button>
|
||||
<Popconfirm
|
||||
v-if="!reimbursement.isDeleted"
|
||||
title="确定要删除该报销单?"
|
||||
ok-text="删除"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<Button danger type="link">移入回收站</Button>
|
||||
</Popconfirm>
|
||||
<Button
|
||||
v-else
|
||||
type="link"
|
||||
@click="handleRestore"
|
||||
>
|
||||
恢复
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2"
|
||||
>
|
||||
<Form.Item label="报销内容" name="description">
|
||||
<Input
|
||||
v-model:value="formState.description"
|
||||
:disabled="!canEdit"
|
||||
placeholder="描述报销事项"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="金额" name="amount">
|
||||
<InputNumber
|
||||
v-model:value="formState.amount"
|
||||
:disabled="!canEdit"
|
||||
:precision="2"
|
||||
:min="0"
|
||||
class="w-full"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="币种" name="currency">
|
||||
<Input
|
||||
v-model:value="formState.currency"
|
||||
:disabled="!canEdit"
|
||||
placeholder="例如 CNY"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="所属项目" name="project">
|
||||
<Input
|
||||
v-model:value="formState.project"
|
||||
:disabled="!canEdit"
|
||||
placeholder="可填写项目或成本中心"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="备注" name="memo">
|
||||
<Input.TextArea
|
||||
v-model:value="formState.memo"
|
||||
:disabled="!canEdit"
|
||||
:rows="3"
|
||||
placeholder="补充说明"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="提交人" name="submittedBy">
|
||||
<Input
|
||||
v-model:value="formState.submittedBy"
|
||||
:disabled="!canEdit"
|
||||
placeholder="报销申请人"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="审批人" name="approvedBy">
|
||||
<Input
|
||||
v-model:value="formState.approvedBy"
|
||||
:disabled="!canEdit"
|
||||
placeholder="审批负责人"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="审批备注" name="reviewNotes">
|
||||
<Input.TextArea
|
||||
v-model:value="formState.reviewNotes"
|
||||
:rows="3"
|
||||
:disabled="!canEdit"
|
||||
placeholder="审批意见、驳回原因等"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="批次号" name="reimbursementBatch">
|
||||
<Input
|
||||
v-model:value="formState.reimbursementBatch"
|
||||
:disabled="!canEdit"
|
||||
placeholder="报销批次或审批编号"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select
|
||||
v-model:value="formState.status"
|
||||
:options="STATUS_OPTIONS"
|
||||
:disabled="!canEdit"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
429
apps/web-antd/src/views/finance/reimbursement/index.vue
Normal file
429
apps/web-antd/src/views/finance/reimbursement/index.vue
Normal file
@@ -0,0 +1,429 @@
|
||||
<script setup lang="ts">
|
||||
import type { TableColumnsType, TableRowSelection } from 'ant-design-vue';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Dropdown,
|
||||
Input,
|
||||
Menu,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import { computed, h, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import type { FinanceApi } from '#/api/core/finance';
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
|
||||
import { STATUS_CONFIG, STATUS_OPTIONS, formatStatus } from './status';
|
||||
|
||||
defineOptions({ name: 'FinanceReimbursementList' });
|
||||
|
||||
const financeStore = useFinanceStore();
|
||||
const router = useRouter();
|
||||
|
||||
const keyword = ref('');
|
||||
const statusFilter = ref<FinanceApi.TransactionStatus[]>(
|
||||
STATUS_OPTIONS.map((item) => item.value),
|
||||
);
|
||||
const dateRange = ref<[Dayjs, Dayjs] | null>(null);
|
||||
const includeDeleted = ref(false);
|
||||
const selectedRowKeys = ref<number[]>([]);
|
||||
const batchStatus = ref<FinanceApi.TransactionStatus>('approved');
|
||||
|
||||
const RangePicker = DatePicker.RangePicker;
|
||||
|
||||
const loading = computed(() => financeStore.loading.reimbursements);
|
||||
|
||||
const reimbursements = computed(() =>
|
||||
financeStore.reimbursements.slice().sort((a, b) => {
|
||||
const statusOrder =
|
||||
STATUS_CONFIG[a.status].order - STATUS_CONFIG[b.status].order;
|
||||
if (statusOrder !== 0) {
|
||||
return statusOrder;
|
||||
}
|
||||
return dayjs(b.transactionDate).valueOf() -
|
||||
dayjs(a.transactionDate).valueOf();
|
||||
}),
|
||||
);
|
||||
|
||||
const filteredReimbursements = computed(() => {
|
||||
return reimbursements.value.filter((item) => {
|
||||
if (!includeDeleted.value && item.isDeleted) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
statusFilter.value.length > 0 &&
|
||||
!statusFilter.value.includes(item.status)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (dateRange.value) {
|
||||
const [start, end] = dateRange.value;
|
||||
const date = dayjs(item.transactionDate);
|
||||
if (
|
||||
date.isBefore(start.startOf('day')) ||
|
||||
date.isAfter(end.endOf('day'))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (keyword.value.trim().length > 0) {
|
||||
const text = keyword.value.trim().toLowerCase();
|
||||
const matcher = [
|
||||
item.description,
|
||||
item.project,
|
||||
item.memo,
|
||||
item.submittedBy,
|
||||
item.reimbursementBatch,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
if (!matcher.includes(text)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const statusSummary = computed(() => {
|
||||
const summary = new Map<
|
||||
FinanceApi.TransactionStatus,
|
||||
{ count: number; amount: number; baseAmount: number }
|
||||
>();
|
||||
for (const status of Object.keys(STATUS_CONFIG) as FinanceApi.TransactionStatus[]) {
|
||||
summary.set(status, { count: 0, amount: 0, baseAmount: 0 });
|
||||
}
|
||||
reimbursements.value.forEach((item) => {
|
||||
const target = summary.get(item.status);
|
||||
if (!target) return;
|
||||
target.count += 1;
|
||||
target.amount += item.amount;
|
||||
target.baseAmount += item.amountInBase ?? 0;
|
||||
});
|
||||
return Array.from(summary.entries()).sort(
|
||||
(a, b) => STATUS_CONFIG[a[0]].order - STATUS_CONFIG[b[0]].order,
|
||||
);
|
||||
});
|
||||
|
||||
const columns: TableColumnsType<FinanceApi.Transaction> = [
|
||||
{
|
||||
title: '报销内容',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'transactionDate',
|
||||
key: 'transactionDate',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
key: 'amount',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '项目/分类',
|
||||
key: 'project',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '提交人',
|
||||
dataIndex: 'submittedBy',
|
||||
key: 'submittedBy',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '批次',
|
||||
dataIndex: 'reimbursementBatch',
|
||||
key: 'reimbursementBatch',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 220,
|
||||
},
|
||||
];
|
||||
|
||||
const rowSelection = computed<TableRowSelection<FinanceApi.Transaction>>(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys as number[];
|
||||
},
|
||||
}));
|
||||
|
||||
function resetFilters() {
|
||||
keyword.value = '';
|
||||
statusFilter.value = STATUS_OPTIONS.map((item) => item.value);
|
||||
dateRange.value = null;
|
||||
includeDeleted.value = false;
|
||||
}
|
||||
|
||||
async function updateStatus(
|
||||
record: FinanceApi.Transaction,
|
||||
status: FinanceApi.TransactionStatus,
|
||||
extra: Partial<FinanceApi.CreateReimbursementParams> = {},
|
||||
) {
|
||||
await financeStore.updateReimbursement(record.id, {
|
||||
status,
|
||||
...extra,
|
||||
});
|
||||
message.success('状态已更新');
|
||||
}
|
||||
|
||||
function handleApprove(record: FinanceApi.Transaction) {
|
||||
updateStatus(record, 'approved');
|
||||
}
|
||||
|
||||
function handleReject(record: FinanceApi.Transaction) {
|
||||
let notes = record.reviewNotes ?? '';
|
||||
Modal.confirm({
|
||||
title: '确认驳回报销申请?',
|
||||
content: () =>
|
||||
h(Input.TextArea, {
|
||||
value: notes,
|
||||
placeholder: '请输入驳回原因',
|
||||
rows: 3,
|
||||
onChange: (event: any) => {
|
||||
notes = event.target.value;
|
||||
},
|
||||
}),
|
||||
okText: '驳回',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
async onOk() {
|
||||
await updateStatus(record, 'rejected', { reviewNotes: notes });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleMarkPaid(record: FinanceApi.Transaction) {
|
||||
updateStatus(record, 'paid', {
|
||||
approvedBy: record.approvedBy ?? 'system',
|
||||
});
|
||||
}
|
||||
|
||||
function handleMoveToPending(record: FinanceApi.Transaction) {
|
||||
updateStatus(record, 'pending');
|
||||
}
|
||||
|
||||
async function handleBulkUpdate(status: FinanceApi.TransactionStatus) {
|
||||
if (selectedRowKeys.value.length === 0) return;
|
||||
const targets = financeStore.reimbursements.filter((item) =>
|
||||
selectedRowKeys.value.includes(item.id),
|
||||
);
|
||||
await Promise.all(
|
||||
targets.map((item) =>
|
||||
financeStore.updateReimbursement(item.id, { status }),
|
||||
),
|
||||
);
|
||||
message.success('批量操作完成');
|
||||
selectedRowKeys.value = [];
|
||||
}
|
||||
|
||||
function handleView(record: FinanceApi.Transaction) {
|
||||
router.push(`/reimbursement/detail/${record.id}`);
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
router.push('/reimbursement/create');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (financeStore.reimbursements.length === 0) {
|
||||
await financeStore.fetchReimbursements();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-4">
|
||||
<Card>
|
||||
<template #title>报销概览</template>
|
||||
<Row :gutter="16">
|
||||
<Col
|
||||
v-for="[status, item] in statusSummary"
|
||||
:key="status"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
:lg="6"
|
||||
>
|
||||
<Card size="small" :bordered="false">
|
||||
<Space direction="vertical" class="w-full">
|
||||
<Space align="center">
|
||||
<Tag :color="formatStatus(status).color">
|
||||
{{ formatStatus(status).label }}
|
||||
</Tag>
|
||||
<span class="text-xs text-gray-500">{{
|
||||
formatStatus(status).description
|
||||
}}</span>
|
||||
</Space>
|
||||
<Statistic title="单据数量" :value="item.count" />
|
||||
<Statistic
|
||||
title="金额(原币)"
|
||||
:precision="2"
|
||||
:value="item.amount"
|
||||
/>
|
||||
<Statistic
|
||||
title="金额(折合CNY)"
|
||||
:precision="2"
|
||||
:value="item.baseAmount"
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<template #title>筛选条件</template>
|
||||
<template #extra>
|
||||
<Space>
|
||||
<Button type="link" @click="resetFilters">重置</Button>
|
||||
<Button type="primary" @click="handleCreate">新增报销</Button>
|
||||
</Space>
|
||||
</template>
|
||||
<Space :size="12" wrap>
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
placeholder="搜索描述 / 项目 / 提交人"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="statusFilter"
|
||||
:options="STATUS_OPTIONS"
|
||||
mode="multiple"
|
||||
allow-clear
|
||||
placeholder="选择状态"
|
||||
style="min-width: 220px"
|
||||
/>
|
||||
<RangePicker
|
||||
v-model:value="dateRange"
|
||||
allow-clear
|
||||
placeholder="请选择日期范围"
|
||||
/>
|
||||
<Space align="center">
|
||||
<Switch v-model:checked="includeDeleted" size="small" />
|
||||
<span class="text-xs text-gray-500">显示已删除</span>
|
||||
</Space>
|
||||
<Select
|
||||
v-model:value="batchStatus"
|
||||
:options="STATUS_OPTIONS"
|
||||
style="min-width: 160px"
|
||||
/>
|
||||
<Button
|
||||
:disabled="selectedRowKeys.length === 0"
|
||||
@click="handleBulkUpdate(batchStatus)"
|
||||
>
|
||||
批量更新状态
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<template #title>报销列表</template>
|
||||
<template #extra>
|
||||
<span class="text-sm text-gray-500"
|
||||
>共 {{ filteredReimbursements.length }} 条记录</span
|
||||
>
|
||||
</template>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="filteredReimbursements"
|
||||
:loading="loading"
|
||||
:row-key="(record) => record.id"
|
||||
:row-selection="rowSelection"
|
||||
:scroll="{ x: 960 }"
|
||||
bordered
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<Tag :color="formatStatus(record.status).color">
|
||||
{{ formatStatus(record.status).label }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'amount'">
|
||||
<Space direction="vertical" size="small">
|
||||
<span class="font-medium">
|
||||
{{ record.amount.toFixed(2) }} {{ record.currency }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
折合 {{ record.amountInBase.toFixed(2) }} CNY
|
||||
</span>
|
||||
</Space>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'project'">
|
||||
<Space direction="vertical" size="small">
|
||||
<span class="font-medium">{{ record.project || '未指定' }}</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ record.memo || '无备注' }}
|
||||
</span>
|
||||
</Space>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<Space>
|
||||
<Button size="small" type="link" @click="handleView(record)">
|
||||
查看
|
||||
</Button>
|
||||
<Dropdown>
|
||||
<template #overlay>
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
key="approve"
|
||||
@click="handleApprove(record)"
|
||||
>
|
||||
审批通过
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="reject"
|
||||
danger
|
||||
@click="handleReject(record)"
|
||||
>
|
||||
驳回
|
||||
</Menu.Item>
|
||||
<Menu.Item key="paid" @click="handleMarkPaid(record)">
|
||||
标记报销
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="pending"
|
||||
@click="handleMoveToPending(record)"
|
||||
>
|
||||
重新提交
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
<Button size="small">更多</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
206
apps/web-antd/src/views/finance/reimbursement/statistics.vue
Normal file
206
apps/web-antd/src/views/finance/reimbursement/statistics.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import { Card, Progress, Skeleton, Statistic, Table, Tag } from 'ant-design-vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
|
||||
import { STATUS_CONFIG, formatStatus } from './status';
|
||||
|
||||
defineOptions({ name: 'FinanceReimbursementStatistics' });
|
||||
|
||||
const financeStore = useFinanceStore();
|
||||
|
||||
const loading = computed(() => financeStore.loading.reimbursements);
|
||||
|
||||
const statusSummary = computed(() => {
|
||||
const summary = new Map<
|
||||
string,
|
||||
{ count: number; baseAmount: number }
|
||||
>();
|
||||
(Object.keys(STATUS_CONFIG) as Array<keyof typeof STATUS_CONFIG>).forEach(
|
||||
(status) => {
|
||||
summary.set(status, { count: 0, baseAmount: 0 });
|
||||
},
|
||||
);
|
||||
financeStore.reimbursements.forEach((item) => {
|
||||
const key = summary.get(item.status);
|
||||
if (!key) return;
|
||||
key.count += 1;
|
||||
key.baseAmount += item.amountInBase ?? 0;
|
||||
});
|
||||
const totalCount = Array.from(summary.values()).reduce(
|
||||
(acc, cur) => acc + cur.count,
|
||||
0,
|
||||
);
|
||||
return Array.from(summary.entries()).map(([status, value]) => ({
|
||||
status,
|
||||
count: value.count,
|
||||
baseAmount: value.baseAmount,
|
||||
percentage: totalCount === 0 ? 0 : Math.round((value.count / totalCount) * 100),
|
||||
}));
|
||||
});
|
||||
|
||||
const monthlySummary = computed(() => {
|
||||
const map = new Map<
|
||||
string,
|
||||
{ total: number; baseAmount: number; approved: number; pending: number }
|
||||
>();
|
||||
financeStore.reimbursements.forEach((item) => {
|
||||
const month = item.transactionDate.slice(0, 7);
|
||||
if (!map.has(month)) {
|
||||
map.set(month, { total: 0, baseAmount: 0, approved: 0, pending: 0 });
|
||||
}
|
||||
const target = map.get(month)!;
|
||||
target.total += item.amount;
|
||||
target.baseAmount += item.amountInBase ?? 0;
|
||||
if (item.status === 'approved' || item.status === 'paid') {
|
||||
target.approved += item.amountInBase ?? 0;
|
||||
}
|
||||
if (item.status === 'draft' || item.status === 'pending') {
|
||||
target.pending += item.amountInBase ?? 0;
|
||||
}
|
||||
});
|
||||
return Array.from(map.entries())
|
||||
.map(([month, value]) => ({
|
||||
month,
|
||||
...value,
|
||||
}))
|
||||
.sort((a, b) => (a.month < b.month ? 1 : -1))
|
||||
.slice(0, 12);
|
||||
});
|
||||
|
||||
const projectSummary = computed(() => {
|
||||
const map = new Map<
|
||||
string,
|
||||
{ count: number; baseAmount: number }
|
||||
>();
|
||||
financeStore.reimbursements.forEach((item) => {
|
||||
const key = item.project || '未指定项目';
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { count: 0, baseAmount: 0 });
|
||||
}
|
||||
const target = map.get(key)!;
|
||||
target.count += 1;
|
||||
target.baseAmount += item.amountInBase ?? 0;
|
||||
});
|
||||
return Array.from(map.entries())
|
||||
.map(([project, value]) => ({
|
||||
project,
|
||||
...value,
|
||||
}))
|
||||
.sort((a, b) => b.baseAmount - a.baseAmount)
|
||||
.slice(0, 10);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (financeStore.reimbursements.length === 0) {
|
||||
financeStore.fetchReimbursements();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-4">
|
||||
<Card title="状态分布">
|
||||
<Skeleton :loading="loading" active>
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
v-for="item in statusSummary"
|
||||
:key="item.status"
|
||||
class="border rounded-md p-4 space-y-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<Tag :color="formatStatus(item.status).color">
|
||||
{{ formatStatus(item.status).label }}
|
||||
</Tag>
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ formatStatus(item.status).description }}
|
||||
</span>
|
||||
</div>
|
||||
<Statistic title="单据数量" :value="item.count" />
|
||||
<Statistic
|
||||
title="折合金额 (CNY)"
|
||||
:precision="2"
|
||||
:value="item.baseAmount"
|
||||
/>
|
||||
<Progress :percent="item.percentage" />
|
||||
</div>
|
||||
</div>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
|
||||
<Card title="按月统计(最近12个月)">
|
||||
<Skeleton :loading="loading" active>
|
||||
<Table
|
||||
:data-source="monthlySummary"
|
||||
:pagination="false"
|
||||
row-key="month"
|
||||
size="small"
|
||||
bordered
|
||||
>
|
||||
<Table.Column title="月份" dataIndex="month" key="month" />
|
||||
<Table.Column
|
||||
title="原币合计"
|
||||
key="total"
|
||||
customRender="total"
|
||||
>
|
||||
<template #bodyCell="{ record }">
|
||||
{{ record.total.toFixed(2) }}
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column
|
||||
title="折合CNY"
|
||||
key="baseAmount"
|
||||
customRender="baseAmount"
|
||||
>
|
||||
<template #bodyCell="{ record }">
|
||||
{{ record.baseAmount.toFixed(2) }}
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column
|
||||
title="已批准金额 (CNY)"
|
||||
key="approved"
|
||||
customRender="approved"
|
||||
>
|
||||
<template #bodyCell="{ record }">
|
||||
{{ record.approved.toFixed(2) }}
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column
|
||||
title="待审批金额 (CNY)"
|
||||
key="pending"
|
||||
customRender="pending"
|
||||
>
|
||||
<template #bodyCell="{ record }">
|
||||
{{ record.pending.toFixed(2) }}
|
||||
</template>
|
||||
</Table.Column>
|
||||
</Table>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
|
||||
<Card title="项目费用 Top 10">
|
||||
<Skeleton :loading="loading" active>
|
||||
<Table
|
||||
:data-source="projectSummary"
|
||||
:pagination="false"
|
||||
row-key="project"
|
||||
size="small"
|
||||
bordered
|
||||
>
|
||||
<Table.Column title="项目" dataIndex="project" key="project" />
|
||||
<Table.Column title="单据数量" dataIndex="count" key="count" />
|
||||
<Table.Column
|
||||
title="折合金额 (CNY)"
|
||||
key="baseAmount"
|
||||
customRender="baseAmount"
|
||||
>
|
||||
<template #bodyCell="{ record }">
|
||||
{{ record.baseAmount.toFixed(2) }}
|
||||
</template>
|
||||
</Table.Column>
|
||||
</Table>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
48
apps/web-antd/src/views/finance/reimbursement/status.ts
Normal file
48
apps/web-antd/src/views/finance/reimbursement/status.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { FinanceApi } from '#/api/core/finance';
|
||||
|
||||
export const STATUS_CONFIG: Record<
|
||||
FinanceApi.TransactionStatus,
|
||||
{ label: string; color: string; description: string; order: number }
|
||||
> = {
|
||||
draft: {
|
||||
label: '草稿',
|
||||
color: 'default',
|
||||
description: '尚未提交或待完善的报销信息',
|
||||
order: 0,
|
||||
},
|
||||
pending: {
|
||||
label: '待审批',
|
||||
color: 'processing',
|
||||
description: '等待审批人审核的报销申请',
|
||||
order: 1,
|
||||
},
|
||||
approved: {
|
||||
label: '已通过',
|
||||
color: 'success',
|
||||
description: '审批通过,待支付或报销完成',
|
||||
order: 2,
|
||||
},
|
||||
rejected: {
|
||||
label: '已驳回',
|
||||
color: 'error',
|
||||
description: '审批被驳回,需要发起人处理',
|
||||
order: 3,
|
||||
},
|
||||
paid: {
|
||||
label: '已报销',
|
||||
color: 'purple',
|
||||
description: '已完成报销或费用报销入账',
|
||||
order: 4,
|
||||
},
|
||||
};
|
||||
|
||||
export const STATUS_OPTIONS = Object.entries(STATUS_CONFIG)
|
||||
.sort((a, b) => a[1].order - b[1].order)
|
||||
.map(([value, config]) => ({
|
||||
label: `${config.label}`,
|
||||
value: value as FinanceApi.TransactionStatus,
|
||||
}));
|
||||
|
||||
export function formatStatus(status: FinanceApi.TransactionStatus) {
|
||||
return STATUS_CONFIG[status] ?? STATUS_CONFIG.pending;
|
||||
}
|
||||
@@ -233,19 +233,19 @@ const exportToExcel = (title: string, timestamp: string) => {
|
||||
['指标', '金额', '', ''],
|
||||
[
|
||||
'总收入',
|
||||
`¥${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
`$${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
'',
|
||||
'',
|
||||
],
|
||||
[
|
||||
'总支出',
|
||||
`¥${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
`$${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
'',
|
||||
'',
|
||||
],
|
||||
[
|
||||
'净收入',
|
||||
`¥${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
`$${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
'',
|
||||
'',
|
||||
],
|
||||
@@ -314,9 +314,9 @@ const exportToCSV = (title: string, timestamp: string) => {
|
||||
if (exportOptions.value.includeSummary) {
|
||||
csvContent += '核心指标汇总\n';
|
||||
csvContent += '指标,金额\n';
|
||||
csvContent += `总收入,¥${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
|
||||
csvContent += `总支出,¥${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
|
||||
csvContent += `净收入,¥${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
|
||||
csvContent += `总收入,$${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
|
||||
csvContent += `总支出,$${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
|
||||
csvContent += `净收入,$${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}\n`;
|
||||
csvContent += `交易笔数,${periodTransactions.value.length}\n\n`;
|
||||
}
|
||||
|
||||
@@ -401,15 +401,15 @@ const printReport = () => {
|
||||
<div class="summary">
|
||||
<div class="summary-card">
|
||||
<div class="label">总收入</div>
|
||||
<div class="value income">¥${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
|
||||
<div class="value income">$${periodIncome.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="label">总支出</div>
|
||||
<div class="value expense">¥${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
|
||||
<div class="value expense">$${periodExpense.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="label">净收入</div>
|
||||
<div class="value net">${periodNet.value >= 0 ? '+' : ''}¥${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
|
||||
<div class="value net">${periodNet.value >= 0 ? '+' : ''}$${periodNet.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="label">交易笔数</div>
|
||||
@@ -427,9 +427,9 @@ const printReport = () => {
|
||||
(item) => `
|
||||
<div class="category-item">
|
||||
<span class="category-name">${item.categoryName}</span>
|
||||
<span class="category-amount income">¥${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
||||
<span class="category-amount income">$${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
||||
<div style="clear: both; margin-top: 5px; color: #888; font-size: 12px;">
|
||||
${item.count} 笔 · 平均 ¥${(item.amount / item.count).toFixed(2)} · ${item.percentage}%
|
||||
${item.count} 笔 · 平均 $${(item.amount / item.count).toFixed(2)} · ${item.percentage}%
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -450,9 +450,9 @@ const printReport = () => {
|
||||
(item) => `
|
||||
<div class="category-item">
|
||||
<span class="category-name">${item.categoryName}</span>
|
||||
<span class="category-amount expense">¥${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
||||
<span class="category-amount expense">$${item.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
||||
<div style="clear: both; margin-top: 5px; color: #888; font-size: 12px;">
|
||||
${item.count} 笔 · 平均 ¥${(item.amount / item.count).toFixed(2)} · ${item.percentage}%
|
||||
${item.count} 笔 · 平均 $${(item.amount / item.count).toFixed(2)} · ${item.percentage}%
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -488,7 +488,7 @@ const printReport = () => {
|
||||
<td>${t.description || ''}</td>
|
||||
<td>${getCategoryName(t.categoryId)}</td>
|
||||
<td class="${t.type === 'income' ? 'income' : 'expense'}">
|
||||
${t.type === 'income' ? '+' : '-'}¥${Math.abs(t.amount).toLocaleString()}
|
||||
${t.type === 'income' ? '+' : '-'}$${Math.abs(t.amount).toLocaleString()}
|
||||
</td>
|
||||
<td>${getAccountName(t.accountId)}</td>
|
||||
</tr>
|
||||
@@ -696,7 +696,7 @@ onMounted(async () => {
|
||||
<div class="mb-2 text-3xl">💰</div>
|
||||
<p class="text-sm text-gray-500">总收入</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
¥{{
|
||||
${{
|
||||
periodIncome.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
|
||||
}}
|
||||
</p>
|
||||
@@ -705,7 +705,7 @@ onMounted(async () => {
|
||||
<div class="mb-2 text-3xl">💸</div>
|
||||
<p class="text-sm text-gray-500">总支出</p>
|
||||
<p class="text-2xl font-bold text-red-600">
|
||||
¥{{
|
||||
${{
|
||||
periodExpense.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
|
||||
}}
|
||||
</p>
|
||||
@@ -717,7 +717,7 @@ onMounted(async () => {
|
||||
class="text-2xl font-bold"
|
||||
:class="periodNet >= 0 ? 'text-purple-600' : 'text-red-600'"
|
||||
>
|
||||
{{ periodNet >= 0 ? '+' : '' }}¥{{
|
||||
{{ periodNet >= 0 ? '+' : '' }}${{
|
||||
periodNet.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
|
||||
}}
|
||||
</p>
|
||||
@@ -748,7 +748,7 @@ onMounted(async () => {
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="font-medium">{{ item.categoryName }}</span>
|
||||
<span class="text-sm font-bold text-green-600">
|
||||
¥{{
|
||||
${{
|
||||
item.amount.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -767,7 +767,7 @@ onMounted(async () => {
|
||||
>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
{{ item.count }} 笔 · 平均 ¥{{
|
||||
{{ item.count }} 笔 · 平均 ${{
|
||||
(item.amount / item.count).toFixed(2)
|
||||
}}
|
||||
</p>
|
||||
@@ -790,7 +790,7 @@ onMounted(async () => {
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="font-medium">{{ item.categoryName }}</span>
|
||||
<span class="text-sm font-bold text-red-600">
|
||||
¥{{
|
||||
${{
|
||||
item.amount.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -809,7 +809,7 @@ onMounted(async () => {
|
||||
>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
{{ item.count }} 笔 · 平均 ¥{{
|
||||
{{ item.count }} 笔 · 平均 ${{
|
||||
(item.amount / item.count).toFixed(2)
|
||||
}}
|
||||
</p>
|
||||
@@ -840,7 +840,7 @@ onMounted(async () => {
|
||||
: 'font-bold text-red-600'
|
||||
"
|
||||
>
|
||||
{{ record.type === 'income' ? '+' : '-' }}¥{{
|
||||
{{ record.type === 'income' ? '+' : '-' }}${{
|
||||
Math.abs(record.amount).toLocaleString()
|
||||
}}
|
||||
</span>
|
||||
|
||||
@@ -167,7 +167,7 @@ const smartInsights = computed(() => {
|
||||
type: 'expense_trend',
|
||||
icon: '📉',
|
||||
title: '支出趋势',
|
||||
description: `本期总支出 ¥${currentPeriodData.value.totalExpense.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
description: `本期总支出 $${currentPeriodData.value.totalExpense.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
value: `${currentPeriodData.value.expenseCount} 笔`,
|
||||
trend: null,
|
||||
valueClass: 'text-red-600',
|
||||
@@ -213,7 +213,7 @@ const smartInsights = computed(() => {
|
||||
icon: '💎',
|
||||
title: '平均单笔',
|
||||
description: '本期平均每笔支出金额',
|
||||
value: `¥${currentPeriodData.value.avgAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
value: `$${currentPeriodData.value.avgAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
|
||||
trend: null,
|
||||
valueClass: 'text-purple-600',
|
||||
trendClass: '',
|
||||
@@ -432,7 +432,7 @@ const initCashFlowChart = () => {
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
formatter: '¥{value}',
|
||||
formatter: '${value}',
|
||||
},
|
||||
},
|
||||
series: [
|
||||
@@ -496,7 +496,7 @@ const initExpenseTreeChart = () => {
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: ¥{c} ({d}%)',
|
||||
formatter: '{b}: ${c} ({d}%)',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -505,7 +505,7 @@ const initExpenseTreeChart = () => {
|
||||
leafDepth: 1,
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}\n¥{c}',
|
||||
formatter: '{b}\n${c}',
|
||||
},
|
||||
upperLabel: {
|
||||
show: true,
|
||||
@@ -823,7 +823,9 @@ window.addEventListener('resize', () => {
|
||||
style="width: 200px"
|
||||
/>
|
||||
<Button @click="nextPeriod" :disabled="!canGoNext"> → </Button>
|
||||
<span class="text-gray-500">共 {{ currentPeriodData.transactionCount }} 笔交易</span>
|
||||
<span class="text-gray-500"
|
||||
>共 {{ currentPeriodData.transactionCount }} 笔交易</span
|
||||
>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane key="quarterly" tab="📊 季度分析">
|
||||
@@ -845,7 +847,9 @@ window.addEventListener('resize', () => {
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Button @click="nextPeriod" :disabled="!canGoNext"> → </Button>
|
||||
<span class="text-gray-500">共 {{ currentPeriodData.transactionCount }} 笔交易</span>
|
||||
<span class="text-gray-500"
|
||||
>共 {{ currentPeriodData.transactionCount }} 笔交易</span
|
||||
>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane key="yearly" tab="📈 年度分析">
|
||||
@@ -867,7 +871,9 @@ window.addEventListener('resize', () => {
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Button @click="nextPeriod" :disabled="!canGoNext"> → </Button>
|
||||
<span class="text-gray-500">共 {{ currentPeriodData.transactionCount }} 笔交易</span>
|
||||
<span class="text-gray-500"
|
||||
>共 {{ currentPeriodData.transactionCount }} 笔交易</span
|
||||
>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane key="custom" tab="🎯 自定义">
|
||||
@@ -877,7 +883,9 @@ window.addEventListener('resize', () => {
|
||||
format="YYYY-MM-DD"
|
||||
@change="handleCustomRangeChange"
|
||||
/>
|
||||
<span class="text-gray-500">共 {{ currentPeriodData.transactionCount }} 笔交易</span>
|
||||
<span class="text-gray-500"
|
||||
>共 {{ currentPeriodData.transactionCount }} 笔交易</span
|
||||
>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
@@ -907,7 +915,9 @@ window.addEventListener('resize', () => {
|
||||
class="text-sm"
|
||||
:class="insight.trendClass"
|
||||
>
|
||||
<template v-if="insight.trend > 0">↗ +{{ insight.trend }}%</template>
|
||||
<template v-if="insight.trend > 0"
|
||||
>↗ +{{ insight.trend }}%</template
|
||||
>
|
||||
<template v-else>↘ {{ insight.trend }}%</template>
|
||||
</span>
|
||||
</div>
|
||||
@@ -922,7 +932,7 @@ window.addEventListener('resize', () => {
|
||||
<div class="text-3xl">💰</div>
|
||||
<p class="text-sm text-gray-500">期间总收入</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
¥{{
|
||||
${{
|
||||
currentPeriodData.totalIncome.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -946,7 +956,7 @@ window.addEventListener('resize', () => {
|
||||
<div class="text-3xl">💸</div>
|
||||
<p class="text-sm text-gray-500">期间总支出</p>
|
||||
<p class="text-2xl font-bold text-red-600">
|
||||
¥{{
|
||||
${{
|
||||
currentPeriodData.totalExpense.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -970,7 +980,7 @@ window.addEventListener('resize', () => {
|
||||
<div class="text-3xl">📊</div>
|
||||
<p class="text-sm text-gray-500">平均单笔金额</p>
|
||||
<p class="text-2xl font-bold text-blue-600">
|
||||
¥{{
|
||||
${{
|
||||
currentPeriodData.avgAmount.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -986,7 +996,7 @@ window.addEventListener('resize', () => {
|
||||
<div class="text-3xl">🏆</div>
|
||||
<p class="text-sm text-gray-500">最大单笔支出</p>
|
||||
<p class="text-2xl font-bold text-orange-600">
|
||||
¥{{
|
||||
${{
|
||||
currentPeriodData.maxExpense.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -1109,7 +1119,7 @@ window.addEventListener('resize', () => {
|
||||
<div>
|
||||
<p class="font-semibold">{{ health.categoryName }}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
¥{{
|
||||
${{
|
||||
health.amount.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -1156,7 +1166,7 @@ window.addEventListener('resize', () => {
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-bold text-red-600">
|
||||
-¥{{
|
||||
-${{
|
||||
anomaly.amount.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -1208,7 +1218,7 @@ window.addEventListener('resize', () => {
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'income'">
|
||||
<span class="font-semibold text-green-600">
|
||||
¥{{
|
||||
${{
|
||||
record.income.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -1217,7 +1227,7 @@ window.addEventListener('resize', () => {
|
||||
</template>
|
||||
<template v-else-if="column.key === 'expense'">
|
||||
<span class="font-semibold text-red-600">
|
||||
¥{{
|
||||
${{
|
||||
record.expense.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -1229,7 +1239,7 @@ window.addEventListener('resize', () => {
|
||||
:class="record.net >= 0 ? 'text-green-600' : 'text-red-600'"
|
||||
class="font-bold"
|
||||
>
|
||||
{{ record.net >= 0 ? '+' : '' }}¥{{
|
||||
{{ record.net >= 0 ? '+' : '' }}${{
|
||||
record.net.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
|
||||
}}
|
||||
</span>
|
||||
@@ -1265,7 +1275,7 @@ window.addEventListener('resize', () => {
|
||||
>
|
||||
<div v-if="record[column.dataIndex]">
|
||||
<div class="font-semibold">
|
||||
¥{{
|
||||
${{
|
||||
record[column.dataIndex].amount.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
@@ -1279,7 +1289,7 @@ window.addEventListener('resize', () => {
|
||||
</template>
|
||||
<template v-else-if="column.key === 'total'">
|
||||
<span class="font-bold text-red-600">
|
||||
¥{{
|
||||
${{
|
||||
record.total.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
|
||||
@@ -1,38 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Button, Card, Input, Tag } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'TaxManagement' });
|
||||
|
||||
// 税务统计(空数据)
|
||||
const taxStats = ref({
|
||||
yearlyIncome: 0,
|
||||
paidTax: 0,
|
||||
potentialSaving: 0,
|
||||
filingStatus: 'pending',
|
||||
});
|
||||
|
||||
// 节税建议(空数据)
|
||||
const taxTips = ref([]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">🧾 税务管理</h1>
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">🧾 税务管理</h1>
|
||||
<p class="text-gray-600">个人所得税计算、申报和税务优化建议</p>
|
||||
</div>
|
||||
|
||||
<!-- 税务概览 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">💰</div>
|
||||
<p class="text-sm text-gray-500">年度收入</p>
|
||||
<p class="text-2xl font-bold text-blue-600">¥{{ taxStats.yearlyIncome.toLocaleString() }}</p>
|
||||
<p class="text-2xl font-bold text-blue-600">
|
||||
${{ taxStats.yearlyIncome.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">🧾</div>
|
||||
<p class="text-sm text-gray-500">已缴税额</p>
|
||||
<p class="text-2xl font-bold text-red-600">¥{{ taxStats.paidTax.toLocaleString() }}</p>
|
||||
<p class="text-2xl font-bold text-red-600">
|
||||
${{ taxStats.paidTax.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">💡</div>
|
||||
<p class="text-sm text-gray-500">可节税</p>
|
||||
<p class="text-2xl font-bold text-green-600">¥{{ taxStats.potentialSaving.toLocaleString() }}</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
${{ taxStats.potentialSaving.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">📅</div>
|
||||
<p class="text-sm text-gray-500">申报状态</p>
|
||||
<Tag :color="taxStats.filingStatus === 'completed' ? 'green' : 'orange'">
|
||||
<Tag
|
||||
:color="taxStats.filingStatus === 'completed' ? 'green' : 'orange'"
|
||||
>
|
||||
{{ taxStats.filingStatus === 'completed' ? '已申报' : '待申报' }}
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -40,7 +67,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 税务工具 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<div class="mb-6 grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<Card title="🧮 个税计算器">
|
||||
<div class="space-y-4">
|
||||
<Input placeholder="月收入" />
|
||||
@@ -51,21 +78,27 @@
|
||||
</Card>
|
||||
|
||||
<Card title="📊 纳税分析">
|
||||
<div class="h-48 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div
|
||||
class="flex h-48 items-center justify-center rounded-lg bg-gray-50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-2">📈</div>
|
||||
<div class="mb-2 text-3xl">📈</div>
|
||||
<p class="text-gray-600">税负分析图</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="💡 节税建议">
|
||||
<div v-if="taxTips.length === 0" class="text-center py-6">
|
||||
<div class="text-3xl mb-2">💡</div>
|
||||
<div v-if="taxTips.length === 0" class="py-6 text-center">
|
||||
<div class="mb-2 text-3xl">💡</div>
|
||||
<p class="text-gray-500">暂无节税建议</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="tip in taxTips" :key="tip.id" class="p-3 bg-blue-50 rounded-lg">
|
||||
<div
|
||||
v-for="tip in taxTips"
|
||||
:key="tip.id"
|
||||
class="rounded-lg bg-blue-50 p-3"
|
||||
>
|
||||
<p class="text-sm font-medium text-blue-800">{{ tip.title }}</p>
|
||||
<p class="text-xs text-blue-600">{{ tip.description }}</p>
|
||||
</div>
|
||||
@@ -75,24 +108,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Card, Tag, Input, Button } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'TaxManagement' });
|
||||
|
||||
// 税务统计(空数据)
|
||||
const taxStats = ref({
|
||||
yearlyIncome: 0,
|
||||
paidTax: 0,
|
||||
potentialSaving: 0,
|
||||
filingStatus: 'pending'
|
||||
});
|
||||
|
||||
// 节税建议(空数据)
|
||||
const taxTips = ref([]);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
</style>
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,52 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Button, Card, Input, Select } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'FinanceTools' });
|
||||
|
||||
// 贷款计算器表单
|
||||
const loanForm = ref({
|
||||
amount: '',
|
||||
rate: '',
|
||||
years: '',
|
||||
});
|
||||
|
||||
const loanResult = ref({
|
||||
monthlyPayment: null,
|
||||
});
|
||||
|
||||
// 投资计算器表单
|
||||
const investmentForm = ref({
|
||||
initial: '',
|
||||
rate: '',
|
||||
years: '',
|
||||
});
|
||||
|
||||
const investmentResult = ref({
|
||||
finalValue: null,
|
||||
});
|
||||
|
||||
// 汇率换算表单
|
||||
const currencyForm = ref({
|
||||
amount: '',
|
||||
from: 'CNY',
|
||||
to: 'USD',
|
||||
});
|
||||
|
||||
const currencyResult = ref({
|
||||
converted: null,
|
||||
});
|
||||
|
||||
// 计算方法
|
||||
const calculateLoan = () => {
|
||||
const amount = Number.parseFloat(loanForm.value.amount);
|
||||
const rate = Number.parseFloat(loanForm.value.rate) / 100 / 12;
|
||||
const months = Number.parseInt(loanForm.value.years) * 12;
|
||||
|
||||
if (amount && rate && months) {
|
||||
const monthlyPayment =
|
||||
(amount * rate * (1 + rate) ** months) / ((1 + rate) ** months - 1);
|
||||
loanResult.value.monthlyPayment = monthlyPayment;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateInvestment = () => {
|
||||
const initial = Number.parseFloat(investmentForm.value.initial);
|
||||
const rate = Number.parseFloat(investmentForm.value.rate) / 100;
|
||||
const years = Number.parseInt(investmentForm.value.years);
|
||||
|
||||
if (initial && rate && years) {
|
||||
const finalValue = initial * (1 + rate) ** years;
|
||||
investmentResult.value.finalValue = finalValue;
|
||||
}
|
||||
};
|
||||
|
||||
const convertCurrency = () => {
|
||||
const amount = Number.parseFloat(currencyForm.value.amount);
|
||||
// 模拟汇率(实际应用中应该调用汇率API)
|
||||
const rate =
|
||||
currencyForm.value.from === 'CNY' && currencyForm.value.to === 'USD'
|
||||
? 0.14
|
||||
: 7.15;
|
||||
|
||||
if (amount) {
|
||||
currencyResult.value.converted = (amount * rate).toFixed(2);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">🛠️ 财务工具</h1>
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">🛠️ 财务工具</h1>
|
||||
<p class="text-gray-600">实用的财务计算和分析工具</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card title="🏠 贷款计算器">
|
||||
<div class="space-y-4">
|
||||
<Input v-model:value="loanForm.amount" placeholder="请输入贷款金额" />
|
||||
<Input v-model:value="loanForm.rate" placeholder="请输入年利率 %" />
|
||||
<Input v-model:value="loanForm.years" placeholder="请输入贷款年限" />
|
||||
<Button type="primary" block @click="calculateLoan">计算月供</Button>
|
||||
<div v-if="loanResult.monthlyPayment" class="mt-4 p-3 bg-blue-50 rounded-lg text-center">
|
||||
<p class="font-medium text-blue-800">月供:¥{{ loanResult.monthlyPayment.toLocaleString() }}</p>
|
||||
<div
|
||||
v-if="loanResult.monthlyPayment"
|
||||
class="mt-4 rounded-lg bg-blue-50 p-3 text-center"
|
||||
>
|
||||
<p class="font-medium text-blue-800">
|
||||
月供:${{ loanResult.monthlyPayment.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card title="📈 投资计算器">
|
||||
<div class="space-y-4">
|
||||
<Input v-model:value="investmentForm.initial" placeholder="请输入初始投资金额" />
|
||||
<Input v-model:value="investmentForm.rate" placeholder="请输入年收益率 %" />
|
||||
<Input v-model:value="investmentForm.years" placeholder="请输入投资期限(年)" />
|
||||
<Button type="primary" block @click="calculateInvestment">计算收益</Button>
|
||||
<div v-if="investmentResult.finalValue" class="mt-4 p-3 bg-green-50 rounded-lg text-center">
|
||||
<p class="font-medium text-green-800">预期收益:¥{{ investmentResult.finalValue.toLocaleString() }}</p>
|
||||
<Input
|
||||
v-model:value="investmentForm.initial"
|
||||
placeholder="请输入初始投资金额"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="investmentForm.rate"
|
||||
placeholder="请输入年收益率 %"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="investmentForm.years"
|
||||
placeholder="请输入投资期限(年)"
|
||||
/>
|
||||
<Button type="primary" block @click="calculateInvestment">
|
||||
计算收益
|
||||
</Button>
|
||||
<div
|
||||
v-if="investmentResult.finalValue"
|
||||
class="mt-4 rounded-lg bg-green-50 p-3 text-center"
|
||||
>
|
||||
<p class="font-medium text-green-800">
|
||||
预期收益:${{ investmentResult.finalValue.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card title="💱 汇率换算">
|
||||
<div class="space-y-4">
|
||||
<Input v-model:value="currencyForm.amount" placeholder="请输入金额" />
|
||||
<Select v-model:value="currencyForm.from" placeholder="原币种" style="width: 100%">
|
||||
<Select
|
||||
v-model:value="currencyForm.from"
|
||||
placeholder="原币种"
|
||||
style="width: 100%"
|
||||
>
|
||||
<Select.Option value="CNY">🇨🇳 人民币</Select.Option>
|
||||
<Select.Option value="USD">🇺🇸 美元</Select.Option>
|
||||
<Select.Option value="EUR">🇪🇺 欧元</Select.Option>
|
||||
</Select>
|
||||
<Select v-model:value="currencyForm.to" placeholder="目标币种" style="width: 100%">
|
||||
<Select
|
||||
v-model:value="currencyForm.to"
|
||||
placeholder="目标币种"
|
||||
style="width: 100%"
|
||||
>
|
||||
<Select.Option value="USD">🇺🇸 美元</Select.Option>
|
||||
<Select.Option value="CNY">🇨🇳 人民币</Select.Option>
|
||||
<Select.Option value="EUR">🇪🇺 欧元</Select.Option>
|
||||
</Select>
|
||||
<Button type="primary" block @click="convertCurrency">立即换算</Button>
|
||||
<div v-if="currencyResult.converted" class="mt-4 p-3 bg-purple-50 rounded-lg text-center">
|
||||
<Button type="primary" block @click="convertCurrency">
|
||||
立即换算
|
||||
</Button>
|
||||
<div
|
||||
v-if="currencyResult.converted"
|
||||
class="mt-4 rounded-lg bg-purple-50 p-3 text-center"
|
||||
>
|
||||
<p class="font-medium text-purple-800">
|
||||
{{ currencyForm.amount }} {{ currencyForm.from }} = {{ currencyResult.converted }} {{ currencyForm.to }}
|
||||
{{ currencyForm.amount }} {{ currencyForm.from }} =
|
||||
{{ currencyResult.converted }} {{ currencyForm.to }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,79 +168,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Card, Input, Button, Select } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'FinanceTools' });
|
||||
|
||||
// 贷款计算器表单
|
||||
const loanForm = ref({
|
||||
amount: '',
|
||||
rate: '',
|
||||
years: ''
|
||||
});
|
||||
|
||||
const loanResult = ref({
|
||||
monthlyPayment: null
|
||||
});
|
||||
|
||||
// 投资计算器表单
|
||||
const investmentForm = ref({
|
||||
initial: '',
|
||||
rate: '',
|
||||
years: ''
|
||||
});
|
||||
|
||||
const investmentResult = ref({
|
||||
finalValue: null
|
||||
});
|
||||
|
||||
// 汇率换算表单
|
||||
const currencyForm = ref({
|
||||
amount: '',
|
||||
from: 'CNY',
|
||||
to: 'USD'
|
||||
});
|
||||
|
||||
const currencyResult = ref({
|
||||
converted: null
|
||||
});
|
||||
|
||||
// 计算方法
|
||||
const calculateLoan = () => {
|
||||
const amount = parseFloat(loanForm.value.amount);
|
||||
const rate = parseFloat(loanForm.value.rate) / 100 / 12;
|
||||
const months = parseInt(loanForm.value.years) * 12;
|
||||
|
||||
if (amount && rate && months) {
|
||||
const monthlyPayment = (amount * rate * Math.pow(1 + rate, months)) / (Math.pow(1 + rate, months) - 1);
|
||||
loanResult.value.monthlyPayment = monthlyPayment;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateInvestment = () => {
|
||||
const initial = parseFloat(investmentForm.value.initial);
|
||||
const rate = parseFloat(investmentForm.value.rate) / 100;
|
||||
const years = parseInt(investmentForm.value.years);
|
||||
|
||||
if (initial && rate && years) {
|
||||
const finalValue = initial * Math.pow(1 + rate, years);
|
||||
investmentResult.value.finalValue = finalValue;
|
||||
}
|
||||
};
|
||||
|
||||
const convertCurrency = () => {
|
||||
const amount = parseFloat(currencyForm.value.amount);
|
||||
// 模拟汇率(实际应用中应该调用汇率API)
|
||||
const rate = currencyForm.value.from === 'CNY' && currencyForm.value.to === 'USD' ? 0.14 : 7.15;
|
||||
|
||||
if (amount) {
|
||||
currencyResult.value.converted = (amount * rate).toFixed(2);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
</style>
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1289,7 +1289,7 @@ const _handleAccountChange = (account: string) => {
|
||||
<div class="text-3xl">📈</div>
|
||||
<p class="text-sm text-gray-500">总收入</p>
|
||||
<p class="text-2xl font-bold text-green-600">
|
||||
¥{{
|
||||
${{
|
||||
statistics.totalIncome.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
@@ -1303,7 +1303,7 @@ const _handleAccountChange = (account: string) => {
|
||||
<div class="text-3xl">📉</div>
|
||||
<p class="text-sm text-gray-500">总支出</p>
|
||||
<p class="text-2xl font-bold text-red-600">
|
||||
¥{{
|
||||
${{
|
||||
statistics.totalExpense.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
@@ -1322,7 +1322,7 @@ const _handleAccountChange = (account: string) => {
|
||||
statistics.netIncome >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
"
|
||||
>
|
||||
¥{{
|
||||
${{
|
||||
statistics.netIncome.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
|
||||
Reference in New Issue
Block a user