chore: 前端代码优化 - ESLint 配置、Prettier 格式化、加载骨架屏、React 组件规范

- 更新 ESLint 配置添加 react-hooks 规则
- 运行 Prettier 格式化所有前端文件
- 为 users、apps、audit、quotas 页面添加 Skeleton 加载骨架屏
- 修复动态组件渲染问题(CategoryIcon/TypeIcon 使用 React.createElement)
- 修复 useRef 渲染期间访问问题(改用 useMemo)
- 修复 effect 中 setState 同步调用问题
- 新增 global-not-found.tsx 404 页面
- 修复 knowledge 页面引号转义问题
This commit is contained in:
selfrelease
2026-06-23 14:43:04 +08:00
parent 6ca1c27162
commit 96b944ac8d
68 changed files with 2182 additions and 1969 deletions
+7 -8
View File
@@ -113,10 +113,7 @@ export default function AdminAppsPage() {
return (
<div>
{/* 操作确认弹窗 */}
<AlertDialog
open={!!actionTarget}
onOpenChange={(open) => !open && setActionTarget(null)}
>
<AlertDialog open={!!actionTarget} onOpenChange={(open) => !open && setActionTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
@@ -195,7 +192,11 @@ export default function AdminAppsPage() {
<tr key={app.id} className="border-t hover:bg-muted/30 transition-colors">
<td className="p-3">
<div className="flex items-center gap-2">
<AppIcon iconUrl={app.icon_url} size={20} className="shrink-0 text-muted-foreground" />
<AppIcon
iconUrl={app.icon_url}
size={20}
className="shrink-0 text-muted-foreground"
/>
<div>
<div className="font-medium">{app.name}</div>
<div className="text-xs text-muted-foreground line-clamp-1">
@@ -211,9 +212,7 @@ export default function AdminAppsPage() {
</td>
<td className="p-3 text-muted-foreground">{app.creator_name}</td>
<td className="p-3">
<Badge variant={statusColors[app.status]}>
{statusLabels[app.status]}
</Badge>
<Badge variant={statusColors[app.status]}>{statusLabels[app.status]}</Badge>
</td>
<td className="p-3 text-muted-foreground">
{visibilityLabels[app.visibility] || app.visibility}
+1 -3
View File
@@ -90,9 +90,7 @@ export default function AuditPage() {
</td>
<td className="p-3">{log.user_name || log.user_id.slice(0, 8)}</td>
<td className="p-3">
<Badge variant={actionColors[log.action] ?? "outline"}>
{log.action}
</Badge>
<Badge variant={actionColors[log.action] ?? "outline"}>{log.action}</Badge>
</td>
<td className="p-3 text-muted-foreground">
{log.resource_type}/{log.resource_id.slice(0, 8)}
+23 -3
View File
@@ -4,7 +4,15 @@ import { useQuery } from "@tanstack/react-query";
import api from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Users, AppWindow, Activity, MessageCircle, Target, DollarSign, type LucideIcon } from "lucide-react";
import {
Users,
AppWindow,
Activity,
MessageCircle,
Target,
DollarSign,
type LucideIcon,
} from "lucide-react";
interface OverviewStats {
total_users: number;
@@ -15,7 +23,15 @@ interface OverviewStats {
monthly_cost: number;
}
function StatCard({ title, value, icon: Icon }: { title: string; value: string | number; icon: LucideIcon }) {
function StatCard({
title,
value,
icon: Icon,
}: {
title: string;
value: string | number;
icon: LucideIcon;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
@@ -63,7 +79,11 @@ export default function DashboardPage() {
<StatCard title="总用户数" value={stats?.total_users || 0} icon={Users} />
<StatCard title="已上架应用" value={stats?.total_apps || 0} icon={AppWindow} />
<StatCard title="今日活跃用户" value={stats?.active_users || 0} icon={Activity} />
<StatCard title="今日对话次数" value={formatNumber(stats?.total_conversations || 0)} icon={MessageCircle} />
<StatCard
title="今日对话次数"
value={formatNumber(stats?.total_conversations || 0)}
icon={MessageCircle}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
+6 -6
View File
@@ -75,9 +75,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
/>
)}
<aside className={`fixed inset-y-[3.5rem] left-0 z-50 w-56 border-r bg-background transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}>
<aside
className={`fixed inset-y-[3.5rem] left-0 z-50 w-56 border-r bg-background transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<nav className="p-3 space-y-1">
{adminNavItems.map((item) => (
<Link
@@ -86,9 +88,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
onClick={() => setSidebarOpen(false)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
pathname === item.href
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
pathname === item.href ? "bg-primary text-primary-foreground" : "hover:bg-muted",
)}
>
<item.icon className="h-4 w-4" />
+64 -11
View File
@@ -10,10 +10,38 @@ const modelGroups = [
description: "用于智能对话、公文写作、政策分析等核心功能",
icon: MessageSquare,
models: [
{ name: "qwen-plus", displayName: "通义千问-Plus", provider: "阿里云百炼", type: "对话", status: "active", desc: "主力模型,适用于复杂推理和长文本生成" },
{ name: "qwen-turbo", displayName: "通义千问-Turbo", provider: "阿里云百炼", type: "对话", status: "active", desc: "快速响应模型,适用于简单对话和问答" },
{ name: "qwen-max", displayName: "通义千问-Max", provider: "阿里云百炼", type: "对话", status: "standby", desc: "旗舰模型,适用于高精度分析场景" },
{ name: "qwen-long", displayName: "通义千问-Long", provider: "阿里云百炼", type: "对话", status: "standby", desc: "长上下文模型,支持百万Token输入" },
{
name: "qwen-plus",
displayName: "通义千问-Plus",
provider: "阿里云百炼",
type: "对话",
status: "active",
desc: "主力模型,适用于复杂推理和长文本生成",
},
{
name: "qwen-turbo",
displayName: "通义千问-Turbo",
provider: "阿里云百炼",
type: "对话",
status: "active",
desc: "快速响应模型,适用于简单对话和问答",
},
{
name: "qwen-max",
displayName: "通义千问-Max",
provider: "阿里云百炼",
type: "对话",
status: "standby",
desc: "旗舰模型,适用于高精度分析场景",
},
{
name: "qwen-long",
displayName: "通义千问-Long",
provider: "阿里云百炼",
type: "对话",
status: "standby",
desc: "长上下文模型,支持百万Token输入",
},
],
},
{
@@ -21,7 +49,14 @@ const modelGroups = [
description: "用于知识库文档检索和语义搜索",
icon: Cpu,
models: [
{ name: "text-embedding-v3", displayName: "通义文本向量V3", provider: "阿里云百炼", type: "嵌入", status: "active", desc: "1024维向量,高精度语义匹配" },
{
name: "text-embedding-v3",
displayName: "通义文本向量V3",
provider: "阿里云百炼",
type: "嵌入",
status: "active",
desc: "1024维向量,高精度语义匹配",
},
],
},
{
@@ -29,12 +64,22 @@ const modelGroups = [
description: "用于长文档分析、政策解读等场景",
icon: FileText,
models: [
{ name: "qwen-plus", displayName: "通义千问-Plus", provider: "阿里云百炼", type: "文档", status: "active", desc: "支持文档理解和内容提取" },
{
name: "qwen-plus",
displayName: "通义千问-Plus",
provider: "阿里云百炼",
type: "文档",
status: "active",
desc: "支持文档理解和内容提取",
},
],
},
];
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "outline" }> = {
const statusConfig: Record<
string,
{ label: string; variant: "default" | "secondary" | "outline" }
> = {
active: { label: "运行中", variant: "default" },
standby: { label: "待启用", variant: "secondary" },
inactive: { label: "未配置", variant: "outline" },
@@ -46,11 +91,15 @@ export default function ModelsPage() {
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1">使DashScope</p>
<p className="text-sm text-muted-foreground mt-1">
使DashScope
</p>
</div>
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-orange-500" />
<Badge variant="secondary" className="gap-1"></Badge>
<Badge variant="secondary" className="gap-1">
</Badge>
</div>
</div>
@@ -83,13 +132,17 @@ export default function ModelsPage() {
<tr key={model.name + model.type} className="border-t">
<td className="p-3">
<div className="font-medium">{model.displayName}</div>
<div className="text-xs text-muted-foreground font-mono">{model.name}</div>
<div className="text-xs text-muted-foreground font-mono">
{model.name}
</div>
</td>
<td className="p-3 text-muted-foreground">{model.provider}</td>
<td className="p-3">
<Badge variant="outline">{model.type}</Badge>
</td>
<td className="p-3 text-muted-foreground text-xs max-w-xs">{model.desc}</td>
<td className="p-3 text-muted-foreground text-xs max-w-xs">
{model.desc}
</td>
<td className="p-3">
<Badge variant={st.variant}>{st.label}</Badge>
</td>
+6 -2
View File
@@ -128,10 +128,14 @@ export default function ReviewsPage() {
rows={4}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setRejectDialog(null)}></Button>
<Button variant="outline" onClick={() => setRejectDialog(null)}>
</Button>
<Button
variant="destructive"
onClick={() => rejectDialog && reject.mutate({ id: rejectDialog, comment: rejectComment })}
onClick={() =>
rejectDialog && reject.mutate({ id: rejectDialog, comment: rejectComment })
}
disabled={!rejectComment.trim() || reject.isPending}
>
+48 -5
View File
@@ -6,6 +6,7 @@ import api from "@/lib/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
@@ -84,7 +85,51 @@ export default function UsersPage() {
/>
</div>
<div className="border rounded-lg overflow-hidden">
{data?.items == null && !search ? (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3"></th>
<th className="text-left p-3"></th>
<th className="text-left p-3"></th>
<th className="text-left p-3"></th>
<th className="text-left p-3"></th>
</tr>
</thead>
<tbody>
{Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-t">
<td className="p-3">
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-1">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-2 w-32" />
</div>
</div>
</td>
<td className="p-3">
<Skeleton className="h-5 w-16 rounded" />
</td>
<td className="p-3">
<Skeleton className="h-5 w-12 rounded" />
</td>
<td className="p-3">
<Skeleton className="h-3 w-8" />
</td>
<td className="p-3">
<div className="flex items-center gap-2">
<Skeleton className="h-7 w-20 rounded" />
<Skeleton className="h-7 w-12 rounded" />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
@@ -111,9 +156,7 @@ export default function UsersPage() {
</div>
</td>
<td className="p-3">
<Badge variant={roleColors[user.role]}>
{roleLabels[user.role]}
</Badge>
<Badge variant={roleColors[user.role]}>{roleLabels[user.role]}</Badge>
</td>
<td className="p-3">
<Badge variant={user.status === "active" ? "default" : "destructive"}>
@@ -154,7 +197,7 @@ export default function UsersPage() {
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+123 -147
View File
@@ -57,18 +57,12 @@ export default function LoginPage() {
const user = useAuthStore.getState().user;
// 平台管理员不绑定机构,登录后保留 super_admin 身份;
// 仅机构管理员在所选机构与自身归属不一致时才触发切换
if (
user &&
user.role === "admin" &&
user.org_id !== selectedOrg
) {
if (user && user.role === "admin" && user.org_id !== selectedOrg) {
await switchOrg(selectedOrg);
}
router.push(user?.role === "super_admin" ? "/platform/overview" : "/store");
} catch (err) {
setErrorMsg(
err instanceof Error ? err.message : "登录失败,请检查账号和密码"
);
setErrorMsg(err instanceof Error ? err.message : "登录失败,请检查账号和密码");
} finally {
setLoading(false);
}
@@ -85,162 +79,144 @@ export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-blue-950 via-blue-900 to-blue-800 px-6 py-12">
<div className="flex w-full max-w-[1000px] items-center gap-16 lg:gap-20">
{/* 左侧品牌区 - 桌面端显示 */}
<div className="hidden lg:flex lg:flex-1 flex-col">
<div className="max-w-md">
<div className="flex items-center gap-3 mb-8">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
<Shield className="h-8 w-8 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-white tracking-tight">
AI
</h1>
<p className="text-blue-200/80 text-sm mt-0.5">
·
</p>
</div>
</div>
<p className="text-blue-100/70 text-lg leading-relaxed mb-10">
AI
</p>
<div className="space-y-4">
{features.map(({ icon: Icon, text }) => (
<div key={text} className="flex items-center gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/10 backdrop-blur-sm">
<Icon className="h-5 w-5 text-blue-200" />
</div>
<span className="text-blue-100/90 text-base">{text}</span>
{/* 左侧品牌区 - 桌面端显示 */}
<div className="hidden lg:flex lg:flex-1 flex-col">
<div className="max-w-md">
<div className="flex items-center gap-3 mb-8">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
<Shield className="h-8 w-8 text-white" />
</div>
))}
<div>
<h1 className="text-3xl font-bold text-white tracking-tight">AI </h1>
<p className="text-blue-200/80 text-sm mt-0.5"> · </p>
</div>
</div>
<p className="text-blue-100/70 text-lg leading-relaxed mb-10">
AI
</p>
<div className="space-y-4">
{features.map(({ icon: Icon, text }) => (
<div key={text} className="flex items-center gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/10 backdrop-blur-sm">
<Icon className="h-5 w-5 text-blue-200" />
</div>
<span className="text-blue-100/90 text-base">{text}</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* 右侧登录区 */}
<div className="flex w-full lg:w-auto lg:shrink-0 items-center justify-center">
<div className="w-full max-w-[460px] rounded-2xl bg-white shadow-2xl border border-white/20 overflow-hidden">
{/* 移动端标题 - 仅在小屏显示 */}
<div className="lg:hidden bg-gradient-to-r from-blue-900 to-blue-800 px-8 pt-8 pb-6 text-center">
<Shield className="h-10 w-10 text-white mx-auto mb-3" />
<h1 className="text-xl font-bold text-white">AI </h1>
<p className="text-blue-200/80 text-sm mt-1">
·
</p>
</div>
{/* 右侧登录区 */}
<div className="flex w-full lg:w-auto lg:shrink-0 items-center justify-center">
<div className="w-full max-w-[460px] rounded-2xl bg-white shadow-2xl border border-white/20 overflow-hidden">
{/* 移动端标题 - 仅在小屏显示 */}
<div className="lg:hidden bg-gradient-to-r from-blue-900 to-blue-800 px-8 pt-8 pb-6 text-center">
<Shield className="h-10 w-10 text-white mx-auto mb-3" />
<h1 className="text-xl font-bold text-white">AI </h1>
<p className="text-blue-200/80 text-sm mt-1"> · </p>
</div>
{/* 表单区域 */}
<div className="px-8 sm:px-10 py-8 sm:py-10">
<h2 className="hidden lg:block text-2xl font-semibold text-gray-900 mb-1">
</h2>
<p className="hidden lg:block text-sm text-gray-500 mb-8">
</p>
{/* 表单区域 */}
<div className="px-8 sm:px-10 py-8 sm:py-10">
<h2 className="hidden lg:block text-2xl font-semibold text-gray-900 mb-1">
</h2>
<p className="hidden lg:block text-sm text-gray-500 mb-8"></p>
<form onSubmit={handleSubmit} className="space-y-5">
{errorMsg && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{errorMsg}
</div>
)}
<div className="space-y-2">
<Label htmlFor="org" className="text-sm font-medium text-gray-700">
<span className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-gray-400" />
</span>
</Label>
{orgs.length > 0 ? (
<select
id="org"
value={selectedOrg}
onChange={(e) => setSelectedOrg(e.target.value)}
className="flex h-11 w-full rounded-xl border border-gray-200 bg-gray-50/50 px-4 py-2 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
>
{orgs.map((org) => (
<option key={org.id} value={org.id}>
{org.short_name || org.name}
</option>
))}
</select>
) : (
<Input
disabled
placeholder="正在加载机构列表..."
className="h-11 rounded-xl"
/>
<form onSubmit={handleSubmit} className="space-y-5">
{errorMsg && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{errorMsg}
</div>
)}
</div>
<div className="space-y-2">
<Label
htmlFor="email"
className="text-sm font-medium text-gray-700"
<div className="space-y-2">
<Label htmlFor="org" className="text-sm font-medium text-gray-700">
<span className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-gray-400" />
</span>
</Label>
{orgs.length > 0 ? (
<select
id="org"
value={selectedOrg}
onChange={(e) => setSelectedOrg(e.target.value)}
className="flex h-11 w-full rounded-xl border border-gray-200 bg-gray-50/50 px-4 py-2 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
>
{orgs.map((org) => (
<option key={org.id} value={org.id}>
{org.short_name || org.name}
</option>
))}
</select>
) : (
<Input disabled placeholder="正在加载机构列表..." className="h-11 rounded-xl" />
)}
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium text-gray-700">
</Label>
<Input
id="email"
type="email"
placeholder="your@gov.cn"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
className="h-11 rounded-xl border-gray-200 bg-gray-50/50 px-4 focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium text-gray-700">
</Label>
<Input
id="password"
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className="h-11 rounded-xl border-gray-200 bg-gray-50/50 px-4 focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
/>
</div>
<Button
type="submit"
className="w-full h-12 text-base font-medium rounded-xl bg-blue-900 hover:bg-blue-800 transition-all duration-200 shadow-lg shadow-blue-900/25 hover:shadow-xl hover:shadow-blue-900/30 mt-2"
disabled={loading}
>
</Label>
<Input
id="email"
type="email"
placeholder="your@gov.cn"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
className="h-11 rounded-xl border-gray-200 bg-gray-50/50 px-4 focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
/>
</div>
{loading ? "登录中..." : "登 录"}
</Button>
</form>
<div className="space-y-2">
<Label
htmlFor="password"
className="text-sm font-medium text-gray-700"
<div className="mt-6 text-center text-sm text-gray-500">
{" "}
<Link
href="/register"
className="text-blue-600 font-medium hover:text-blue-700 underline-offset-4 hover:underline"
>
</Label>
<Input
id="password"
type="password"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className="h-11 rounded-xl border-gray-200 bg-gray-50/50 px-4 focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
/>
</Link>
</div>
<Button
type="submit"
className="w-full h-12 text-base font-medium rounded-xl bg-blue-900 hover:bg-blue-800 transition-all duration-200 shadow-lg shadow-blue-900/25 hover:shadow-xl hover:shadow-blue-900/30 mt-2"
disabled={loading}
>
{loading ? "登录中..." : "登 录"}
</Button>
</form>
<div className="mt-6 text-center text-sm text-gray-500">
{" "}
<Link
href="/register"
className="text-blue-600 font-medium hover:text-blue-700 underline-offset-4 hover:underline"
>
</Link>
</div>
<div className="mt-8 text-center text-xs text-gray-400 border-t border-gray-100 pt-5">
使 ·
<div className="mt-8 text-center text-xs text-gray-400 border-t border-gray-100 pt-5">
使 ·
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+6 -1
View File
@@ -39,7 +39,12 @@ export default function RegisterPage() {
setLoading(true);
try {
const res = await api.post<{
user: { id: string; name: string; email: string; role: "user" | "super_admin" | "admin" | "creator" };
user: {
id: string;
name: string;
email: string;
role: "user" | "super_admin" | "admin" | "creator";
};
access_token: string;
}>("/api/v1/auth/register", { name, email, password });
@@ -18,10 +18,7 @@ const ANALYSIS_SLUGS = new Set(["analysis-agent"]);
export default function AppPage() {
const { appId: slugOrId } = useParams<{ appId: string }>();
const isUUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
slugOrId
);
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(slugOrId);
const { data: appBySlug, isLoading: slugLoading } = useQuery({
queryKey: ["chatApp", slugOrId],
@@ -32,9 +29,7 @@ export default function AppPage() {
const { data: appById, isLoading: idLoading } = useQuery({
queryKey: ["chatAppById", slugOrId],
queryFn: async () => {
const results = await api.get<{ items: App[] }>(
`/api/v1/store/apps?page_size=50`
);
const results = await api.get<{ items: App[] }>(`/api/v1/store/apps?page_size=50`);
return results.items?.find((a) => a.id === slugOrId) || null;
},
enabled: isUUID,
+169 -168
View File
@@ -10,12 +10,7 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
@@ -35,16 +30,7 @@ import {
} from "@/components/ui/select";
import type { App, Category, KnowledgeBase } from "@/lib/types";
import { toast } from "sonner";
import {
Plus,
Pencil,
Trash2,
Send,
Eye,
BookOpen,
Settings2,
BarChart3,
} from "lucide-react";
import { Plus, Pencil, Trash2, Send, Eye, BookOpen, Settings2, BarChart3 } from "lucide-react";
const statusLabels: Record<string, string> = {
draft: "草稿",
@@ -54,10 +40,7 @@ const statusLabels: Record<string, string> = {
archived: "已归档",
};
const statusColors: Record<
string,
"default" | "secondary" | "destructive" | "outline"
> = {
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
pending_review: "secondary",
approved: "default",
@@ -68,11 +51,11 @@ const statusColors: Record<
import { appTypeConfigs } from "@/lib/app-type-config";
const appTypeLabels: Record<string, string> = Object.fromEntries(
Object.entries(appTypeConfigs).map(([k, v]) => [k, v.label])
Object.entries(appTypeConfigs).map(([k, v]) => [k, v.label]),
);
const appTypeColors: Record<string, string> = Object.fromEntries(
Object.entries(appTypeConfigs).map(([k, v]) => [k, v.badgeColor])
Object.entries(appTypeConfigs).map(([k, v]) => [k, v.badgeColor]),
);
const visibilityLabels: Record<string, string> = {
@@ -165,9 +148,7 @@ export default function CreatePage() {
id: string;
name: string;
} | null>(null);
const [activeTab, setActiveTab] = useState<
"basic" | "prompt" | "runtime" | "advanced"
>("basic");
const [activeTab, setActiveTab] = useState<"basic" | "prompt" | "runtime" | "advanced">("basic");
const { data: myApps } = useQuery({
queryKey: ["creatorApps"],
@@ -182,7 +163,9 @@ export default function CreatePage() {
const { data: knowledgeBases } = useQuery({
queryKey: ["knowledgeBases", user?.org_id],
queryFn: () =>
api.get<KnowledgeBase[]>(`/api/v1/knowledge${user?.org_id ? `?org_id=${user.org_id}` : ""}`).catch(() => []),
api
.get<KnowledgeBase[]>(`/api/v1/knowledge${user?.org_id ? `?org_id=${user.org_id}` : ""}`)
.catch(() => []),
});
const createApp = useMutation({
@@ -206,8 +189,7 @@ export default function CreatePage() {
});
const submitReview = useMutation({
mutationFn: (id: string) =>
api.post(`/api/v1/creator/apps/${id}/submit-review`),
mutationFn: (id: string) => api.post(`/api/v1/creator/apps/${id}/submit-review`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["creatorApps"] });
toast.success("已提交审核");
@@ -216,8 +198,7 @@ export default function CreatePage() {
});
const withdrawReview = useMutation({
mutationFn: (id: string) =>
api.post(`/api/v1/creator/apps/${id}/withdraw`),
mutationFn: (id: string) => api.post(`/api/v1/creator/apps/${id}/withdraw`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["creatorApps"] });
toast.success("已撤回审核");
@@ -226,8 +207,7 @@ export default function CreatePage() {
});
const requestDelist = useMutation({
mutationFn: (id: string) =>
api.post(`/api/v1/creator/apps/${id}/request-delist`),
mutationFn: (id: string) => api.post(`/api/v1/creator/apps/${id}/request-delist`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["creatorApps"] });
toast.success("已下架");
@@ -263,42 +243,39 @@ export default function CreatePage() {
setShowForm(true);
}, []);
const openEdit = useCallback(
async (appId: string) => {
try {
const app = await api.get<App>(`/api/v1/creator/apps/${appId}`);
const config = parseAppConfig(app.app_config);
setEditingId(appId);
setForm({
name: app.name || "",
description: app.description || "",
long_description: app.long_description || "",
system_prompt: (config.system_prompt as string) || "",
welcome_message: app.welcome_message || "",
suggested_prompts: parseSuggestedPrompts(app.suggested_prompts),
category_id: app.category_id || "",
visibility: app.visibility || "private",
app_type: app.dify_app_type || "chatbot",
model: (config.model as string) || "",
temperature: app.temperature ?? 0.7,
max_tokens: app.max_tokens ?? 4096,
knowledge_base_id: app.knowledge_base_id || "",
tools: Array.isArray(config.tools) ? (config.tools as string[]) : [],
data_sources: Array.isArray(config.data_sources) ? (config.data_sources as string[]) : [],
template_set: (config.template_set as string) || "",
input_label: (config.input_label as string) || "",
output_label: (config.output_label as string) || "",
input_placeholder: (config.input_placeholder as string) || "",
format_templates: (config.format_templates as AppForm["format_templates"]) || {},
});
setActiveTab("basic");
setShowForm(true);
} catch {
toast.error("获取应用详情失败");
}
},
[]
);
const openEdit = useCallback(async (appId: string) => {
try {
const app = await api.get<App>(`/api/v1/creator/apps/${appId}`);
const config = parseAppConfig(app.app_config);
setEditingId(appId);
setForm({
name: app.name || "",
description: app.description || "",
long_description: app.long_description || "",
system_prompt: (config.system_prompt as string) || "",
welcome_message: app.welcome_message || "",
suggested_prompts: parseSuggestedPrompts(app.suggested_prompts),
category_id: app.category_id || "",
visibility: app.visibility || "private",
app_type: app.dify_app_type || "chatbot",
model: (config.model as string) || "",
temperature: app.temperature ?? 0.7,
max_tokens: app.max_tokens ?? 4096,
knowledge_base_id: app.knowledge_base_id || "",
tools: Array.isArray(config.tools) ? (config.tools as string[]) : [],
data_sources: Array.isArray(config.data_sources) ? (config.data_sources as string[]) : [],
template_set: (config.template_set as string) || "",
input_label: (config.input_label as string) || "",
output_label: (config.output_label as string) || "",
input_placeholder: (config.input_placeholder as string) || "",
format_templates: (config.format_templates as AppForm["format_templates"]) || {},
});
setActiveTab("basic");
setShowForm(true);
} catch {
toast.error("获取应用详情失败");
}
}, []);
const addPrompt = useCallback(() => {
const v = promptInput.trim();
@@ -331,10 +308,7 @@ export default function CreatePage() {
return (
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-6">
{/* 删除确认 */}
<AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
@@ -359,9 +333,7 @@ export default function CreatePage() {
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1">
{isAdmin
? "管理全部AI应用(管理员模式)"
: "创建、编辑和管理你的AI应用"}
{isAdmin ? "管理全部AI应用(管理员模式)" : "创建、编辑和管理你的AI应用"}
</p>
</div>
<Button onClick={openCreate} className="gap-1.5">
@@ -379,22 +351,17 @@ export default function CreatePage() {
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{myApps?.map((app) => (
<Card
key={app.id}
className="hover:shadow-md transition-shadow group"
>
<Card key={app.id} className="hover:shadow-md transition-shadow group">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base truncate pr-2">
{app.name}
</CardTitle>
<CardTitle className="text-base truncate pr-2">{app.name}</CardTitle>
<div className="flex items-center gap-1.5 shrink-0">
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${appTypeColors[app.dify_app_type || "chatbot"]}`}>
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full ${appTypeColors[app.dify_app_type || "chatbot"]}`}
>
{appTypeLabels[app.dify_app_type || "chatbot"]}
</span>
<Badge variant={statusColors[app.status]}>
{statusLabels[app.status]}
</Badge>
<Badge variant={statusColors[app.status]}>{statusLabels[app.status]}</Badge>
</div>
</div>
</CardHeader>
@@ -446,9 +413,7 @@ export default function CreatePage() {
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() =>
setDeleteTarget({ id: app.id, name: app.name })
}
onClick={() => setDeleteTarget({ id: app.id, name: app.name })}
>
<Trash2 className="h-3 w-3" />
</Button>
@@ -468,11 +433,12 @@ export default function CreatePage() {
{/* 创建/编辑对话框 */}
<Dialog open={showForm} onOpenChange={(open) => !open && closeForm()}>
<DialogContent className="sm:max-w-5xl max-h-[85vh] overflow-hidden flex flex-col" style={{ maxWidth: "min(1024px, calc(100vw - 2rem))" }}>
<DialogContent
className="sm:max-w-5xl max-h-[85vh] overflow-hidden flex flex-col"
style={{ maxWidth: "min(1024px, calc(100vw - 2rem))" }}
>
<DialogHeader className="shrink-0">
<DialogTitle>
{editingId ? "编辑应用" : "创建AI应用"}
</DialogTitle>
<DialogTitle>{editingId ? "编辑应用" : "创建AI应用"}</DialogTitle>
</DialogHeader>
{/* Tab切换 */}
@@ -520,9 +486,7 @@ export default function CreatePage() {
</Label>
<Input
value={form.name}
onChange={(e) =>
setForm({ ...form, name: e.target.value })
}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="例如:政策法规问答"
/>
</div>
@@ -530,9 +494,7 @@ export default function CreatePage() {
<Label></Label>
<Input
value={form.description}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="一句话描述应用功能"
/>
</div>
@@ -540,9 +502,7 @@ export default function CreatePage() {
<Label></Label>
<Textarea
value={form.long_description}
onChange={(e) =>
setForm({ ...form, long_description: e.target.value })
}
onChange={(e) => setForm({ ...form, long_description: e.target.value })}
placeholder="详细介绍应用的功能、使用场景等"
rows={3}
/>
@@ -552,9 +512,7 @@ export default function CreatePage() {
<Label></Label>
<Select
value={form.app_type}
onValueChange={(v) =>
v && setForm({ ...form, app_type: v })
}
onValueChange={(v) => v && setForm({ ...form, app_type: v })}
>
<SelectTrigger>
<span>{appTypeLabels[form.app_type] || "对话型"}</span>
@@ -572,9 +530,7 @@ export default function CreatePage() {
<Label></Label>
<Select
value={form.visibility}
onValueChange={(v) =>
v && setForm({ ...form, visibility: v })
}
onValueChange={(v) => v && setForm({ ...form, visibility: v })}
>
<SelectTrigger>
<span>{visibilityLabels[form.visibility] || "仅自己"}</span>
@@ -593,7 +549,11 @@ export default function CreatePage() {
<Label></Label>
{(() => {
const currentCat = categories?.find((c) => c.id === form.category_id);
const catDisplay = currentCat ? currentCat.name : form.category_id ? "加载中..." : "未分类";
const catDisplay = currentCat
? currentCat.name
: form.category_id
? "加载中..."
: "未分类";
return (
<Select
value={form.category_id || "__none__"}
@@ -626,10 +586,14 @@ export default function CreatePage() {
<strong>{appTypeLabels[form.app_type]}</strong>
</p>
<p className="text-xs text-muted-foreground">
{form.app_type === "chatbot" && "多轮对话,支持知识库检索(RAG)、上下文记忆。适合:政策问答、咨询服务。"}
{form.app_type === "agent" && "可调用工具和数据源,支持多步推理和报告生成。适合:数据分析、综合研判。"}
{form.app_type === "completion" && "单次输入生成结果,无对话上下文。适合:文档摘要、翻译、格式转换。"}
{form.app_type === "workflow" && "按预定流程分步处理,用户依次输入。适合:项目评估、政策分析。"}
{form.app_type === "chatbot" &&
"多轮对话,支持知识库检索(RAG)、上下文记忆。适合:政策问答、咨询服务。"}
{form.app_type === "agent" &&
"可调用工具和数据源,支持多步推理和报告生成。适合:数据分析、综合研判。"}
{form.app_type === "completion" &&
"单次输入生成结果,无对话上下文。适合:文档摘要、翻译、格式转换。"}
{form.app_type === "workflow" &&
"按预定流程分步处理,用户依次输入。适合:项目评估、政策分析。"}
</p>
</div>
<div className="space-y-2">
@@ -639,17 +603,15 @@ export default function CreatePage() {
</p>
<Textarea
value={form.system_prompt}
onChange={(e) =>
setForm({ ...form, system_prompt: e.target.value })
}
onChange={(e) => setForm({ ...form, system_prompt: e.target.value })}
placeholder={
form.app_type === "chatbot"
? "你是一位AI助手,专业、准确地解答相关问题..."
: form.app_type === "agent"
? "你是一位智能助手,可以调用工具完成复杂任务..."
: form.app_type === "completion"
? "根据用户输入的主题,生成一份规范的公文..."
: "按照以下步骤处理用户的请求..."
? "你是一位智能助手,可以调用工具完成复杂任务..."
: form.app_type === "completion"
? "根据用户输入的主题,生成一份规范的公文..."
: "按照以下步骤处理用户的请求..."
}
rows={8}
className="font-mono text-sm"
@@ -660,9 +622,7 @@ export default function CreatePage() {
<Label></Label>
<Textarea
value={form.welcome_message}
onChange={(e) =>
setForm({ ...form, welcome_message: e.target.value })
}
onChange={(e) => setForm({ ...form, welcome_message: e.target.value })}
placeholder="用户打开应用时看到的第一条消息"
rows={3}
/>
@@ -671,16 +631,12 @@ export default function CreatePage() {
{(form.app_type === "chatbot" || form.app_type === "agent") && (
<div className="space-y-2">
<Label></Label>
<p className="text-xs text-muted-foreground">
</p>
<p className="text-xs text-muted-foreground"></p>
<div className="flex gap-2">
<Input
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && (e.preventDefault(), addPrompt())
}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addPrompt())}
placeholder="输入推荐问题,按回车添加"
/>
<Button
@@ -721,8 +677,8 @@ export default function CreatePage() {
const displayValue = currentKb
? `${currentKb.name}${currentKb.document_count} 篇文档)`
: form.knowledge_base_id
? `加载中... (${form.knowledge_base_id.slice(0, 8)}...)`
: "不关联知识库";
? `加载中... (${form.knowledge_base_id.slice(0, 8)}...)`
: "不关联知识库";
return (
<Select
value={form.knowledge_base_id || "__none__"}
@@ -759,10 +715,14 @@ export default function CreatePage() {
</p>
<p className="text-xs text-blue-600/80 dark:text-blue-400/80">
{form.app_type === "chatbot" && "对话型应用通过知识库RAG和上下文记忆提供智能问答,可在「提示词与知识库」中配置。"}
{form.app_type === "agent" && "智能体应用通过调用工具和数据源完成复杂任务,请配置可用的工具和数据源。"}
{form.app_type === "completion" && "补全型应用将用户输入转换为结构化输出,请配置输入输出标签。"}
{form.app_type === "workflow" && "工作流应用按预定流程分步处理,请配置处理步骤和参数。"}
{form.app_type === "chatbot" &&
"对话型应用通过知识库RAG和上下文记忆提供智能问答,可在「提示词与知识库」中配置。"}
{form.app_type === "agent" &&
"智能体应用通过调用工具和数据源完成复杂任务,请配置可用的工具和数据源。"}
{form.app_type === "completion" &&
"补全型应用将用户输入转换为结构化输出,请配置输入输出标签。"}
{form.app_type === "workflow" &&
"工作流应用按预定流程分步处理,请配置处理步骤和参数。"}
</p>
</div>
@@ -774,7 +734,11 @@ export default function CreatePage() {
{
group: "经济分析类",
tools: [
{ id: "数据检索", label: "数据检索", desc: "查询区县经济指标等结构化数据" },
{
id: "数据检索",
label: "数据检索",
desc: "查询区县经济指标等结构化数据",
},
{ id: "趋势分析", label: "趋势分析", desc: "分析多年数据变化趋势" },
{ id: "对比分析", label: "对比分析", desc: "多区县/多维度横向对比" },
{ id: "图表生成", label: "图表生成", desc: "生成数据可视化图表" },
@@ -792,7 +756,11 @@ export default function CreatePage() {
{
group: "通用工具",
tools: [
{ id: "政策检索", label: "政策法规检索", desc: "全文检索国家和地方政策法规" },
{
id: "政策检索",
label: "政策法规检索",
desc: "全文检索国家和地方政策法规",
},
{ id: "公文生成", label: "公文生成", desc: "按公文模板生成规范公文" },
{ id: "报告生成", label: "报告生成", desc: "生成综合分析报告" },
{ id: "报告汇总", label: "报告汇总", desc: "汇总多份子报告为总报告" },
@@ -804,7 +772,11 @@ export default function CreatePage() {
{
group: "经济数据",
sources: [
{ id: "区域经济数据", label: "区域经济数据", desc: "各区县GDP、财政收入等指标" },
{
id: "区域经济数据",
label: "区域经济数据",
desc: "各区县GDP、财政收入等指标",
},
{ id: "统计年鉴", label: "统计年鉴", desc: "年度统计数据汇总" },
{ id: "政府工作报告", label: "政府工作报告", desc: "年度政府工作报告" },
],
@@ -813,7 +785,11 @@ export default function CreatePage() {
group: "干部管理",
sources: [
{ id: "干部信息库", label: "干部信息库", desc: "干部基本信息和履历" },
{ id: "绩效考核数据", label: "绩效考核数据", desc: "年度绩效考核评分数据" },
{
id: "绩效考核数据",
label: "绩效考核数据",
desc: "年度绩效考核评分数据",
},
{ id: "民主测评数据", label: "民主测评数据", desc: "民主测评投票结果" },
{ id: "培训记录", label: "培训记录", desc: "干部教育培训记录" },
],
@@ -840,11 +816,15 @@ export default function CreatePage() {
<>
<div className="space-y-2">
<Label></Label>
<p className="text-xs text-muted-foreground">使</p>
<p className="text-xs text-muted-foreground">
使
</p>
<div className="space-y-3">
{toolGroups.map((group) => (
<div key={group.group}>
<p className="text-xs font-medium text-muted-foreground mb-1.5 pl-0.5">{group.group}</p>
<p className="text-xs font-medium text-muted-foreground mb-1.5 pl-0.5">
{group.group}
</p>
<div className="grid grid-cols-2 gap-2">
{group.tools.map((tool) => (
<label
@@ -868,7 +848,9 @@ export default function CreatePage() {
/>
<div>
<div className="text-sm font-medium">{tool.label}</div>
<div className="text-xs text-muted-foreground">{tool.desc}</div>
<div className="text-xs text-muted-foreground">
{tool.desc}
</div>
</div>
</label>
))}
@@ -880,11 +862,15 @@ export default function CreatePage() {
<div className="space-y-2">
<Label></Label>
<p className="text-xs text-muted-foreground">访使</p>
<p className="text-xs text-muted-foreground">
访使
</p>
<div className="space-y-3">
{dataSourceGroups.map((group) => (
<div key={group.group}>
<p className="text-xs font-medium text-muted-foreground mb-1.5 pl-0.5">{group.group}</p>
<p className="text-xs font-medium text-muted-foreground mb-1.5 pl-0.5">
{group.group}
</p>
<div className="grid grid-cols-2 gap-2">
{group.sources.map((ds) => (
<label
@@ -908,7 +894,9 @@ export default function CreatePage() {
/>
<div>
<div className="text-sm font-medium">{ds.label}</div>
<div className="text-xs text-muted-foreground">{ds.desc}</div>
<div className="text-xs text-muted-foreground">
{ds.desc}
</div>
</div>
</label>
))}
@@ -920,7 +908,9 @@ export default function CreatePage() {
<div className="space-y-2">
<Label></Label>
<p className="text-xs text-muted-foreground">使</p>
<p className="text-xs text-muted-foreground">
使
</p>
<Select
value={form.template_set || "__none__"}
onValueChange={(v) =>
@@ -928,12 +918,18 @@ export default function CreatePage() {
}
>
<SelectTrigger>
<span>{form.template_set ? templateLabels[form.template_set] || form.template_set : "选择模板集"}</span>
<span>
{form.template_set
? templateLabels[form.template_set] || form.template_set
: "选择模板集"}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">使</SelectItem>
{Object.entries(templateLabels).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
<SelectItem key={k} value={k}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -991,7 +987,9 @@ export default function CreatePage() {
<div className="flex items-center justify-between">
<div>
<Label></Label>
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Button
type="button"
@@ -1065,7 +1063,9 @@ export default function CreatePage() {
className="h-7 text-xs"
/>
<div>
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-xs text-muted-foreground mb-1">
</p>
<Input
value={tpl.sections.join("、")}
onChange={(e) =>
@@ -1113,10 +1113,14 @@ export default function CreatePage() {
<>
<div className="rounded-lg bg-muted/40 p-3 mb-2">
<p className="text-xs text-muted-foreground">
{form.app_type === "chatbot" && "对话型推荐 通义千问-Plus(16K输出,支持知识库RAG)或 通义千问-Max(高质量推理)"}
{form.app_type === "agent" && "智能体型推荐 通义千问-Plus(16K输出,支持长报告生成"}
{form.app_type === "completion" && "补全型推荐 通义千问-Turbo(快速响应)或 通义千问-Plus(高质量输出)"}
{form.app_type === "workflow" && "工作流型推荐 通义千问-Max(精确推理)或 通义千问-Plus(详细输出"}
{form.app_type === "chatbot" &&
"对话型推荐 通义千问-Plus(16K输出,支持知识库RAG)或 通义千问-Max(高质量推理"}
{form.app_type === "agent" &&
"智能体型推荐 通义千问-Plus(16K输出,支持长报告生成"}
{form.app_type === "completion" &&
"补全型推荐 通义千问-Turbo(快速响应)或 通义千问-Plus(高质量输出)"}
{form.app_type === "workflow" &&
"工作流型推荐 通义千问-Max(精确推理)或 通义千问-Plus(详细输出)"}
</p>
</div>
<div className="space-y-2">
@@ -1136,12 +1140,18 @@ export default function CreatePage() {
}
>
<SelectTrigger>
<span>{form.model ? (modelLabels[form.model] || form.model) : "系统默认(通义千问-Plus"}</span>
<span>
{form.model
? modelLabels[form.model] || form.model
: "系统默认(通义千问-Plus"}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">-Plus</SelectItem>
{Object.entries(modelLabels).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
<SelectItem key={k} value={k}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -1175,9 +1185,7 @@ export default function CreatePage() {
</div>
<div className="space-y-2">
<Label></Label>
<p className="text-xs text-muted-foreground">
Token数
</p>
<p className="text-xs text-muted-foreground">Token数</p>
<Input
type="number"
value={form.max_tokens}
@@ -1199,15 +1207,8 @@ export default function CreatePage() {
<Button variant="outline" onClick={closeForm}>
</Button>
<Button
onClick={handleSave}
disabled={!form.name.trim() || isPending}
>
{isPending
? "保存中..."
: editingId
? "保存修改"
: "创建应用"}
<Button onClick={handleSave} disabled={!form.name.trim() || isPending}>
{isPending ? "保存中..." : editingId ? "保存修改" : "创建应用"}
</Button>
</div>
</DialogContent>
+45 -29
View File
@@ -12,15 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { toast } from "sonner";
import { useAuthStore } from "@/stores/auth";
import {
BookOpen,
Upload,
FileText,
Trash2,
Plus,
Database,
Search,
} from "lucide-react";
import { BookOpen, Upload, FileText, Trash2, Plus, Database, Search } from "lucide-react";
interface KnowledgeBase {
id: string;
@@ -64,9 +56,12 @@ function getStatusBadge(status: string) {
function getVisibilityLabel(v: string) {
switch (v) {
case "public": return "全单位";
case "department": return "本科室";
default: return "私有";
case "public":
return "全单位";
case "department":
return "本科室";
default:
return "私有";
}
}
@@ -147,16 +142,19 @@ export default function KnowledgePage() {
fileInputRef.current?.click();
}, []);
const onFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
uploadDoc.mutate(file);
e.target.value = "";
}
}, [uploadDoc]);
const onFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
uploadDoc.mutate(file);
e.target.value = "";
}
},
[uploadDoc],
);
const filteredKBs = knowledgeBases?.filter(
(kb) => !searchTerm || kb.name.includes(searchTerm) || kb.description?.includes(searchTerm)
(kb) => !searchTerm || kb.name.includes(searchTerm) || kb.description?.includes(searchTerm),
);
return (
@@ -229,7 +227,9 @@ export default function KnowledgePage() {
<BookOpen className="h-4 w-4 text-blue-600" />
{kb.name}
</CardTitle>
<Badge variant="secondary" className="text-xs">{kb.document_count} </Badge>
<Badge variant="secondary" className="text-xs">
{kb.document_count}
</Badge>
</div>
</CardHeader>
<CardContent>
@@ -238,7 +238,9 @@ export default function KnowledgePage() {
</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">{getVisibilityLabel(kb.visibility)}</Badge>
<Badge variant="outline" className="text-xs">
{getVisibilityLabel(kb.visibility)}
</Badge>
<span className="text-xs text-muted-foreground">
{new Date(kb.updated_at).toLocaleDateString("zh-CN")}
</span>
@@ -275,7 +277,9 @@ export default function KnowledgePage() {
<p className="text-sm text-muted-foreground mt-1">{selectedKB.description}</p>
<div className="flex items-center gap-2 mt-2">
<Badge variant="outline">{getVisibilityLabel(selectedKB.visibility)}</Badge>
<span className="text-xs text-muted-foreground">{selectedKB.document_count} </span>
<span className="text-xs text-muted-foreground">
{selectedKB.document_count}
</span>
</div>
</div>
<div>
@@ -286,7 +290,11 @@ export default function KnowledgePage() {
accept=".txt,.md,.pdf,.docx,.csv,.xlsx"
onChange={onFileChange}
/>
<Button onClick={handleFileUpload} disabled={uploadDoc.isPending} className="gap-2">
<Button
onClick={handleFileUpload}
disabled={uploadDoc.isPending}
className="gap-2"
>
<Upload className="h-4 w-4" />
{uploadDoc.isPending ? "上传中..." : "上传文档"}
</Button>
@@ -320,10 +328,16 @@ export default function KnowledgePage() {
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
<span className="truncate max-w-[200px]">{doc.filename}</span>
</td>
<td className="p-3 text-muted-foreground uppercase text-xs">{doc.file_type || "-"}</td>
<td className="p-3 text-muted-foreground">{formatFileSize(doc.file_size)}</td>
<td className="p-3 text-muted-foreground uppercase text-xs">
{doc.file_type || "-"}
</td>
<td className="p-3 text-muted-foreground">
{formatFileSize(doc.file_size)}
</td>
<td className="p-3">{getStatusBadge(doc.status)}</td>
<td className="p-3 text-muted-foreground text-xs">{new Date(doc.created_at).toLocaleString("zh-CN")}</td>
<td className="p-3 text-muted-foreground text-xs">
{new Date(doc.created_at).toLocaleString("zh-CN")}
</td>
<td className="p-3">
<Button
variant="ghost"
@@ -347,7 +361,7 @@ export default function KnowledgePage() {
<CardContent className="py-20 text-center text-muted-foreground">
<Database className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
<p className="text-sm"></p>
<p className="text-xs mt-1">"新建知识库"</p>
<p className="text-xs mt-1"></p>
</CardContent>
</Card>
)}
@@ -403,7 +417,9 @@ export default function KnowledgePage() {
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setShowCreate(false)}></Button>
<Button variant="outline" onClick={() => setShowCreate(false)}>
</Button>
<Button
onClick={() => createKB.mutate()}
disabled={!form.name.trim() || createKB.isPending}
@@ -1,5 +1,6 @@
"use client";
import React from "react";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -13,25 +14,11 @@ import { Separator } from "@/components/ui/separator";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { toast } from "sonner";
import {
ArrowLeft,
Heart,
MessageSquare,
Star,
Users,
Clock,
Play,
} from "lucide-react";
import { ArrowLeft, Heart, MessageSquare, Star, Users, Clock, Play } from "lucide-react";
import { getCategoryIcon, getCategoryColor } from "@/lib/category-config";
import { getAppTypeConfig } from "@/lib/app-type-config";
function StarRatingDisplay({
rating,
count,
}: {
rating: number;
count: number;
}) {
function StarRatingDisplay({ rating, count }: { rating: number; count: number }) {
const stars = Math.round(rating);
return (
<div className="flex items-center gap-1.5">
@@ -39,18 +26,12 @@ function StarRatingDisplay({
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={`h-4 w-4 ${
i < stars
? "fill-amber-400 text-amber-400"
: "text-gray-300"
}`}
className={`h-4 w-4 ${i < stars ? "fill-amber-400 text-amber-400" : "text-gray-300"}`}
/>
))}
</div>
<span className="text-sm font-medium">{rating.toFixed(1)}</span>
<span className="text-sm text-muted-foreground">
({count} )
</span>
<span className="text-sm text-muted-foreground">({count} )</span>
</div>
);
}
@@ -61,8 +42,7 @@ function RatingInput({ appId }: { appId: string }) {
const queryClient = useQueryClient();
const rate = useMutation({
mutationFn: (score: number) =>
api.post(`/api/v1/apps/${appId}/rating`, { score }),
mutationFn: (score: number) => api.post(`/api/v1/apps/${appId}/rating`, { score }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["appDetail"] });
toast.success("评分成功");
@@ -94,9 +74,7 @@ function RatingInput({ appId }: { appId: string }) {
</button>
))}
{selected > 0 && (
<span className="text-sm text-muted-foreground ml-1">
{selected}
</span>
<span className="text-sm text-muted-foreground ml-1"> {selected} </span>
)}
</div>
);
@@ -144,26 +122,20 @@ export default function AppDetailPage() {
return (
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-20 text-center">
<p className="text-lg text-muted-foreground"></p>
<Button
variant="ghost"
className="mt-4"
onClick={() => router.push("/store")}
>
<Button variant="ghost" className="mt-4" onClick={() => router.push("/store")}>
</Button>
</div>
);
}
const CategoryIcon = getCategoryIcon(app.category_slug);
const CategoryIconComponent = getCategoryIcon(app.category_slug);
const categoryColor = getCategoryColor(app.category_slug);
const isFavorited = app.is_favorited;
const typeConfig = getAppTypeConfig(app.dify_app_type);
const TypeIcon = typeConfig.icon;
const TypeIconComponent = typeConfig.icon;
const longDesc = app.long_description
? app.long_description.replace(/\\n/g, "\n")
: null;
const longDesc = app.long_description ? app.long_description.replace(/\\n/g, "\n") : null;
return (
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-8">
@@ -183,18 +155,20 @@ export default function AppDetailPage() {
<div
className={`flex h-16 w-16 items-center justify-center rounded-2xl ${categoryColor} shrink-0`}
>
<CategoryIcon className="h-8 w-8" />
{CategoryIconComponent
? React.createElement(CategoryIconComponent, { className: "h-8 w-8" })
: null}
</div>
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-bold">{app.name}</h1>
<p className="text-muted-foreground mt-1.5 leading-relaxed">
{app.description}
</p>
<p className="text-muted-foreground mt-1.5 leading-relaxed">{app.description}</p>
<div className="flex flex-wrap items-center gap-3 mt-3">
<Badge variant="secondary">{app.category_name || "其他"}</Badge>
<Badge className={`${typeConfig.badgeColor} gap-1`}>
<TypeIcon className="h-3 w-3" />
{TypeIconComponent
? React.createElement(TypeIconComponent, { className: "h-3 w-3" })
: null}
{typeConfig.label}
</Badge>
<span className="flex items-center gap-1 text-sm text-muted-foreground">
@@ -209,18 +183,14 @@ export default function AppDetailPage() {
)}
{app.published_at && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
v{app.version}
<Clock className="h-3.5 w-3.5" />v{app.version}
</span>
)}
</div>
{app.avg_rating > 0 && (
<div className="mt-3">
<StarRatingDisplay
rating={app.avg_rating}
count={app.rating_count}
/>
<StarRatingDisplay rating={app.avg_rating} count={app.rating_count} />
</div>
)}
</div>
@@ -241,11 +211,7 @@ export default function AppDetailPage() {
className="gap-2"
onClick={() => toggleFav.mutate(!!isFavorited)}
>
<Heart
className={`h-4 w-4 ${
isFavorited ? "fill-red-500 text-red-500" : ""
}`}
/>
<Heart className={`h-4 w-4 ${isFavorited ? "fill-red-500 text-red-500" : ""}`} />
{isFavorited ? "已收藏" : "收藏"}
</Button>
</div>
@@ -261,7 +227,8 @@ export default function AppDetailPage() {
<span className="inline-block w-1 h-5 bg-blue-800 rounded-full" />
</h2>
<div className="prose prose-sm max-w-none dark:prose-invert
<div
className="prose prose-sm max-w-none dark:prose-invert
prose-p:text-muted-foreground prose-p:leading-relaxed prose-p:my-2
prose-headings:text-foreground prose-headings:font-semibold
[&_h2]:text-base [&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:flex [&_h2]:items-center [&_h2]:gap-2
@@ -277,10 +244,9 @@ export default function AppDetailPage() {
[&_li]:border [&_li]:border-blue-100 [&_li]:dark:border-blue-900/30
[&_li:before]:content-['✦'] [&_li:before]:text-blue-600 [&_li:before]:text-xs [&_li:before]:mt-0.5 [&_li:before]:shrink-0
[&_strong]:text-blue-800 [&_strong]:dark:text-blue-300
">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{longDesc}
</ReactMarkdown>
"
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{longDesc}</ReactMarkdown>
</div>
</CardContent>
</Card>
@@ -28,12 +28,13 @@ export default function CategoryPage() {
const orgId = user?.org_id || "";
const orgParam = orgId ? `&org_id=${orgId}` : "";
const [sort, setSort] = useState<SortOption>(
(searchParams.get("sort") as SortOption) || "popular"
(searchParams.get("sort") as SortOption) || "popular",
);
const { data: categories } = useQuery({
queryKey: ["categories", orgId],
queryFn: () => api.get<Category[]>(`/api/v1/store/categories?${orgId ? `org_id=${orgId}` : ""}`),
queryFn: () =>
api.get<Category[]>(`/api/v1/store/categories?${orgId ? `org_id=${orgId}` : ""}`),
});
const currentCategory = categories?.find((c) => c.slug === slug);
@@ -42,7 +43,7 @@ export default function CategoryPage() {
queryKey: ["category-apps", slug, sort, orgId],
queryFn: () =>
api.get<{ items: App[] }>(
`/api/v1/store/apps?category=${encodeURIComponent(slug)}&sort=${sort}&page_size=50${orgParam}`
`/api/v1/store/apps?category=${encodeURIComponent(slug)}&sort=${sort}&page_size=50${orgParam}`,
),
});
@@ -57,13 +58,9 @@ export default function CategoryPage() {
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">
{currentCategory?.name || slug}
</h1>
<h1 className="text-2xl font-bold">{currentCategory?.name || slug}</h1>
{currentCategory?.description && (
<p className="text-sm text-muted-foreground mt-0.5">
{currentCategory.description}
</p>
<p className="text-sm text-muted-foreground mt-0.5">{currentCategory.description}</p>
)}
</div>
</div>
+7 -8
View File
@@ -76,16 +76,15 @@ export default function StorePage() {
const { data: searchResults, isLoading: searchLoading } = useQuery({
queryKey: ["search", query, orgId],
queryFn: () => api.get<{ items: App[] }>(`/api/v1/store/apps?q=${encodeURIComponent(query)}&${orgParam}`),
queryFn: () =>
api.get<{ items: App[] }>(`/api/v1/store/apps?q=${encodeURIComponent(query)}&${orgParam}`),
enabled: !!query,
});
if (query) {
return (
<div className="mx-auto w-full max-w-7xl px-3 md:px-6 lg:px-8 py-4 md:py-6">
<h1 className="text-xl font-bold mb-4">
&ldquo;{query}&rdquo;
</h1>
<h1 className="text-xl font-bold mb-4">&ldquo;{query}&rdquo;</h1>
{searchLoading ? (
<AppGridSkeleton count={8} />
) : searchResults?.items?.length ? (
@@ -95,9 +94,7 @@ export default function StorePage() {
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
</div>
<div className="text-center py-12 text-muted-foreground"></div>
)}
</div>
);
@@ -124,7 +121,9 @@ export default function StorePage() {
<SectionHeader title="应用分类" icon={LayoutGrid} />
<div className="flex flex-wrap gap-2">
<Link href="/store">
<Badge variant="default" className="cursor-pointer"></Badge>
<Badge variant="default" className="cursor-pointer">
</Badge>
</Link>
{categories?.map((cat) => (
<Link key={cat.id} href={`/store/category/${cat.slug}`}>
+37
View File
@@ -0,0 +1,37 @@
"use client";
import { FileQuestion, Home, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
export default function GlobalNotFound() {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body>
<div className="flex min-h-screen flex-col items-center justify-center px-4">
<div className="flex flex-col items-center text-center max-w-md">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-6">
<FileQuestion className="h-8 w-8 text-muted-foreground" />
</div>
<h2 className="text-xl font-semibold mb-2"></h2>
<p className="text-sm text-muted-foreground mb-6">
访
</p>
<div className="flex gap-3">
<Link href="/store">
<Button className="gap-2">
<Home className="h-4 w-4" />
</Button>
</Link>
<Button variant="outline" className="gap-2" onClick={() => window.history.back()}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</body>
</html>
);
}
+69 -7
View File
@@ -17,6 +17,7 @@ import {
import { toast } from "sonner";
import { Star, Archive } from "lucide-react";
import { Pagination } from "@/components/ui/pagination";
import { Skeleton } from "@/components/ui/skeleton";
const statusLabels: Record<string, string> = {
draft: "草稿",
@@ -99,7 +100,13 @@ export default function PlatformAppsPage() {
<div className="flex flex-wrap items-center gap-3 mb-4">
<Select value={orgFilter} onValueChange={resetOrg}>
<SelectTrigger className="w-40">
<span>{orgFilter === "all" ? "全部机构" : (orgs?.find((o) => o.id === orgFilter)?.short_name || orgs?.find((o) => o.id === orgFilter)?.name || orgFilter)}</span>
<span>
{orgFilter === "all"
? "全部机构"
: orgs?.find((o) => o.id === orgFilter)?.short_name ||
orgs?.find((o) => o.id === orgFilter)?.name ||
orgFilter}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
@@ -112,7 +119,9 @@ export default function PlatformAppsPage() {
</Select>
<Select value={statusFilter} onValueChange={resetStatus}>
<SelectTrigger className="w-32">
<span>{statusFilter === "all" ? "全部状态" : (statusLabels[statusFilter] || statusFilter)}</span>
<span>
{statusFilter === "all" ? "全部状态" : statusLabels[statusFilter] || statusFilter}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
@@ -132,7 +141,55 @@ export default function PlatformAppsPage() {
onChange={setPage}
/>
<div className="border rounded-lg overflow-hidden">
{data?.items == null && !statusFilter && orgFilter === "all" ? (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3"></th>
<th className="text-left p-3"></th>
<th className="text-left p-3"></th>
<th className="text-left p-3"></th>
<th className="text-left p-3">使</th>
<th className="text-left p-3"></th>
</tr>
</thead>
<tbody>
{Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-t">
<td className="p-3">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded" />
<div className="space-y-1">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-2 w-40" />
</div>
</div>
</td>
<td className="p-3">
<Skeleton className="h-5 w-16 rounded" />
</td>
<td className="p-3">
<Skeleton className="h-3 w-16" />
</td>
<td className="p-3">
<Skeleton className="h-5 w-16 rounded" />
</td>
<td className="p-3">
<Skeleton className="h-3 w-12" />
</td>
<td className="p-3">
<div className="flex gap-1">
<Skeleton className="h-6 w-14 rounded" />
<Skeleton className="h-6 w-16 rounded" />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
@@ -149,11 +206,17 @@ export default function PlatformAppsPage() {
<tr key={app.id} className="border-t hover:bg-muted/30">
<td className="p-3">
<div className="flex items-center gap-2">
<AppIcon iconUrl={app.icon_url} size={20} className="shrink-0 text-muted-foreground" />
<AppIcon
iconUrl={app.icon_url}
size={20}
className="shrink-0 text-muted-foreground"
/>
<div>
<div className="font-medium flex items-center gap-1">
{app.name}
{app.is_featured && <Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />}
{app.is_featured && (
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
)}
</div>
<div className="text-xs text-muted-foreground line-clamp-1">
{app.description}
@@ -206,8 +269,7 @@ export default function PlatformAppsPage() {
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+49 -4
View File
@@ -6,6 +6,7 @@ import api from "@/lib/api";
import type { PlatformAuditLog, PlatformOrg } from "@/lib/types";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
@@ -66,7 +67,13 @@ export default function PlatformAuditPage() {
/>
<Select value={orgFilter} onValueChange={resetOrg}>
<SelectTrigger className="w-40">
<span>{orgFilter === "all" ? "全部机构" : (orgs?.find((o) => o.id === orgFilter)?.short_name || orgs?.find((o) => o.id === orgFilter)?.name || orgFilter)}</span>
<span>
{orgFilter === "all"
? "全部机构"
: orgs?.find((o) => o.id === orgFilter)?.short_name ||
orgs?.find((o) => o.id === orgFilter)?.name ||
orgFilter}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
@@ -86,7 +93,46 @@ export default function PlatformAuditPage() {
onChange={setPage}
/>
<div className="border rounded-lg overflow-hidden">
{data?.items == null && !search && orgFilter === "all" ? (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3"></th>
<th className="text-left p-3"></th>
<th className="text-left p-3"></th>
<th className="text-left p-3"></th>
<th className="text-left p-3"></th>
<th className="text-left p-3">IP</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 10 }).map((_, i) => (
<tr key={i} className="border-t">
<td className="p-3">
<Skeleton className="h-3 w-32" />
</td>
<td className="p-3">
<Skeleton className="h-5 w-16 rounded" />
</td>
<td className="p-3">
<Skeleton className="h-8 w-24" />
</td>
<td className="p-3">
<Skeleton className="h-3 w-20" />
</td>
<td className="p-3">
<Skeleton className="h-3 w-24" />
</td>
<td className="p-3">
<Skeleton className="h-3 w-20" />
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
@@ -127,8 +173,7 @@ export default function PlatformAuditPage() {
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+1 -3
View File
@@ -98,9 +98,7 @@ export default function PlatformLayout({ children }: { children: React.ReactNode
onClick={() => setSidebarOpen(false)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
pathname === item.href
? "bg-amber-600 text-white"
: "hover:bg-muted",
pathname === item.href ? "bg-amber-600 text-white" : "hover:bg-muted",
)}
>
<item.icon className="h-4 w-4" />
+8 -27
View File
@@ -9,12 +9,7 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
@@ -120,9 +115,7 @@ export default function PlatformOrgsPage() {
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1">
/
</p>
<p className="text-sm text-muted-foreground mt-1">/</p>
</div>
<Button onClick={() => setEditing({ ...emptyForm })} className="gap-2">
<Plus className="h-4 w-4" />
@@ -130,19 +123,11 @@ export default function PlatformOrgsPage() {
</Button>
</div>
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={orgs?.length ?? 0}
onChange={setPage}
/>
<Pagination page={page} pageSize={PAGE_SIZE} total={orgs?.length ?? 0} onChange={setPage} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{pagedOrgs?.map((org) => (
<div
key={org.id}
className="border rounded-lg p-4 hover:shadow-sm transition-shadow"
>
<div key={org.id} className="border rounded-lg p-4 hover:shadow-sm transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className="h-9 w-9 rounded-md bg-amber-50 flex items-center justify-center">
@@ -158,9 +143,7 @@ export default function PlatformOrgsPage() {
</Badge>
</div>
{org.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-3">
{org.description}
</p>
<p className="text-xs text-muted-foreground line-clamp-2 mb-3">{org.description}</p>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground mb-3">
<span>{org.user_count} </span>
@@ -274,10 +257,7 @@ export default function PlatformOrgsPage() {
<Button variant="outline" onClick={() => setEditing(null)}>
</Button>
<Button
onClick={handleSubmit}
disabled={create.isPending || update.isPending}
>
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
{editing?.id ? "保存" : "创建"}
</Button>
</div>
@@ -291,7 +271,8 @@ export default function PlatformOrgsPage() {
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget?.name}
{deleteTarget?.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
+16 -11
View File
@@ -9,12 +9,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
@@ -43,7 +38,7 @@ const emptyForm: ProviderForm = {
name: "",
base_url: "",
api_key: "",
models: '[]',
models: "[]",
is_active: true,
priority: 0,
};
@@ -130,7 +125,7 @@ export default function PlatformProvidersPage() {
const testConnection = useMutation({
mutationFn: (id: string) =>
api.post<{ success: boolean; message?: string; response?: string }>(
`/api/v1/platform/providers/${id}/test`
`/api/v1/platform/providers/${id}/test`,
),
onSuccess: (data) => {
if (data.success) {
@@ -211,7 +206,11 @@ export default function PlatformProvidersPage() {
) : (
<XCircle className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className={p.is_active ? "text-green-700 dark:text-green-400" : "text-muted-foreground"}>
<span
className={
p.is_active ? "text-green-700 dark:text-green-400" : "text-muted-foreground"
}
>
{p.is_active ? "已启用" : "已停用"}
</span>
</div>
@@ -221,7 +220,11 @@ export default function PlatformProvidersPage() {
<div className="flex flex-wrap gap-1 mb-3 min-h-[1.5rem]">
{Array.isArray(p.models) && p.models.length > 0 ? (
p.models.slice(0, 6).map((m) => (
<Badge key={m.name} variant="outline" className="text-[10px] py-0 px-1.5 font-mono">
<Badge
key={m.name}
variant="outline"
className="text-[10px] py-0 px-1.5 font-mono"
>
{m.display_name || m.name}
</Badge>
))
@@ -229,7 +232,9 @@ export default function PlatformProvidersPage() {
<span className="text-xs text-muted-foreground italic"></span>
)}
{Array.isArray(p.models) && p.models.length > 6 && (
<span className="text-[10px] text-muted-foreground self-center">+{p.models.length - 6} </span>
<span className="text-[10px] text-muted-foreground self-center">
+{p.models.length - 6}
</span>
)}
</div>
<div className="flex gap-1">
+51 -41
View File
@@ -8,21 +8,12 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { toast } from "sonner";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { Pagination } from "@/components/ui/pagination";
import { Skeleton } from "@/components/ui/skeleton";
interface QuotaForm {
id?: string;
@@ -81,9 +72,10 @@ export default function PlatformQuotasPage() {
api.get<{ items: PlatformUser[] }>("/api/v1/platform/users?page=1&page_size=200"),
});
const allModels = providersData
?.flatMap((p) => (Array.isArray(p.models) ? p.models : []))
.filter((m, i, arr) => arr.findIndex((x) => x.name === m.name) === i) ?? [];
const allModels =
providersData
?.flatMap((p) => (Array.isArray(p.models) ? p.models : []))
.filter((m, i, arr) => arr.findIndex((x) => x.name === m.name) === i) ?? [];
const pagedQuotas = quotas?.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
@@ -133,12 +125,7 @@ export default function PlatformQuotasPage() {
</Button>
</div>
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={quotas?.length ?? 0}
onChange={setPage}
/>
<Pagination page={page} pageSize={PAGE_SIZE} total={quotas?.length ?? 0} onChange={setPage} />
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
@@ -155,7 +142,39 @@ export default function PlatformQuotasPage() {
</tr>
</thead>
<tbody>
{pagedQuotas?.length ? (
{quotas == null ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-t">
<td className="p-3">
<Skeleton className="h-5 w-12 rounded" />
</td>
<td className="p-3">
<Skeleton className="h-3 w-24" />
</td>
<td className="p-3">
<Skeleton className="h-3 w-20" />
</td>
<td className="p-3">
<Skeleton className="h-3 w-12" />
</td>
<td className="p-3">
<Skeleton className="h-3 w-12" />
</td>
<td className="p-3">
<Skeleton className="h-3 w-12" />
</td>
<td className="p-3">
<Skeleton className="h-5 w-10 rounded" />
</td>
<td className="p-3">
<div className="flex gap-1">
<Skeleton className="h-6 w-12 rounded" />
<Skeleton className="h-6 w-12 rounded" />
</div>
</td>
</tr>
))
) : pagedQuotas?.length ? (
pagedQuotas.map((q) => (
<tr key={q.id} className="border-t">
<td className="p-3">
@@ -236,8 +255,7 @@ export default function PlatformQuotasPage() {
<Select
value={editing?.target_type || "global"}
onValueChange={(v) =>
v &&
setEditing({ ...editing!, target_type: v as QuotaForm["target_type"] })
v && setEditing({ ...editing!, target_type: v as QuotaForm["target_type"] })
}
>
<SelectTrigger>
@@ -260,9 +278,10 @@ export default function PlatformQuotasPage() {
<SelectTrigger>
<span>
{editing?.target_id
? (usersData?.items?.find((u) => u.id === editing.target_id)?.name +
? usersData?.items?.find((u) => u.id === editing.target_id)?.name +
" (" +
(usersData?.items?.find((u) => u.id === editing.target_id)?.email ?? "") + ")")
(usersData?.items?.find((u) => u.id === editing.target_id)?.email ?? "") +
")"
: "请选择用户"}
</span>
</SelectTrigger>
@@ -298,8 +317,8 @@ export default function PlatformQuotasPage() {
<SelectTrigger>
<span>
{editing?.model_name
? (allModels.find((m) => m.name === editing.model_name)?.display_name ||
editing.model_name)
? allModels.find((m) => m.name === editing.model_name)?.display_name ||
editing.model_name
: "所有模型"}
</span>
</SelectTrigger>
@@ -319,9 +338,7 @@ export default function PlatformQuotasPage() {
<Input
type="number"
value={editing?.daily_token_limit || ""}
onChange={(e) =>
setEditing({ ...editing!, daily_token_limit: e.target.value })
}
onChange={(e) => setEditing({ ...editing!, daily_token_limit: e.target.value })}
placeholder="留空=不限"
/>
</div>
@@ -330,9 +347,7 @@ export default function PlatformQuotasPage() {
<Input
type="number"
value={editing?.monthly_token_limit || ""}
onChange={(e) =>
setEditing({ ...editing!, monthly_token_limit: e.target.value })
}
onChange={(e) => setEditing({ ...editing!, monthly_token_limit: e.target.value })}
placeholder="留空=不限"
/>
</div>
@@ -342,9 +357,7 @@ export default function PlatformQuotasPage() {
<Input
type="number"
value={editing?.daily_request_limit || ""}
onChange={(e) =>
setEditing({ ...editing!, daily_request_limit: e.target.value })
}
onChange={(e) => setEditing({ ...editing!, daily_request_limit: e.target.value })}
placeholder="留空=不限"
/>
</div>
@@ -364,10 +377,7 @@ export default function PlatformQuotasPage() {
<Button variant="outline" onClick={() => setEditing(null)}>
</Button>
<Button
onClick={() => editing && upsert.mutate(editing)}
disabled={upsert.isPending}
>
<Button onClick={() => editing && upsert.mutate(editing)} disabled={upsert.isPending}>
</Button>
</div>
+11 -14
View File
@@ -15,12 +15,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { toast } from "sonner";
import { Building2 } from "lucide-react";
import { Pagination } from "@/components/ui/pagination";
@@ -120,9 +115,7 @@ export default function PlatformUsersPage() {
<div>
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold"></h1>
<span className="text-xs text-muted-foreground">
{data?.total ?? 0}
</span>
<span className="text-xs text-muted-foreground"> {data?.total ?? 0} </span>
</div>
<div className="flex flex-wrap items-center gap-3 mb-4">
@@ -134,7 +127,13 @@ export default function PlatformUsersPage() {
/>
<Select value={orgFilter} onValueChange={resetOrg}>
<SelectTrigger className="w-40">
<span>{orgFilter === "all" ? "全部机构" : (orgs?.find((o) => o.id === orgFilter)?.short_name || orgs?.find((o) => o.id === orgFilter)?.name || orgFilter)}</span>
<span>
{orgFilter === "all"
? "全部机构"
: orgs?.find((o) => o.id === orgFilter)?.short_name ||
orgs?.find((o) => o.id === orgFilter)?.name ||
orgFilter}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
@@ -147,7 +146,7 @@ export default function PlatformUsersPage() {
</Select>
<Select value={roleFilter} onValueChange={resetRole}>
<SelectTrigger className="w-36">
<span>{roleFilter === "all" ? "全部角色" : (roleLabels[roleFilter] || roleFilter)}</span>
<span>{roleFilter === "all" ? "全部角色" : roleLabels[roleFilter] || roleFilter}</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
@@ -211,9 +210,7 @@ export default function PlatformUsersPage() {
{u.status === "active" ? "正常" : "禁用"}
</Badge>
</td>
<td className="p-3 text-muted-foreground text-xs">
{u.login_count}
</td>
<td className="p-3 text-muted-foreground text-xs">{u.login_count} </td>
<td className="p-3">
<div className="flex items-center gap-1.5">
<Select