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

633 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}