chore: migrate to KT financial system

This commit is contained in:
woshiqp465
2025-11-04 16:06:44 +08:00
parent 2c0505b73d
commit f4cd0a5f22
289 changed files with 7362 additions and 41458 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}`);
}
}

View File

@@ -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];

View File

@@ -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];

View File

@@ -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];

View File

@@ -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: '⚙️ 系统设置',
},
},

View File

@@ -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,

View File

@@ -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: '₩',

View File

@@ -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>

View File

@@ -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>

View File

@@ -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' : '笔交易' }}

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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;
}

View File

@@ -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>

View File

@@ -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,
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,