Initial commit: GovAI 政务AI平台

This commit is contained in:
freedakgmail
2026-06-15 23:48:37 +08:00
commit 0f490f72a9
245 changed files with 51669 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import api from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface DailyStat {
date: string;
count: number;
total_tokens: number;
}
interface TopApp {
name: string;
count: number;
}
interface UsageData {
daily: DailyStat[];
top_apps: TopApp[];
}
export default function AnalyticsPage() {
const [days, setDays] = useState("7");
const { data } = useQuery<UsageData>({
queryKey: ["usageAnalytics", days],
queryFn: () => api.get(`/api/v1/admin/analytics/usage?days=${days}`),
});
const maxCount = Math.max(...(data?.daily?.map((d) => d.count) || [1]));
const maxTokens = Math.max(...(data?.daily?.map((d) => d.total_tokens) || [1]));
const maxAppCount = Math.max(...(data?.top_apps?.map((a) => a.count) || [1]));
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">使</h1>
<Select value={days} onValueChange={(v) => v && setDays(v)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7"> 7 </SelectItem>
<SelectItem value="14"> 14 </SelectItem>
<SelectItem value="30"> 30 </SelectItem>
<SelectItem value="90"> 90 </SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Daily Usage Bar Chart */}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{data?.daily?.length ? (
<div className="space-y-2">
{data.daily.map((d) => (
<div key={d.date} className="flex items-center gap-2 text-sm">
<span className="w-20 text-muted-foreground text-xs">{d.date.slice(5)}</span>
<div className="flex-1 bg-muted rounded-full h-5 overflow-hidden">
<div
className="bg-blue-500 h-full rounded-full transition-all"
style={{ width: `${(d.count / maxCount) * 100}%` }}
/>
</div>
<span className="w-12 text-right text-xs">{d.count}</span>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground"></div>
)}
</CardContent>
</Card>
{/* Daily Tokens Bar Chart */}
<Card>
<CardHeader>
<CardTitle className="text-base"> Token </CardTitle>
</CardHeader>
<CardContent>
{data?.daily?.length ? (
<div className="space-y-2">
{data.daily.map((d) => (
<div key={d.date} className="flex items-center gap-2 text-sm">
<span className="w-20 text-muted-foreground text-xs">{d.date.slice(5)}</span>
<div className="flex-1 bg-muted rounded-full h-5 overflow-hidden">
<div
className="bg-purple-500 h-full rounded-full transition-all"
style={{ width: `${(d.total_tokens / maxTokens) * 100}%` }}
/>
</div>
<span className="w-16 text-right text-xs">
{d.total_tokens >= 1000
? (d.total_tokens / 1000).toFixed(1) + "K"
: d.total_tokens}
</span>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground"></div>
)}
</CardContent>
</Card>
{/* Top Apps */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-base"> TOP 10</CardTitle>
</CardHeader>
<CardContent>
{data?.top_apps?.length ? (
<div className="space-y-3">
{data.top_apps.map((app, i) => (
<div key={app.name} className="flex items-center gap-3">
<span className="w-6 text-center text-sm font-bold text-muted-foreground">
{i + 1}
</span>
<span className="w-32 text-sm truncate">{app.name}</span>
<div className="flex-1 bg-muted rounded-full h-6 overflow-hidden">
<div
className="bg-gradient-to-r from-blue-500 to-purple-500 h-full rounded-full transition-all flex items-center justify-end px-2"
style={{ width: `${(app.count / maxAppCount) * 100}%` }}
>
<span className="text-xs text-white font-medium">{app.count}</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground"></div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
+267
View File
@@ -0,0 +1,267 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
import { AppIcon } from "@/lib/app-icon";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { Archive, RotateCcw } from "lucide-react";
interface AdminApp {
id: string;
name: string;
description: string;
icon_url?: string;
dify_app_type?: string;
status: string;
visibility: string;
usage_count: number;
avg_rating: number;
creator_name: string;
created_at: string;
}
const statusLabels: Record<string, string> = {
draft: "草稿",
pending_review: "审核中",
approved: "已上架",
rejected: "已驳回",
archived: "已归档",
};
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
pending_review: "secondary",
approved: "default",
rejected: "destructive",
archived: "outline",
};
const appTypeLabels: Record<string, string> = {
chatbot: "对话型",
completion: "文本生成",
workflow: "工作流",
agent: "智能体",
};
const visibilityLabels: Record<string, string> = {
public: "全单位",
department: "部门",
private: "私有",
};
export default function AdminAppsPage() {
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [actionTarget, setActionTarget] = useState<{
type: "delist" | "relist";
id: string;
name: string;
} | null>(null);
const { data } = useQuery({
queryKey: ["adminApps", search, statusFilter],
queryFn: () => {
const params = new URLSearchParams();
if (search) params.set("q", search);
if (statusFilter !== "all") params.set("status", statusFilter);
return api.get<{ items: AdminApp[] }>(`/api/v1/admin/apps?${params}`);
},
});
const delistApp = useMutation({
mutationFn: (id: string) => api.post(`/api/v1/admin/apps/${id}/delist`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["adminApps"] });
toast.success("已撤架");
setActionTarget(null);
},
onError: (err: Error) => toast.error(err.message),
});
const relistApp = useMutation({
mutationFn: (id: string) => api.post(`/api/v1/admin/apps/${id}/relist`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["adminApps"] });
toast.success("已重新上架");
setActionTarget(null);
},
onError: (err: Error) => toast.error(err.message),
});
return (
<div>
{/* 操作确认弹窗 */}
<AlertDialog
open={!!actionTarget}
onOpenChange={(open) => !open && setActionTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{actionTarget?.type === "delist" ? "确认撤架" : "确认重新上架"}
</AlertDialogTitle>
<AlertDialogDescription>
{actionTarget?.type === "delist"
? `确定要将应用「${actionTarget?.name}」从应用商店撤架吗?撤架后用户将无法使用。`
: `确定要将应用「${actionTarget?.name}」重新上架到应用商店吗?`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
className={
actionTarget?.type === "delist"
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: ""
}
onClick={() => {
if (actionTarget?.type === "delist") {
delistApp.mutate(actionTarget.id);
} else if (actionTarget?.type === "relist") {
relistApp.mutate(actionTarget!.id);
}
}}
>
{actionTarget?.type === "delist" ? "确认撤架" : "确认上架"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<h1 className="text-2xl font-bold mb-6"></h1>
<div className="flex items-center gap-3 mb-4">
<Input
placeholder="搜索应用..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-64"
/>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v ?? "all")}>
<SelectTrigger className="w-32">
<span>
{statusFilter === "all" ? "全部" : statusLabels[statusFilter] || statusFilter}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="pending_review"></SelectItem>
<SelectItem value="approved"></SelectItem>
<SelectItem value="rejected"></SelectItem>
<SelectItem value="archived"></SelectItem>
</SelectContent>
</Select>
</div>
<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>
<th className="text-left p-3"></th>
<th className="text-left p-3"></th>
</tr>
</thead>
<tbody>
{data?.items?.map((app) => (
<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" />
<div>
<div className="font-medium">{app.name}</div>
<div className="text-xs text-muted-foreground line-clamp-1">
{app.description}
</div>
</div>
</div>
</td>
<td className="p-3">
<span className="text-xs text-muted-foreground">
{appTypeLabels[app.dify_app_type || "chatbot"] || "对话型"}
</span>
</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>
</td>
<td className="p-3 text-muted-foreground">
{visibilityLabels[app.visibility] || app.visibility}
</td>
<td className="p-3 text-muted-foreground">{app.usage_count}</td>
<td className="p-3">
{app.avg_rating > 0 ? (
<span className="text-yellow-500"> {app.avg_rating.toFixed(1)}</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
<td className="p-3">
<div className="flex gap-1.5">
{app.status === "approved" && (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs text-destructive hover:text-destructive"
onClick={() =>
setActionTarget({ type: "delist", id: app.id, name: app.name })
}
>
<Archive className="h-3 w-3" />
</Button>
)}
{app.status === "archived" && (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs text-primary"
onClick={() =>
setActionTarget({ type: "relist", id: app.id, name: app.name })
}
>
<RotateCcw className="h-3 w-3" />
</Button>
)}
{app.status !== "approved" && app.status !== "archived" && (
<span className="text-xs text-muted-foreground">-</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import api from "@/lib/api";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface AuditLog {
id: string;
user_id: string;
user_name?: string;
action: string;
resource_type: string;
resource_id: string;
details: string;
ip_address: string;
created_at: string;
}
const actionColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
POST: "default",
PUT: "secondary",
DELETE: "destructive",
GET: "outline",
};
export default function AuditPage() {
const [search, setSearch] = useState("");
const [actionFilter, setActionFilter] = useState("all");
const { data } = useQuery({
queryKey: ["auditLogs", search, actionFilter],
queryFn: () => {
const params = new URLSearchParams();
if (search) params.set("q", search);
if (actionFilter !== "all") params.set("action", actionFilter);
return api.get<{ items: AuditLog[] }>(`/api/v1/admin/audit-logs?${params}`);
},
});
return (
<div>
<h1 className="text-2xl font-bold mb-6"></h1>
<div className="flex items-center gap-3 mb-4">
<Input
placeholder="搜索操作..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-64"
/>
<Select value={actionFilter} onValueChange={(v) => setActionFilter(v ?? "all")}>
<SelectTrigger className="w-36">
<SelectValue placeholder="操作类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="POST"></SelectItem>
<SelectItem value="PUT"></SelectItem>
<SelectItem value="DELETE"></SelectItem>
</SelectContent>
</Select>
</div>
<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">IP</th>
<th className="text-left p-3"></th>
</tr>
</thead>
<tbody>
{data?.items?.map((log) => (
<tr key={log.id} className="border-t">
<td className="p-3 text-muted-foreground whitespace-nowrap">
{new Date(log.created_at).toLocaleString("zh-CN")}
</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>
</td>
<td className="p-3 text-muted-foreground">
{log.resource_type}/{log.resource_id.slice(0, 8)}
</td>
<td className="p-3 text-muted-foreground font-mono text-xs">{log.ip_address}</td>
<td className="p-3 text-xs text-muted-foreground max-w-xs truncate">
{log.details}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
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";
interface OverviewStats {
total_users: number;
total_apps: number;
active_users: number;
total_conversations: number;
monthly_tokens: number;
monthly_cost: number;
}
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">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50">
<Icon className="h-5 w-5 text-blue-700" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
</CardContent>
</Card>
);
}
function formatNumber(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
return String(n);
}
export default function DashboardPage() {
const { data: stats, isLoading } = useQuery({
queryKey: ["adminOverview"],
queryFn: () => api.get<OverviewStats>("/api/v1/admin/analytics/overview"),
});
if (isLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-6"></h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28" />
))}
</div>
</div>
);
}
return (
<div>
<h1 className="text-2xl font-bold mb-6"></h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<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} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<StatCard
title="本月 Token 消耗"
value={formatNumber(stats?.monthly_tokens || 0)}
icon={Target}
/>
<StatCard
title="本月估算成本"
value={`$${(stats?.monthly_cost || 0).toFixed(2)}`}
icon={DollarSign}
/>
</div>
<div className="mt-8">
<Card>
<CardHeader>
<CardTitle>使</CardTitle>
</CardHeader>
<CardContent className="h-64 flex items-center justify-center text-muted-foreground">
</CardContent>
</Card>
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useEffect } from "react";
import { AlertCircle, RotateCcw, BarChart3 } from "lucide-react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[AdminError]", error);
}, [error]);
return (
<div className="flex min-h-[60vh] 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-destructive/10 mb-6">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<h2 className="text-xl font-semibold mb-2"></h2>
<p className="text-sm text-muted-foreground mb-6">
</p>
{error.digest && (
<p className="text-xs text-muted-foreground/60 mb-4 font-mono">
{error.digest}
</p>
)}
<div className="flex gap-3">
<Button variant="outline" onClick={reset} className="gap-2">
<RotateCcw className="h-4 w-4" />
</Button>
<Link href="/dashboard">
<Button className="gap-2">
<BarChart3 className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useAuthStore } from "@/stores/auth";
import { Header } from "@/components/layout/header";
import Link from "next/link";
import { cn } from "@/lib/utils";
import {
BarChart3,
TrendingUp,
AppWindow,
CheckCircle,
Users,
Bot,
ClipboardList,
ShieldCheck,
Menu,
X,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
const adminNavItems: { href: string; label: string; icon: LucideIcon }[] = [
{ href: "/dashboard", label: "数据总览", icon: BarChart3 },
{ href: "/analytics", label: "使用分析", icon: TrendingUp },
{ href: "/apps", label: "应用管理", icon: AppWindow },
{ href: "/reviews", label: "审核队列", icon: CheckCircle },
{ href: "/users", label: "人员管理", icon: Users },
{ href: "/models", label: "模型管理", icon: Bot },
{ href: "/audit", label: "审计日志", icon: ClipboardList },
{ href: "/security", label: "安全管理", icon: ShieldCheck },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isLoading = useAuthStore((s) => s.isLoading);
const router = useRouter();
const pathname = usePathname();
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
if (!isLoading && (!isAuthenticated || !["admin", "super_admin"].includes(user?.role || ""))) {
router.replace("/store");
}
}, [isAuthenticated, isLoading, user, router]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (!isAuthenticated) return null;
return (
<div className="flex min-h-screen flex-col">
<Header />
<div className="flex flex-1 relative">
{/* 手机端侧边栏切换按钮 */}
<button
className="md:hidden fixed bottom-4 right-4 z-50 p-3 rounded-full bg-primary text-primary-foreground shadow-lg"
onClick={() => setSidebarOpen(!sidebarOpen)}
>
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
{/* 手机端遮罩层 */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/40 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<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
key={item.href}
href={item.href}
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"
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</nav>
</aside>
<main className="flex-1 p-3 md:p-6 min-w-0">{children}</main>
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Loader2 } from "lucide-react";
export default function AdminLoading() {
return (
<div className="flex h-[calc(100vh-3.5rem)] items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">...</p>
</div>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Bot, Cpu, MessageSquare, FileText, Sparkles } from "lucide-react";
const modelGroups = [
{
title: "对话模型",
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输入" },
],
},
{
title: "向量模型",
description: "用于知识库文档检索和语义搜索",
icon: Cpu,
models: [
{ name: "text-embedding-v3", displayName: "通义文本向量V3", provider: "阿里云百炼", type: "嵌入", status: "active", desc: "1024维向量,高精度语义匹配" },
],
},
{
title: "文档理解",
description: "用于长文档分析、政策解读等场景",
icon: FileText,
models: [
{ name: "qwen-plus", displayName: "通义千问-Plus", provider: "阿里云百炼", type: "文档", status: "active", desc: "支持文档理解和内容提取" },
],
},
];
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "outline" }> = {
active: { label: "运行中", variant: "default" },
standby: { label: "待启用", variant: "secondary" },
inactive: { label: "未配置", variant: "outline" },
};
export default function ModelsPage() {
return (
<div>
<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>
</div>
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-orange-500" />
<Badge variant="secondary" className="gap-1"></Badge>
</div>
</div>
<div className="space-y-4">
{modelGroups.map((group) => (
<Card key={group.title}>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<group.icon className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-base">{group.title}</CardTitle>
</div>
<CardDescription>{group.description}</CardDescription>
</CardHeader>
<CardContent>
<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>
{group.models.map((model) => {
const st = statusConfig[model.status] || statusConfig.inactive;
return (
<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>
</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">
<Badge variant={st.variant}>{st.label}</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
))}
</div>
<Card className="mt-4">
<CardContent className="pt-6">
<div className="grid sm:grid-cols-3 gap-4 text-sm">
<div className="p-3 rounded-lg bg-muted/40">
<div className="text-muted-foreground">API </div>
<div className="font-mono text-xs mt-1">dashscope.aliyuncs.com</div>
</div>
<div className="p-3 rounded-lg bg-muted/40">
<div className="text-muted-foreground"></div>
<div className="font-mono text-xs mt-1">OpenAI Compatible</div>
</div>
<div className="p-3 rounded-lg bg-muted/40">
<div className="text-muted-foreground"></div>
<div className="text-xs mt-1 text-green-600 font-medium"></div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { toast } from "sonner";
interface Review {
id: string;
app_id: string;
version: string;
submit_comment?: string;
submitted_at: string;
app_name?: string;
app_description?: string;
app_icon?: string;
submitter_name: string;
}
export default function ReviewsPage() {
const queryClient = useQueryClient();
const [rejectDialog, setRejectDialog] = useState<string | null>(null);
const [rejectComment, setRejectComment] = useState("");
const { data: reviews } = useQuery({
queryKey: ["pendingReviews"],
queryFn: () => api.get<Review[]>("/api/v1/admin/reviews"),
});
const approve = useMutation({
mutationFn: (id: string) => api.post(`/api/v1/admin/reviews/${id}/approve`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["pendingReviews"] });
toast.success("已通过审核");
},
onError: (err: Error) => toast.error(err.message),
});
const reject = useMutation({
mutationFn: ({ id, comment }: { id: string; comment: string }) =>
api.post(`/api/v1/admin/reviews/${id}/reject`, { comment }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["pendingReviews"] });
setRejectDialog(null);
setRejectComment("");
toast.success("已驳回");
},
onError: (err: Error) => toast.error(err.message),
});
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold"></h1>
<Badge variant="secondary">{reviews?.length || 0} </Badge>
</div>
{reviews?.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
</CardContent>
</Card>
) : (
<div className="space-y-4">
{reviews?.map((review) => (
<Card key={review.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className="text-3xl">{review.app_icon || "🤖"}</span>
<div>
<CardTitle className="text-base">{review.app_name}</CardTitle>
<p className="text-sm text-muted-foreground">{review.app_description}</p>
</div>
</div>
<Badge variant="outline">v{review.version}</Badge>
</div>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground mb-4">
<span>: {review.submitter_name}</span>
<span className="mx-2">|</span>
<span>: {new Date(review.submitted_at).toLocaleString("zh-CN")}</span>
{review.submit_comment && (
<>
<span className="mx-2">|</span>
<span>: {review.submit_comment}</span>
</>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => approve.mutate(review.id)}
disabled={approve.isPending}
>
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setRejectDialog(review.id)}
>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<Dialog open={!!rejectDialog} onOpenChange={() => setRejectDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<Textarea
placeholder="请填写驳回原因..."
value={rejectComment}
onChange={(e) => setRejectComment(e.target.value)}
rows={4}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setRejectDialog(null)}></Button>
<Button
variant="destructive"
onClick={() => rejectDialog && reject.mutate({ id: rejectDialog, comment: rejectComment })}
disabled={!rejectComment.trim() || reject.isPending}
>
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ShieldCheck, Key, Lock, Eye, UserCheck, Globe } from "lucide-react";
const securityItems = [
{
title: "登录安全",
icon: Key,
status: "已启用",
items: [
{ label: "密码强度要求", value: "中等(8位以上,含字母和数字)" },
{ label: "登录失败锁定", value: "连续5次失败后锁定30分钟" },
{ label: "会话超时", value: "24小时" },
{ label: "JWT Token 有效期", value: "24小时" },
],
},
{
title: "访问控制",
icon: Lock,
status: "已启用",
items: [
{ label: "基于角色的访问控制(RBAC)", value: "已启用" },
{ label: "角色层级", value: "超级管理员 > 管理员 > 创作者 > 普通用户" },
{ label: "多租户数据隔离", value: "已启用(按机构隔离)" },
{ label: "API 接口鉴权", value: "Bearer Token" },
],
},
{
title: "审计与监控",
icon: Eye,
status: "已启用",
items: [
{ label: "操作审计日志", value: "已启用(记录所有管理操作)" },
{ label: "登录日志", value: "已启用(记录登录次数和时间)" },
{ label: "API 调用记录", value: "已启用" },
{ label: "日志保留期限", value: "永久" },
],
},
{
title: "用户认证",
icon: UserCheck,
status: "密码认证",
items: [
{ label: "认证方式", value: "本地密码认证" },
{ label: "LDAP/AD 集成", value: "未配置" },
{ label: "OAuth2/SSO", value: "未配置" },
{ label: "双因素认证(2FA)", value: "未启用" },
],
},
{
title: "网络安全",
icon: Globe,
status: "已启用",
items: [
{ label: "HTTPS/TLS", value: "已启用(Let's Encrypt" },
{ label: "CORS 策略", value: "仅允许同源请求" },
{ label: "请求频率限制", value: "未配置" },
{ label: "IP 白名单", value: "未配置" },
],
},
];
export default function SecurityPage() {
return (
<div>
<div className="flex items-center gap-3 mb-6">
<ShieldCheck className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-bold"></h1>
</div>
<div className="grid gap-4">
{securityItems.map((section) => (
<Card key={section.title}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<section.icon className="h-4 w-4 text-muted-foreground" />
{section.title}
</CardTitle>
<Badge
variant={
section.status === "已启用"
? "default"
: section.status === "密码认证"
? "secondary"
: "outline"
}
>
{section.status}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="grid sm:grid-cols-2 gap-3">
{section.items.map((item) => (
<div
key={item.label}
className="flex items-start justify-between p-3 rounded-lg bg-muted/40"
>
<span className="text-sm text-muted-foreground">{item.label}</span>
<span
className={`text-sm font-medium text-right ml-4 ${
item.value.includes("未") ? "text-orange-500" : ""
}`}
>
{item.value}
</span>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
<p className="text-sm text-muted-foreground mt-6">
</p>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { toast } from "sonner";
interface User {
id: string;
name: string;
email: string;
avatar_url?: string;
role: string;
status: string;
employee_id?: string;
last_login_at?: string;
login_count: number;
created_at: string;
}
const roleLabels: Record<string, string> = {
super_admin: "平台管理员",
admin: "机构管理员",
creator: "创作者",
user: "普通用户",
};
const roleColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
super_admin: "destructive",
admin: "default",
creator: "secondary",
user: "outline",
};
export default function UsersPage() {
const [search, setSearch] = useState("");
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ["adminUsers", search],
queryFn: () => api.get<{ items: User[] }>(`/api/v1/admin/users?q=${search}`),
});
const updateRole = useMutation({
mutationFn: ({ id, role }: { id: string; role: string }) =>
api.put(`/api/v1/admin/users/${id}/role`, { role }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["adminUsers"] });
toast.success("角色更新成功");
},
onError: (err: Error) => toast.error(err.message),
});
const updateStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
api.put(`/api/v1/admin/users/${id}/status`, { status }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["adminUsers"] });
toast.success("状态更新成功");
},
onError: (err: Error) => toast.error(err.message),
});
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold"></h1>
<Input
placeholder="搜索姓名或邮箱..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-64"
/>
</div>
<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>
{data?.items?.map((user) => (
<tr key={user.id} className="border-t">
<td className="p-3">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={user.avatar_url} />
<AvatarFallback>{user.name.charAt(0)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{user.name}</div>
<div className="text-xs text-muted-foreground">{user.email}</div>
</div>
</div>
</td>
<td className="p-3">
<Badge variant={roleColors[user.role]}>
{roleLabels[user.role]}
</Badge>
</td>
<td className="p-3">
<Badge variant={user.status === "active" ? "default" : "destructive"}>
{user.status === "active" ? "正常" : "禁用"}
</Badge>
</td>
<td className="p-3 text-muted-foreground">{user.login_count}</td>
<td className="p-3">
<div className="flex items-center gap-2">
<Select
defaultValue={user.role}
onValueChange={(role) => role && updateRole.mutate({ id: user.id, role })}
>
<SelectTrigger className="w-28 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user"></SelectItem>
<SelectItem value="creator"></SelectItem>
<SelectItem value="admin"></SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() =>
updateStatus.mutate({
id: user.id,
status: user.status === "active" ? "disabled" : "active",
})
}
>
{user.status === "active" ? "禁用" : "启用"}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+246
View File
@@ -0,0 +1,246 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAuthStore } from "@/stores/auth";
import type { Organization } from "@/stores/auth";
import api from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Shield,
Building2,
Sparkles,
BookOpen,
FileText,
Brain,
GraduationCap,
} from "lucide-react";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [orgs, setOrgs] = useState<Organization[]>([]);
const [selectedOrg, setSelectedOrg] = useState("");
const { login, switchOrg } = useAuthStore();
const router = useRouter();
useEffect(() => {
api
.get<Organization[]>("/api/v1/organizations")
.then((data) => {
setOrgs(data);
if (data.length > 0) setSelectedOrg(data[0].id);
})
.catch(() => {});
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
setErrorMsg("请输入邮箱和密码");
return;
}
if (!selectedOrg) {
setErrorMsg("请选择所属机构");
return;
}
setLoading(true);
setErrorMsg("");
try {
await login(email, password, selectedOrg);
const user = useAuthStore.getState().user;
// 平台管理员不绑定机构,登录后保留 super_admin 身份;
// 仅机构管理员在所选机构与自身归属不一致时才触发切换
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 : "登录失败,请检查账号和密码"
);
} finally {
setLoading(false);
}
};
const features = [
{ icon: Sparkles, text: "AI 驱动的智能办公" },
{ icon: FileText, text: "一键生成公文与报告" },
{ icon: BookOpen, text: "智能知识库问答" },
{ icon: Brain, text: "多场景 AI 应用中心" },
{ icon: GraduationCap, text: "支持多机构独立部署" },
];
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>
))}
</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="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"
/>
)}
</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}
>
{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>
</div>
</div>
</div>
</div>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import api from "@/lib/api";
import { useAuthStore } from "@/stores/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { Shield } from "lucide-react";
export default function RegisterPage() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const { setAuth } = useAuthStore();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name || !email || !password) {
toast.error("请填写所有必填项");
return;
}
if (password.length < 6) {
toast.error("密码长度不能少于6位");
return;
}
if (password !== confirmPassword) {
toast.error("两次密码输入不一致");
return;
}
setLoading(true);
try {
const res = await api.post<{
user: { id: string; name: string; email: string; role: "user" | "super_admin" | "admin" | "creator" };
access_token: string;
}>("/api/v1/auth/register", { name, email, password });
setAuth(res.user, res.access_token);
toast.success("注册成功");
router.push("/store");
} catch (err) {
toast.error(err instanceof Error ? err.message : "注册失败");
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-blue-950 via-blue-900 to-blue-800 p-4">
<Card className="w-full max-w-md border-blue-200/20 shadow-2xl">
<CardHeader className="text-center">
<div className="mx-auto mb-2 flex items-center justify-center gap-2">
<Shield className="h-10 w-10 text-blue-700" />
</div>
<CardTitle className="text-2xl"></CardTitle>
<CardDescription>AI智能应用平台</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
placeholder="您的真实姓名"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
placeholder="your@gov.cn"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
placeholder="至少6位密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword"></Label>
<Input
id="confirmPassword"
type="password"
placeholder="再次输入密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "提交中..." : "提交注册"}
</Button>
</form>
<div className="mt-4 text-center text-sm text-muted-foreground">
{" "}
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
</Link>
</div>
<div className="mt-6 text-center text-xs text-muted-foreground border-t pt-4">
使
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,75 @@
"use client";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import api from "@/lib/api";
import type { App } from "@/lib/types";
import { Loader2 } from "lucide-react";
import ChatbotUI from "@/components/app-ui/chatbot-ui";
import CompletionUI from "@/components/app-ui/completion-ui";
import WorkflowUI from "@/components/app-ui/workflow-ui";
import AgentUI from "@/components/app-ui/agent-ui";
import DocWriterUI from "@/components/app-ui/doc-writer-ui";
import AnalysisUI from "@/components/app-ui/analysis-ui";
const DOC_WRITER_SLUGS = new Set(["official-doc-writer", "fagai-doc-writer"]);
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 { data: appBySlug, isLoading: slugLoading } = useQuery({
queryKey: ["chatApp", slugOrId],
queryFn: () => api.get<App>(`/api/v1/store/apps/${slugOrId}`),
enabled: !isUUID,
});
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`
);
return results.items?.find((a) => a.id === slugOrId) || null;
},
enabled: isUUID,
});
const app = isUUID ? appById : appBySlug;
const isLoading = isUUID ? idLoading : slugLoading;
if (isLoading || !app) {
return (
<div className="flex h-[calc(100vh-3.5rem)] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (DOC_WRITER_SLUGS.has(app.slug)) {
return <DocWriterUI app={app} />;
}
if (ANALYSIS_SLUGS.has(app.slug)) {
return <AnalysisUI app={app} />;
}
const appType = app.dify_app_type || "chatbot";
switch (appType) {
case "completion":
return <CompletionUI app={app} />;
case "workflow":
return <WorkflowUI app={app} />;
case "agent":
return <AgentUI app={app} />;
case "chatbot":
default:
return <ChatbotUI app={app} />;
}
}
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useEffect } from "react";
import { AlertCircle, RotateCcw, Home } from "lucide-react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
export default function PortalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[PortalError]", error);
}, [error]);
return (
<div className="flex min-h-[60vh] 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-destructive/10 mb-6">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<h2 className="text-xl font-semibold mb-2"></h2>
<p className="text-sm text-muted-foreground mb-6">
</p>
{error.digest && (
<p className="text-xs text-muted-foreground/60 mb-4 font-mono">
{error.digest}
</p>
)}
<div className="flex gap-3">
<Button variant="outline" onClick={reset} className="gap-2">
<RotateCcw className="h-4 w-4" />
</Button>
<Link href="/store">
<Button className="gap-2">
<Home className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
</div>
);
}
@@ -0,0 +1,419 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 { toast } from "sonner";
import { useAuthStore } from "@/stores/auth";
import {
BookOpen,
Upload,
FileText,
Trash2,
Plus,
Database,
Search,
} from "lucide-react";
interface KnowledgeBase {
id: string;
name: string;
description: string;
visibility: string;
document_count: number;
total_chars: number;
status: string;
created_at: string;
updated_at: string;
}
interface KBDocument {
id: string;
filename: string;
file_size: number;
file_type: string;
status: string;
created_at: string;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
function getStatusBadge(status: string) {
switch (status) {
case "completed":
return <Badge className="bg-emerald-50 text-emerald-700 border-emerald-200"></Badge>;
case "indexing":
return <Badge className="bg-amber-50 text-amber-700 border-amber-200"></Badge>;
case "failed":
return <Badge variant="destructive"></Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
}
}
function getVisibilityLabel(v: string) {
switch (v) {
case "public": return "全单位";
case "department": return "本科室";
default: return "私有";
}
}
export default function KnowledgePage() {
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const orgId = user?.org_id;
const [showCreate, setShowCreate] = useState(false);
const [selectedKB, setSelectedKB] = useState<KnowledgeBase | null>(null);
const [form, setForm] = useState({ name: "", description: "", visibility: "private" });
const [searchTerm, setSearchTerm] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const { data: knowledgeBases, isLoading: kbLoading } = useQuery({
queryKey: ["knowledgeBases", orgId],
queryFn: () => api.get<KnowledgeBase[]>(`/api/v1/knowledge/${orgId ? `?org_id=${orgId}` : ""}`),
});
const { data: documents } = useQuery({
queryKey: ["kbDocuments", selectedKB?.id],
queryFn: () => api.get<KBDocument[]>(`/api/v1/knowledge/${selectedKB!.id}/documents`),
enabled: !!selectedKB,
});
const createKB = useMutation({
mutationFn: () => api.post("/api/v1/knowledge/", form),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["knowledgeBases"] });
setShowCreate(false);
setForm({ name: "", description: "", visibility: "private" });
toast.success("知识库创建成功");
},
onError: (err: Error) => toast.error(err.message),
});
const deleteKB = useMutation({
mutationFn: (id: string) => api.delete(`/api/v1/knowledge/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["knowledgeBases"] });
if (selectedKB) setSelectedKB(null);
toast.success("知识库已删除");
},
onError: (err: Error) => toast.error(err.message),
});
const uploadDoc = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`/api/v1/knowledge/${selectedKB!.id}/documents`, {
method: "POST",
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
body: formData,
});
if (!res.ok) throw new Error("上传失败");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["kbDocuments"] });
queryClient.invalidateQueries({ queryKey: ["knowledgeBases"] });
toast.success("文档上传成功");
},
onError: (err: Error) => toast.error(err.message),
});
const deleteDoc = useMutation({
mutationFn: (docId: string) =>
api.delete(`/api/v1/knowledge/${selectedKB!.id}/documents/${docId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["kbDocuments"] });
queryClient.invalidateQueries({ queryKey: ["knowledgeBases"] });
toast.success("文档已删除");
},
onError: (err: Error) => toast.error(err.message),
});
const handleFileUpload = useCallback(() => {
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 filteredKBs = knowledgeBases?.filter(
(kb) => !searchTerm || kb.name.includes(searchTerm) || kb.description?.includes(searchTerm)
);
return (
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-100 text-blue-700">
<Database className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl font-bold"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
<Button onClick={() => setShowCreate(true)} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1 space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索知识库..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
{kbLoading && (
<div className="space-y-3">
{[1, 2].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="py-6">
<div className="h-4 bg-muted rounded w-3/4 mb-2" />
<div className="h-3 bg-muted rounded w-1/2" />
</CardContent>
</Card>
))}
</div>
)}
{!kbLoading && filteredKBs?.length === 0 && (
<Card>
<CardContent className="py-10 text-center">
<BookOpen className="h-10 w-10 mx-auto text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
{searchTerm ? "未找到匹配的知识库" : "暂无知识库,点击上方按钮创建"}
</p>
</CardContent>
</Card>
)}
{filteredKBs?.map((kb) => (
<Card
key={kb.id}
className={`cursor-pointer transition-all ${
selectedKB?.id === kb.id
? "border-primary ring-1 ring-primary/20"
: "hover:border-muted-foreground/30"
}`}
onClick={() => setSelectedKB(kb)}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<BookOpen className="h-4 w-4 text-blue-600" />
{kb.name}
</CardTitle>
<Badge variant="secondary" className="text-xs">{kb.document_count} </Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
{kb.description || "暂无描述"}
</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>
<span className="text-xs text-muted-foreground">
{new Date(kb.updated_at).toLocaleDateString("zh-CN")}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive h-6 px-2"
onClick={(e) => {
e.stopPropagation();
if (confirm("确定要删除该知识库吗?所有文档将一并删除。")) {
deleteKB.mutate(kb.id);
}
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
<div className="lg:col-span-2">
{selectedKB ? (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<BookOpen className="h-5 w-5 text-blue-600" />
{selectedKB.name}
</CardTitle>
<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>
</div>
</div>
<div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".txt,.md,.pdf,.docx,.csv,.xlsx"
onChange={onFileChange}
/>
<Button onClick={handleFileUpload} disabled={uploadDoc.isPending} className="gap-2">
<Upload className="h-4 w-4" />
{uploadDoc.isPending ? "上传中..." : "上传文档"}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{documents?.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<FileText className="h-10 w-10 mx-auto text-muted-foreground/40 mb-3" />
<p className="text-sm"></p>
<p className="text-xs mt-1"> PDFDOCXTXTMDCSVXLSX </p>
</div>
) : (
<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 font-medium"></th>
<th className="text-left p-3 font-medium"></th>
<th className="text-left p-3 font-medium"></th>
<th className="text-left p-3 font-medium"></th>
<th className="text-left p-3 font-medium"></th>
<th className="text-left p-3 font-medium"></th>
</tr>
</thead>
<tbody>
{documents?.map((doc) => (
<tr key={doc.id} className="border-t hover:bg-muted/30 transition-colors">
<td className="p-3 font-medium flex items-center gap-2">
<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">{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">
<Button
variant="ghost"
size="sm"
className="text-destructive h-7 px-2"
onClick={() => deleteDoc.mutate(doc.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
) : (
<Card>
<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>
</CardContent>
</Card>
)}
</div>
</div>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<BookOpen className="h-5 w-5 text-blue-600" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label> *</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="例如:科技局政策法规库"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="简要描述知识库的用途和包含的文档类型"
rows={3}
/>
</div>
<div className="space-y-2">
<Label></Label>
<div className="grid grid-cols-3 gap-2">
{[
{ value: "private", label: "私有" },
{ value: "department", label: "本科室" },
{ value: "public", label: "全单位" },
].map((opt) => (
<button
key={opt.value}
onClick={() => setForm({ ...form, visibility: opt.value })}
className={`p-2.5 rounded-lg border text-sm text-center transition-colors ${
form.visibility === opt.value
? "border-primary bg-primary/5 text-primary font-medium"
: "border-border hover:border-primary/30"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setShowCreate(false)}></Button>
<Button
onClick={() => createKB.mutate()}
disabled={!form.name.trim() || createKB.isPending}
>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/auth";
import { Header } from "@/components/layout/header";
export default function PortalLayout({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isLoading = useAuthStore((s) => s.isLoading);
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace("/login");
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (!isAuthenticated) return null;
return (
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">{children}</main>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Loader2 } from "lucide-react";
export default function PortalLoading() {
return (
<div className="flex h-[calc(100vh-3.5rem)] items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">...</p>
</div>
</div>
);
}
@@ -0,0 +1,331 @@
"use client";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
import type { App } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent } from "@/components/ui/card";
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 { getCategoryIcon, getCategoryColor } from "@/lib/category-config";
import { getAppTypeConfig } from "@/lib/app-type-config";
function StarRatingDisplay({
rating,
count,
}: {
rating: number;
count: number;
}) {
const stars = Math.round(rating);
return (
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-0.5">
{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"
}`}
/>
))}
</div>
<span className="text-sm font-medium">{rating.toFixed(1)}</span>
<span className="text-sm text-muted-foreground">
({count} )
</span>
</div>
);
}
function RatingInput({ appId }: { appId: string }) {
const [hover, setHover] = useState(0);
const [selected, setSelected] = useState(0);
const queryClient = useQueryClient();
const rate = useMutation({
mutationFn: (score: number) =>
api.post(`/api/v1/apps/${appId}/rating`, { score }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["appDetail"] });
toast.success("评分成功");
},
onError: (err: Error) => toast.error(err.message),
});
return (
<div className="flex items-center gap-1.5">
<span className="text-sm text-muted-foreground mr-1"></span>
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
className="transition-transform hover:scale-110"
onMouseEnter={() => setHover(star)}
onMouseLeave={() => setHover(0)}
onClick={() => {
setSelected(star);
rate.mutate(star);
}}
>
<Star
className={`h-5 w-5 transition-colors ${
star <= (hover || selected)
? "fill-amber-400 text-amber-400"
: "text-gray-300 hover:text-amber-300"
}`}
/>
</button>
))}
{selected > 0 && (
<span className="text-sm text-muted-foreground ml-1">
{selected}
</span>
)}
</div>
);
}
export default function AppDetailPage() {
const { slug } = useParams<{ slug: string }>();
const router = useRouter();
const queryClient = useQueryClient();
const { data: app, isLoading } = useQuery({
queryKey: ["appDetail", slug],
queryFn: () => api.get<App>(`/api/v1/store/apps/${slug}`),
});
const toggleFav = useMutation({
mutationFn: (isFav: boolean) =>
isFav
? api.delete(`/api/v1/apps/${app?.id}/favorite`)
: api.post(`/api/v1/apps/${app?.id}/favorite`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["appDetail"] });
queryClient.invalidateQueries({ queryKey: ["favorites"] });
},
onError: (err: Error) => toast.error(err.message),
});
if (isLoading) {
return (
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-8 space-y-6">
<div className="flex items-start gap-5">
<Skeleton className="h-16 w-16 rounded-2xl" />
<div className="space-y-3 flex-1">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<Skeleton className="h-64 w-full rounded-lg" />
</div>
);
}
if (!app) {
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>
</div>
);
}
const CategoryIcon = getCategoryIcon(app.category_slug);
const categoryColor = getCategoryColor(app.category_slug);
const isFavorited = (app as any).is_favorited;
const typeConfig = getAppTypeConfig(app.dify_app_type);
const TypeIcon = typeConfig.icon;
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">
<Button
variant="ghost"
size="sm"
className="mb-4 gap-1.5 -ml-2"
onClick={() => router.back()}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Card className="border-border/60">
<CardContent className="p-6 sm:p-8">
<div className="flex items-start gap-5">
<div
className={`flex h-16 w-16 items-center justify-center rounded-2xl ${categoryColor} shrink-0`}
>
<CategoryIcon className="h-8 w-8" />
</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>
<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" />
{typeConfig.label}
</Badge>
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
{app.usage_count} 使
</span>
{app.creator_name && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Users className="h-3.5 w-3.5" />
{app.creator_name}
</span>
)}
{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}
</span>
)}
</div>
{app.avg_rating > 0 && (
<div className="mt-3">
<StarRatingDisplay
rating={app.avg_rating}
count={app.rating_count}
/>
</div>
)}
</div>
</div>
<div className="flex items-center gap-3 mt-6">
<Button
size="lg"
className="gap-2 bg-blue-900 hover:bg-blue-800 text-white shadow-sm"
onClick={() => router.push(`/chat/${app.slug}`)}
>
<Play className="h-4 w-4" />
使
</Button>
<Button
variant="outline"
size="lg"
className="gap-2"
onClick={() => toggleFav.mutate(!!isFavorited)}
>
<Heart
className={`h-4 w-4 ${
isFavorited ? "fill-red-500 text-red-500" : ""
}`}
/>
{isFavorited ? "已收藏" : "收藏"}
</Button>
</div>
</CardContent>
</Card>
<div className="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{longDesc && (
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-6">
<h2 className="text-lg font-semibold mb-5 flex items-center gap-2">
<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
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
[&_h2:before]:content-[''] [&_h2:before]:inline-block [&_h2:before]:w-0.5 [&_h2:before]:h-4 [&_h2:before]:bg-blue-700 [&_h2:before]:rounded-full [&_h2:before]:shrink-0
[&_h3]:text-sm [&_h3]:mt-4 [&_h3]:mb-2 [&_h3]:text-blue-800 [&_h3]:dark:text-blue-300
[&_h4]:text-sm [&_h4]:mt-3 [&_h4]:mb-1.5 [&_h4]:text-blue-700
prose-li:text-foreground prose-li:my-1
prose-ul:my-2 prose-ul:space-y-1
[&_ul]:list-none [&_ul]:pl-0
[&_li]:flex [&_li]:items-start [&_li]:gap-2.5
[&_li]:rounded-lg [&_li]:bg-blue-50/60 [&_li]:dark:bg-blue-950/20
[&_li]:px-3.5 [&_li]:py-2.5
[&_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>
</div>
</CardContent>
</Card>
)}
</div>
<div className="space-y-6">
<Card className="border-border/60">
<CardContent className="p-6">
<h3 className="font-semibold mb-4"></h3>
<RatingInput appId={app.id} />
</CardContent>
</Card>
{app.suggested_prompts && (
<Card className="border-border/60">
<CardContent className="p-6">
<h3 className="font-semibold mb-3"></h3>
<div className="space-y-2">
{(typeof app.suggested_prompts === "string"
? (() => {
try {
return JSON.parse(app.suggested_prompts) as string[];
} catch {
return [];
}
})()
: app.suggested_prompts
).map((prompt: string, i: number) => (
<Button
key={i}
variant="outline"
size="sm"
className="w-full justify-start text-left h-auto py-2 text-xs"
onClick={() => router.push(`/chat/${app.slug}`)}
>
{prompt}
</Button>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,129 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useParams, useSearchParams } from "next/navigation";
import Link from "next/link";
import api from "@/lib/api";
import type { App, Category } from "@/lib/types";
import { useAuthStore } from "@/stores/auth";
import { AppCard } from "@/components/app-card/app-card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ArrowLeft, SlidersHorizontal } from "lucide-react";
import { useState } from "react";
type SortOption = "popular" | "latest" | "rating";
const sortLabels: Record<SortOption, string> = {
popular: "最热门",
latest: "最新发布",
rating: "评分最高",
};
export default function CategoryPage() {
const { slug } = useParams<{ slug: string }>();
const searchParams = useSearchParams();
const { user } = useAuthStore();
const orgId = user?.org_id || "";
const orgParam = orgId ? `&org_id=${orgId}` : "";
const [sort, setSort] = useState<SortOption>(
(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}` : ""}`),
});
const currentCategory = categories?.find((c) => c.slug === slug);
const { data, isLoading } = useQuery({
queryKey: ["category-apps", slug, sort, orgId],
queryFn: () =>
api.get<{ items: App[] }>(
`/api/v1/store/apps?category=${encodeURIComponent(slug)}&sort=${sort}&page_size=50${orgParam}`
),
});
const apps = data?.items || [];
return (
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-8">
<div className="flex items-center gap-3 mb-6">
<Link href="/store">
<Button variant="ghost" size="icon" className="h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<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>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 mb-6">
<Link href="/store">
<Badge variant="outline" className="cursor-pointer hover:bg-muted">
</Badge>
</Link>
{categories?.map((cat) => (
<Link key={cat.id} href={`/store/category/${cat.slug}`}>
<Badge
variant={cat.slug === slug ? "default" : "secondary"}
className="cursor-pointer hover:bg-secondary/80"
>
{cat.name}
</Badge>
</Link>
))}
</div>
<div className="flex items-center justify-between mb-6">
<p className="text-sm text-muted-foreground">
{isLoading ? "加载中..." : `${apps.length} 个应用`}
</p>
<div className="flex items-center gap-1">
<SlidersHorizontal className="h-4 w-4 text-muted-foreground mr-1" />
{(Object.keys(sortLabels) as SortOption[]).map((key) => (
<Button
key={key}
variant={sort === key ? "default" : "ghost"}
size="sm"
className="text-xs h-7"
onClick={() => setSort(key)}
>
{sortLabels[key]}
</Button>
))}
</div>
</div>
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-36 rounded-lg" />
))}
</div>
) : apps.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</div>
) : (
<div className="text-center py-20 text-muted-foreground">
<p className="text-lg mb-2"></p>
<p className="text-sm">线</p>
</div>
)}
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import api from "@/lib/api";
import type { App, Category } from "@/lib/types";
import { useAuthStore } from "@/stores/auth";
import { AppCard } from "@/components/app-card/app-card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Sparkles, LayoutGrid } from "lucide-react";
import type { LucideIcon } from "lucide-react";
function SectionHeader({
title,
icon: Icon,
href,
}: {
title: string;
icon?: LucideIcon;
href?: string;
}) {
return (
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-bold flex items-center gap-2">
<span className="inline-block w-1 h-5 bg-blue-800 rounded-full mr-1" />
{Icon && <Icon className="h-5 w-5 text-blue-700" />}
{title}
</h2>
{href && (
<Link
href={href}
className="text-sm text-blue-700 hover:text-blue-900 font-medium transition-colors"
>
</Link>
)}
</div>
);
}
function AppGridSkeleton({ count = 4 }: { count?: number }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: count }).map((_, i) => (
<Skeleton key={i} className="h-36 rounded-lg" />
))}
</div>
);
}
export default function StorePage() {
const searchParams = useSearchParams();
const query = searchParams.get("q") || "";
const { user } = useAuthStore();
const orgId = user?.org_id || "";
const orgParam = orgId ? `org_id=${orgId}` : "";
const { data: categories } = useQuery({
queryKey: ["categories", orgId],
queryFn: () => api.get<Category[]>(`/api/v1/store/categories?${orgParam}`),
});
const { data: featured, isLoading: featuredLoading } = useQuery({
queryKey: ["featured", orgId],
queryFn: () => api.get<App[]>(`/api/v1/store/featured?${orgParam}`),
enabled: !query,
});
const { data: topApps, isLoading: topLoading } = useQuery({
queryKey: ["topApps", orgId],
queryFn: () => api.get<App[]>(`/api/v1/store/rankings?${orgParam}`),
enabled: !query,
});
const { data: searchResults, isLoading: searchLoading } = useQuery({
queryKey: ["search", query, orgId],
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>
{searchLoading ? (
<AppGridSkeleton count={8} />
) : searchResults?.items?.length ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{searchResults.items.map((app) => (
<AppCard key={app.id} app={app} />
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
</div>
)}
</div>
);
}
return (
<div className="mx-auto w-full max-w-7xl px-3 md:px-6 lg:px-8 py-4 md:py-8 space-y-6 md:space-y-10">
{/* Featured */}
<section>
<SectionHeader title="推荐应用" icon={Sparkles} />
{featuredLoading ? (
<AppGridSkeleton />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{featured?.map((app) => (
<AppCard key={app.id} app={app} />
))}
</div>
)}
</section>
{/* Categories */}
<section>
<SectionHeader title="应用分类" icon={LayoutGrid} />
<div className="flex flex-wrap gap-2">
<Link href="/store">
<Badge variant="default" className="cursor-pointer"></Badge>
</Link>
{categories?.map((cat) => (
<Link key={cat.id} href={`/store/category/${cat.slug}`}>
<Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80">
{cat.name}
{cat.app_count != null && cat.app_count > 0 && (
<span className="ml-1 text-muted-foreground">{cat.app_count}</span>
)}
</Badge>
</Link>
))}
</div>
</section>
{/* All Apps */}
<section>
<SectionHeader title="全部应用" icon={LayoutGrid} />
{topLoading ? (
<AppGridSkeleton />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{topApps?.map((app) => (
<AppCard key={app.id} app={app} />
))}
</div>
)}
</section>
</div>
);
}
@@ -0,0 +1,83 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import api from "@/lib/api";
import type { App } from "@/lib/types";
import { AppCard } from "@/components/app-card/app-card";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Clock, Star, LayoutDashboard } from "lucide-react";
export default function WorkspacePage() {
const { data: recentApps, isLoading: recentLoading } = useQuery({
queryKey: ["recentApps"],
queryFn: () => api.get<App[]>("/api/v1/store/recent"),
});
const { data: favorites, isLoading: favsLoading } = useQuery({
queryKey: ["favorites"],
queryFn: () => api.get<App[]>("/api/v1/me/favorites"),
});
return (
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-6 space-y-8">
<div className="flex items-center gap-3 mb-2">
<LayoutDashboard className="h-6 w-6 text-blue-800" />
<h1 className="text-2xl font-bold text-foreground"></h1>
</div>
<p className="text-sm text-muted-foreground mb-6">访</p>
{/* Recent */}
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Clock className="h-5 w-5 text-blue-600" /> 使
</h2>
{recentLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-36 rounded-lg" />
))}
</div>
) : recentApps?.length ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{recentApps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</div>
) : (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
使
</CardContent>
</Card>
)}
</section>
{/* Favorites */}
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Star className="h-5 w-5 text-amber-500" />
</h2>
{favsLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-36 rounded-lg" />
))}
</div>
) : favorites?.length ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{favorites.map((app) => (
<AppCard key={app.id} app={app} />
))}
</div>
) : (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
</CardContent>
</Card>
)}
</section>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { useEffect } from "react";
import { AlertCircle, RotateCcw, Home } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[GlobalError]", error);
}, [error]);
return (
<div className="flex min-h-[60vh] 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-destructive/10 mb-6">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<h2 className="text-xl font-semibold mb-2"></h2>
<p className="text-sm text-muted-foreground mb-6">
</p>
{error.digest && (
<p className="text-xs text-muted-foreground/60 mb-4 font-mono">
{error.digest}
</p>
)}
<div className="flex gap-3">
<Button variant="outline" onClick={reset} className="gap-2">
<RotateCcw className="h-4 w-4" />
</Button>
<a href="/store">
<Button className="gap-2">
<Home className="h-4 w-4" />
</Button>
</a>
</div>
</div>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+130
View File
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(0.985 0.002 250);
--foreground: oklch(0.145 0.015 250);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0.015 250);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0.015 250);
--primary: oklch(0.30 0.10 250);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.96 0.01 250);
--secondary-foreground: oklch(0.25 0.06 250);
--muted: oklch(0.96 0.008 250);
--muted-foreground: oklch(0.50 0.02 250);
--accent: oklch(0.96 0.01 250);
--accent-foreground: oklch(0.25 0.06 250);
--destructive: oklch(0.55 0.22 25);
--border: oklch(0.90 0.01 250);
--input: oklch(0.90 0.01 250);
--ring: oklch(0.45 0.12 250);
--chart-1: oklch(0.45 0.15 250);
--chart-2: oklch(0.55 0.20 25);
--chart-3: oklch(0.60 0.10 150);
--chart-4: oklch(0.65 0.12 300);
--chart-5: oklch(0.55 0.15 50);
--radius: 0.5rem;
--sidebar: oklch(0.97 0.005 250);
--sidebar-foreground: oklch(0.145 0.015 250);
--sidebar-primary: oklch(0.35 0.12 250);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.94 0.01 250);
--sidebar-accent-foreground: oklch(0.25 0.06 250);
--sidebar-border: oklch(0.90 0.01 250);
--sidebar-ring: oklch(0.45 0.12 250);
}
.dark {
--background: oklch(0.145 0.015 250);
--foreground: oklch(0.985 0.002 250);
--card: oklch(0.20 0.02 250);
--card-foreground: oklch(0.985 0.002 250);
--popover: oklch(0.20 0.02 250);
--popover-foreground: oklch(0.985 0.002 250);
--primary: oklch(0.60 0.15 250);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.27 0.02 250);
--secondary-foreground: oklch(0.985 0.002 250);
--muted: oklch(0.27 0.02 250);
--muted-foreground: oklch(0.70 0.02 250);
--accent: oklch(0.27 0.02 250);
--accent-foreground: oklch(0.985 0.002 250);
--destructive: oklch(0.70 0.19 22);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.55 0.12 250);
--chart-1: oklch(0.60 0.15 250);
--chart-2: oklch(0.65 0.20 25);
--chart-3: oklch(0.65 0.10 150);
--chart-4: oklch(0.70 0.12 300);
--chart-5: oklch(0.60 0.15 50);
--sidebar: oklch(0.20 0.02 250);
--sidebar-foreground: oklch(0.985 0.002 250);
--sidebar-primary: oklch(0.55 0.18 250);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.27 0.02 250);
--sidebar-accent-foreground: oklch(0.985 0.002 250);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.55 0.12 250);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Providers } from "@/components/providers";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "AI智能应用平台",
description: "AI智能办公平台,提升效能,赋能智慧办公",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="zh-CN"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<Providers>{children}</Providers>
<Toaster position="top-center" richColors />
</body>
</html>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Loader2 } from "lucide-react";
export default function GlobalLoading() {
return (
<div className="flex h-screen items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">...</p>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { FileQuestion, Home } from "lucide-react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
export default function NotFound() {
return (
<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>
<Link href="/store">
<Button className="gap-2">
<Home className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/store");
}
+213
View File
@@ -0,0 +1,213 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
import { AppIcon } from "@/lib/app-icon";
import type { PlatformApp, PlatformOrg } from "@/lib/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { Star, Archive } from "lucide-react";
import { Pagination } from "@/components/ui/pagination";
const statusLabels: Record<string, string> = {
draft: "草稿",
pending_review: "审核中",
approved: "已上架",
rejected: "已驳回",
archived: "已归档",
};
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
pending_review: "secondary",
approved: "default",
rejected: "destructive",
archived: "outline",
};
export default function PlatformAppsPage() {
const qc = useQueryClient();
const [statusFilter, setStatusFilter] = useState("all");
const [orgFilter, setOrgFilter] = useState("all");
const [page, setPage] = useState(1);
const { data: orgs } = useQuery({
queryKey: ["platformOrgs"],
queryFn: () => api.get<PlatformOrg[]>("/api/v1/platform/orgs"),
});
const { data } = useQuery({
queryKey: ["platformApps", statusFilter, orgFilter, page],
queryFn: () => {
const p = new URLSearchParams();
p.set("page", String(page));
if (statusFilter !== "all") p.set("status", statusFilter);
if (orgFilter !== "all") p.set("org_id", orgFilter);
return api.get<{
items: PlatformApp[];
total: number;
page: number;
page_size: number;
}>(`/api/v1/platform/apps?${p}`);
},
});
const resetStatus = (v: string | null) => {
setStatusFilter(v ?? "all");
setPage(1);
};
const resetOrg = (v: string | null) => {
setOrgFilter(v ?? "all");
setPage(1);
};
const setFeatured = useMutation({
mutationFn: ({ id, is_featured }: { id: string; is_featured: boolean }) =>
api.put(`/api/v1/platform/apps/${id}/featured`, { is_featured }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platformApps"] });
toast.success("已更新");
},
onError: (e: Error) => toast.error(e.message),
});
const forceDelist = useMutation({
mutationFn: (id: string) => api.post(`/api/v1/platform/apps/${id}/force-delist`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platformApps"] });
toast.success("已强制下架");
},
onError: (e: Error) => toast.error(e.message),
});
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold"></h1>
<span className="text-xs text-muted-foreground"> {data?.total ?? 0} </span>
</div>
<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>
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{orgs?.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.short_name || o.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={resetStatus}>
<SelectTrigger className="w-32">
<span>{statusFilter === "all" ? "全部状态" : (statusLabels[statusFilter] || statusFilter)}</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="approved"></SelectItem>
<SelectItem value="pending_review"></SelectItem>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="rejected"></SelectItem>
<SelectItem value="archived"></SelectItem>
</SelectContent>
</Select>
</div>
<Pagination
page={data?.page ?? 1}
pageSize={data?.page_size ?? 20}
total={data?.total ?? 0}
onChange={setPage}
/>
<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>
{data?.items?.map((app) => (
<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" />
<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" />}
</div>
<div className="text-xs text-muted-foreground line-clamp-1">
{app.description}
</div>
</div>
</div>
</td>
<td className="p-3">
{app.org_name ? (
<Badge variant="outline">{app.org_short || app.org_name}</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="p-3 text-muted-foreground text-xs">{app.creator_name}</td>
<td className="p-3">
<Badge variant={statusColors[app.status]}>{statusLabels[app.status]}</Badge>
</td>
<td className="p-3 text-muted-foreground">{app.usage_count}</td>
<td className="p-3">
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() =>
setFeatured.mutate({ id: app.id, is_featured: !app.is_featured })
}
>
<Star
className={`h-3 w-3 ${
app.is_featured ? "fill-yellow-400 text-yellow-400" : ""
}`}
/>
{app.is_featured ? "取消精选" : "精选"}
</Button>
{app.status === "approved" && (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs text-destructive hover:text-destructive"
onClick={() => forceDelist.mutate(app.id)}
>
<Archive className="h-3 w-3" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Pagination } from "@/components/ui/pagination";
export default function PlatformAuditPage() {
const [search, setSearch] = useState("");
const [orgFilter, setOrgFilter] = useState("all");
const [page, setPage] = useState(1);
const { data: orgs } = useQuery({
queryKey: ["platformOrgs"],
queryFn: () => api.get<PlatformOrg[]>("/api/v1/platform/orgs"),
});
const { data } = useQuery({
queryKey: ["platformAuditLogs", search, orgFilter, page],
queryFn: () => {
const p = new URLSearchParams();
p.set("page", String(page));
if (search) p.set("action", search);
if (orgFilter !== "all") p.set("org_id", orgFilter);
return api.get<{
items: PlatformAuditLog[];
total: number;
page: number;
page_size: number;
}>(`/api/v1/platform/audit-logs?${p}`);
},
});
const resetSearch = (v: string) => {
setSearch(v);
setPage(1);
};
const resetOrg = (v: string | null) => {
setOrgFilter(v ?? "all");
setPage(1);
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold"></h1>
<span className="text-xs text-muted-foreground"> {data?.total ?? 0} </span>
</div>
<div className="flex flex-wrap items-center gap-3 mb-4">
<Input
placeholder="搜索操作(如 POST 或 path 关键字)..."
value={search}
onChange={(e) => resetSearch(e.target.value)}
className="w-72"
/>
<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>
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{orgs?.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.short_name || o.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Pagination
page={data?.page ?? 1}
pageSize={data?.page_size ?? 20}
total={data?.total ?? 0}
onChange={setPage}
/>
<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>
{data?.items?.map((log) => (
<tr key={log.id} className="border-t">
<td className="p-3 text-muted-foreground whitespace-nowrap text-xs">
{new Date(log.created_at).toLocaleString("zh-CN")}
</td>
<td className="p-3">
{log.org_name ? (
<Badge variant="outline">{log.org_short || log.org_name}</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="p-3">
<div className="text-xs">
<div>{log.user_name}</div>
<div className="text-muted-foreground">{log.user_email}</div>
</div>
</td>
<td className="p-3 font-mono text-xs">{log.action}</td>
<td className="p-3 text-muted-foreground text-xs">
{log.resource_type}
{log.resource_id ? `/${log.resource_id.slice(0, 8)}` : ""}
</td>
<td className="p-3 text-muted-foreground font-mono text-xs">{log.ip_address}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useAuthStore } from "@/stores/auth";
import { Header } from "@/components/layout/header";
import Link from "next/link";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
Building2,
UsersRound,
Boxes,
ScrollText,
Cpu,
Gauge,
ShieldAlert,
Menu,
X,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
const platformNavItems: { href: string; label: string; icon: LucideIcon }[] = [
{ href: "/platform/overview", label: "平台总览", icon: LayoutDashboard },
{ href: "/platform/orgs", label: "机构管理", icon: Building2 },
{ href: "/platform/users", label: "全局用户", icon: UsersRound },
{ href: "/platform/apps", label: "全局应用", icon: Boxes },
{ href: "/platform/audit", label: "全局审计", icon: ScrollText },
{ href: "/platform/providers", label: "模型提供商", icon: Cpu },
{ href: "/platform/quotas", label: "配额管理", icon: Gauge },
];
export default function PlatformLayout({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isLoading = useAuthStore((s) => s.isLoading);
const router = useRouter();
const pathname = usePathname();
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
if (!isLoading && (!isAuthenticated || user?.role !== "super_admin")) {
router.replace("/store");
}
}, [isAuthenticated, isLoading, user, router]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (!isAuthenticated || user?.role !== "super_admin") return null;
return (
<div className="flex min-h-screen flex-col">
<Header />
<div className="flex flex-1 relative">
{/* 平台管理区身份标识条 */}
<div className="hidden md:block absolute top-0 left-56 right-0 h-1 bg-gradient-to-r from-amber-500 via-orange-500 to-amber-500 z-10" />
{/* 手机端侧边栏切换按钮 */}
<button
className="md:hidden fixed bottom-4 right-4 z-50 p-3 rounded-full bg-amber-600 text-white shadow-lg"
onClick={() => setSidebarOpen(!sidebarOpen)}
>
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/40 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<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"
}`}
>
<div className="px-3 py-3 border-b bg-amber-50/50 dark:bg-amber-950/20">
<div className="flex items-center gap-2 text-sm font-medium text-amber-900 dark:text-amber-200">
<ShieldAlert className="h-4 w-4" />
</div>
<p className="text-[11px] text-amber-700/70 dark:text-amber-300/60 mt-0.5">
</p>
</div>
<nav className="p-3 space-y-1">
{platformNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
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",
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</nav>
</aside>
<main className="flex-1 p-3 md:p-6 min-w-0">{children}</main>
</div>
</div>
);
}
+310
View File
@@ -0,0 +1,310 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
import type { PlatformOrg } from "@/lib/types";
import { Button } from "@/components/ui/button";
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { Plus, Pencil, Power, Trash2, Building2, LogIn } from "lucide-react";
import { Pagination } from "@/components/ui/pagination";
import { useAuthStore } from "@/stores/auth";
interface OrgForm {
id?: string;
name: string;
slug: string;
short_name: string;
description: string;
logo_url: string;
sort_order: number;
}
const emptyForm: OrgForm = {
name: "",
slug: "",
short_name: "",
description: "",
logo_url: "",
sort_order: 0,
};
const PAGE_SIZE = 12;
export default function PlatformOrgsPage() {
const qc = useQueryClient();
const { switchOrg } = useAuthStore();
const [editing, setEditing] = useState<OrgForm | null>(null);
const [deleteTarget, setDeleteTarget] = useState<PlatformOrg | null>(null);
const [page, setPage] = useState(1);
const { data: orgs } = useQuery({
queryKey: ["platformOrgs"],
queryFn: () => api.get<PlatformOrg[]>("/api/v1/platform/orgs"),
});
const pagedOrgs = orgs?.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const create = useMutation({
mutationFn: (form: OrgForm) => api.post("/api/v1/platform/orgs", form),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platformOrgs"] });
toast.success("机构已创建");
setEditing(null);
},
onError: (e: Error) => toast.error(e.message),
});
const update = useMutation({
mutationFn: (form: OrgForm) => api.put(`/api/v1/platform/orgs/${form.id}`, form),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platformOrgs"] });
toast.success("已更新");
setEditing(null);
},
onError: (e: Error) => toast.error(e.message),
});
const toggle = useMutation({
mutationFn: ({ id, is_active }: { id: string; is_active: boolean }) =>
api.put(`/api/v1/platform/orgs/${id}`, { is_active }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platformOrgs"] });
toast.success("已更新状态");
},
onError: (e: Error) => toast.error(e.message),
});
const remove = useMutation({
mutationFn: (id: string) => api.delete(`/api/v1/platform/orgs/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platformOrgs"] });
toast.success("已删除");
setDeleteTarget(null);
},
onError: (e: Error) => toast.error(e.message),
});
const handleSubmit = () => {
if (!editing) return;
if (!editing.name.trim() || !editing.slug.trim()) {
toast.error("名称和标识不能为空");
return;
}
if (editing.id) update.mutate(editing);
else create.mutate(editing);
};
return (
<div>
<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>
</div>
<Button onClick={() => setEditing({ ...emptyForm })} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
<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 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">
<Building2 className="h-5 w-5 text-amber-700" />
</div>
<div>
<div className="font-medium leading-tight">{org.name}</div>
<div className="text-xs text-muted-foreground">{org.short_name}</div>
</div>
</div>
<Badge variant={org.is_active ? "default" : "outline"}>
{org.is_active ? "启用" : "停用"}
</Badge>
</div>
{org.description && (
<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>
<span>{org.app_count} </span>
<span className="font-mono text-[10px]">{org.slug}</span>
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 text-xs text-blue-600 hover:text-blue-700"
onClick={async () => {
await switchOrg(org.id);
window.location.href = "/dashboard";
}}
>
<LogIn className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 text-xs"
onClick={() =>
setEditing({
id: org.id,
name: org.name,
slug: org.slug,
short_name: org.short_name,
description: org.description,
logo_url: org.logo_url,
sort_order: org.sort_order,
})
}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 text-xs"
onClick={() => toggle.mutate({ id: org.id, is_active: !org.is_active })}
>
<Power className="h-3 w-3" />
{org.is_active ? "停用" : "启用"}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 text-xs text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(org)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
{/* 新建/编辑对话框 */}
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing?.id ? "编辑机构" : "新增机构"}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label> *</Label>
<Input
value={editing?.name || ""}
onChange={(e) => setEditing({ ...editing!, name: e.target.value })}
placeholder="例:科学技术局"
/>
</div>
<div>
<Label></Label>
<Input
value={editing?.short_name || ""}
onChange={(e) => setEditing({ ...editing!, short_name: e.target.value })}
placeholder="例:科技局"
/>
</div>
<div>
<Label> (slug) *</Label>
<Input
value={editing?.slug || ""}
onChange={(e) => setEditing({ ...editing!, slug: e.target.value })}
disabled={!!editing?.id}
placeholder="例:keji(创建后不可修改)"
/>
</div>
<div>
<Label></Label>
<Textarea
value={editing?.description || ""}
onChange={(e) => setEditing({ ...editing!, description: e.target.value })}
rows={3}
placeholder="机构职能描述"
/>
</div>
<div>
<Label></Label>
<Input
type="number"
value={editing?.sort_order ?? 0}
onChange={(e) =>
setEditing({ ...editing!, sort_order: parseInt(e.target.value) || 0 })
}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setEditing(null)}>
</Button>
<Button
onClick={handleSubmit}
disabled={create.isPending || update.isPending}
>
{editing?.id ? "保存" : "创建"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* 删除确认 */}
<AlertDialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget?.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteTarget && remove.mutate(deleteTarget.id)}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import api from "@/lib/api";
import type { PlatformOverview, OrgRanking } from "@/lib/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Building2,
Users,
Boxes,
Activity,
Coins,
DollarSign,
type LucideIcon,
} from "lucide-react";
function formatNumber(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
return String(n);
}
function StatCard({
title,
primary,
secondary,
icon: Icon,
accent,
}: {
title: string;
primary: string | number;
secondary?: string;
icon: LucideIcon;
accent: string;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
<div className={`flex h-9 w-9 items-center justify-center rounded-lg ${accent}`}>
<Icon className="h-5 w-5" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{primary}</div>
{secondary && <p className="text-xs text-muted-foreground mt-1">{secondary}</p>}
</CardContent>
</Card>
);
}
export default function PlatformOverviewPage() {
const { data: stats, isLoading } = useQuery({
queryKey: ["platformOverview"],
queryFn: () => api.get<PlatformOverview>("/api/v1/platform/overview"),
});
const { data: ranking } = useQuery({
queryKey: ["platformOrgRanking"],
queryFn: () => api.get<OrgRanking[]>("/api/v1/platform/org-ranking"),
});
const maxConv = Math.max(1, ...(ranking?.map((r) => r.conversations) || [1]));
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1"></p>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-28" />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<StatCard
title="入驻机构"
primary={stats?.total_orgs ?? 0}
secondary={`其中 ${stats?.active_orgs ?? 0} 个活跃`}
icon={Building2}
accent="bg-amber-50 text-amber-700"
/>
<StatCard
title="平台用户"
primary={formatNumber(stats?.total_users ?? 0)}
secondary={`${formatNumber(stats?.active_users ?? 0)} 活跃`}
icon={Users}
accent="bg-blue-50 text-blue-700"
/>
<StatCard
title="平台应用"
primary={formatNumber(stats?.total_apps ?? 0)}
secondary={`${stats?.approved_apps ?? 0} 已上架`}
icon={Boxes}
accent="bg-purple-50 text-purple-700"
/>
<StatCard
title="今日登录"
primary={formatNumber(stats?.today_logins ?? 0)}
secondary={`今日对话 ${formatNumber(stats?.today_convs ?? 0)}`}
icon={Activity}
accent="bg-green-50 text-green-700"
/>
<StatCard
title="本月 Token"
primary={formatNumber(stats?.monthly_tokens ?? 0)}
icon={Coins}
accent="bg-rose-50 text-rose-700"
/>
<StatCard
title="本月成本"
primary={`$${(stats?.monthly_cost ?? 0).toFixed(2)}`}
icon={DollarSign}
accent="bg-orange-50 text-orange-700"
/>
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{ranking?.length ? (
<div className="space-y-3">
{ranking.map((r, i) => (
<div key={r.id} className="flex items-center gap-3">
<span className="w-6 text-center text-sm font-bold text-muted-foreground">
{i + 1}
</span>
<span className="w-32 text-sm truncate font-medium">
{r.short_name || r.name}
</span>
<div className="flex-1 bg-muted rounded-full h-6 overflow-hidden">
<div
className="bg-gradient-to-r from-amber-500 to-orange-500 h-full rounded-full transition-all flex items-center justify-end px-2"
style={{ width: `${(r.conversations / maxConv) * 100}%` }}
>
<span className="text-xs text-white font-medium">{r.conversations}</span>
</div>
</div>
<span className="w-20 text-xs text-muted-foreground text-right">
{r.users}/{r.apps}
</span>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground text-sm"></div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,350 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
import type { ModelProvider } from "@/lib/types";
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 { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { Plus, Pencil, Power, Trash2, Cpu, CheckCircle2, XCircle } from "lucide-react";
import { Pagination } from "@/components/ui/pagination";
interface ProviderForm {
id?: string;
name: string;
base_url: string;
api_key: string;
models: string; // JSON string
is_active: boolean;
priority: number;
}
const emptyForm: ProviderForm = {
name: "",
base_url: "",
api_key: "",
models: '[]',
is_active: true,
priority: 0,
};
const PAGE_SIZE = 8;
export default function PlatformProvidersPage() {
const qc = useQueryClient();
const [editing, setEditing] = useState<ProviderForm | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ModelProvider | null>(null);
const [page, setPage] = useState(1);
const { data: providers } = useQuery({
queryKey: ["platformProviders"],
queryFn: () => api.get<ModelProvider[]>("/api/v1/platform/providers"),
});
const pagedProviders = providers?.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const create = useMutation({
mutationFn: (form: ProviderForm) => {
let modelsJSON: unknown;
try {
modelsJSON = JSON.parse(form.models || "[]");
} catch {
throw new Error("models 不是合法 JSON");
}
return api.post("/api/v1/platform/providers", {
...form,
models: modelsJSON,
});
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platformProviders"] });
toast.success("已创建");
setEditing(null);
},
onError: (e: Error) => toast.error(e.message),
});
const update = useMutation({
mutationFn: (form: ProviderForm) => {
let modelsJSON: unknown;
try {
modelsJSON = JSON.parse(form.models || "[]");
} catch {
throw new Error("models 不是合法 JSON");
}
const payload: Record<string, unknown> = {
name: form.name,
base_url: form.base_url,
models: modelsJSON,
is_active: form.is_active,
priority: form.priority,
};
if (form.api_key) payload.api_key = form.api_key;
return api.put(`/api/v1/platform/providers/${form.id}`, payload);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platformProviders"] });
toast.success("已更新");
setEditing(null);
},
onError: (e: Error) => toast.error(e.message),
});
const toggle = useMutation({
mutationFn: ({ id, is_active }: { id: string; is_active: boolean }) =>
api.put(`/api/v1/platform/providers/${id}`, { is_active }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["platformProviders"] }),
});
const remove = useMutation({
mutationFn: (id: string) => api.delete(`/api/v1/platform/providers/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platformProviders"] });
toast.success("已删除");
setDeleteTarget(null);
},
onError: (e: Error) => toast.error(e.message),
});
const handleSubmit = () => {
if (!editing) return;
if (!editing.name || !editing.base_url) {
toast.error("名称和 URL 不能为空");
return;
}
if (!editing.id && !editing.api_key) {
toast.error("新增时必须填写 API Key");
return;
}
if (editing.id) update.mutate(editing);
else create.mutate(editing);
};
return (
<div>
<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">
LLM OpenAI
</p>
</div>
<Button onClick={() => setEditing({ ...emptyForm })} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={providers?.length ?? 0}
onChange={setPage}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{pagedProviders?.map((p) => (
<div key={p.id} className="border rounded-lg p-4">
<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-purple-50 flex items-center justify-center">
<Cpu className="h-5 w-5 text-purple-700" />
</div>
<div>
<div className="font-medium">{p.name}</div>
<div className="text-xs text-muted-foreground font-mono">{p.base_url}</div>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-1 text-xs">
{p.is_active ? (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
) : (
<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"}>
{p.is_active ? "已启用" : "已停用"}
</span>
</div>
<span className="text-xs text-muted-foreground"> {p.priority}</span>
</div>
</div>
<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">
{m.display_name || m.name}
</Badge>
))
) : (
<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>
)}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 text-xs"
onClick={() =>
setEditing({
id: p.id,
name: p.name,
base_url: p.base_url,
api_key: "",
models: JSON.stringify(p.models, null, 2),
is_active: p.is_active,
priority: p.priority,
})
}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 text-xs"
onClick={() => toggle.mutate({ id: p.id, is_active: !p.is_active })}
>
<Power className="h-3 w-3" />
{p.is_active ? "停用" : "启用"}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 text-xs text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(p)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
{pagedProviders?.length === 0 && providers?.length === 0 && (
<div className="md:col-span-2 text-center py-12 text-muted-foreground text-sm border border-dashed rounded-lg">
</div>
)}
</div>
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editing?.id ? "编辑提供商" : "新增提供商"}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label> *</Label>
<Input
value={editing?.name || ""}
onChange={(e) => setEditing({ ...editing!, name: e.target.value })}
placeholder="阿里云百炼"
/>
</div>
<div>
<Label>Base URL *</Label>
<Input
value={editing?.base_url || ""}
onChange={(e) => setEditing({ ...editing!, base_url: e.target.value })}
placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1"
/>
</div>
<div>
<Label>API Key {editing?.id ? "(留空则不修改)" : "*"}</Label>
<Input
type="password"
value={editing?.api_key || ""}
onChange={(e) => setEditing({ ...editing!, api_key: e.target.value })}
placeholder={editing?.id ? "保留为空表示不更新密钥" : "sk-xxxx"}
/>
</div>
<div>
<Label> (JSON)</Label>
<Textarea
value={editing?.models || "[]"}
onChange={(e) => setEditing({ ...editing!, models: e.target.value })}
rows={5}
className="font-mono text-xs"
placeholder='[{"name":"qwen-plus","display_name":"通义千问-Plus"}]'
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input
type="number"
value={editing?.priority ?? 0}
onChange={(e) =>
setEditing({ ...editing!, priority: parseInt(e.target.value) || 0 })
}
/>
</div>
<div className="flex items-end gap-2">
<input
type="checkbox"
id="is_active"
checked={editing?.is_active ?? true}
onChange={(e) => setEditing({ ...editing!, is_active: e.target.checked })}
className="h-4 w-4"
/>
<Label htmlFor="is_active" className="cursor-pointer">
</Label>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setEditing(null)}>
</Button>
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
{editing?.id ? "保存" : "创建"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget?.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteTarget && remove.mutate(deleteTarget.id)}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+379
View File
@@ -0,0 +1,379 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
import type { ModelQuota, ModelProvider, PlatformUser } from "@/lib/types";
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 { toast } from "sonner";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { Pagination } from "@/components/ui/pagination";
interface QuotaForm {
id?: string;
target_type: "global" | "department" | "user";
target_id: string;
model_name: string;
daily_token_limit: string;
monthly_token_limit: string;
daily_request_limit: string;
is_active: boolean;
}
const emptyForm: QuotaForm = {
target_type: "global",
target_id: "",
model_name: "",
daily_token_limit: "",
monthly_token_limit: "",
daily_request_limit: "",
is_active: true,
};
const targetTypeLabel: Record<string, string> = {
global: "全局",
department: "部门",
user: "用户",
};
function formatNum(n?: number | null): string {
if (n == null) return "—";
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
return String(n);
}
const PAGE_SIZE = 20;
export default function PlatformQuotasPage() {
const qc = useQueryClient();
const [editing, setEditing] = useState<QuotaForm | null>(null);
const [page, setPage] = useState(1);
const { data: quotas } = useQuery({
queryKey: ["platformQuotas"],
queryFn: () => api.get<ModelQuota[]>("/api/v1/platform/quotas"),
});
const { data: providersData } = useQuery({
queryKey: ["platformProviders"],
queryFn: () => api.get<ModelProvider[]>("/api/v1/platform/providers"),
});
const { data: usersData } = useQuery({
queryKey: ["platformUsersAll"],
queryFn: () =>
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 pagedQuotas = quotas?.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const upsert = useMutation({
mutationFn: (form: QuotaForm) => {
const payload = {
id: form.id,
target_type: form.target_type,
target_id: form.target_type === "global" ? null : form.target_id || null,
model_name: form.model_name || null,
daily_token_limit: form.daily_token_limit ? parseInt(form.daily_token_limit) : null,
monthly_token_limit: form.monthly_token_limit ? parseInt(form.monthly_token_limit) : null,
daily_request_limit: form.daily_request_limit ? parseInt(form.daily_request_limit) : null,
is_active: form.is_active,
};
return api.post("/api/v1/platform/quotas", payload);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platformQuotas"] });
toast.success("已保存");
setEditing(null);
},
onError: (e: Error) => toast.error(e.message),
});
const remove = useMutation({
mutationFn: (id: string) => api.delete(`/api/v1/platform/quotas/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platformQuotas"] });
toast.success("已删除");
},
onError: (e: Error) => toast.error(e.message),
});
return (
<div>
<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">
Token
</p>
</div>
<Button onClick={() => setEditing({ ...emptyForm })} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
<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">
<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"> Token </th>
<th className="text-left p-3"> Token </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>
{pagedQuotas?.length ? (
pagedQuotas.map((q) => (
<tr key={q.id} className="border-t">
<td className="p-3">
<Badge variant="outline">{targetTypeLabel[q.target_type]}</Badge>
</td>
<td className="p-3 text-xs">
{q.target_type === "global" ? (
<span className="text-muted-foreground italic"></span>
) : (
q.target_name || q.target_id?.slice(0, 8)
)}
</td>
<td className="p-3 text-xs font-mono">
{q.model_name || <span className="text-muted-foreground"></span>}
</td>
<td className="p-3 text-xs">{formatNum(q.daily_token_limit)}</td>
<td className="p-3 text-xs">{formatNum(q.monthly_token_limit)}</td>
<td className="p-3 text-xs">{formatNum(q.daily_request_limit)}</td>
<td className="p-3">
<Badge variant={q.is_active ? "default" : "outline"}>
{q.is_active ? "启用" : "停用"}
</Badge>
</td>
<td className="p-3">
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 text-xs"
onClick={() =>
setEditing({
id: q.id,
target_type: q.target_type,
target_id: q.target_id || "",
model_name: q.model_name || "",
daily_token_limit: q.daily_token_limit?.toString() || "",
monthly_token_limit: q.monthly_token_limit?.toString() || "",
daily_request_limit: q.daily_request_limit?.toString() || "",
is_active: q.is_active,
})
}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 text-xs text-destructive hover:text-destructive"
onClick={() => {
if (confirm("确认删除此配额?")) remove.mutate(q.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="p-8 text-center text-muted-foreground text-sm">
</td>
</tr>
)}
</tbody>
</table>
</div>
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing?.id ? "编辑配额" : "新增配额"}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label></Label>
<Select
value={editing?.target_type || "global"}
onValueChange={(v) =>
v &&
setEditing({ ...editing!, target_type: v as QuotaForm["target_type"] })
}
>
<SelectTrigger>
<span>{targetTypeLabel[editing?.target_type || "global"]}</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="global"></SelectItem>
<SelectItem value="department"></SelectItem>
<SelectItem value="user"></SelectItem>
</SelectContent>
</Select>
</div>
{editing?.target_type === "user" && (
<div>
<Label></Label>
<Select
value={editing?.target_id || ""}
onValueChange={(v) => v && setEditing({ ...editing!, target_id: v })}
>
<SelectTrigger>
<span>
{editing?.target_id
? (usersData?.items?.find((u) => u.id === editing.target_id)?.name +
" (" +
(usersData?.items?.find((u) => u.id === editing.target_id)?.email ?? "") + ")")
: "请选择用户"}
</span>
</SelectTrigger>
<SelectContent>
{usersData?.items?.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name}
<span className="text-muted-foreground text-xs ml-1">({u.email})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{editing?.target_type === "department" && (
<div>
<Label> ID</Label>
<Input
value={editing?.target_id || ""}
onChange={(e) => setEditing({ ...editing!, target_id: e.target.value })}
placeholder="部门 UUID"
/>
</div>
)}
<div>
<Label> = </Label>
<Select
value={editing?.model_name || "__all__"}
onValueChange={(v) =>
v !== null && setEditing({ ...editing!, model_name: v === "__all__" ? "" : v })
}
>
<SelectTrigger>
<span>
{editing?.model_name
? (allModels.find((m) => m.name === editing.model_name)?.display_name ||
editing.model_name)
: "所有模型"}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
{allModels.map((m) => (
<SelectItem key={m.name} value={m.name}>
{m.display_name || m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label> Token </Label>
<Input
type="number"
value={editing?.daily_token_limit || ""}
onChange={(e) =>
setEditing({ ...editing!, daily_token_limit: e.target.value })
}
placeholder="留空=不限"
/>
</div>
<div>
<Label> Token </Label>
<Input
type="number"
value={editing?.monthly_token_limit || ""}
onChange={(e) =>
setEditing({ ...editing!, monthly_token_limit: e.target.value })
}
placeholder="留空=不限"
/>
</div>
</div>
<div>
<Label></Label>
<Input
type="number"
value={editing?.daily_request_limit || ""}
onChange={(e) =>
setEditing({ ...editing!, daily_request_limit: e.target.value })
}
placeholder="留空=不限"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="quota_active"
checked={editing?.is_active ?? true}
onChange={(e) => setEditing({ ...editing!, is_active: e.target.checked })}
className="h-4 w-4"
/>
<Label htmlFor="quota_active" className="cursor-pointer">
</Label>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setEditing(null)}>
</Button>
<Button
onClick={() => editing && upsert.mutate(editing)}
disabled={upsert.isPending}
>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+305
View File
@@ -0,0 +1,305 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
import type { PlatformUser, PlatformOrg } from "@/lib/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { Building2 } from "lucide-react";
import { Pagination } from "@/components/ui/pagination";
const roleLabels: Record<string, string> = {
super_admin: "平台管理员",
admin: "机构管理员",
creator: "创作者",
user: "普通用户",
};
const roleColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
super_admin: "destructive",
admin: "default",
creator: "secondary",
user: "outline",
};
export default function PlatformUsersPage() {
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [orgFilter, setOrgFilter] = useState("all");
const [roleFilter, setRoleFilter] = useState("all");
const [page, setPage] = useState(1);
const [migrateTarget, setMigrateTarget] = useState<PlatformUser | null>(null);
const [newOrgID, setNewOrgID] = useState("");
const { data: orgs } = useQuery({
queryKey: ["platformOrgs"],
queryFn: () => api.get<PlatformOrg[]>("/api/v1/platform/orgs"),
});
const { data } = useQuery({
queryKey: ["platformUsers", search, orgFilter, roleFilter, page],
queryFn: () => {
const p = new URLSearchParams();
p.set("page", String(page));
if (search) p.set("q", search);
if (orgFilter !== "all") p.set("org_id", orgFilter);
if (roleFilter !== "all") p.set("role", roleFilter);
return api.get<{
items: PlatformUser[];
total: number;
page: number;
page_size: number;
}>(`/api/v1/platform/users?${p}`);
},
});
// 切换过滤条件时重置到第一页
const resetSearch = (v: string) => {
setSearch(v);
setPage(1);
};
const resetOrg = (v: string | null) => {
setOrgFilter(v ?? "all");
setPage(1);
};
const resetRole = (v: string | null) => {
setRoleFilter(v ?? "all");
setPage(1);
};
const updateRole = useMutation({
mutationFn: ({ id, role }: { id: string; role: string }) =>
api.put(`/api/v1/platform/users/${id}/role`, { role }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platformUsers"] });
toast.success("角色已更新");
},
onError: (e: Error) => toast.error(e.message),
});
const updateStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
api.put(`/api/v1/platform/users/${id}/status`, { status }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platformUsers"] });
toast.success("状态已更新");
},
onError: (e: Error) => toast.error(e.message),
});
const assignOrg = useMutation({
mutationFn: ({ id, org_id }: { id: string; org_id: string }) =>
api.put(`/api/v1/platform/users/${id}/org`, { org_id }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platformUsers"] });
toast.success("已迁移到目标机构");
setMigrateTarget(null);
setNewOrgID("");
},
onError: (e: Error) => toast.error(e.message),
});
return (
<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>
</div>
<div className="flex flex-wrap items-center gap-3 mb-4">
<Input
placeholder="搜索姓名或邮箱..."
value={search}
onChange={(e) => resetSearch(e.target.value)}
className="w-64"
/>
<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>
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{orgs?.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.short_name || o.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={roleFilter} onValueChange={resetRole}>
<SelectTrigger className="w-36">
<span>{roleFilter === "all" ? "全部角色" : (roleLabels[roleFilter] || roleFilter)}</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="super_admin"></SelectItem>
<SelectItem value="admin"></SelectItem>
<SelectItem value="creator"></SelectItem>
<SelectItem value="user"></SelectItem>
</SelectContent>
</Select>
</div>
<Pagination
page={data?.page ?? 1}
pageSize={data?.page_size ?? 20}
total={data?.total ?? 0}
onChange={setPage}
/>
<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>
{data?.items?.map((u) => (
<tr key={u.id} className="border-t">
<td className="p-3">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={u.avatar_url} />
<AvatarFallback>{u.name.charAt(0)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{u.name}</div>
<div className="text-xs text-muted-foreground">{u.email}</div>
</div>
</div>
</td>
<td className="p-3">
{u.org_name ? (
<Badge variant="outline" className="gap-1">
<Building2 className="h-3 w-3" />
{u.org_short || u.org_name}
</Badge>
) : (
<span className="text-xs text-muted-foreground italic"></span>
)}
</td>
<td className="p-3">
<Badge variant={roleColors[u.role]}>{roleLabels[u.role]}</Badge>
</td>
<td className="p-3">
<Badge variant={u.status === "active" ? "default" : "destructive"}>
{u.status === "active" ? "正常" : "禁用"}
</Badge>
</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
defaultValue={u.role}
onValueChange={(role) => role && updateRole.mutate({ id: u.id, role })}
>
<SelectTrigger className="w-28 h-7 text-xs">
<span>{roleLabels[u.role] || u.role}</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="user"></SelectItem>
<SelectItem value="creator"></SelectItem>
<SelectItem value="admin"></SelectItem>
<SelectItem value="super_admin"></SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => {
setMigrateTarget(u);
setNewOrgID(u.org_id || "");
}}
>
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() =>
updateStatus.mutate({
id: u.id,
status: u.status === "active" ? "disabled" : "active",
})
}
>
{u.status === "active" ? "禁用" : "启用"}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 迁移机构对话框 */}
<Dialog open={!!migrateTarget} onOpenChange={(o) => !o && setMigrateTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{migrateTarget?.name}
</p>
<Select value={newOrgID} onValueChange={(v) => setNewOrgID(v ?? "")}>
<SelectTrigger>
<SelectValue placeholder="选择目标机构" />
</SelectTrigger>
<SelectContent>
{orgs?.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.name}{o.short_name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setMigrateTarget(null)}>
</Button>
<Button
disabled={!newOrgID || newOrgID === migrateTarget?.org_id}
onClick={() =>
migrateTarget && assignOrg.mutate({ id: migrateTarget.id, org_id: newOrgID })
}
>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}