Files
selfrelease 5278190750 feat: 凭证生成、成本分析、AI问答、前端页面、集成测试与E2E测试
- 后端: 凭证生成引擎、金蝶导出器、凭证模板服务
- 后端: 成本分析服务、AI问答服务
- 后端: 科目映射CRUD API、分析API、QA API
- 后端: 集成测试(认证/任务/凭证) 49个测试全部通过
- 前端: 凭证管理、成本分析、导出中心、知识库、系统设置页面
- 前端: AuthGuard认证守卫、Dashboard AI聊天功能
- 前端: Playwright E2E测试 16 passed, 1 skipped
- 基础设施: Docker Compose、Nginx反向代理、.env.example
- 文档: 用户手册、管理员手册、发布检查清单
2026-07-07 21:21:29 +08:00

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