feat: 凭证生成、成本分析、AI问答、前端页面、集成测试与E2E测试

- 后端: 凭证生成引擎、金蝶导出器、凭证模板服务
- 后端: 成本分析服务、AI问答服务
- 后端: 科目映射CRUD API、分析API、QA API
- 后端: 集成测试(认证/任务/凭证) 49个测试全部通过
- 前端: 凭证管理、成本分析、导出中心、知识库、系统设置页面
- 前端: AuthGuard认证守卫、Dashboard AI聊天功能
- 前端: Playwright E2E测试 16 passed, 1 skipped
- 基础设施: Docker Compose、Nginx反向代理、.env.example
- 文档: 用户手册、管理员手册、发布检查清单
This commit is contained in:
selfrelease
2026-07-07 21:21:29 +08:00
parent feebbb10ac
commit 5278190750
47 changed files with 6567 additions and 156 deletions
+632
View File
@@ -0,0 +1,632 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
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 {
TrendingUp,
TrendingDown,
Users,
Wallet,
Download,
Loader2,
Sparkles,
MessageSquare,
Send,
PiggyBank,
Building2,
} from "lucide-react";
import {
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { api } from "@/lib/api/client";
interface CostSummary {
total_cost: number;
salary_cost: number;
social_security_cost: number;
fund_cost: number;
employee_count: number;
}
interface DepartmentCost {
department: string;
employee_count: number;
salary_cost: number;
social_security_cost: number;
fund_cost: number;
total_cost: number;
}
interface ExpenseBreakdown {
expense_type: string;
amount: number;
}
interface FullAnalysis {
summary: CostSummary;
departments: DepartmentCost[];
expenses: ExpenseBreakdown[];
changes?: Record<string, any>;
ai_summary?: string;
}
interface SuggestedQuestions {
questions: string[];
}
interface QAResponse {
answer: string;
data_points: string[];
}
const COLORS = ["#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#06b6d4"];
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: { staggerChildren: 0.08 },
},
};
const item = {
hidden: { opacity: 0, y: 12 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] as const },
},
};
function formatCurrency(value: number): string {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
minimumFractionDigits: 2,
}).format(value);
}
function formatNumber(value: number): string {
return new Intl.NumberFormat("zh-CN").format(value);
}
export default function AnalysisPage() {
const searchParams = useSearchParams();
const [analysis, setAnalysis] = useState<FullAnalysis | null>(null);
const [loading, setLoading] = useState(true);
const [taskId, setTaskId] = useState(searchParams.get("task_id") || "");
const [prevTaskId, setPrevTaskId] = useState(searchParams.get("prev_task_id") || "");
const [questions, setQuestions] = useState<string[]>([]);
const [answer, setAnswer] = useState<string>("");
const [askLoading, setAskLoading] = useState(false);
const [customQuestion, setCustomQuestion] = useState("");
const loadAnalysis = useCallback(async () => {
if (!taskId) {
setLoading(false);
return;
}
setLoading(true);
try {
const params: Record<string, any> = {};
if (prevTaskId) params.prev_task_id = prevTaskId;
const res = await api.get<FullAnalysis>(
`/api/analysis/labor-cost/${taskId}`,
{ params }
);
setAnalysis(res.data);
} catch (error) {
console.error("加载成本分析失败:", error);
} finally {
setLoading(false);
}
}, [taskId, prevTaskId]);
const loadQuestions = useCallback(async () => {
if (!taskId) return;
try {
const res = await api.get<SuggestedQuestions>(
`/api/qa/suggested-questions`,
{ params: { task_id: taskId, context: "cost_analysis" } }
);
setQuestions(res.data.questions || []);
} catch (error) {
console.error("加载建议问题失败:", error);
}
}, [taskId]);
useEffect(() => {
loadAnalysis();
loadQuestions();
}, [loadAnalysis, loadQuestions]);
async function askQuestion(question: string) {
if (!question || !taskId) return;
setAskLoading(true);
setAnswer("");
try {
const res = await api.post<QAResponse>(`/api/qa/ask`, {
task_id: Number(taskId),
question,
context: "cost_analysis",
});
setAnswer(res.data.answer);
} catch (error) {
console.error("提问失败:", error);
setAnswer("暂时无法回答该问题,请稍后重试。");
} finally {
setAskLoading(false);
}
}
function handleExport() {
if (!taskId) return;
const token = localStorage.getItem("auth_token");
const companyId = localStorage.getItem("company_id");
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/analysis/labor-cost/${taskId}/export`;
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 = `cost_analysis_${taskId}.xlsx`;
a.click();
URL.revokeObjectURL(a.href);
});
}
const deptChartData = analysis?.departments.map((d) => ({
name: d.department,
工资: d.salary_cost,
社保: d.social_security_cost,
公积金: d.fund_cost,
})) || [];
const expenseChartData = analysis?.expenses.map((e) => ({
name: e.expense_type,
value: e.amount,
})) || [];
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] }}
className="flex items-center justify-between"
>
<div>
<h1 className="text-heading-1 text-foreground"></h1>
<p className="text-muted-foreground mt-1"></p>
</div>
{analysis && (
<Button variant="outline" onClick={handleExport} className="btn-press">
<Download className="w-4 h-4 mr-2" />
Excel
</Button>
)}
</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>
<div className="flex-1 min-w-[180px]">
<label className="text-caption text-muted-foreground mb-1.5 block">ID</label>
<Input
placeholder="输入上月任务ID"
value={prevTaskId}
onChange={(e) => setPrevTaskId(e.target.value)}
className="h-9"
/>
</div>
<Button onClick={loadAnalysis} className="h-9 btn-press">
</Button>
</div>
</CardContent>
</Card>
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : !analysis ? (
<Card>
<CardContent className="p-8 text-center">
<div className="w-12 h-12 rounded-full bg-muted mx-auto mb-4 flex items-center justify-center">
<Wallet className="w-6 h-6 text-muted-foreground" />
</div>
<p className="text-body text-muted-foreground mb-2">
ID开始成本分析
</p>
</CardContent>
</Card>
) : (
<>
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"
>
<motion.div variants={item}>
<Card className="hover-lift">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1"></p>
<p className="text-2xl font-semibold tracking-tight text-foreground">
{formatCurrency(analysis.summary.total_cost)}
</p>
</div>
<div className="p-2.5 rounded-lg bg-indigo-50 dark:bg-indigo-950/30">
<Wallet className="w-5 h-5 text-indigo-600" />
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div variants={item}>
<Card className="hover-lift">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1"></p>
<p className="text-2xl font-semibold tracking-tight text-foreground">
{formatCurrency(analysis.summary.salary_cost)}
</p>
<p className="text-caption text-muted-foreground mt-1">
{((analysis.summary.salary_cost / analysis.summary.total_cost) * 100).toFixed(1)}%
</p>
</div>
<div className="p-2.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/30">
<PiggyBank className="w-5 h-5 text-emerald-600" />
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div variants={item}>
<Card className="hover-lift">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">+</p>
<p className="text-2xl font-semibold tracking-tight text-foreground">
{formatCurrency(analysis.summary.social_security_cost + analysis.summary.fund_cost)}
</p>
<p className="text-caption text-muted-foreground mt-1">
{(((analysis.summary.social_security_cost + analysis.summary.fund_cost) / analysis.summary.total_cost) * 100).toFixed(1)}%
</p>
</div>
<div className="p-2.5 rounded-lg bg-amber-50 dark:bg-amber-950/30">
<Building2 className="w-5 h-5 text-amber-600" />
</div>
</div>
</CardContent>
</Card>
</motion.div>
<motion.div variants={item}>
<Card className="hover-lift">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div>
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1"></p>
<p className="text-2xl font-semibold tracking-tight text-foreground">
{formatNumber(analysis.summary.employee_count)}
</p>
<p className="text-caption text-muted-foreground mt-1">
{formatCurrency(analysis.summary.total_cost / (analysis.summary.employee_count || 1))}
</p>
</div>
<div className="p-2.5 rounded-lg bg-violet-50 dark:bg-violet-950/30">
<Users className="w-5 h-5 text-violet-600" />
</div>
</div>
</CardContent>
</Card>
</motion.div>
</motion.div>
{analysis.ai_summary && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<Card className="border-primary/20 bg-primary/5">
<CardContent className="p-5">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Sparkles className="w-5 h-5 text-primary" />
</div>
<div>
<h3 className="font-medium text-foreground mb-1">AI </h3>
<p className="text-body text-foreground/80">{analysis.ai_summary}</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
)}
{analysis.changes && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.35 }}
>
<Card>
<CardHeader>
<CardTitle className="text-lg font-medium"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "总成本", data: analysis.changes.total_cost },
{ label: "工资成本", data: analysis.changes.salary_cost },
{ label: "社保成本", data: analysis.changes.social_security_cost },
{ label: "公积金成本", data: analysis.changes.fund_cost },
].map((item) => {
const change = item.data?.amount_change || 0;
const ratio = item.data?.ratio_change || 0;
const isUp = change > 0;
return (
<div key={item.label} className="p-4 rounded-lg bg-muted/50">
<p className="text-caption text-muted-foreground mb-1">{item.label}</p>
<p className="text-xl font-semibold text-foreground">
{formatCurrency(item.data?.current || 0)}
</p>
<div className="flex items-center gap-1 mt-2">
{isUp ? (
<TrendingUp className="w-3 h-3 text-red-500" />
) : (
<TrendingDown className="w-3 h-3 text-emerald-500" />
)}
<span className={`text-sm ${isUp ? "text-red-500" : "text-emerald-500"}`}>
{isUp ? "+" : ""}{formatCurrency(change)} ({ratio}%)
</span>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</motion.div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<Card>
<CardHeader>
<CardTitle className="text-lg font-medium"></CardTitle>
</CardHeader>
<CardContent>
{deptChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={deptChartData}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip
formatter={(value: any) => formatCurrency(Number(value))}
contentStyle={{ borderRadius: "8px", border: "1px solid hsl(var(--border))" }}
/>
<Legend />
<Bar dataKey="工资" stackId="a" fill="#6366f1" />
<Bar dataKey="社保" stackId="a" fill="#10b981" />
<Bar dataKey="公积金" stackId="a" fill="#f59e0b" />
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-muted-foreground text-center py-12"></p>
)}
</CardContent>
</Card>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.45 }}
>
<Card>
<CardHeader>
<CardTitle className="text-lg font-medium"></CardTitle>
</CardHeader>
<CardContent>
{expenseChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={expenseChartData}
cx="50%"
cy="50%"
outerRadius={100}
dataKey="value"
label={(entry) => `${entry.name}: ${formatCurrency(entry.value)}`}
>
{expenseChartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value: any) => formatCurrency(Number(value))} />
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-muted-foreground text-center py-12"></p>
)}
</CardContent>
</Card>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.5 }}
>
<Card>
<CardHeader>
<CardTitle className="text-lg font-medium"></CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{analysis.departments.map((dept) => (
<TableRow key={dept.department}>
<TableCell className="font-medium">{dept.department}</TableCell>
<TableCell className="text-right">{dept.employee_count}</TableCell>
<TableCell className="text-right font-mono">{formatCurrency(dept.salary_cost)}</TableCell>
<TableCell className="text-right font-mono">{formatCurrency(dept.social_security_cost)}</TableCell>
<TableCell className="text-right font-mono">{formatCurrency(dept.fund_cost)}</TableCell>
<TableCell className="text-right font-mono font-semibold">{formatCurrency(dept.total_cost)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.55 }}
>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-primary" />
<CardTitle className="text-lg font-medium">AI </CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
{questions.length > 0 && (
<div className="flex flex-wrap gap-2">
{questions.map((q, i) => (
<Button
key={i}
variant="outline"
size="sm"
onClick={() => askQuestion(q)}
disabled={askLoading}
className="btn-press"
>
{q}
</Button>
))}
</div>
)}
<div className="flex gap-2">
<Input
placeholder="输入您的问题..."
value={customQuestion}
onChange={(e) => setCustomQuestion(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && customQuestion) {
askQuestion(customQuestion);
setCustomQuestion("");
}
}}
/>
<Button
onClick={() => {
if (customQuestion) {
askQuestion(customQuestion);
setCustomQuestion("");
}
}}
disabled={askLoading || !customQuestion}
className="btn-press"
>
<Send className="w-4 h-4 mr-1" />
</Button>
</div>
{askLoading && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
...
</div>
)}
{answer && !askLoading && (
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<div className="flex items-start gap-2">
<Sparkles className="w-4 h-4 text-primary mt-0.5 shrink-0" />
<p className="text-body text-foreground/90">{answer}</p>
</div>
</div>
)}
</CardContent>
</Card>
</motion.div>
</>
)}
</div>
);
}