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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
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 {
|
||||
Download,
|
||||
FileText,
|
||||
Receipt,
|
||||
BarChart3,
|
||||
Loader2,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api/client";
|
||||
|
||||
interface ExportRecord {
|
||||
id: number;
|
||||
task_id: number;
|
||||
type: string;
|
||||
format: string;
|
||||
status: string;
|
||||
file_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
cost_analysis: "成本分析",
|
||||
voucher: "会计凭证",
|
||||
exception: "异常清单",
|
||||
reconciliation: "对账结果",
|
||||
};
|
||||
|
||||
const formatLabels: Record<string, string> = {
|
||||
csv: "CSV",
|
||||
excel: "Excel",
|
||||
pdf: "PDF",
|
||||
};
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
COMPLETED: { label: "已完成", color: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300" },
|
||||
PROCESSING: { label: "处理中", color: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300" },
|
||||
FAILED: { label: "失败", color: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300" },
|
||||
};
|
||||
|
||||
export default function ExportsPage() {
|
||||
const [records, setRecords] = useState<ExportRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [taskId, setTaskId] = useState("");
|
||||
|
||||
const loadRecords = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, any> = {};
|
||||
if (taskId) params.task_id = taskId;
|
||||
const res = await api.get<{ items: ExportRecord[] }>("/api/exports/list", { params });
|
||||
setRecords(res.data.items || []);
|
||||
} catch (error) {
|
||||
console.error("加载导出记录失败:", error);
|
||||
setRecords([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRecords();
|
||||
}, [loadRecords]);
|
||||
|
||||
function handleExport(type: string) {
|
||||
if (!taskId) return;
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const companyId = localStorage.getItem("company_id");
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/exports/task/${taskId}?type=${type}`;
|
||||
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 = `export_${taskId}_${type}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
}
|
||||
|
||||
const quickExports = [
|
||||
{
|
||||
title: "成本分析导出",
|
||||
desc: "导出人工成本分析 Excel",
|
||||
icon: BarChart3,
|
||||
type: "cost_analysis",
|
||||
color: "text-violet-500",
|
||||
bgColor: "bg-violet-500/10",
|
||||
},
|
||||
{
|
||||
title: "凭证导出 (金蝶CSV)",
|
||||
desc: "导出金蝶 K3 格式凭证",
|
||||
icon: Receipt,
|
||||
type: "voucher_csv",
|
||||
color: "text-emerald-500",
|
||||
bgColor: "bg-emerald-500/10",
|
||||
},
|
||||
{
|
||||
title: "凭证导出 (Excel)",
|
||||
desc: "导出 Excel 格式凭证",
|
||||
icon: FileText,
|
||||
type: "voucher_excel",
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-500/10",
|
||||
},
|
||||
];
|
||||
|
||||
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] }}
|
||||
>
|
||||
<h1 className="text-heading-1 text-foreground">导出中心</h1>
|
||||
<p className="text-muted-foreground mt-1">集中管理和导出各类财务数据</p>
|
||||
</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>
|
||||
<Button onClick={loadRecords} variant="outline" className="h-9 btn-press">
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
>
|
||||
<h2 className="text-heading-3 text-foreground mb-4">快捷导出</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{quickExports.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Card key={action.type} className="group cursor-pointer hover-lift" onClick={() => handleExport(action.type)}>
|
||||
<CardContent className="p-5 flex items-start gap-4">
|
||||
<div className={`p-2.5 rounded-lg ${action.bgColor} group-hover:scale-110 transition-transform`}>
|
||||
<Icon className={`w-5 h-5 ${action.color}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-foreground mb-1">{action.title}</h3>
|
||||
<p className="text-caption text-muted-foreground">{action.desc}</p>
|
||||
</div>
|
||||
<Download className="w-4 h-4 text-muted-foreground group-hover:text-primary group-hover:translate-y-1 transition-all self-center" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.2 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-medium">导出记录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>任务ID</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>格式</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>文件名</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : records.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-12">
|
||||
<div className="w-10 h-10 rounded-full bg-muted mx-auto mb-3 flex items-center justify-center">
|
||||
<Download className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-muted-foreground">暂无导出记录</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
records.map((record) => (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell className="font-mono">#{record.task_id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{typeLabels[record.type] || record.type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatLabels[record.format] || record.format}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={statusConfig[record.status]?.color || ""}>
|
||||
{statusConfig[record.status]?.label || record.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{record.file_name}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{new Date(record.created_at).toLocaleString("zh-CN")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
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 {
|
||||
BookOpen,
|
||||
Search,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
Lightbulb,
|
||||
TrendingUp,
|
||||
Receipt,
|
||||
Upload,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
|
||||
interface KnowledgeArticle {
|
||||
id: number;
|
||||
title: string;
|
||||
category: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const categoryConfig: Record<string, { label: string; icon: any; color: string }> = {
|
||||
getting_started: { label: "快速入门", icon: Upload, color: "text-blue-500" },
|
||||
reconciliation: { label: "对账指南", icon: FileText, color: "text-primary" },
|
||||
analysis: { label: "成本分析", icon: TrendingUp, color: "text-violet-500" },
|
||||
voucher: { label: "凭证管理", icon: Receipt, color: "text-emerald-500" },
|
||||
faq: { label: "常见问题", icon: HelpCircle, color: "text-amber-500" },
|
||||
tips: { label: "使用技巧", icon: Lightbulb, color: "text-orange-500" },
|
||||
};
|
||||
|
||||
const mockArticles: KnowledgeArticle[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "如何上传工资表和社保表",
|
||||
category: "getting_started",
|
||||
summary: "详细介绍文件上传流程,支持 Excel 格式,包括文件大小限制和格式要求。",
|
||||
tags: ["上传", "Excel", "工资表"],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "AI 字段识别使用指南",
|
||||
category: "getting_started",
|
||||
summary: "AI 自动识别源字段到标准字段的映射,支持人工确认和规则沉淀。",
|
||||
tags: ["AI", "字段映射", "自动识别"],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "对账规则配置说明",
|
||||
category: "reconciliation",
|
||||
summary: "了解如何配置对账规则,包括金额容差、必填字段、异常阈值等设置。",
|
||||
tags: ["规则", "容差", "配置"],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "异常处理最佳实践",
|
||||
category: "reconciliation",
|
||||
summary: "异常分类说明、处理流程建议、批量操作技巧。",
|
||||
tags: ["异常", "处理", "批量"],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "人工成本分析维度说明",
|
||||
category: "analysis",
|
||||
summary: "总成本、部门拆分、费用科目拆分、环比变化等分析维度详解。",
|
||||
tags: ["成本", "分析", "部门"],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "AI 成本分析摘要解读",
|
||||
category: "analysis",
|
||||
summary: "AI 生成的成本变化摘要如何理解,如何利用建议追问深入分析。",
|
||||
tags: ["AI", "摘要", "环比"],
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "凭证生成与科目映射",
|
||||
category: "voucher",
|
||||
summary: "科目映射配置方法、凭证自动生成流程、借贷分录预览说明。",
|
||||
tags: ["凭证", "科目", "映射"],
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: "金蝶 K3 导出格式说明",
|
||||
category: "voucher",
|
||||
summary: "金蝶 CSV 导出格式字段说明、导入金蝶 K3 的操作步骤。",
|
||||
tags: ["金蝶", "导出", "CSV"],
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: "AI 问答使用技巧",
|
||||
category: "faq",
|
||||
summary: "如何有效提问获取准确回答,预置问题 vs 自由提问的使用场景。",
|
||||
tags: ["AI", "问答", "技巧"],
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
title: "如何提高对账匹配率",
|
||||
category: "tips",
|
||||
summary: "通过完善字段映射、调整规则容差、清理异常数据来提升匹配率。",
|
||||
tags: ["匹配率", "优化", "技巧"],
|
||||
},
|
||||
];
|
||||
|
||||
export default function KnowledgePage() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState("");
|
||||
|
||||
const filteredArticles = mockArticles.filter((article) => {
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
article.title.includes(searchQuery) ||
|
||||
article.summary.includes(searchQuery) ||
|
||||
article.tags.some((tag) => tag.includes(searchQuery));
|
||||
const matchesCategory = !selectedCategory || article.category === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
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] }}
|
||||
>
|
||||
<h1 className="text-heading-1 text-foreground">企业知识库</h1>
|
||||
<p className="text-muted-foreground mt-1">使用指南、最佳实践和常见问题</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索文章、标签..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-9 pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.15 }}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
<Button
|
||||
variant={selectedCategory === "" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory("")}
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
{Object.entries(categoryConfig).map(([key, config]) => {
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
variant={selectedCategory === key ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(key)}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-1" />
|
||||
{config.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{filteredArticles.map((article, index) => {
|
||||
const cat = categoryConfig[article.category];
|
||||
const Icon = cat?.icon || BookOpen;
|
||||
return (
|
||||
<motion.div
|
||||
key={article.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
>
|
||||
<Card className="group cursor-pointer hover-lift">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2.5 rounded-lg bg-muted group-hover:bg-primary/10 transition-colors">
|
||||
<Icon className={`w-5 h-5 ${cat?.color || "text-muted-foreground"}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{article.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-caption text-muted-foreground mb-3">
|
||||
{article.summary}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{article.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all self-center" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredArticles.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-muted mx-auto mb-4 flex items-center justify-center">
|
||||
<BookOpen className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-body text-muted-foreground">未找到相关文章</p>
|
||||
<p className="text-caption text-muted-foreground mt-1">尝试其他关键词或分类</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { AuthGuard } from "@/components/layout/AuthGuard";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
@@ -7,14 +8,16 @@ export default function DashboardLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<main className="flex-1 min-h-[calc(100vh-3.5rem)]">
|
||||
{children}
|
||||
</main>
|
||||
<AuthGuard>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<main className="flex-1 min-h-[calc(100vh-3.5rem)]">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Settings,
|
||||
User,
|
||||
Building2,
|
||||
Key,
|
||||
Bell,
|
||||
Palette,
|
||||
Save,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
const settingsTabs = [
|
||||
{ key: "profile", label: "个人资料", icon: User },
|
||||
{ key: "company", label: "企业信息", icon: Building2 },
|
||||
{ key: "ai", label: "AI 配置", icon: Key },
|
||||
{ key: "notifications", label: "通知设置", icon: Bell },
|
||||
{ key: "appearance", label: "外观偏好", icon: Palette },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState("profile");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const [profile, setProfile] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
});
|
||||
|
||||
const [company, setCompany] = useState({
|
||||
name: "",
|
||||
tax_id: "",
|
||||
contact: "",
|
||||
plan: "standard",
|
||||
});
|
||||
|
||||
const [aiConfig, setAiConfig] = useState({
|
||||
provider: "zhipu",
|
||||
model: "glm-4",
|
||||
temperature: "0.3",
|
||||
max_tokens: "2000",
|
||||
});
|
||||
|
||||
const [notifications, setNotifications] = useState({
|
||||
email_alert: true,
|
||||
exception_alert: true,
|
||||
weekly_report: false,
|
||||
cost_threshold: "10000",
|
||||
});
|
||||
|
||||
const [appearance, setAppearance] = useState({
|
||||
theme: "system",
|
||||
density: "comfortable",
|
||||
language: "zh-CN",
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
setSaving(true);
|
||||
setTimeout(() => {
|
||||
setSaving(false);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}, 800);
|
||||
}
|
||||
|
||||
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] }}
|
||||
>
|
||||
<h1 className="text-heading-1 text-foreground">系统设置</h1>
|
||||
<p className="text-muted-foreground mt-1">管理个人资料、企业信息和系统配置</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Settings Nav */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<nav className="space-y-1">
|
||||
{settingsTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all text-sm font-medium ${
|
||||
activeTab === tab.key
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Settings Content */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
className="lg:col-span-3"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg font-medium">
|
||||
{settingsTabs.find((t) => t.key === activeTab)?.label}
|
||||
</CardTitle>
|
||||
<Button onClick={handleSave} disabled={saving} className="btn-press" size="sm">
|
||||
{saving ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
保存中
|
||||
</span>
|
||||
) : saved ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
已保存
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Save className="w-4 h-4" />
|
||||
保存
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{activeTab === "profile" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">姓名</label>
|
||||
<Input
|
||||
value={profile.name}
|
||||
onChange={(e) => setProfile({ ...profile, name: e.target.value })}
|
||||
placeholder="请输入姓名"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">邮箱</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={profile.email}
|
||||
onChange={(e) => setProfile({ ...profile, email: e.target.value })}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">手机号</label>
|
||||
<Input
|
||||
value={profile.phone}
|
||||
onChange={(e) => setProfile({ ...profile, phone: e.target.value })}
|
||||
placeholder="138****1234"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "company" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">企业名称</label>
|
||||
<Input
|
||||
value={company.name}
|
||||
onChange={(e) => setCompany({ ...company, name: e.target.value })}
|
||||
placeholder="请输入企业名称"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">税号</label>
|
||||
<Input
|
||||
value={company.tax_id}
|
||||
onChange={(e) => setCompany({ ...company, tax_id: e.target.value })}
|
||||
placeholder="请输入统一社会信用代码"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">联系人</label>
|
||||
<Input
|
||||
value={company.contact}
|
||||
onChange={(e) => setCompany({ ...company, contact: e.target.value })}
|
||||
placeholder="请输入联系人姓名"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">套餐类型</label>
|
||||
<Select value={company.plan} onValueChange={(v) => setCompany({ ...company, plan: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="free">免费版</SelectItem>
|
||||
<SelectItem value="standard">标准版</SelectItem>
|
||||
<SelectItem value="professional">专业版</SelectItem>
|
||||
<SelectItem value="enterprise">企业版</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "ai" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">AI 服务商</label>
|
||||
<Select value={aiConfig.provider} onValueChange={(v) => setAiConfig({ ...aiConfig, provider: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zhipu">智谱 AI (GLM-4)</SelectItem>
|
||||
<SelectItem value="openai">OpenAI (GPT-4)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">模型</label>
|
||||
<Input
|
||||
value={aiConfig.model}
|
||||
onChange={(e) => setAiConfig({ ...aiConfig, model: e.target.value })}
|
||||
placeholder="glm-4 / gpt-4-turbo-preview"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">Temperature</label>
|
||||
<Input
|
||||
value={aiConfig.temperature}
|
||||
onChange={(e) => setAiConfig({ ...aiConfig, temperature: e.target.value })}
|
||||
placeholder="0.3"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">Max Tokens</label>
|
||||
<Input
|
||||
value={aiConfig.max_tokens}
|
||||
onChange={(e) => setAiConfig({ ...aiConfig, max_tokens: e.target.value })}
|
||||
placeholder="2000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-amber-50 dark:bg-amber-950/30">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400">
|
||||
API Key 在 .env 文件中配置,不建议在此页面修改。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "notifications" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border border-border">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">邮件通知</p>
|
||||
<p className="text-caption text-muted-foreground">接收任务完成、异常提醒等邮件</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={notifications.email_alert ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setNotifications({ ...notifications, email_alert: !notifications.email_alert })}
|
||||
>
|
||||
{notifications.email_alert ? "已开启" : "已关闭"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border border-border">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">异常提醒</p>
|
||||
<p className="text-caption text-muted-foreground">检测到异常时发送通知</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={notifications.exception_alert ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setNotifications({ ...notifications, exception_alert: !notifications.exception_alert })}
|
||||
>
|
||||
{notifications.exception_alert ? "已开启" : "已关闭"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border border-border">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">周报</p>
|
||||
<p className="text-caption text-muted-foreground">每周一发送成本分析周报</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={notifications.weekly_report ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setNotifications({ ...notifications, weekly_report: !notifications.weekly_report })}
|
||||
>
|
||||
{notifications.weekly_report ? "已开启" : "已关闭"}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">成本预警阈值(元)</label>
|
||||
<Input
|
||||
value={notifications.cost_threshold}
|
||||
onChange={(e) => setNotifications({ ...notifications, cost_threshold: e.target.value })}
|
||||
placeholder="10000"
|
||||
/>
|
||||
<p className="text-caption text-muted-foreground mt-1">当月成本超过此值时发送预警</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "appearance" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">主题</label>
|
||||
<Select value={appearance.theme} onValueChange={(v) => setAppearance({ ...appearance, theme: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">浅色</SelectItem>
|
||||
<SelectItem value="dark">深色</SelectItem>
|
||||
<SelectItem value="system">跟随系统</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">界面密度</label>
|
||||
<Select value={appearance.density} onValueChange={(v) => setAppearance({ ...appearance, density: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="comfortable">舒适</SelectItem>
|
||||
<SelectItem value="compact">紧凑</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">语言</label>
|
||||
<Select value={appearance.language} onValueChange={(v) => setAppearance({ ...appearance, language: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zh-CN">简体中文</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
"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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Receipt,
|
||||
Download,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
FileText,
|
||||
Settings,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api/client";
|
||||
|
||||
interface VoucherEntry {
|
||||
account_code: string;
|
||||
account_name: string;
|
||||
debit_amount: number;
|
||||
credit_amount: number;
|
||||
summary: string;
|
||||
department?: string;
|
||||
}
|
||||
|
||||
interface Voucher {
|
||||
id: number;
|
||||
voucher_number: string;
|
||||
voucher_date: string;
|
||||
period: string;
|
||||
summary: string;
|
||||
entries: VoucherEntry[];
|
||||
total_debit: number;
|
||||
total_credit: number;
|
||||
status: string;
|
||||
confirmed_by?: number;
|
||||
confirmed_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AccountMapping {
|
||||
id: number;
|
||||
standard_field: string;
|
||||
debit_account: string;
|
||||
debit_account_name: string;
|
||||
credit_account: string;
|
||||
credit_account_name: string;
|
||||
cost_center?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
DRAFT: { label: "草稿", color: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
|
||||
CONFIRMED: { label: "已确认", color: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300" },
|
||||
EXPORTED: { label: "已导出", color: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300" },
|
||||
};
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
return new Intl.NumberFormat("zh-CN", {
|
||||
style: "currency",
|
||||
currency: "CNY",
|
||||
minimumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default function VouchersPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const [voucher, setVoucher] = useState<Voucher | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [taskId, setTaskId] = useState(searchParams.get("task_id") || "");
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const [mappings, setMappings] = useState<AccountMapping[]>([]);
|
||||
const [mappingDialogOpen, setMappingDialogOpen] = useState(false);
|
||||
const [editingMapping, setEditingMapping] = useState<AccountMapping | null>(null);
|
||||
const [mappingForm, setMappingForm] = useState({
|
||||
standard_field: "",
|
||||
debit_account: "",
|
||||
debit_account_name: "",
|
||||
credit_account: "",
|
||||
credit_account_name: "",
|
||||
cost_center: "",
|
||||
});
|
||||
|
||||
const loadVoucher = useCallback(async () => {
|
||||
if (!taskId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<Voucher>(`/api/vouchers/task/${taskId}`);
|
||||
setVoucher(res.data);
|
||||
} catch (error) {
|
||||
console.error("加载凭证失败:", error);
|
||||
setVoucher(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const loadMappings = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<AccountMapping[]>(`/api/vouchers/account-mappings/list`);
|
||||
setMappings(res.data || []);
|
||||
} catch (error) {
|
||||
console.error("加载科目映射失败:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadVoucher();
|
||||
loadMappings();
|
||||
}, [loadVoucher, loadMappings]);
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!taskId) return;
|
||||
setGenerating(true);
|
||||
try {
|
||||
const res = await api.post<Voucher>(`/api/vouchers/generate`, {
|
||||
task_id: Number(taskId),
|
||||
});
|
||||
setVoucher(res.data);
|
||||
} catch (error) {
|
||||
console.error("生成凭证失败:", error);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!voucher) return;
|
||||
setConfirming(true);
|
||||
try {
|
||||
const userId = Number(localStorage.getItem("user_id") || "1");
|
||||
const res = await api.post<Voucher>(`/api/vouchers/${voucher.id}/confirm`, {
|
||||
user_id: userId,
|
||||
});
|
||||
setVoucher(res.data);
|
||||
setConfirmOpen(false);
|
||||
} catch (error) {
|
||||
console.error("确认凭证失败:", error);
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleExport(format: string) {
|
||||
if (!taskId) return;
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const companyId = localStorage.getItem("company_id");
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/vouchers/task/${taskId}/export?format=${format}`;
|
||||
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 = `voucher_${taskId}.${format === "excel" ? "xlsx" : "csv"}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
}
|
||||
|
||||
function openMappingDialog(mapping?: AccountMapping) {
|
||||
if (mapping) {
|
||||
setEditingMapping(mapping);
|
||||
setMappingForm({
|
||||
standard_field: mapping.standard_field,
|
||||
debit_account: mapping.debit_account,
|
||||
debit_account_name: mapping.debit_account_name,
|
||||
credit_account: mapping.credit_account,
|
||||
credit_account_name: mapping.credit_account_name,
|
||||
cost_center: mapping.cost_center || "",
|
||||
});
|
||||
} else {
|
||||
setEditingMapping(null);
|
||||
setMappingForm({
|
||||
standard_field: "",
|
||||
debit_account: "",
|
||||
debit_account_name: "",
|
||||
credit_account: "",
|
||||
credit_account_name: "",
|
||||
cost_center: "",
|
||||
});
|
||||
}
|
||||
setMappingDialogOpen(true);
|
||||
}
|
||||
|
||||
async function saveMapping() {
|
||||
try {
|
||||
if (editingMapping) {
|
||||
await api.put(`/api/vouchers/account-mappings/${editingMapping.id}`, mappingForm);
|
||||
} else {
|
||||
await api.post(`/api/vouchers/account-mappings`, mappingForm);
|
||||
}
|
||||
setMappingDialogOpen(false);
|
||||
loadMappings();
|
||||
} catch (error) {
|
||||
console.error("保存科目映射失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMapping(id: number) {
|
||||
try {
|
||||
await api.delete(`/api/vouchers/account-mappings/${id}`);
|
||||
loadMappings();
|
||||
} catch (error) {
|
||||
console.error("删除科目映射失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => openMappingDialog()} className="btn-press">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
科目映射
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
<Button onClick={loadVoucher} variant="outline" className="h-9 btn-press">
|
||||
查询凭证
|
||||
</Button>
|
||||
<Button onClick={handleGenerate} disabled={generating || !taskId} className="h-9 btn-press">
|
||||
{generating ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Receipt className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
生成凭证
|
||||
</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>
|
||||
) : !voucher ? (
|
||||
<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">
|
||||
<Receipt className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-body text-muted-foreground mb-2">
|
||||
{taskId ? "该任务暂无凭证,请点击「生成凭证」" : "请输入任务ID查询凭证"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-lg font-medium">
|
||||
{voucher.voucher_number}
|
||||
</CardTitle>
|
||||
<Badge className={statusConfig[voucher.status]?.color || statusConfig.DRAFT.color}>
|
||||
{statusConfig[voucher.status]?.label || voucher.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleExport("csv")} className="btn-press">
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
金蝶CSV
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleExport("excel")} className="btn-press">
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
Excel
|
||||
</Button>
|
||||
{voucher.status === "DRAFT" && (
|
||||
<Button size="sm" onClick={() => setConfirmOpen(true)} className="btn-press">
|
||||
<CheckCircle className="w-4 h-4 mr-1" />
|
||||
确认凭证
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">凭证日期</p>
|
||||
<p className="font-medium">{voucher.voucher_date}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">会计期间</p>
|
||||
<p className="font-medium">{voucher.period}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">借方合计</p>
|
||||
<p className="font-mono font-semibold text-foreground">{formatCurrency(voucher.total_debit)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-caption text-muted-foreground">贷方合计</p>
|
||||
<p className="font-mono font-semibold text-foreground">{formatCurrency(voucher.total_credit)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-caption text-muted-foreground">摘要</p>
|
||||
<p className="text-body">{voucher.summary}</p>
|
||||
</div>
|
||||
|
||||
{voucher.confirmed_at && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-emerald-50 dark:bg-emerald-950/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-600" />
|
||||
<span className="text-sm text-emerald-700 dark:text-emerald-400">
|
||||
已于 {new Date(voucher.confirmed_at).toLocaleString("zh-CN")} 确认
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-medium">凭证分录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[60px]">序号</TableHead>
|
||||
<TableHead>科目代码</TableHead>
|
||||
<TableHead>科目名称</TableHead>
|
||||
<TableHead>摘要</TableHead>
|
||||
<TableHead className="text-right">借方金额</TableHead>
|
||||
<TableHead className="text-right">贷方金额</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{voucher.entries.map((entry, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="text-muted-foreground">{i + 1}</TableCell>
|
||||
<TableCell className="font-mono">{entry.account_code}</TableCell>
|
||||
<TableCell>{entry.account_name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{entry.summary}</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{entry.debit_amount > 0 ? formatCurrency(entry.debit_amount) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{entry.credit_amount > 0 ? formatCurrency(entry.credit_amount) : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="border-t-2 font-semibold">
|
||||
<TableCell colSpan={4} className="text-right">合计</TableCell>
|
||||
<TableCell className="text-right font-mono">{formatCurrency(voucher.total_debit)}</TableCell>
|
||||
<TableCell className="text-right font-mono">{formatCurrency(voucher.total_credit)}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mappings.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.2 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg font-medium">科目映射配置</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => openMappingDialog()} className="btn-press">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
新增映射
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>标准字段</TableHead>
|
||||
<TableHead>借方科目</TableHead>
|
||||
<TableHead>贷方科目</TableHead>
|
||||
<TableHead>成本中心</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="w-[100px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mappings.map((m) => (
|
||||
<TableRow key={m.id} className="group">
|
||||
<TableCell className="font-medium">{m.standard_field}</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-sm">{m.debit_account}</span>
|
||||
<span className="text-muted-foreground ml-2">{m.debit_account_name}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-sm">{m.credit_account}</span>
|
||||
<span className="text-muted-foreground ml-2">{m.credit_account_name}</span>
|
||||
</TableCell>
|
||||
<TableCell>{m.cost_center || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={m.is_active ? "default" : "secondary"}>
|
||||
{m.is_active ? "启用" : "禁用"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => openMappingDialog(m)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => deleteMapping(m.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 确认对话框 */}
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认凭证</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-body text-muted-foreground">
|
||||
确认后凭证将不能修改。确认凭证后将可以导出金蝶格式文件。
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>取消</Button>
|
||||
<Button onClick={handleConfirm} disabled={confirming} className="btn-press">
|
||||
{confirming ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <CheckCircle className="w-4 h-4 mr-2" />}
|
||||
确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 科目映射对话框 */}
|
||||
<Dialog open={mappingDialogOpen} onOpenChange={setMappingDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingMapping ? "编辑科目映射" : "新增科目映射"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">标准字段</label>
|
||||
<Input
|
||||
value={mappingForm.standard_field}
|
||||
onChange={(e) => setMappingForm({ ...mappingForm, standard_field: e.target.value })}
|
||||
placeholder="如:基本工资"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">借方科目代码</label>
|
||||
<Input
|
||||
value={mappingForm.debit_account}
|
||||
onChange={(e) => setMappingForm({ ...mappingForm, debit_account: e.target.value })}
|
||||
placeholder="如:6601.01"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">借方科目名称</label>
|
||||
<Input
|
||||
value={mappingForm.debit_account_name}
|
||||
onChange={(e) => setMappingForm({ ...mappingForm, debit_account_name: e.target.value })}
|
||||
placeholder="如:管理费用-工资"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">贷方科目代码</label>
|
||||
<Input
|
||||
value={mappingForm.credit_account}
|
||||
onChange={(e) => setMappingForm({ ...mappingForm, credit_account: e.target.value })}
|
||||
placeholder="如:2211.01"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">贷方科目名称</label>
|
||||
<Input
|
||||
value={mappingForm.credit_account_name}
|
||||
onChange={(e) => setMappingForm({ ...mappingForm, credit_account_name: e.target.value })}
|
||||
placeholder="如:应付职工薪酬-工资"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-caption text-muted-foreground mb-1.5 block">成本中心(可选)</label>
|
||||
<Input
|
||||
value={mappingForm.cost_center}
|
||||
onChange={(e) => setMappingForm({ ...mappingForm, cost_center: e.target.value })}
|
||||
placeholder="如:管理部"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setMappingDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={saveMapping} className="btn-press">保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user