5278190750
- 后端: 凭证生成引擎、金蝶导出器、凭证模板服务 - 后端: 成本分析服务、AI问答服务 - 后端: 科目映射CRUD API、分析API、QA API - 后端: 集成测试(认证/任务/凭证) 49个测试全部通过 - 前端: 凭证管理、成本分析、导出中心、知识库、系统设置页面 - 前端: AuthGuard认证守卫、Dashboard AI聊天功能 - 前端: Playwright E2E测试 16 passed, 1 skipped - 基础设施: Docker Compose、Nginx反向代理、.env.example - 文档: 用户手册、管理员手册、发布检查清单
256 lines
8.6 KiB
TypeScript
256 lines
8.6 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { motion } from "motion/react";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Download,
|
|
FileText,
|
|
Receipt,
|
|
BarChart3,
|
|
Loader2,
|
|
Search,
|
|
} from "lucide-react";
|
|
import { api } from "@/lib/api/client";
|
|
|
|
interface ExportRecord {
|
|
id: number;
|
|
task_id: number;
|
|
type: string;
|
|
format: string;
|
|
status: string;
|
|
file_name: string;
|
|
created_at: string;
|
|
}
|
|
|
|
const typeLabels: Record<string, string> = {
|
|
cost_analysis: "成本分析",
|
|
voucher: "会计凭证",
|
|
exception: "异常清单",
|
|
reconciliation: "对账结果",
|
|
};
|
|
|
|
const formatLabels: Record<string, string> = {
|
|
csv: "CSV",
|
|
excel: "Excel",
|
|
pdf: "PDF",
|
|
};
|
|
|
|
const statusConfig: Record<string, { label: string; color: string }> = {
|
|
COMPLETED: { label: "已完成", color: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300" },
|
|
PROCESSING: { label: "处理中", color: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300" },
|
|
FAILED: { label: "失败", color: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300" },
|
|
};
|
|
|
|
export default function ExportsPage() {
|
|
const [records, setRecords] = useState<ExportRecord[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [taskId, setTaskId] = useState("");
|
|
|
|
const loadRecords = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params: Record<string, any> = {};
|
|
if (taskId) params.task_id = taskId;
|
|
const res = await api.get<{ items: ExportRecord[] }>("/api/exports/list", { params });
|
|
setRecords(res.data.items || []);
|
|
} catch (error) {
|
|
console.error("加载导出记录失败:", error);
|
|
setRecords([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [taskId]);
|
|
|
|
useEffect(() => {
|
|
loadRecords();
|
|
}, [loadRecords]);
|
|
|
|
function handleExport(type: string) {
|
|
if (!taskId) return;
|
|
const token = localStorage.getItem("auth_token");
|
|
const companyId = localStorage.getItem("company_id");
|
|
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/exports/task/${taskId}?type=${type}`;
|
|
fetch(url, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"X-Company-ID": companyId || "",
|
|
},
|
|
})
|
|
.then((res) => res.blob())
|
|
.then((blob) => {
|
|
const a = document.createElement("a");
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = `export_${taskId}_${type}.xlsx`;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
});
|
|
}
|
|
|
|
const quickExports = [
|
|
{
|
|
title: "成本分析导出",
|
|
desc: "导出人工成本分析 Excel",
|
|
icon: BarChart3,
|
|
type: "cost_analysis",
|
|
color: "text-violet-500",
|
|
bgColor: "bg-violet-500/10",
|
|
},
|
|
{
|
|
title: "凭证导出 (金蝶CSV)",
|
|
desc: "导出金蝶 K3 格式凭证",
|
|
icon: Receipt,
|
|
type: "voucher_csv",
|
|
color: "text-emerald-500",
|
|
bgColor: "bg-emerald-500/10",
|
|
},
|
|
{
|
|
title: "凭证导出 (Excel)",
|
|
desc: "导出 Excel 格式凭证",
|
|
icon: FileText,
|
|
type: "voucher_excel",
|
|
color: "text-blue-500",
|
|
bgColor: "bg-blue-500/10",
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
|
>
|
|
<h1 className="text-heading-1 text-foreground">导出中心</h1>
|
|
<p className="text-muted-foreground mt-1">集中管理和导出各类财务数据</p>
|
|
</motion.div>
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex flex-wrap items-end gap-3">
|
|
<div className="flex-1 min-w-[180px]">
|
|
<label className="text-caption text-muted-foreground mb-1.5 block">任务ID</label>
|
|
<Input
|
|
placeholder="输入对账任务ID"
|
|
value={taskId}
|
|
onChange={(e) => setTaskId(e.target.value)}
|
|
className="h-9"
|
|
/>
|
|
</div>
|
|
<Button onClick={loadRecords} variant="outline" className="h-9 btn-press">
|
|
<Search className="w-4 h-4 mr-2" />
|
|
查询
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.1 }}
|
|
>
|
|
<h2 className="text-heading-3 text-foreground mb-4">快捷导出</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{quickExports.map((action) => {
|
|
const Icon = action.icon;
|
|
return (
|
|
<Card key={action.type} className="group cursor-pointer hover-lift" onClick={() => handleExport(action.type)}>
|
|
<CardContent className="p-5 flex items-start gap-4">
|
|
<div className={`p-2.5 rounded-lg ${action.bgColor} group-hover:scale-110 transition-transform`}>
|
|
<Icon className={`w-5 h-5 ${action.color}`} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-medium text-foreground mb-1">{action.title}</h3>
|
|
<p className="text-caption text-muted-foreground">{action.desc}</p>
|
|
</div>
|
|
<Download className="w-4 h-4 text-muted-foreground group-hover:text-primary group-hover:translate-y-1 transition-all self-center" />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.2 }}
|
|
>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg font-medium">导出记录</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="hover:bg-transparent">
|
|
<TableHead>任务ID</TableHead>
|
|
<TableHead>类型</TableHead>
|
|
<TableHead>格式</TableHead>
|
|
<TableHead>状态</TableHead>
|
|
<TableHead>文件名</TableHead>
|
|
<TableHead>时间</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center py-12">
|
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground mx-auto" />
|
|
</TableCell>
|
|
</TableRow>
|
|
) : records.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center py-12">
|
|
<div className="w-10 h-10 rounded-full bg-muted mx-auto mb-3 flex items-center justify-center">
|
|
<Download className="w-5 h-5 text-muted-foreground" />
|
|
</div>
|
|
<p className="text-muted-foreground">暂无导出记录</p>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
records.map((record) => (
|
|
<TableRow key={record.id}>
|
|
<TableCell className="font-mono">#{record.task_id}</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline">{typeLabels[record.type] || record.type}</Badge>
|
|
</TableCell>
|
|
<TableCell>{formatLabels[record.format] || record.format}</TableCell>
|
|
<TableCell>
|
|
<Badge className={statusConfig[record.status]?.color || ""}>
|
|
{statusConfig[record.status]?.label || record.status}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">{record.file_name}</TableCell>
|
|
<TableCell className="text-muted-foreground text-sm">
|
|
{new Date(record.created_at).toLocaleString("zh-CN")}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}
|