feat: 新增岗位管理、提示词模板与资源标签,完善用户提示词与推荐问题接口
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2f414c6e08
commit
3855c30edd
52 files changed
+3143
-57
No files matched your search
@@ -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 <Loader2 className="text-muted-foreground size-4 animate-spin" />;
|
||||
}
|
||||
if (!tags || tags.length === 0) {
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
暂无{POSITION_RESOURCE_LABELS[resourceType]}标签,请先到「标签管理」创建。
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{tags.map((tag) => {
|
||||
const checked = value.includes(tag.id);
|
||||
const color = resolveTagColor(tag.color);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggle(tag.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-all",
|
||||
color.chip,
|
||||
checked ? "ring-foreground/40 ring-2 ring-offset-1" : "opacity-45 hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{checked && <CheckIcon className="size-3" />}
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "编辑岗位" : "新增岗位"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">岗位名称</label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:理赔专员" maxLength={191} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">描述</label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} maxLength={1024} />
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
|
||||
设为默认岗位(未分配岗位的用户继承此岗位的配置)
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={pending || !name.trim()}>
|
||||
{pending && <Loader2 className="size-4 animate-spin" />}保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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<Set<string>>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量添加成员</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索邮箱…" />
|
||||
<div className="max-h-72 overflow-y-auto rounded-md border">
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-6">
|
||||
<Loader2 className="size-4 animate-spin" />加载用户…
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="text-muted-foreground py-6 text-center text-sm">无匹配用户</div>
|
||||
)}
|
||||
{filtered.map((u) => {
|
||||
const checked = selected.has(u.id);
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => toggle(u.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 border-b px-3 py-2 text-left text-sm last:border-b-0 hover:bg-muted/50",
|
||||
checked && "bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{u.email}
|
||||
{u.system_role === "admin" && <span className="text-muted-foreground ml-2 text-xs">(管理员)</span>}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{u.position_id && u.position_id !== positionId && (
|
||||
<span className="text-muted-foreground text-xs">已属其他岗位</span>
|
||||
)}
|
||||
<span className={cn("flex size-4 items-center justify-center rounded border", checked ? "bg-foreground text-background border-foreground" : "border-muted-foreground/40")}>
|
||||
{checked && <CheckIcon className="size-3" />}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={addMembers.isPending || selected.size === 0}>
|
||||
{addMembers.isPending && <Loader2 className="size-4 animate-spin" />}添加 {selected.size > 0 ? `(${selected.size})` : ""}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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<TagBindings>({});
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
||||
// Reload the draft whenever the selected position or its server bindings change.
|
||||
const [loadedFor, setLoadedFor] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">默认资源标签配置</h3>
|
||||
<Button size="sm" onClick={handleSaveBindings} disabled={setBindings.isPending}>
|
||||
{setBindings.isPending && <Loader2 className="size-4 animate-spin" />}保存标签配置
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y">
|
||||
{POSITION_RESOURCE_TYPES.map((rt) => (
|
||||
<div key={rt} className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sm font-medium">{POSITION_RESOURCE_LABELS[rt]}</span>
|
||||
<span className="text-muted-foreground text-xs">{POSITION_RESOURCE_HINT[rt]}</span>
|
||||
</div>
|
||||
<TagMultiSelect resourceType={rt} value={draft[rt] ?? []} onChange={(ids) => setType(rt, ids)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold">
|
||||
<UsersIcon className="size-4" />岗位成员 ({members?.length ?? 0})
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleResync} disabled={resync.isPending}>
|
||||
{resync.isPending ? <Loader2 className="size-4 animate-spin" /> : <RefreshCwIcon className="size-4" />}重新同步
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
<UserPlusIcon className="size-4" />添加成员
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{members && members.length > 0 ? (
|
||||
<div className="flex flex-col divide-y">
|
||||
{members.map((m) => (
|
||||
<div key={m.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<span className="truncate">{m.email}</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleRemove(m.id)} aria-label="移除">
|
||||
<Trash2Icon className="text-destructive size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground py-6 text-center text-sm">暂无成员</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddMembersDialog open={addOpen} onOpenChange={setAddOpen} positionId={position.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PositionManagementPage() {
|
||||
const { data: positions, isLoading, error } = usePositions();
|
||||
const deletePosition = useDeletePosition();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Position | null>(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 (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">岗位管理</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
为岗位配置默认智能体/技能/定时任务/常用提示词/推荐问题的标签,并批量分配给用户。
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}>
|
||||
<PlusIcon className="size-4" />新增岗位
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12">
|
||||
<Loader2 className="size-4 animate-spin" />加载中…
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="text-destructive text-sm">{errMsg(error)}</div>}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
|
||||
<div className="flex flex-col gap-2">
|
||||
{positions && positions.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed py-8 text-center text-sm">
|
||||
还没有岗位,点击「新增岗位」创建。
|
||||
</div>
|
||||
)}
|
||||
{positions?.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => 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",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{p.name}</span>
|
||||
{p.is_default && (
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-950 dark:text-blue-300">默认</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button variant="ghost" size="icon" onClick={(e) => { e.stopPropagation(); setEditing(p); setDialogOpen(true); }} aria-label="编辑">
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={(e) => { e.stopPropagation(); void handleDelete(p); }} aria-label="删除">
|
||||
<Trash2Icon className="text-destructive size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<UsersIcon className="size-3" />{p.member_count} 名成员
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{selected ? (
|
||||
<PositionDetail key={selected.id} position={selected} />
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed py-16 text-center text-sm">
|
||||
选择左侧一个岗位以配置标签与成员
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PositionFormDialog open={dialogOpen} onOpenChange={setDialogOpen} editing={editing} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "编辑提示词模板" : "新增提示词模板"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">标题</label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="如:理赔标准话术" maxLength={255} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">内容</label>
|
||||
<Textarea value={content} onChange={(e) => setContent(e.target.value)} rows={5} placeholder="提示词正文…" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={pending || !content.trim()}>
|
||||
{pending && <Loader2 className="size-4 animate-spin" />}保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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<string[]>([]);
|
||||
|
||||
const [syncedFor, setSyncedFor] = useState<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>为模板打标签</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!tags || tags.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
暂无「常用提示词」标签,请先到「标签管理」创建。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags.map((tag) => {
|
||||
const checked = selected.includes(tag.id);
|
||||
const color = resolveTagColor(tag.color);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggle(tag.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-all",
|
||||
color.chip,
|
||||
checked ? "ring-foreground/40 ring-2 ring-offset-1" : "opacity-45 hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{checked && <CheckIcon className="size-3" />}
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={setTags.isPending}>
|
||||
{setTags.isPending && <Loader2 className="size-4 animate-spin" />}保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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<PromptTemplate | null>(null);
|
||||
const [tagging, setTagging] = useState<PromptTemplate | null>(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 (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">常用提示词模板</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
管理员维护提示词模板并打标签;岗位配置了对应标签的用户,会自动加载这些模板到「常用提示词」。
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => { setEditing(null); setFormOpen(true); }}>
|
||||
<PlusIcon className="size-4" />新增模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12">
|
||||
<Loader2 className="size-4 animate-spin" />加载中…
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="text-destructive text-sm">{errMsg(error)}</div>}
|
||||
|
||||
{!isLoading && !error && templates && templates.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed py-12 text-center text-sm">
|
||||
还没有模板,点击「新增模板」创建。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && templates && templates.length > 0 && (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-muted-foreground px-4 py-3 text-left font-medium">标题</th>
|
||||
<th className="text-muted-foreground px-4 py-3 text-left font-medium">内容</th>
|
||||
<th className="text-muted-foreground px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map((tpl, i) => (
|
||||
<tr key={tpl.id} className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}>
|
||||
<td className="px-4 py-3 font-medium">{tpl.title || "—"}</td>
|
||||
<td className="text-muted-foreground max-w-md truncate px-4 py-3">{tpl.content}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => { setTagging(tpl); setTagsOpen(true); }} aria-label="打标签">
|
||||
<TagIcon className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => { setEditing(tpl); setFormOpen(true); }} aria-label="编辑">
|
||||
<PencilIcon className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(tpl)} aria-label="删除">
|
||||
<Trash2Icon className="text-destructive size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TemplateFormDialog open={formOpen} onOpenChange={setFormOpen} editing={editing} />
|
||||
{tagging && <TemplateTagsDialog open={tagsOpen} onOpenChange={setTagsOpen} template={tagging} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="agent">{t.adminTags.typeAgent}</TabsTrigger>
|
||||
<TabsTrigger value="skill">{t.adminTags.typeSkill}</TabsTrigger>
|
||||
<TabsTrigger value="scheduled_task">
|
||||
{t.adminTags.typeScheduledTask}
|
||||
</TabsTrigger>
|
||||
{TAG_TYPES.map((tt) => (
|
||||
<TabsTrigger key={tt} value={tt}>
|
||||
{TAG_TYPE_LABELS[tt]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
|
||||
@@ -521,25 +521,6 @@ export function InputBox({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||
{recommendedQuestions.map((q) => (
|
||||
<button
|
||||
key={q.id}
|
||||
type="button"
|
||||
className="rounded-full border border-[#e4e4e7] bg-white px-4 py-2 text-sm text-[#18181b] shadow-none transition-colors hover:bg-[#f4f4f5] cursor-pointer dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
onClick={() => {
|
||||
textInput.setInput(q.content);
|
||||
setFollowupsHidden(true);
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
}}
|
||||
>
|
||||
{desensitize(q.title)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFollowups && (
|
||||
<div className="flex items-center justify-center pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1060,6 +1041,35 @@ export function InputBox({
|
||||
</ModelSelectorList>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelector>
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title="推荐问题"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[#e4e4e7] bg-background px-3 py-1.5 text-xs text-[#18181b] shadow-sm transition-colors hover:bg-[#f4f4f5] cursor-pointer dark:border-slate-600 dark:bg-slate-700/60 dark:text-slate-200 dark:hover:bg-slate-700"
|
||||
>
|
||||
<LightbulbIcon className="size-4 text-amber-500" />
|
||||
推荐问题
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="end" className="max-h-80 w-72 overflow-y-auto">
|
||||
{recommendedQuestions.map((q) => (
|
||||
<DropdownMenuItem
|
||||
key={q.id}
|
||||
className="cursor-pointer whitespace-normal"
|
||||
onSelect={() => {
|
||||
textInput.setInput(q.content);
|
||||
setFollowupsHidden(true);
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
}}
|
||||
>
|
||||
{desensitize(q.title)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<PromptInputSubmit
|
||||
className="rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none"
|
||||
disabled={disabled}
|
||||
|
||||
@@ -176,13 +176,20 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
||||
// Optional model override for the admin "一键补充中文名" action (null = default).
|
||||
const [fillModel, setFillModel] = useState<string | null>(null);
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
// Non-admins get their 岗位 (position) visibility applied (scope=mine): a
|
||||
// position that binds skill tags restricts which skills they can see.
|
||||
const { skills, isLoading, error } = useSkills({
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
scope: isAdmin ? undefined : "mine",
|
||||
});
|
||||
|
||||
// 把可见技能分类成 UI 关心的几个桶。
|
||||
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
|
||||
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, Loader2 } 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 { resolveTagColor } from "@/components/workspace/tags/tag-colors";
|
||||
import { useResourceTags, useSetResourceTags, useTags, type TagType } from "@/core/tags";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Generic admin dialog to assign a resource's tags (multi-select chips). */
|
||||
export function ResourceTagDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetType,
|
||||
targetId,
|
||||
title = "打标签",
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
targetType: TagType;
|
||||
targetId: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const { data: tags } = useTags(targetType);
|
||||
const { data: current } = useResourceTags(targetType, targetId, open);
|
||||
const setTags = useSetResourceTags();
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
const [syncedFor, setSyncedFor] = useState<string | null>(null);
|
||||
const syncKey = `${open}:${targetId}:${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, targetId, tagIds: selected });
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
toast.error(String(e instanceof Error ? e.message : e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!tags || tags.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">暂无可用标签,请先到「标签管理」创建对应类型的标签。</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags.map((tag) => {
|
||||
const checked = selected.includes(tag.id);
|
||||
const color = resolveTagColor(tag.color);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggle(tag.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-all",
|
||||
color.chip,
|
||||
checked ? "ring-foreground/40 ring-2 ring-offset-1" : "opacity-45 hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{checked && <CheckIcon className="size-3" />}
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={setTags.isPending}>
|
||||
{setTags.isPending && <Loader2 className="size-4 animate-spin" />}保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -15,11 +15,14 @@ export interface CustomPrompt {
|
||||
* selected.
|
||||
*/
|
||||
agent_id?: string | null;
|
||||
/** ``"user"`` (the user's own) or ``"position"`` (granted by their 岗位). */
|
||||
origin?: string;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
const QUERY_KEY = ["user-prompts"] as const;
|
||||
const TEMPLATES_KEY = ["user-prompts", "templates"] as const;
|
||||
const EMPTY_PROMPTS: CustomPrompt[] = [];
|
||||
const baseUrl = () => `${getBackendBaseURL()}/api/user-prompts`;
|
||||
|
||||
@@ -117,3 +120,91 @@ export function useDeleteCustomPrompt() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- admin prompt templates
|
||||
//
|
||||
// Templates are admin-maintained prompts that carry ``user_prompt`` tags. The
|
||||
// 岗位 (position) sync engine clones the templates a user's position grants
|
||||
// into that user's own prompts. Tagging itself reuses the generic tags API.
|
||||
|
||||
export interface PromptTemplate {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
sort_order?: number;
|
||||
agent_id?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
const templatesUrl = () => `${getBackendBaseURL()}/api/user-prompts/templates`;
|
||||
|
||||
async function listTemplates(): Promise<PromptTemplate[]> {
|
||||
const res = await apiFetch(templatesUrl());
|
||||
if (!res.ok) throw new Error(`Failed to load templates: ${res.statusText}`);
|
||||
const data = (await res.json()) as { templates: PromptTemplate[] };
|
||||
return data.templates;
|
||||
}
|
||||
|
||||
async function createTemplate(input: { title: string; content: string; agent_id?: string | null }): Promise<PromptTemplate> {
|
||||
const res = await apiFetch(templatesUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to create template: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<PromptTemplate>;
|
||||
}
|
||||
|
||||
async function updateTemplate(id: string, input: { title?: string; content?: string; agent_id?: string | null }): Promise<PromptTemplate> {
|
||||
const res = await apiFetch(`${templatesUrl()}/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to update template: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<PromptTemplate>;
|
||||
}
|
||||
|
||||
async function deleteTemplate(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${templatesUrl()}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to delete template: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function usePromptTemplates() {
|
||||
return useQuery({ queryKey: TEMPLATES_KEY, queryFn: listTemplates });
|
||||
}
|
||||
|
||||
export function useCreatePromptTemplate() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createTemplate,
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: TEMPLATES_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePromptTemplate() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: { title?: string; content?: string; agent_id?: string | null } }) =>
|
||||
updateTemplate(id, input),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: TEMPLATES_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePromptTemplate() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteTemplate,
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: TEMPLATES_KEY }),
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
Brain,
|
||||
Briefcase,
|
||||
CalendarClock,
|
||||
CircleHelp,
|
||||
Database,
|
||||
@@ -246,6 +247,20 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
path: "/page/strategy/admin/users",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-positions",
|
||||
label: "岗位管理",
|
||||
icon: Briefcase,
|
||||
path: "/page/strategy/admin/positions",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-prompt-templates",
|
||||
label: "提示词模板",
|
||||
icon: MessageSquareCode,
|
||||
path: "/page/strategy/admin/prompt-templates",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-user-leaderboard",
|
||||
label: "?????",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type {
|
||||
Position,
|
||||
PositionCreateInput,
|
||||
PositionListResponse,
|
||||
PositionMember,
|
||||
PositionUpdateInput,
|
||||
PositionUser,
|
||||
TagBindings,
|
||||
TagBindingsResponse,
|
||||
} from "./types";
|
||||
|
||||
async function parseDetail(res: Response, fallback: string): Promise<Error> {
|
||||
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
|
||||
const detail = typeof data.detail === "string" && data.detail ? data.detail : null;
|
||||
return new Error(detail ?? `${fallback} (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
const base = () => `${getBackendBaseURL()}/api/positions`;
|
||||
|
||||
export async function listPositions(): Promise<Position[]> {
|
||||
const res = await apiFetch(base());
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to list positions");
|
||||
const data = (await res.json()) as PositionListResponse;
|
||||
return data.positions;
|
||||
}
|
||||
|
||||
export async function createPosition(input: PositionCreateInput): Promise<Position> {
|
||||
const res = await apiFetch(base(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to create position");
|
||||
return (await res.json()) as Position;
|
||||
}
|
||||
|
||||
export async function updatePosition(id: string, input: PositionUpdateInput): Promise<Position> {
|
||||
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to update position");
|
||||
return (await res.json()) as Position;
|
||||
}
|
||||
|
||||
export async function deletePosition(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to delete position");
|
||||
}
|
||||
|
||||
export async function getTagBindings(positionId: string): Promise<TagBindings> {
|
||||
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/tag-bindings`);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to load tag bindings");
|
||||
const data = (await res.json()) as TagBindingsResponse;
|
||||
return data.bindings;
|
||||
}
|
||||
|
||||
export async function setTagBindings(positionId: string, bindings: TagBindings): Promise<TagBindings> {
|
||||
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/tag-bindings`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bindings }),
|
||||
});
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to save tag bindings");
|
||||
const data = (await res.json()) as TagBindingsResponse;
|
||||
return data.bindings;
|
||||
}
|
||||
|
||||
export async function listMembers(positionId: string): Promise<PositionMember[]> {
|
||||
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/members`);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to list members");
|
||||
const data = (await res.json()) as { members: PositionMember[] };
|
||||
return data.members;
|
||||
}
|
||||
|
||||
export async function addMembers(positionId: string, userIds: string[]): Promise<PositionMember[]> {
|
||||
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/members`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_ids: userIds }),
|
||||
});
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to add members");
|
||||
const data = (await res.json()) as { members: PositionMember[] };
|
||||
return data.members;
|
||||
}
|
||||
|
||||
export async function removeMembers(positionId: string, userIds: string[]): Promise<PositionMember[]> {
|
||||
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/members`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_ids: userIds }),
|
||||
});
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to remove members");
|
||||
const data = (await res.json()) as { members: PositionMember[] };
|
||||
return data.members;
|
||||
}
|
||||
|
||||
export async function resyncPosition(positionId: string): Promise<number> {
|
||||
const res = await apiFetch(`${base()}/${encodeURIComponent(positionId)}/resync`, { method: "POST" });
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to resync position");
|
||||
const data = (await res.json()) as { synced: number };
|
||||
return data.synced;
|
||||
}
|
||||
|
||||
export async function listPositionUsers(): Promise<PositionUser[]> {
|
||||
const res = await apiFetch(`${base()}/users`);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to list users");
|
||||
const data = (await res.json()) as { users: PositionUser[] };
|
||||
return data.users;
|
||||
}
|
||||
|
||||
export async function setUserPosition(userId: string, positionId: string | null): Promise<void> {
|
||||
const res = await apiFetch(`${base()}/users/${encodeURIComponent(userId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ position_id: positionId }),
|
||||
});
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to set user position");
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
addMembers,
|
||||
createPosition,
|
||||
deletePosition,
|
||||
getTagBindings,
|
||||
listMembers,
|
||||
listPositionUsers,
|
||||
listPositions,
|
||||
removeMembers,
|
||||
resyncPosition,
|
||||
setTagBindings,
|
||||
setUserPosition,
|
||||
updatePosition,
|
||||
} from "./api";
|
||||
import type {
|
||||
PositionCreateInput,
|
||||
PositionUpdateInput,
|
||||
TagBindings,
|
||||
} from "./types";
|
||||
|
||||
const positionKeys = {
|
||||
root: ["positions"] as const,
|
||||
list: ["positions", "list"] as const,
|
||||
bindings: (id: string) => ["positions", "bindings", id] as const,
|
||||
members: (id: string) => ["positions", "members", id] as const,
|
||||
users: ["positions", "users"] as const,
|
||||
};
|
||||
|
||||
export function usePositions() {
|
||||
return useQuery({ queryKey: positionKeys.list, queryFn: listPositions });
|
||||
}
|
||||
|
||||
export function usePositionUsers() {
|
||||
return useQuery({ queryKey: positionKeys.users, queryFn: listPositionUsers });
|
||||
}
|
||||
|
||||
export function usePositionTagBindings(positionId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: positionKeys.bindings(positionId ?? ""),
|
||||
queryFn: () => getTagBindings(positionId as string),
|
||||
enabled: !!positionId,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePositionMembers(positionId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: positionKeys.members(positionId ?? ""),
|
||||
queryFn: () => listMembers(positionId as string),
|
||||
enabled: !!positionId,
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidate() {
|
||||
const qc = useQueryClient();
|
||||
return () => void qc.invalidateQueries({ queryKey: positionKeys.root });
|
||||
}
|
||||
|
||||
export function useCreatePosition() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: (input: PositionCreateInput) => createPosition(input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePosition() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: PositionUpdateInput }) => updatePosition(id, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePosition() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deletePosition(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetTagBindings() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ positionId, bindings }: { positionId: string; bindings: TagBindings }) =>
|
||||
setTagBindings(positionId, bindings),
|
||||
onSuccess: (_data, { positionId }) => {
|
||||
void qc.invalidateQueries({ queryKey: positionKeys.bindings(positionId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddMembers() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ positionId, userIds }: { positionId: string; userIds: string[] }) =>
|
||||
addMembers(positionId, userIds),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: positionKeys.root }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveMembers() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ positionId, userIds }: { positionId: string; userIds: string[] }) =>
|
||||
removeMembers(positionId, userIds),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: positionKeys.root }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useResyncPosition() {
|
||||
return useMutation({ mutationFn: (positionId: string) => resyncPosition(positionId) });
|
||||
}
|
||||
|
||||
export function useSetUserPosition() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, positionId }: { userId: string; positionId: string | null }) =>
|
||||
setUserPosition(userId, positionId),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: positionKeys.root }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./types";
|
||||
export * from "./api";
|
||||
export * from "./hooks";
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { TagType } from "@/core/tags/types";
|
||||
|
||||
/** Resource types a 岗位 (position) can grant, by tag. */
|
||||
export type PositionResourceType =
|
||||
| "agent"
|
||||
| "skill"
|
||||
| "scheduled_task"
|
||||
| "user_prompt"
|
||||
| "recommended_question";
|
||||
|
||||
export const POSITION_RESOURCE_TYPES: PositionResourceType[] = [
|
||||
"agent",
|
||||
"skill",
|
||||
"scheduled_task",
|
||||
"user_prompt",
|
||||
"recommended_question",
|
||||
];
|
||||
|
||||
// A position's resource types are exactly the tag types — reuse the labels.
|
||||
export const POSITION_RESOURCE_LABELS: Record<PositionResourceType, string> = {
|
||||
agent: "智能体",
|
||||
skill: "技能",
|
||||
scheduled_task: "定时任务",
|
||||
user_prompt: "常用提示词",
|
||||
recommended_question: "推荐问题",
|
||||
};
|
||||
|
||||
export const POSITION_RESOURCE_HINT: Record<PositionResourceType, string> = {
|
||||
agent: "不配置标签 = 默认加载全部内置智能体;配置后只加载所选标签下的智能体。",
|
||||
skill: "不配置标签 = 可见全部技能;配置后只可见所选标签下的技能。",
|
||||
scheduled_task: "不配置标签 = 不订阅任何定时任务;配置后订阅所选标签下的定时任务。",
|
||||
user_prompt: "不配置标签 = 加载未打标签的全局模板;配置后加载所选标签下的提示词模板。",
|
||||
recommended_question: "不配置标签 = 展示全部推荐问题;配置后只展示所选标签下的推荐问题。",
|
||||
};
|
||||
|
||||
export interface Position {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_default: boolean;
|
||||
member_count: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface PositionListResponse {
|
||||
positions: Position[];
|
||||
resource_types: PositionResourceType[];
|
||||
}
|
||||
|
||||
export interface PositionCreateInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface PositionUpdateInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
/** resource_type -> [tag_id] */
|
||||
export type TagBindings = Partial<Record<PositionResourceType, string[]>>;
|
||||
|
||||
export interface TagBindingsResponse {
|
||||
position_id: string;
|
||||
bindings: TagBindings;
|
||||
}
|
||||
|
||||
export interface PositionMember {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface PositionUser {
|
||||
id: string;
|
||||
email: string;
|
||||
system_role: string;
|
||||
position_id: string | null;
|
||||
}
|
||||
|
||||
// The resource types double as tag types for the binding pickers.
|
||||
export function resourceTypeToTagType(rt: PositionResourceType): TagType {
|
||||
return rt as TagType;
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import type {
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/recommended-questions`;
|
||||
|
||||
export async function listRecommendedQuestions(): Promise<RecommendedQuestion[]> {
|
||||
const res = await apiFetch(BASE());
|
||||
export async function listRecommendedQuestions(all = false): Promise<RecommendedQuestion[]> {
|
||||
// ``all=true`` is the admin management view: every question, unfiltered by the
|
||||
// caller's 岗位 (position). The chat input uses the default (filtered) view.
|
||||
const res = await apiFetch(`${BASE()}${all ? "?all=1" : ""}`);
|
||||
if (!res.ok) throw new Error(`Failed to load questions: ${res.statusText}`);
|
||||
const data = (await res.json()) as { questions: RecommendedQuestion[] };
|
||||
return data.questions;
|
||||
|
||||
@@ -10,11 +10,21 @@ import {
|
||||
import type { CreateQuestionRequest, UpdateQuestionRequest } from "./index";
|
||||
|
||||
const QK = ["recommended-questions"] as const;
|
||||
const ADMIN_QK = ["recommended-questions", "admin"] as const;
|
||||
|
||||
export function useRecommendedQuestions() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: QK,
|
||||
queryFn: listRecommendedQuestions,
|
||||
queryFn: () => listRecommendedQuestions(false),
|
||||
});
|
||||
return { questions: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
/** Admin management view — every question, unfiltered by 岗位. */
|
||||
export function useAdminRecommendedQuestions() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ADMIN_QK,
|
||||
queryFn: () => listRecommendedQuestions(true),
|
||||
});
|
||||
return { questions: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface ListSkillsParams {
|
||||
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
|
||||
/** Keep only skills filed under this publish square (发布广场). */
|
||||
square?: string;
|
||||
/** ``"mine"`` restricts to the skills the caller's 岗位 (position) makes visible. */
|
||||
scope?: "mine";
|
||||
}
|
||||
|
||||
export async function loadSkills(params: ListSkillsParams = {}) {
|
||||
@@ -17,6 +19,7 @@ export async function loadSkills(params: ListSkillsParams = {}) {
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
if (params.sort) qs.set("sort", params.sort);
|
||||
if (params.square) qs.set("square", params.square);
|
||||
if (params.scope) qs.set("scope", params.scope);
|
||||
const query = qs.toString();
|
||||
const skills = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
|
||||
|
||||
@@ -42,9 +42,10 @@ export function useSkills(params: ListSkillsParams = {}) {
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const sort = params.sort ?? "time_desc";
|
||||
const scope = params.scope;
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills", search, [...tagIds].sort(), sort],
|
||||
queryFn: () => loadSkills({ search, tagIds, sort }),
|
||||
queryKey: ["skills", search, [...tagIds].sort(), sort, scope ?? "all"],
|
||||
queryFn: () => loadSkills({ search, tagIds, sort, scope }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
export type TagType = "agent" | "skill" | "scheduled_task";
|
||||
export type TagType =
|
||||
| "agent"
|
||||
| "skill"
|
||||
| "scheduled_task"
|
||||
| "user_prompt"
|
||||
| "recommended_question";
|
||||
|
||||
export const TAG_TYPES: TagType[] = ["agent", "skill", "scheduled_task"];
|
||||
export const TAG_TYPES: TagType[] = [
|
||||
"agent",
|
||||
"skill",
|
||||
"scheduled_task",
|
||||
"user_prompt",
|
||||
"recommended_question",
|
||||
];
|
||||
|
||||
// Human labels for the tag types (used by the tag management + 岗位 pages).
|
||||
export const TAG_TYPE_LABELS: Record<TagType, string> = {
|
||||
agent: "智能体",
|
||||
skill: "技能",
|
||||
scheduled_task: "定时任务",
|
||||
user_prompt: "常用提示词",
|
||||
recommended_question: "推荐问题",
|
||||
};
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
|
||||
@@ -343,6 +343,23 @@ export function useThreadStream({
|
||||
) {
|
||||
const e = event as { type: "llm_retry"; message: string };
|
||||
toast(e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Context compression about to run a (blocking) summarization LLM call —
|
||||
// surface a transient hint so the wait before the answer streams isn't a
|
||||
// silent dead air. Emitted by DeerFlowSummarizationMiddleware.
|
||||
if (
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"type" in event &&
|
||||
event.type === "context_compacting" &&
|
||||
"message" in event &&
|
||||
typeof event.message === "string" &&
|
||||
event.message.trim()
|
||||
) {
|
||||
const e = event as { type: "context_compacting"; message: string };
|
||||
toast(e.message);
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PositionManagementPage } from "@/components/workspace/admin/position-management-page";
|
||||
|
||||
export default function PositionsPage() {
|
||||
return <PositionManagementPage />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PromptTemplatesPage as PromptTemplatesManagement } from "@/components/workspace/admin/prompt-templates-page";
|
||||
|
||||
export default function PromptTemplatesPage() {
|
||||
return <PromptTemplatesManagement />;
|
||||
}
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
Loader2Icon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
TagIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResourceTagDialog } from "@/components/workspace/tags/resource-tag-dialog";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -24,9 +26,9 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useAdminRecommendedQuestions,
|
||||
useCreateRecommendedQuestion,
|
||||
useDeleteRecommendedQuestion,
|
||||
useRecommendedQuestions,
|
||||
useReorderRecommendedQuestions,
|
||||
useUpdateRecommendedQuestion,
|
||||
} from "@/core/recommended-questions/hooks";
|
||||
@@ -65,7 +67,7 @@ export default function RecommendedQuestionsPage({
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { theme } = useTheme();
|
||||
const { questions, isLoading, error } = useRecommendedQuestions();
|
||||
const { questions, isLoading, error } = useAdminRecommendedQuestions();
|
||||
const createMutation = useCreateRecommendedQuestion();
|
||||
const updateMutation = useUpdateRecommendedQuestion();
|
||||
const deleteMutation = useDeleteRecommendedQuestion();
|
||||
@@ -75,6 +77,7 @@ export default function RecommendedQuestionsPage({
|
||||
const [formTitle, setFormTitle] = useState("");
|
||||
const [formContent, setFormContent] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<RecommendedQuestion | null>(null);
|
||||
const [tagTarget, setTagTarget] = useState<RecommendedQuestion | null>(null);
|
||||
|
||||
// drag state
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
@@ -245,6 +248,16 @@ export default function RecommendedQuestionsPage({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => setTagTarget(q)}
|
||||
disabled={isSaving}
|
||||
title="打标签(按岗位分发)"
|
||||
>
|
||||
<TagIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -308,6 +321,17 @@ export default function RecommendedQuestionsPage({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 打标签弹窗(按岗位分发推荐问题) */}
|
||||
{tagTarget && (
|
||||
<ResourceTagDialog
|
||||
open={tagTarget !== null}
|
||||
onOpenChange={(o) => !o && setTagTarget(null)}
|
||||
targetType="recommended_question"
|
||||
targetId={tagTarget.id}
|
||||
title={`为「${tagTarget.title}」打标签`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<Dialog
|
||||
open={deleteTarget !== null}
|
||||
|
||||
@@ -40,6 +40,8 @@ import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||
import SkillsPage from "./SkillsPage";
|
||||
import PositionsPage from "./PositionsPage";
|
||||
import PromptTemplatesPage from "./PromptTemplatesPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
@@ -287,6 +289,26 @@ export default function StrategyRoutes() {
|
||||
path="admin/tags"
|
||||
element={<TagsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="admin/positions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<PositionsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/prompt-templates"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<PromptTemplatesPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -39,6 +39,8 @@ import AdminUserActivityPage from './AdminUserActivityPage';
|
||||
import AdminMemoryPage from './AdminMemoryPage';
|
||||
import AdminUserHubPage from './AdminUserHubPage';
|
||||
import TagsPage from './TagsPage';
|
||||
import PositionsPage from './PositionsPage';
|
||||
import PromptTemplatesPage from './PromptTemplatesPage';
|
||||
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import StudioNotebooksPage from './StudioNotebooksPage';
|
||||
@@ -471,6 +473,30 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/positions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PositionsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/prompt-templates"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PromptTemplatesPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
|
||||
Reference in new issue
Block a user