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
+274 -86
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { motion } from "motion/react";
import {
FileText,
@@ -9,7 +9,13 @@ import {
TrendingUp,
Clock,
ArrowRight,
Loader2
Loader2,
Sparkles,
Send,
MessageSquare,
Receipt,
BarChart3,
Upload,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -67,11 +73,25 @@ const item = {
},
};
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);
@@ -91,9 +111,42 @@ export default function DashboardPage() {
}
}, []);
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();
}, [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("-");
@@ -109,9 +162,9 @@ export default function DashboardPage() {
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"></h1>
<h1 className="text-heading-1 text-foreground mb-2">AI </h1>
<p className="text-muted-foreground text-body">
</p>
</motion.div>
@@ -237,89 +290,184 @@ export default function DashboardPage() {
</motion.div>
</motion.div>
{/* Recent Tasks */}
<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="mb-8"
>
<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 ? (
<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">
<FileText className="w-6 h-6 text-muted-foreground" />
{/* 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>
<p className="text-body text-muted-foreground mb-2">
</p>
<p className="text-caption text-muted-foreground">
使
</p>
<Button
className="mt-4 btn-press"
onClick={() => router.push("/tasks/new")}
>
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{recentTasks.map((task, index) => (
<motion.div
key={task.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
delay: 0.1 + index * 0.05,
}}
>
<Card
className="cursor-pointer hover-lift transition-all"
onClick={() => router.push(`/tasks/${task.id}/result`)}
>
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2.5 rounded-lg bg-muted">
<FileText className="w-5 h-5 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-foreground">
{formatPeriod(task.period)}
</h3>
<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>
{/* 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>
<ArrowRight className="w-4 h-4 text-muted-foreground" />
</CardContent>
</Card>
</motion.div>
))}
</div>
)}
</motion.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
@@ -328,7 +476,7 @@ export default function DashboardPage() {
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-3 gap-4">
<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")}
@@ -369,6 +517,46 @@ export default function DashboardPage() {
</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")}