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
+388
View File
@@ -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>
);
}