diff --git a/frontend-web/src/components/workspace/admin/position-management-page.tsx b/frontend-web/src/components/workspace/admin/position-management-page.tsx
new file mode 100644
index 00000000..07ed6e18
--- /dev/null
+++ b/frontend-web/src/components/workspace/admin/position-management-page.tsx
@@ -0,0 +1,479 @@
+"use client";
+
+import {
+ CheckIcon,
+ Loader2,
+ PencilIcon,
+ PlusIcon,
+ RefreshCwIcon,
+ Trash2Icon,
+ UserPlusIcon,
+ UsersIcon,
+} from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { resolveTagColor } from "@/components/workspace/tags/tag-colors";
+import {
+ POSITION_RESOURCE_HINT,
+ POSITION_RESOURCE_LABELS,
+ POSITION_RESOURCE_TYPES,
+ resourceTypeToTagType,
+ useAddMembers,
+ useCreatePosition,
+ useDeletePosition,
+ usePositionMembers,
+ usePositionTagBindings,
+ usePositionUsers,
+ usePositions,
+ useRemoveMembers,
+ useResyncPosition,
+ useSetTagBindings,
+ useUpdatePosition,
+ type Position,
+ type PositionResourceType,
+ type TagBindings,
+} from "@/core/positions";
+import { useTags } from "@/core/tags";
+import { cn } from "@/lib/utils";
+
+function errMsg(e: unknown): string {
+ return String(e instanceof Error ? e.message : e);
+}
+
+/** Inline multi-select chip row for the tags of one resource type. */
+function TagMultiSelect({
+ resourceType,
+ value,
+ onChange,
+}: {
+ resourceType: PositionResourceType;
+ value: string[];
+ onChange: (next: string[]) => void;
+}) {
+ const { data: tags, isLoading } = useTags(resourceTypeToTagType(resourceType));
+ const toggle = (id: string) =>
+ onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, id]);
+
+ if (isLoading) {
+ return ;
+ }
+ if (!tags || tags.length === 0) {
+ return (
+
+ 暂无{POSITION_RESOURCE_LABELS[resourceType]}标签,请先到「标签管理」创建。
+
+ );
+ }
+ return (
+
+ {tags.map((tag) => {
+ const checked = value.includes(tag.id);
+ const color = resolveTagColor(tag.color);
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function PositionFormDialog({
+ open,
+ onOpenChange,
+ editing,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ editing: Position | null;
+}) {
+ const createPosition = useCreatePosition();
+ const updatePosition = useUpdatePosition();
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [isDefault, setIsDefault] = useState(false);
+
+ const [syncedFor, setSyncedFor] = useState(null);
+ const syncKey = `${open}:${editing?.id ?? "new"}`;
+ if (open && syncedFor !== syncKey) {
+ setName(editing?.name ?? "");
+ setDescription(editing?.description ?? "");
+ setIsDefault(editing?.is_default ?? false);
+ setSyncedFor(syncKey);
+ }
+ if (!open && syncedFor !== null) setSyncedFor(null);
+
+ const pending = createPosition.isPending || updatePosition.isPending;
+ const handleSave = async () => {
+ const trimmed = name.trim();
+ if (!trimmed) return;
+ try {
+ if (editing) {
+ await updatePosition.mutateAsync({ id: editing.id, input: { name: trimmed, description, is_default: isDefault } });
+ } else {
+ await createPosition.mutateAsync({ name: trimmed, description, is_default: isDefault });
+ }
+ onOpenChange(false);
+ } catch (e) {
+ toast.error(errMsg(e));
+ }
+ };
+
+ return (
+
+ );
+}
+
+function AddMembersDialog({
+ open,
+ onOpenChange,
+ positionId,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ positionId: string;
+}) {
+ const { data: users, isLoading } = usePositionUsers();
+ const addMembers = useAddMembers();
+ const [selected, setSelected] = useState>(new Set());
+ const [search, setSearch] = useState("");
+
+ useEffect(() => {
+ if (open) setSelected(new Set());
+ }, [open]);
+
+ const filtered = useMemo(() => {
+ const list = users ?? [];
+ const q = search.trim().toLowerCase();
+ return q ? list.filter((u) => u.email.toLowerCase().includes(q)) : list;
+ }, [users, search]);
+
+ const toggle = (id: string) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ const handleSave = async () => {
+ if (selected.size === 0) return;
+ try {
+ await addMembers.mutateAsync({ positionId, userIds: [...selected] });
+ toast.success(`已为 ${selected.size} 名用户配置岗位`);
+ onOpenChange(false);
+ } catch (e) {
+ toast.error(errMsg(e));
+ }
+ };
+
+ return (
+
+ );
+}
+
+function PositionDetail({ position }: { position: Position }) {
+ const { data: serverBindings } = usePositionTagBindings(position.id);
+ const { data: members } = usePositionMembers(position.id);
+ const setBindings = useSetTagBindings();
+ const removeMembers = useRemoveMembers();
+ const resync = useResyncPosition();
+ const [draft, setDraft] = useState({});
+ const [addOpen, setAddOpen] = useState(false);
+
+ // Reload the draft whenever the selected position or its server bindings change.
+ const [loadedFor, setLoadedFor] = useState(null);
+ const loadKey = `${position.id}:${serverBindings ? "ready" : "loading"}`;
+ if (serverBindings && loadedFor !== loadKey) {
+ setDraft({ ...serverBindings });
+ setLoadedFor(loadKey);
+ }
+
+ const setType = (rt: PositionResourceType, ids: string[]) =>
+ setDraft((prev) => ({ ...prev, [rt]: ids }));
+
+ const handleSaveBindings = async () => {
+ try {
+ await setBindings.mutateAsync({ positionId: position.id, bindings: draft });
+ toast.success("标签配置已保存,已同步岗位成员");
+ } catch (e) {
+ toast.error(errMsg(e));
+ }
+ };
+
+ const handleResync = async () => {
+ try {
+ const n = await resync.mutateAsync(position.id);
+ toast.success(`已重新同步 ${n} 名成员`);
+ } catch (e) {
+ toast.error(errMsg(e));
+ }
+ };
+
+ const handleRemove = async (userId: string) => {
+ try {
+ await removeMembers.mutateAsync({ positionId: position.id, userIds: [userId] });
+ } catch (e) {
+ toast.error(errMsg(e));
+ }
+ };
+
+ return (
+
+
+
+
默认资源标签配置
+
+
+
+ {POSITION_RESOURCE_TYPES.map((rt) => (
+
+
+ {POSITION_RESOURCE_LABELS[rt]}
+ {POSITION_RESOURCE_HINT[rt]}
+
+
setType(rt, ids)} />
+
+ ))}
+
+
+
+
+
+
+ 岗位成员 ({members?.length ?? 0})
+
+
+
+
+
+
+ {members && members.length > 0 ? (
+
+ {members.map((m) => (
+
+ {m.email}
+
+
+ ))}
+
+ ) : (
+
暂无成员
+ )}
+
+
+
+
+ );
+}
+
+export function PositionManagementPage() {
+ const { data: positions, isLoading, error } = usePositions();
+ const deletePosition = useDeletePosition();
+ const [selectedId, setSelectedId] = useState(null);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+
+ const selected = useMemo(
+ () => positions?.find((p) => p.id === selectedId) ?? null,
+ [positions, selectedId],
+ );
+ // Auto-select the first position once loaded.
+ useEffect(() => {
+ if (!selectedId && positions && positions.length > 0) setSelectedId(positions[0].id);
+ }, [positions, selectedId]);
+
+ const handleDelete = async (p: Position) => {
+ if (!window.confirm(`确认删除岗位「${p.name}」?其成员将回退到默认岗位。`)) return;
+ try {
+ await deletePosition.mutateAsync(p.id);
+ if (selectedId === p.id) setSelectedId(null);
+ } catch (e) {
+ toast.error(errMsg(e));
+ }
+ };
+
+ return (
+
+
+
+
岗位管理
+
+ 为岗位配置默认智能体/技能/定时任务/常用提示词/推荐问题的标签,并批量分配给用户。
+
+
+
+
+
+ {isLoading && (
+
+ 加载中…
+
+ )}
+ {error &&
{errMsg(error)}
}
+
+ {!isLoading && !error && (
+
+
+ {positions && positions.length === 0 && (
+
+ 还没有岗位,点击「新增岗位」创建。
+
+ )}
+ {positions?.map((p) => (
+
setSelectedId(p.id)}
+ className={cn(
+ "group cursor-pointer rounded-lg border p-3 transition-colors",
+ selectedId === p.id ? "border-foreground bg-muted/40" : "hover:bg-muted/30",
+ )}
+ >
+
+
+ {p.name}
+ {p.is_default && (
+ 默认
+ )}
+
+
+
+
+
+
+
+ {p.member_count} 名成员
+
+
+ ))}
+
+
+
+ {selected ? (
+
+ ) : (
+
+ 选择左侧一个岗位以配置标签与成员
+
+ )}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend-web/src/components/workspace/admin/prompt-templates-page.tsx b/frontend-web/src/components/workspace/admin/prompt-templates-page.tsx
new file mode 100644
index 00000000..139c4f88
--- /dev/null
+++ b/frontend-web/src/components/workspace/admin/prompt-templates-page.tsx
@@ -0,0 +1,258 @@
+"use client";
+
+import { CheckIcon, Loader2, PencilIcon, PlusIcon, TagIcon, Trash2Icon } from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { resolveTagColor } from "@/components/workspace/tags/tag-colors";
+import {
+ usePromptTemplates,
+ useCreatePromptTemplate,
+ useDeletePromptTemplate,
+ useUpdatePromptTemplate,
+ type PromptTemplate,
+} from "@/core/custom-prompts";
+import { useResourceTags, useSetResourceTags, useTags } from "@/core/tags";
+import { cn } from "@/lib/utils";
+
+function errMsg(e: unknown): string {
+ return String(e instanceof Error ? e.message : e);
+}
+
+function TemplateFormDialog({
+ open,
+ onOpenChange,
+ editing,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ editing: PromptTemplate | null;
+}) {
+ const createT = useCreatePromptTemplate();
+ const updateT = useUpdatePromptTemplate();
+ const [title, setTitle] = useState("");
+ const [content, setContent] = useState("");
+
+ const [syncedFor, setSyncedFor] = useState(null);
+ const syncKey = `${open}:${editing?.id ?? "new"}`;
+ if (open && syncedFor !== syncKey) {
+ setTitle(editing?.title ?? "");
+ setContent(editing?.content ?? "");
+ setSyncedFor(syncKey);
+ }
+ if (!open && syncedFor !== null) setSyncedFor(null);
+
+ const pending = createT.isPending || updateT.isPending;
+ const handleSave = async () => {
+ if (!content.trim()) return;
+ try {
+ if (editing) {
+ await updateT.mutateAsync({ id: editing.id, input: { title: title.trim(), content } });
+ } else {
+ await createT.mutateAsync({ title: title.trim(), content });
+ }
+ onOpenChange(false);
+ } catch (e) {
+ toast.error(errMsg(e));
+ }
+ };
+
+ return (
+
+ );
+}
+
+function TemplateTagsDialog({
+ open,
+ onOpenChange,
+ template,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ template: PromptTemplate;
+}) {
+ const { data: tags } = useTags("user_prompt");
+ const { data: current } = useResourceTags("user_prompt", template.id, open);
+ const setTags = useSetResourceTags();
+ const [selected, setSelected] = useState([]);
+
+ const [syncedFor, setSyncedFor] = useState(null);
+ const syncKey = `${open}:${current ? "ready" : "loading"}`;
+ if (open && current && syncedFor !== syncKey) {
+ setSelected(current.map((t) => t.id));
+ setSyncedFor(syncKey);
+ }
+ if (!open && syncedFor !== null) setSyncedFor(null);
+
+ const toggle = (id: string) =>
+ setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
+
+ const handleSave = async () => {
+ try {
+ await setTags.mutateAsync({ targetType: "user_prompt", targetId: template.id, tagIds: selected });
+ onOpenChange(false);
+ } catch (e) {
+ toast.error(errMsg(e));
+ }
+ };
+
+ return (
+
+ );
+}
+
+export function PromptTemplatesPage() {
+ const { data: templates, isLoading, error } = usePromptTemplates();
+ const deleteT = useDeletePromptTemplate();
+ const [formOpen, setFormOpen] = useState(false);
+ const [tagsOpen, setTagsOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [tagging, setTagging] = useState(null);
+
+ const handleDelete = async (tpl: PromptTemplate) => {
+ if (!window.confirm(`确认删除模板「${tpl.title || tpl.content.slice(0, 20)}」?已分发给用户的克隆将在下次同步时移除。`)) return;
+ try {
+ await deleteT.mutateAsync(tpl.id);
+ } catch (e) {
+ toast.error(errMsg(e));
+ }
+ };
+
+ return (
+
+
+
+
常用提示词模板
+
+ 管理员维护提示词模板并打标签;岗位配置了对应标签的用户,会自动加载这些模板到「常用提示词」。
+
+
+
+
+
+ {isLoading && (
+
+ 加载中…
+
+ )}
+ {error &&
{errMsg(error)}
}
+
+ {!isLoading && !error && templates && templates.length === 0 && (
+
+ 还没有模板,点击「新增模板」创建。
+
+ )}
+
+ {!isLoading && !error && templates && templates.length > 0 && (
+
+
+
+
+ | 标题 |
+ 内容 |
+ 操作 |
+
+
+
+ {templates.map((tpl, i) => (
+
+ | {tpl.title || "—"} |
+ {tpl.content} |
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ {tagging &&
}
+
+ );
+}
diff --git a/frontend-web/src/components/workspace/admin/tag-management-page.tsx b/frontend-web/src/components/workspace/admin/tag-management-page.tsx
index d0484985..57bc6a2a 100644
--- a/frontend-web/src/components/workspace/admin/tag-management-page.tsx
+++ b/frontend-web/src/components/workspace/admin/tag-management-page.tsx
@@ -27,6 +27,7 @@ import {
type Tag,
type TagType,
} from "@/core/tags";
+import { TAG_TYPES, TAG_TYPE_LABELS } from "@/core/tags/types";
import { cn } from "@/lib/utils";
function TagFormDialog({
@@ -191,11 +192,11 @@ export function TagManagementPage() {
onValueChange={(v) => setActiveType(v as TagType)}
>
- {t.adminTags.typeAgent}
- {t.adminTags.typeSkill}
-
- {t.adminTags.typeScheduledTask}
-
+ {TAG_TYPES.map((tt) => (
+
+ {TAG_TYPE_LABELS[tt]}
+
+ ))}