Initial commit: GovAI 政务AI平台
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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">支持上传 PDF、DOCX、TXT、MD、CSV、XLSX 格式</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
搜索结果:“{query}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/store");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user