为深度研究报告结构增加检索方向与技能来源、对话式智能新增页与详情排版;澄清中间件在 after_model 可靠结束本轮,避免弱模型出卡后继续生成;任务按钮补充 host 跳转与相关深链能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
547 lines
25 KiB
TypeScript
547 lines
25 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* 按钮管理(仅 admin):配置任务工作区(rwfx / 3qfx / xdfx,见 ``TaskFxWorkspace``)
|
||
* 左侧栏的可配置跳转按钮——改名称、改跳转方式与地址、启停、排序、增删。
|
||
*
|
||
* 全局共享、整体替换保存(``PUT /api/task-buttons``)。后端整张表为空时回退代码种子默认
|
||
* (rwfx 的 4 个历史按钮),此页首次进入也以种子回填工作副本,便于从当前行为起步。
|
||
* 见 [[task-buttons]]。
|
||
*/
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { Navigate } from "react-router-dom";
|
||
import { ArrowDown, ArrowUp, Download, MousePointerClick, Plus, Save, Trash2, X } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { cn } from "@/lib/utils";
|
||
import { getStoredAuth } from "@/core/auth";
|
||
import {
|
||
DEFAULT_TASK_DEEPLINK_CONFIG,
|
||
fetchTaskDeeplinkConfig,
|
||
type TaskDeeplinkConfig,
|
||
} from "@/core/auth/task-deeplink-config";
|
||
import {
|
||
TASK_BUSINESSES,
|
||
TASK_BUSINESS_TARGETS,
|
||
defaultTaskButtons,
|
||
type TaskBusiness,
|
||
type TaskButton,
|
||
type TaskButtonLoginParam,
|
||
} from "@/core/auth/task-buttons";
|
||
import { LOGIN_SOURCE_OPTIONS } from "@/strategy-components/lib/light-app";
|
||
import { useTaskButtonActions, useTaskButtons } from "@/strategy-components/api/task-buttons";
|
||
|
||
let _seq = 1;
|
||
const newId = (b: TaskBusiness) => `${b}-custom-${Date.now()}-${_seq++}`;
|
||
|
||
/** 归一化用于「脏」判断(按 id 排序后整体比较)。 */
|
||
function serialize(rows: TaskButton[]): string {
|
||
return JSON.stringify([...rows].sort((a, b) => a.id.localeCompare(b.id)));
|
||
}
|
||
|
||
export default function TaskButtonsPage() {
|
||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||
const { data: serverButtons = [], isLoading } = useTaskButtons();
|
||
const { save } = useTaskButtonActions();
|
||
|
||
const [cfg, setCfg] = useState<TaskDeeplinkConfig>(() => DEFAULT_TASK_DEEPLINK_CONFIG);
|
||
useEffect(() => {
|
||
let alive = true;
|
||
void fetchTaskDeeplinkConfig().then((c) => {
|
||
if (alive) setCfg(c);
|
||
});
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, []);
|
||
|
||
const [rows, setRows] = useState<TaskButton[]>([]);
|
||
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
||
const [tab, setTab] = useState<TaskBusiness>("rwfx");
|
||
|
||
// 服务端首次/变化时回填工作副本(空表回填种子默认)。以序列化值判定,避免覆盖未保存编辑。
|
||
const sourceKey = useMemo(() => serialize(serverButtons), [serverButtons]);
|
||
useEffect(() => {
|
||
if (sourceKey !== loadedKey) {
|
||
setRows(serverButtons.length > 0 ? serverButtons : defaultTaskButtons(cfg));
|
||
setLoadedKey(sourceKey);
|
||
}
|
||
}, [sourceKey, loadedKey, serverButtons, cfg]);
|
||
|
||
const tabRows = useMemo(
|
||
() => rows.filter((r) => r.business === tab).sort((a, b) => a.sortOrder - b.sortOrder),
|
||
[rows, tab],
|
||
);
|
||
|
||
const dirty = useMemo(() => serialize(rows) !== sourceKey, [rows, sourceKey]);
|
||
|
||
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
|
||
|
||
const update = (id: string, patch: Partial<TaskButton>) =>
|
||
setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||
|
||
const removeRow = (id: string) => setRows((rs) => rs.filter((r) => r.id !== id));
|
||
|
||
const addRow = () => {
|
||
const maxSort = tabRows.reduce((m, r) => Math.max(m, r.sortOrder), -1);
|
||
setRows((rs) => [
|
||
...rs,
|
||
{
|
||
id: newId(tab),
|
||
business: tab,
|
||
label: "新按钮",
|
||
linkType: "url",
|
||
target: "",
|
||
appendTaskId: true,
|
||
idParam: "id",
|
||
idKind: "task",
|
||
openMode: "blank",
|
||
loginParams: [],
|
||
appendAuth: false,
|
||
authTokenParam: "token",
|
||
authNameParam: "username",
|
||
enabled: true,
|
||
sortOrder: maxSort + 1,
|
||
},
|
||
]);
|
||
};
|
||
|
||
// ── 登录参数(url 外链追加,与应用管理一致)增删改 ──
|
||
const updateLoginParams = (id: string, fn: (ps: TaskButtonLoginParam[]) => TaskButtonLoginParam[]) =>
|
||
setRows((rs) =>
|
||
rs.map((r) => (r.id === id ? { ...r, loginParams: fn(r.loginParams ?? []) } : r)),
|
||
);
|
||
|
||
const addLoginParam = (id: string) =>
|
||
updateLoginParams(id, (ps) => [...ps, { key: "access_token", source: "access_token", customValue: "" }]);
|
||
|
||
const patchLoginParam = (id: string, idx: number, patch: Partial<TaskButtonLoginParam>) =>
|
||
updateLoginParams(id, (ps) => ps.map((p, i) => (i === idx ? { ...p, ...patch } : p)));
|
||
|
||
const removeLoginParam = (id: string, idx: number) =>
|
||
updateLoginParams(id, (ps) => ps.filter((_, i) => i !== idx));
|
||
|
||
const moveRow = (id: string, dir: "up" | "down") => {
|
||
const list = [...tabRows];
|
||
const i = list.findIndex((r) => r.id === id);
|
||
const j = dir === "up" ? i - 1 : i + 1;
|
||
if (i < 0 || j < 0 || j >= list.length) return;
|
||
[list[i], list[j]] = [list[j], list[i]];
|
||
const reordered = list.map((r, idx) => ({ ...r, sortOrder: idx }));
|
||
setRows((rs) => [...rs.filter((r) => r.business !== tab), ...reordered]);
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
const payload = rows.map((r) => ({ ...r, label: r.label.trim() }));
|
||
try {
|
||
await save.mutateAsync(payload);
|
||
toast.success("已保存按钮配置");
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||
}
|
||
};
|
||
|
||
const importDefaults = (business?: TaskBusiness) => {
|
||
const have = new Set(rows.map((r) => r.id));
|
||
const adds = defaultTaskButtons(cfg)
|
||
.filter((d) => !business || d.business === business)
|
||
.filter((d) => !have.has(d.id));
|
||
if (!adds.length) {
|
||
toast.info("没有可导入的默认按钮");
|
||
return;
|
||
}
|
||
setRows((rs) => [...rs, ...adds]);
|
||
toast.success(`已导入 ${adds.length} 个默认按钮`);
|
||
};
|
||
|
||
return (
|
||
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-background">
|
||
<div className="border-border/70 flex items-center justify-between gap-3 border-b px-5 py-4">
|
||
<div className="flex items-center gap-2">
|
||
<MousePointerClick className="h-5 w-5 text-[var(--color-main,#0062D9)]" />
|
||
<div>
|
||
<div className="text-foreground text-base font-semibold">按钮管理</div>
|
||
<div className="text-muted-foreground text-xs">
|
||
配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序)。「宿主路由」用于 iframe 套用时让对方系统跳转。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" onClick={() => importDefaults()}>
|
||
<Download className="h-4 w-4" /> 导入全部默认
|
||
</Button>
|
||
<Button size="sm" onClick={handleSave} disabled={!dirty || save.isPending}>
|
||
<Save className="h-4 w-4" /> {save.isPending ? "保存中…" : "保存"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 业务 tab:每个 tab = 一个任务深链页面,明确区分三种页面的按钮 */}
|
||
<div className="border-border/70 flex items-stretch gap-2 border-b px-5 pt-3">
|
||
{TASK_BUSINESSES.map((b) => {
|
||
const count = rows.filter((r) => r.business === b.code).length;
|
||
const active = tab === b.code;
|
||
return (
|
||
<button
|
||
key={b.code}
|
||
type="button"
|
||
onClick={() => setTab(b.code)}
|
||
className={cn(
|
||
"flex min-w-[8.5rem] flex-col gap-0.5 rounded-t-lg border border-b-0 px-4 py-2 text-left transition-colors",
|
||
active
|
||
? "border-[var(--color-main,#0062D9)] bg-[var(--color-main,#0062D9)] text-white"
|
||
: "border-border/70 text-muted-foreground hover:bg-muted bg-transparent",
|
||
)}
|
||
>
|
||
<span className="flex items-center gap-1.5 text-sm font-semibold">
|
||
{b.name}
|
||
<span
|
||
className={cn(
|
||
"rounded px-1.5 text-[11px] tabular-nums",
|
||
active ? "bg-white/20" : "bg-muted-foreground/15",
|
||
)}
|
||
>
|
||
{count}
|
||
</span>
|
||
</span>
|
||
<span className={cn("text-[11px]", active ? "text-white/80" : "opacity-70")}>
|
||
?goPath={b.goPath}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 当前页面说明 */}
|
||
{(() => {
|
||
const meta = TASK_BUSINESSES.find((b) => b.code === tab)!;
|
||
return (
|
||
<div className="border-border/70 bg-muted/40 text-muted-foreground border-b px-5 py-2 text-xs">
|
||
正在编辑「<span className="text-foreground font-medium">{meta.name}</span>
|
||
」页面的按钮 · {meta.desc} 下方按钮会显示在该页面底部的「快捷跳转」栏,从上到下对应栏内从左到右的顺序。
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
||
{isLoading ? (
|
||
<div className="text-muted-foreground text-sm">加载中…</div>
|
||
) : tabRows.length === 0 ? (
|
||
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-md border border-dashed p-6 text-center text-sm">
|
||
该页面暂无按钮。可「导入该页面默认按钮」一键填充,或点下方「添加按钮」自定义。
|
||
<Button variant="outline" size="sm" onClick={() => importDefaults(tab)}>
|
||
<Download className="h-4 w-4" /> 导入该页面默认按钮
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col gap-3">
|
||
{tabRows.map((r, idx) => (
|
||
<div
|
||
key={r.id}
|
||
className="border-border/70 grid grid-cols-[1fr_auto] items-start gap-3 rounded-lg border bg-card/60 p-3"
|
||
>
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<label className="text-muted-foreground w-16 shrink-0 text-xs">名称</label>
|
||
<Input
|
||
value={r.label}
|
||
onChange={(e) => update(r.id, { label: e.target.value })}
|
||
placeholder="按钮名称"
|
||
className="h-8 max-w-xs"
|
||
/>
|
||
<label className="text-muted-foreground ml-2 shrink-0 text-xs">类型</label>
|
||
<Select
|
||
value={r.linkType}
|
||
onValueChange={(v) => update(r.id, { linkType: v as TaskButton["linkType"] })}
|
||
>
|
||
<SelectTrigger className="h-8 w-40">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="url">外部链接</SelectItem>
|
||
<SelectItem value="business">切换业务</SelectItem>
|
||
<SelectItem value="purpose">目的树弹框</SelectItem>
|
||
<SelectItem value="host">宿主路由</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<label className="text-muted-foreground w-16 shrink-0 text-xs">目标</label>
|
||
{r.linkType === "purpose" ? (
|
||
<span className="text-muted-foreground text-xs">
|
||
点击弹出当前任务的「任务目的」表格,无需配置目标。
|
||
</span>
|
||
) : r.linkType === "host" ? (
|
||
<>
|
||
<Input
|
||
value={r.target}
|
||
onChange={(e) => update(r.id, { target: e.target.value })}
|
||
placeholder="/task/…(宿主系统路由)"
|
||
className="h-8 flex-1 min-w-[16rem]"
|
||
title="宿主系统路由,点击后通过 postMessage 让父页面跳转"
|
||
/>
|
||
<label className="text-muted-foreground ml-2 flex shrink-0 items-center gap-1.5 text-xs">
|
||
<Switch
|
||
checked={r.appendTaskId}
|
||
onCheckedChange={(v) => update(r.id, { appendTaskId: v })}
|
||
/>
|
||
追加任务id
|
||
</label>
|
||
{r.appendTaskId && (
|
||
<div className="flex shrink-0 items-center gap-1 text-xs">
|
||
<span className="text-muted-foreground">queryParams.</span>
|
||
<Input
|
||
value={r.idParam}
|
||
onChange={(e) => update(r.id, { idParam: e.target.value })}
|
||
placeholder="id"
|
||
className="h-8 w-24"
|
||
title="发给宿主的参数名(默认 id)"
|
||
/>
|
||
<span className="text-muted-foreground">= 任务id(数字)</span>
|
||
</div>
|
||
)}
|
||
<span className="text-muted-foreground w-full text-[11px]">
|
||
iframe 嵌入时向父页面发送 HOST_NAVIGATE,由宿主系统跳转;独立打开本系统时无效。
|
||
</span>
|
||
</>
|
||
) : r.linkType === "url" ? (
|
||
<>
|
||
<Input
|
||
value={r.target}
|
||
onChange={(e) => update(r.id, { target: e.target.value })}
|
||
placeholder="https://…(跳转地址)"
|
||
className="h-8 flex-1 min-w-[16rem]"
|
||
/>
|
||
<label className="text-muted-foreground ml-2 flex shrink-0 items-center gap-1.5 text-xs">
|
||
<Switch
|
||
checked={r.appendTaskId}
|
||
onCheckedChange={(v) => update(r.id, { appendTaskId: v })}
|
||
/>
|
||
追加参数
|
||
</label>
|
||
{r.appendTaskId && (
|
||
<div className="flex shrink-0 items-center gap-1 text-xs">
|
||
<span className="text-muted-foreground">?</span>
|
||
<Input
|
||
value={r.idParam}
|
||
onChange={(e) => update(r.id, { idParam: e.target.value })}
|
||
placeholder="id"
|
||
className="h-8 w-24"
|
||
title="查询参数名(如 id / task_id)"
|
||
/>
|
||
<span className="text-muted-foreground">=</span>
|
||
{/* 行动分析(xdfx):行动id 与任务id 不同,可选追加哪种;其它业务固定任务id */}
|
||
{tab === "xdfx" ? (
|
||
<Select
|
||
value={r.idKind}
|
||
onValueChange={(v) => update(r.id, { idKind: v as TaskButton["idKind"] })}
|
||
>
|
||
<SelectTrigger className="h-8 w-24">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="task">任务id</SelectItem>
|
||
<SelectItem value="action">行动id</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<span className="text-muted-foreground">任务id</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
<label className="text-muted-foreground ml-2 shrink-0 text-xs">打开方式</label>
|
||
<Select
|
||
value={r.openMode}
|
||
onValueChange={(v) => update(r.id, { openMode: v as TaskButton["openMode"] })}
|
||
>
|
||
<SelectTrigger className="h-8 w-32" title="外链打开方式">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="blank">新窗口</SelectItem>
|
||
<SelectItem value="self">当前窗口</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<label
|
||
className="text-muted-foreground ml-2 flex shrink-0 items-center gap-1.5 text-xs"
|
||
title="跳转时在链接后追加 token + 用户名(仅 token 登录时生效)"
|
||
>
|
||
<Switch
|
||
checked={r.appendAuth}
|
||
onCheckedChange={(v) => update(r.id, { appendAuth: v })}
|
||
/>
|
||
追加登录态
|
||
</label>
|
||
{r.appendAuth && (
|
||
<div className="flex shrink-0 items-center gap-1 text-xs">
|
||
<span className="text-muted-foreground">&</span>
|
||
<Input
|
||
value={r.authTokenParam}
|
||
onChange={(e) => update(r.id, { authTokenParam: e.target.value })}
|
||
placeholder="token"
|
||
className="h-8 w-24"
|
||
title="token 的查询参数名(默认 token)"
|
||
/>
|
||
<span className="text-muted-foreground">=token1 &</span>
|
||
<Input
|
||
value={r.authNameParam}
|
||
onChange={(e) => update(r.id, { authNameParam: e.target.value })}
|
||
placeholder="username"
|
||
className="h-8 w-28"
|
||
title="用户名的查询参数名(默认 username)"
|
||
/>
|
||
<span className="text-muted-foreground">=用户名</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<Select value={r.target} onValueChange={(v) => update(r.id, { target: v })}>
|
||
<SelectTrigger className="h-8 w-48">
|
||
<SelectValue placeholder="选择目标业务" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{TASK_BUSINESS_TARGETS.map((t) => (
|
||
<SelectItem key={t.code} value={t.code}>
|
||
{t.label}({t.code})
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
)}
|
||
</div>
|
||
|
||
{/* 登录参数(仅外链):与「应用管理」一致,运行时从 userInfo 解析后拼到地址尾部 */}
|
||
{r.linkType === "url" && (
|
||
<div className="border-border/60 ml-16 flex flex-col gap-2 rounded-md border border-dashed bg-muted/30 p-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-muted-foreground text-xs">
|
||
登录参数(追加到地址,值取自登录信息)
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-7"
|
||
onClick={() => addLoginParam(r.id)}
|
||
>
|
||
<Plus className="h-3.5 w-3.5" /> 添加登录参数
|
||
</Button>
|
||
</div>
|
||
{(r.loginParams ?? []).length === 0 ? (
|
||
<span className="text-muted-foreground text-[11px]">
|
||
暂无登录参数,跳转时不追加额外参数。
|
||
</span>
|
||
) : (
|
||
(r.loginParams ?? []).map((p, pi) => (
|
||
<div key={pi} className="flex flex-wrap items-center gap-2">
|
||
<Input
|
||
value={p.key}
|
||
onChange={(e) => patchLoginParam(r.id, pi, { key: e.target.value })}
|
||
placeholder="参数名 key"
|
||
className="h-8 w-32"
|
||
title="拼到地址的查询参数名"
|
||
/>
|
||
<span className="text-muted-foreground text-xs">=</span>
|
||
<Select
|
||
value={p.source}
|
||
onValueChange={(v) => patchLoginParam(r.id, pi, { source: v })}
|
||
>
|
||
<SelectTrigger className="h-8 w-36" title="取值来源">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{LOGIN_SOURCE_OPTIONS.map((o) => (
|
||
<SelectItem key={o.value} value={o.value}>
|
||
{o.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{p.source === "other" && (
|
||
<Input
|
||
value={p.customValue}
|
||
onChange={(e) =>
|
||
patchLoginParam(r.id, pi, { customValue: e.target.value })
|
||
}
|
||
placeholder="自定义值"
|
||
className="h-8 w-40"
|
||
/>
|
||
)}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||
onClick={() => removeLoginParam(r.id, pi)}
|
||
title="删除该登录参数"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1">
|
||
<label className="text-muted-foreground mr-1 flex items-center gap-1.5 text-xs">
|
||
<Switch
|
||
checked={r.enabled}
|
||
onCheckedChange={(v) => update(r.id, { enabled: v })}
|
||
/>
|
||
启用
|
||
</label>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8"
|
||
disabled={idx === 0}
|
||
onClick={() => moveRow(r.id, "up")}
|
||
title="上移"
|
||
>
|
||
<ArrowUp className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8"
|
||
disabled={idx === tabRows.length - 1}
|
||
onClick={() => moveRow(r.id, "down")}
|
||
title="下移"
|
||
>
|
||
<ArrowDown className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||
onClick={() => removeRow(r.id)}
|
||
title="删除"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<Button variant="outline" size="sm" className="mt-4" onClick={addRow}>
|
||
<Plus className="h-4 w-4" /> 添加按钮
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|