Files
selfrelease 5278190750 feat: 凭证生成、成本分析、AI问答、前端页面、集成测试与E2E测试
- 后端: 凭证生成引擎、金蝶导出器、凭证模板服务
- 后端: 成本分析服务、AI问答服务
- 后端: 科目映射CRUD API、分析API、QA API
- 后端: 集成测试(认证/任务/凭证) 49个测试全部通过
- 前端: 凭证管理、成本分析、导出中心、知识库、系统设置页面
- 前端: AuthGuard认证守卫、Dashboard AI聊天功能
- 前端: Playwright E2E测试 16 passed, 1 skipped
- 基础设施: Docker Compose、Nginx反向代理、.env.example
- 文档: 用户手册、管理员手册、发布检查清单
2026-07-07 21:21:29 +08:00

582 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback } 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>
);
}