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

585 lines
25 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, useRef } from "react";
import { motion } from "motion/react";
import {
FileText,
AlertTriangle,
CheckCircle,
TrendingUp,
Clock,
ArrowRight,
Loader2,
Sparkles,
Send,
MessageSquare,
Receipt,
BarChart3,
Upload,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api/client";
import ENDPOINTS from "@/lib/api/endpoints";
interface TaskStats {
monthly_tasks: number;
pending_exceptions: number;
completed_tasks: number;
match_rate: number;
current_period: string;
}
interface Task {
id: number;
period: string;
status: string;
total_employees: number;
matched_count: number;
exception_count: number;
created_at: string;
completed_at: string | null;
}
const statusLabels: Record<string, { label: string; color: string }> = {
PENDING: { label: "待处理", color: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
PROCESSING: { label: "处理中", color: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300" },
MAPPING_COMPLETED: { label: "映射完成", color: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300" },
COMPLETED: { label: "已完成", color: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300" },
FAILED: { label: "失败", color: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300" },
};
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.08,
delayChildren: 0.1,
},
},
};
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,
},
},
};
interface SuggestedQuestions {
questions: string[];
}
interface QAResponse {
answer: string;
data_points: string[];
}
export default function DashboardPage() {
const router = useRouter();
const [stats, setStats] = useState<TaskStats | null>(null);
const [recentTasks, setRecentTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [questions, setQuestions] = useState<string[]>([]);
const [askLoading, setAskLoading] = useState(false);
const [customQuestion, setCustomQuestion] = useState("");
const [chatHistory, setChatHistory] = useState<{ q: string; a: string }[]>([]);
const chatEndRef = useRef<HTMLDivElement>(null);
const loadDashboardData = useCallback(async () => {
setLoading(true);
try {
// 并行获取统计数据和任务列表
const [statsRes, tasksRes] = await Promise.all([
api.get<TaskStats>(ENDPOINTS.TASK.STATS),
api.get<{ items: Task[] }>(ENDPOINTS.TASK.LIST, { params: { page: 1, page_size: 5 } }),
]);
setStats(statsRes.data);
setRecentTasks(tasksRes.data.items || []);
} catch (error) {
console.error("加载数据失败:", error);
} finally {
setLoading(false);
}
}, []);
const loadQuestions = useCallback(async () => {
try {
const res = await api.get<SuggestedQuestions>(`/api/qa/suggested-questions`, {
params: { task_id: 1, context: "" },
});
setQuestions(res.data.questions || []);
} catch (error) {
// 静默处理
}
}, []);
useEffect(() => {
loadDashboardData();
loadQuestions();
}, [loadDashboardData, loadQuestions]);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [chatHistory]);
async function askQuestion(question: string) {
if (!question) return;
setAskLoading(true);
try {
const res = await api.post<QAResponse>(`/api/qa/ask`, {
task_id: recentTasks[0]?.id || 1,
question,
context: "",
});
setChatHistory([...chatHistory, { q: question, a: res.data.answer }]);
} catch (error) {
setChatHistory([...chatHistory, { q: question, a: "暂时无法回答该问题,请稍后重试。" }]);
} finally {
setAskLoading(false);
}
}
const formatPeriod = (period: string) => {
const [year, month] = period.split("-");
return `${year}${parseInt(month)}`;
};
return (
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] as const }}
className="mb-8"
>
<h1 className="text-heading-1 text-foreground mb-2">AI </h1>
<p className="text-muted-foreground text-body">
</p>
</motion.div>
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : (
<>
{/* Stats Grid */}
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"
>
<motion.div variants={item}>
<Card className="relative overflow-hidden 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-3xl font-semibold tracking-tight text-foreground">
{stats?.monthly_tasks ?? 0}
</p>
<div className="flex items-center gap-1 mt-2">
<Clock className="w-3 h-3 text-muted-foreground" />
<span className="text-caption text-muted-foreground">
{stats?.current_period ? formatPeriod(stats.current_period) : "-"}
</span>
</div>
</div>
<div className="p-2.5 rounded-lg bg-blue-50 dark:bg-blue-950/30">
<FileText className="w-5 h-5 text-blue-600" />
</div>
</div>
</CardContent>
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-blue-50 dark:bg-blue-950/30 opacity-50" />
</Card>
</motion.div>
<motion.div variants={item}>
<Card className="relative overflow-hidden 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-3xl font-semibold tracking-tight text-foreground">
{stats?.pending_exceptions ?? 0}
</p>
<div className="flex items-center gap-1 mt-2">
<AlertTriangle className="w-3 h-3 text-amber-500" />
<span className="text-caption text-muted-foreground">
</span>
</div>
</div>
<div className="p-2.5 rounded-lg bg-amber-50 dark:bg-amber-950/30">
<AlertTriangle className="w-5 h-5 text-amber-600" />
</div>
</div>
</CardContent>
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-amber-50 dark:bg-amber-950/30 opacity-50" />
</Card>
</motion.div>
<motion.div variants={item}>
<Card className="relative overflow-hidden 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-3xl font-semibold tracking-tight text-foreground">
{stats?.completed_tasks ?? 0}
</p>
<div className="flex items-center gap-1 mt-2">
<CheckCircle className="w-3 h-3 text-emerald-500" />
<span className="text-caption text-muted-foreground">
</span>
</div>
</div>
<div className="p-2.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/30">
<CheckCircle className="w-5 h-5 text-emerald-600" />
</div>
</div>
</CardContent>
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-emerald-50 dark:bg-emerald-950/30 opacity-50" />
</Card>
</motion.div>
<motion.div variants={item}>
<Card className="relative overflow-hidden 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-3xl font-semibold tracking-tight text-foreground">
{stats?.match_rate ?? 0}%
</p>
<div className="flex items-center gap-1 mt-2">
<TrendingUp className="w-3 h-3 text-violet-500" />
<span className="text-caption text-muted-foreground">
</span>
</div>
</div>
<div className="p-2.5 rounded-lg bg-violet-50 dark:bg-violet-950/30">
<TrendingUp className="w-5 h-5 text-violet-600" />
</div>
</div>
</CardContent>
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-violet-50 dark:bg-violet-950/30 opacity-50" />
</Card>
</motion.div>
</motion.div>
{/* AI Chat + Recent Tasks */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* AI Chat Panel */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3, ease: [0.16, 1, 0.3, 1] as const }}
className="lg:col-span-2"
>
<Card className="h-full flex flex-col">
<CardContent className="p-5 flex flex-col h-full">
<div className="flex items-center gap-2 mb-4">
<div className="p-2 rounded-lg bg-primary/10">
<Sparkles className="w-5 h-5 text-primary" />
</div>
<div>
<h2 className="text-heading-3 text-foreground">AI </h2>
<p className="text-caption text-muted-foreground"></p>
</div>
</div>
{/* Chat History */}
<div className="flex-1 min-h-[200px] max-h-[400px] overflow-y-auto space-y-3 mb-4 pr-2">
{chatHistory.length === 0 && !askLoading && (
<div className="text-center py-8">
<MessageSquare className="w-8 h-8 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-body text-muted-foreground">
AI
</p>
</div>
)}
{chatHistory.map((chat, i) => (
<div key={i} className="space-y-2">
<div className="flex justify-end">
<div className="bg-primary/10 rounded-lg px-4 py-2 max-w-[80%]">
<p className="text-sm text-foreground">{chat.q}</p>
</div>
</div>
<div className="flex justify-start">
<div className="bg-muted rounded-lg px-4 py-2 max-w-[80%]">
<div className="flex items-start gap-2">
<Sparkles className="w-3 h-3 text-primary mt-0.5 shrink-0" />
<p className="text-sm text-foreground/90">{chat.a}</p>
</div>
</div>
</div>
</div>
))}
{askLoading && (
<div className="flex justify-start">
<div className="bg-muted rounded-lg px-4 py-2">
<div className="flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">...</span>
</div>
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
{/* Suggested Questions */}
{chatHistory.length === 0 && questions.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{questions.slice(0, 4).map((q, i) => (
<Button
key={i}
variant="outline"
size="sm"
onClick={() => askQuestion(q)}
disabled={askLoading}
className="btn-press"
>
{q}
</Button>
))}
</div>
)}
{/* Input */}
<div className="flex gap-2">
<input
type="text"
placeholder="输入您的问题..."
value={customQuestion}
onChange={(e) => setCustomQuestion(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && customQuestion) {
askQuestion(customQuestion);
setCustomQuestion("");
}
}}
className="flex-1 h-10 px-3 rounded-lg border border-input bg-background text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<Button
onClick={() => {
if (customQuestion) {
askQuestion(customQuestion);
setCustomQuestion("");
}
}}
disabled={askLoading || !customQuestion}
className="btn-press"
size="icon"
>
<Send className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
</motion.div>
{/* Recent Tasks */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.35, ease: [0.16, 1, 0.3, 1] as const }}
>
<Card className="h-full">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-heading-3 text-foreground"></h2>
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/tasks")}
>
<ArrowRight className="w-4 h-4 ml-1" />
</Button>
</div>
{recentTasks.length === 0 ? (
<div className="text-center py-8">
<div className="w-10 h-10 rounded-full bg-muted mx-auto mb-3 flex items-center justify-center">
<FileText className="w-5 h-5 text-muted-foreground" />
</div>
<p className="text-caption text-muted-foreground mb-3">
</p>
<Button
size="sm"
className="btn-press"
onClick={() => router.push("/tasks/new")}
>
<Upload className="w-4 h-4 mr-1" />
</Button>
</div>
) : (
<div className="space-y-3">
{recentTasks.map((task) => (
<div
key={task.id}
className="p-3 rounded-lg border border-border hover:bg-accent cursor-pointer transition-colors"
onClick={() => router.push(`/tasks/${task.id}/result`)}
>
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm text-foreground">
{formatPeriod(task.period)}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusLabels[task.status]?.color || statusLabels.PENDING.color}`}>
{statusLabels[task.status]?.label || task.status}
</span>
</div>
<p className="text-caption text-muted-foreground">
{task.total_employees} · {task.matched_count} · {task.exception_count}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</motion.div>
</div>
{/* Quick Actions */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.4, ease: [0.16, 1, 0.3, 1] as const }}
>
<h2 className="text-heading-3 text-foreground mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card
className="group cursor-pointer hover-lift"
onClick={() => router.push("/tasks/new")}
>
<CardContent className="p-5 flex items-start gap-4">
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-primary/10 transition-colors">
<FileText className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground mb-1 group-hover:text-primary transition-colors">
</h3>
<p className="text-caption text-muted-foreground">
</p>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all self-center" />
</CardContent>
</Card>
<Card
className="group cursor-pointer hover-lift"
onClick={() => router.push("/exceptions")}
>
<CardContent className="p-5 flex items-start gap-4">
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-amber-500/10 transition-colors">
<AlertTriangle className="w-5 h-5 text-muted-foreground group-hover:text-amber-500 transition-colors" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground mb-1 group-hover:text-amber-500 transition-colors">
</h3>
<p className="text-caption text-muted-foreground">
</p>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-amber-500 group-hover:translate-x-1 transition-all self-center" />
</CardContent>
</Card>
<Card
className="group cursor-pointer hover-lift"
onClick={() => router.push("/analysis")}
>
<CardContent className="p-5 flex items-start gap-4">
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-violet-500/10 transition-colors">
<BarChart3 className="w-5 h-5 text-muted-foreground group-hover:text-violet-500 transition-colors" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground mb-1 group-hover:text-violet-500 transition-colors">
</h3>
<p className="text-caption text-muted-foreground">
</p>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-violet-500 group-hover:translate-x-1 transition-all self-center" />
</CardContent>
</Card>
<Card
className="group cursor-pointer hover-lift"
onClick={() => router.push("/vouchers")}
>
<CardContent className="p-5 flex items-start gap-4">
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-emerald-500/10 transition-colors">
<Receipt className="w-5 h-5 text-muted-foreground group-hover:text-emerald-500 transition-colors" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground mb-1 group-hover:text-emerald-500 transition-colors">
</h3>
<p className="text-caption text-muted-foreground">
</p>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-emerald-500 group-hover:translate-x-1 transition-all self-center" />
</CardContent>
</Card>
<Card
className="group cursor-pointer hover-lift"
onClick={() => router.push("/settings/rules")}
>
<CardContent className="p-5 flex items-start gap-4">
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-violet-500/10 transition-colors">
<TrendingUp className="w-5 h-5 text-muted-foreground group-hover:text-violet-500 transition-colors" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground mb-1 group-hover:text-violet-500 transition-colors">
</h3>
<p className="text-caption text-muted-foreground">
</p>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-violet-500 group-hover:translate-x-1 transition-all self-center" />
</CardContent>
</Card>
</div>
</motion.div>
</>
)}
</div>
);
}