feat: 凭证生成、成本分析、AI问答、前端页面、集成测试与E2E测试

- 后端: 凭证生成引擎、金蝶导出器、凭证模板服务
- 后端: 成本分析服务、AI问答服务
- 后端: 科目映射CRUD API、分析API、QA API
- 后端: 集成测试(认证/任务/凭证) 49个测试全部通过
- 前端: 凭证管理、成本分析、导出中心、知识库、系统设置页面
- 前端: AuthGuard认证守卫、Dashboard AI聊天功能
- 前端: Playwright E2E测试 16 passed, 1 skipped
- 基础设施: Docker Compose、Nginx反向代理、.env.example
- 文档: 用户手册、管理员手册、发布检查清单
This commit is contained in:
selfrelease
2026-07-07 21:21:29 +08:00
parent feebbb10ac
commit 5278190750
47 changed files with 6567 additions and 156 deletions
+581
View File
@@ -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>
);
}