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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user