96b944ac8d
- 更新 ESLint 配置添加 react-hooks 规则 - 运行 Prettier 格式化所有前端文件 - 为 users、apps、audit、quotas 页面添加 Skeleton 加载骨架屏 - 修复动态组件渲染问题(CategoryIcon/TypeIcon 使用 React.createElement) - 修复 useRef 渲染期间访问问题(改用 useMemo) - 修复 effect 中 setState 同步调用问题 - 新增 global-not-found.tsx 404 页面 - 修复 knowledge 页面引号转义问题
267 lines
9.3 KiB
TypeScript
267 lines
9.3 KiB
TypeScript
"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>
|
|
);
|
|
}
|