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