Files
deerflow-code/frontend-web/src/components/workspace/settings/skill-settings-page.tsx
T
xuhdandClaude Sonnet 4.6 40cd8a24af feat: admin skill public upload + user management page
- Admin users can choose to install skills to public (built-in) or custom category via UI radio selector in InstallSkillDialog
- Backend: SkillStorage.ainstall_skill_from_archive accepts target_category param; non-admin 403 on public install
- Backend: AgentStore/SkillStore add count_owned(user_id); UserRepository/LocalAuthProvider add list_users()
- Backend: GET /api/v1/auth/users endpoint (admin-only) returns all users with skill_count and agent_count
- Frontend: admin-only "用户管理" nav item in sidebar; /page/workspace/admin/users route with user table
- i18n: added sidebar.users and adminUsers section in zh-CN/en-US

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:40:23 +08:00

647 lines
22 KiB
TypeScript

"use client";
import {
AlertCircle,
CheckCircle2,
EyeIcon,
EyeOffIcon,
FileArchive,
Loader2,
SparklesIcon,
Trash2Icon,
TrendingUpIcon,
Upload,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import {
Item,
ItemActions,
ItemTitle,
ItemContent,
ItemDescription,
} from "@/components/ui/item";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useDeleteCustomSkill,
useEnableSkill,
useInstallSkillUpload,
useSetSkillPublished,
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
const { t } = useI18n();
const { skills, isLoading, error } = useSkills();
return (
<SettingsSection
title={t.settings.skills.title}
description={t.settings.skills.description}
>
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div>Error: {error.message}</div>
) : (
<SkillSettingsList skills={skills} onClose={onClose} />
)}
</SettingsSection>
);
}
function SkillSettingsList({
skills,
onClose,
}: {
skills: Skill[];
onClose?: () => void;
}) {
const { t } = useI18n();
const router = useRouter();
const [filter, setFilter] = useState<string>("public");
const [installOpen, setInstallOpen] = useState(false);
const { mutate: enableSkill } = useEnableSkill();
const filteredSkills = useMemo(
() => skills.filter((skill) => skill.category === filter),
[skills, filter],
);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
const handleCreateSkill = () => {
onClose?.();
router.push("/?mode=skill");
};
return (
<div className="flex w-full flex-col gap-4">
<header className="flex justify-between">
<div className="flex gap-2">
<Tabs defaultValue="public" onValueChange={setFilter}>
<TabsList variant="line">
<TabsTrigger value="public">{t.common.public}</TabsTrigger>
<TabsTrigger value="custom">{t.common.custom}</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
onClose?.();
router.push("/page/workspace/skills/evolution");
}}
>
<TrendingUpIcon className="size-4" />
{t.settings.skills.goToEvolution}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setInstallOpen(true)}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
>
<Upload className="size-4" />
{t.settings.skills.installSkill}
</Button>
<Button size="sm" onClick={handleCreateSkill}>
<SparklesIcon className="size-4" />
{t.settings.skills.createSkill}
</Button>
</div>
</header>
{filteredSkills.length === 0 && (
<EmptySkill onCreateSkill={handleCreateSkill} />
)}
{filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
onToggleEnabled={(enabled) =>
enableSkill({ skillName: skill.name, enabled })
}
/>
))}
<InstallSkillDialog
open={installOpen}
onClose={() => setInstallOpen(false)}
isAdmin={isAdmin}
/>
</div>
);
}
function SkillRow({
skill,
currentUserId,
isAdmin,
onToggleEnabled,
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
onToggleEnabled: (enabled: boolean) => void;
}) {
const { t } = useI18n();
const setPublished = useSetSkillPublished();
const deleteSkill = useDeleteCustomSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without
// the multitenancy fields still classify public skills correctly.
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
const isOwned =
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isFromOthers = isCustom && !isBuiltin && !isOwned;
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin);
const canTogglePublish = canManage && !isBuiltin;
async function handleTogglePublished(next: boolean) {
try {
await setPublished.mutateAsync({ skillName: skill.name, published: next });
toast.success(
next
? t.settings.skills.publishSuccess
: t.settings.skills.unpublishSuccess,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete() {
try {
await deleteSkill.mutateAsync(skill.name);
toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<>
<Item className="w-full" variant="outline">
<ItemContent>
<ItemTitle>
<div className="flex flex-wrap items-center gap-2">
<span>{skill.name}</span>
{isBuiltin && isCustom && (
<Badge variant="secondary" className="text-xs">
{t.settings.skills.badgeBuiltin}
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
{t.settings.skills.badgeMine}
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={
skill.owner_user_id
? t.settings.skills.badgeFromOthersTitle.replace(
"{user_id}",
skill.owner_user_id,
)
: undefined
}
>
{t.settings.skills.badgeFromOthers}
</Badge>
)}
{isOwned && skill.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title={t.settings.skills.badgePublishedTitle}
>
{t.settings.skills.badgePublished}
</Badge>
)}
</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{skill.description}
</ItemDescription>
</ItemContent>
<ItemActions>
{canTogglePublish && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPublished.isPending}
onClick={() => void handleTogglePublished(!skill.published)}
title={
skill.published
? t.settings.skills.unpublishAction
: t.settings.skills.publishAction
}
>
{skill.published ? (
<EyeOffIcon className="size-3.5" />
) : (
<EyeIcon className="size-3.5" />
)}
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
<Trash2Icon className="size-3.5" />
</Button>
)}
<Switch
checked={skill.enabled}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onCheckedChange={(checked) => onToggleEnabled(checked)}
/>
</ItemActions>
</Item>
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.settings.skills.deleteAction}</DialogTitle>
<DialogDescription>
{t.settings.skills.deleteConfirm.replace("{name}", skill.name)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteSkill.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteSkill.isPending}
>
{deleteSkill.isPending ? t.common.loading : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
const { t } = useI18n();
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<SparklesIcon />
</EmptyMedia>
<EmptyTitle>{t.settings.skills.emptyTitle}</EmptyTitle>
<EmptyDescription>
{t.settings.skills.emptyDescription}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={onCreateSkill}>{t.settings.skills.emptyButton}</Button>
</EmptyContent>
</Empty>
);
}
function InstallSkillDialog({
open,
onClose,
isAdmin,
}: {
open: boolean;
onClose: () => void;
isAdmin: boolean;
}) {
const { t } = useI18n();
const dialog = t.settings.skills.installDialog;
const inputRef = useRef<HTMLInputElement | null>(null);
const [file, setFile] = useState<File | null>(null);
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom");
const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload();
const reset = useCallback(() => {
setFile(null);
setValidation(null);
setValidationError(null);
setIsDragging(false);
setTargetCategory("custom");
if (inputRef.current) inputRef.current.value = "";
}, []);
const handleClose = useCallback(() => {
if (validateMutation.isPending || installMutation.isPending) return;
reset();
onClose();
}, [installMutation.isPending, onClose, reset, validateMutation.isPending]);
const acceptFile = useCallback(
async (selected: File) => {
const lower = selected.name.toLowerCase();
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
toast.error(dialog.rejectInvalidExt);
return;
}
setFile(selected);
setValidation(null);
setValidationError(null);
try {
const result = await validateMutation.mutateAsync(selected);
setValidation(result);
} catch (err) {
setValidationError(err instanceof Error ? err.message : String(err));
}
},
[dialog.rejectInvalidExt, validateMutation],
);
const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
// Stop the global chat-upload listener on `document` from also receiving this drop.
// React's stopPropagation alone doesn't reach native document listeners — we need to
// halt the underlying DOM event too.
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setIsDragging(false);
const dropped = e.dataTransfer.files?.[0];
if (dropped) void acceptFile(dropped);
},
[acceptFile],
);
const onPick = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files?.[0];
if (picked) void acceptFile(picked);
},
[acceptFile],
);
const handleInstall = useCallback(
async (overwrite: boolean) => {
if (!file) return;
try {
const result = await installMutation.mutateAsync({ file, overwrite, targetCategory });
toast.success(
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
"{name}",
result.skill_name,
),
);
reset();
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
},
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset, targetCategory],
);
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
// Admin targeting public directory can overwrite an existing public skill.
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{dialog.title}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
className={cn(
"flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-6 text-center transition-colors",
isDragging ? "border-primary bg-accent/40" : "border-muted-foreground/30",
)}
>
<FileArchive className="text-muted-foreground size-8" />
<div className="text-muted-foreground text-sm">
{dialog.dropHint}
<button
type="button"
className="text-primary ml-1 underline underline-offset-4"
onClick={() => inputRef.current?.click()}
>
{dialog.chooseFile}
</button>
</div>
{file && (
<div className="text-muted-foreground mt-1 text-xs">
{file.name} · {(file.size / 1024).toFixed(1)} KB
</div>
)}
<input
ref={inputRef}
type="file"
accept=".zip,.skill,application/zip"
hidden
onChange={onPick}
/>
</div>
{isAdmin && (
<div className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
<div className="flex gap-4">
{(["custom", "public"] as const).map((cat) => (
<label key={cat} className="flex cursor-pointer items-center gap-1.5">
<input
type="radio"
name="targetCategory"
value={cat}
checked={targetCategory === cat}
onChange={() => setTargetCategory(cat)}
className="accent-primary"
/>
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
</label>
))}
</div>
</div>
)}
{validateMutation.isPending && (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
{dialog.validating}
</div>
)}
{validationError && (
<div className="text-destructive flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{validationError}</span>
</div>
)}
{validation && !validationError && (
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm">
<div className="flex items-center gap-2 text-emerald-600">
<CheckCircle2 className="size-4" />
<span className="font-medium">{validation.skill_name}</span>
</div>
<Field label={dialog.descriptionLabel}>
<span className="line-clamp-3 whitespace-pre-wrap">
{validation.description || "—"}
</span>
</Field>
{validation.license && (
<Field label={dialog.licenseLabel}>{validation.license}</Field>
)}
{validation.allowed_tools.length > 0 && (
<Field label={dialog.toolsLabel}>
<div className="flex flex-wrap gap-1">
{validation.allowed_tools.map((tool) => (
<Badge key={tool} variant="secondary" className="text-xs">
{tool}
</Badge>
))}
</div>
</Field>
)}
{validation.warnings.length > 0 && (
<Field label={dialog.warningsLabel}>
<ul className="list-disc pl-4 text-xs">
{validation.warnings.map((w, idx) => (
<li key={idx}>{w}</li>
))}
</ul>
</Field>
)}
{isCustomConflict && (
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{dialog.conflictCustom.replace("{name}", validation.skill_name)}
</span>
</div>
)}
{isPublicConflict && (
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
</span>
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={installMutation.isPending}
>
{dialog.cancel}
</Button>
{canOverwrite && (
<Button
variant="destructive"
onClick={() => handleInstall(true)}
disabled={installMutation.isPending}
>
{installMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{dialog.replace}
</Button>
)}
<Button
onClick={() => handleInstall(false)}
disabled={!canInstall || installMutation.isPending}
>
{installMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{installMutation.isPending ? dialog.installing : dialog.install}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-[80px_1fr] gap-2">
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
<div className="min-w-0">{children}</div>
</div>
);
}