Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
# Conflicts: # CLAUDE.md
This commit is contained in:
commit
6a8f118ad0
32 files changed
+1729
-181
No files matched your search
@@ -37,6 +37,8 @@ export default function AgentChatPage({
|
||||
embeddedAgentId,
|
||||
chatBasePath,
|
||||
taskGate,
|
||||
taskBind,
|
||||
autoStartWhenPurposeEmpty,
|
||||
}: {
|
||||
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。
|
||||
* 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */
|
||||
@@ -49,6 +51,11 @@ export default function AgentChatPage({
|
||||
/** rwfx 任务深链闸门:新建对话时顶部展示目的树面板(覆盖确认后才开始问答),
|
||||
* 并给新建 thread 打 `metadata.taskId` 标签(任务↔对话 1:N)。见 task-purpose。 */
|
||||
taskGate?: boolean;
|
||||
/** 3qfx / xdfx 任务深链:仅做任务↔对话 1:N 绑定(打 `metadata.taskId` 标签 + 切换会话停旧 run),
|
||||
* 不展示目的树闸门;新建对话直接把任务开场信息自动发出。与 `taskGate` 互斥使用。 */
|
||||
taskBind?: boolean;
|
||||
/** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */
|
||||
autoStartWhenPurposeEmpty?: boolean;
|
||||
} = {}) {
|
||||
const routeBase = useRouteBase();
|
||||
const { t } = useI18n();
|
||||
@@ -63,15 +70,23 @@ export default function AgentChatPage({
|
||||
// 对话路由前缀:调用方可覆盖(任务深链嵌入态),否则用当前 routeBase 下的独立智能体对话路径。
|
||||
const agentChatsBase = chatBasePath ?? `${routeBase}/agents/${agent_id}/chats`;
|
||||
|
||||
// rwfx 任务闸门:读取登录深链/「新建对话」暂存的任务上下文(taskId + 开场信息)。
|
||||
// 任务深链上下文(taskId + 开场信息):闸门(rwfx)与纯绑定(3qfx/xdfx)都要读取。
|
||||
const taskCtxOn = !!(taskGate || taskBind);
|
||||
const taskContext = useMemo(
|
||||
() => (taskGate ? readTaskContext() : null),
|
||||
[taskGate],
|
||||
() => (taskCtxOn ? readTaskContext() : null),
|
||||
[taskCtxOn],
|
||||
);
|
||||
const taskId =
|
||||
taskGate && taskContext && taskContext.agentId === agent_id
|
||||
taskCtxOn && taskContext && taskContext.agentId === agent_id
|
||||
? taskContext.taskId
|
||||
: "";
|
||||
// 纯绑定(taskBind 且无闸门):新建对话自动发出的任务开场信息。rwfx 走闸门发出,不在此列。
|
||||
const taskAutoSendText =
|
||||
taskBind && !taskGate && taskContext && taskContext.agentId === agent_id
|
||||
? (taskContext.openingText?.trim() ?? "")
|
||||
: "";
|
||||
// 纯绑定每次进入新建对话都自动发一次开场信息;ref 防止同一新建会话内重复发出。
|
||||
const taskAutoSentRef = useRef(false);
|
||||
|
||||
// If ChatPage stashed an opening message for this agent, skip the landing
|
||||
// screen on first render so the user doesn't see a flash of the welcome UI
|
||||
@@ -88,7 +103,7 @@ export default function AgentChatPage({
|
||||
}
|
||||
};
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(
|
||||
hasPendingForThisAgent,
|
||||
() => hasPendingForThisAgent() || !!taskAutoSendText,
|
||||
);
|
||||
|
||||
const { agent, isLoading: isAgentLoading } = useAgent(agent_id);
|
||||
@@ -107,13 +122,15 @@ export default function AgentChatPage({
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(hasPendingForThisAgent());
|
||||
setHasStartedConversation(hasPendingForThisAgent() || !!taskAutoSendText);
|
||||
// 进入新建对话:允许纯绑定再次自动发出开场信息。
|
||||
taskAutoSentRef.current = false;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isNewThread, threadId, agent_id]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
const [thread, sendMessage, , streamThreadId] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
assistantId: agent_id,
|
||||
context: { ...settings.context, agent_id },
|
||||
@@ -162,20 +179,20 @@ export default function AgentChatPage({
|
||||
);
|
||||
|
||||
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
|
||||
// 仅 taskGate(rwfx 任务工作区)启用——普通问答页保留后台续跑行为不变。
|
||||
// 任务工作区(rwfx 闸门 + 3qfx/xdfx 纯绑定)启用——普通问答页保留后台续跑行为不变。
|
||||
const prevTaskThreadRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!taskGate) return;
|
||||
if (!taskCtxOn) return;
|
||||
const prev = prevTaskThreadRef.current;
|
||||
if (prev && prev !== threadId) void stopThreadRun(prev);
|
||||
prevTaskThreadRef.current = threadId ?? null;
|
||||
}, [taskGate, threadId]);
|
||||
}, [taskCtxOn, threadId]);
|
||||
useEffect(() => {
|
||||
// 离开任务问答页(卸载)时也停掉当前会话的服务端 run。
|
||||
return () => {
|
||||
if (taskGate) void stopThreadRun(prevTaskThreadRef.current);
|
||||
if (taskCtxOn) void stopThreadRun(prevTaskThreadRef.current);
|
||||
};
|
||||
}, [taskGate]);
|
||||
}, [taskCtxOn]);
|
||||
|
||||
// 任务闸门通过(无目的数据 / 覆盖成功):发出开场信息开始问答;无开场信息则
|
||||
// 仅切到对话态(隐藏面板),让用户手动输入。
|
||||
@@ -210,6 +227,22 @@ export default function AgentChatPage({
|
||||
handleSubmit({ text: pending.text, files: [] });
|
||||
}, [agent_id, threadId, isNewThread, handleSubmit]);
|
||||
|
||||
// 纯绑定任务深链(3qfx/xdfx,无闸门):进入新建对话即自动发出任务开场信息。
|
||||
// 每次新建对话都带(与 rwfx 闸门「开始问答」发出等价);ref 防止同一会话内重复发出。
|
||||
//
|
||||
// 必须等 `streamThreadId == null`(底层 stream 已切到全新线程)才发:从已有会话点「新建对话」时,
|
||||
// `isNewThread` 先翻 true,但 useThreadStream 的 onStreamThreadId 要慢一帧才重置为 null。
|
||||
// 若在那一帧就 submit,hook 仍绑定上一个会话,submit 的 threadId 选项被忽略 → 开场信息发进
|
||||
// 上一个对话(表现为「一直在同一个聊天框里发消息」)。等它归零再发即落到新建线程。
|
||||
useEffect(() => {
|
||||
if (!taskAutoSendText) return;
|
||||
if (!isNewThread) return;
|
||||
if (streamThreadId != null) return;
|
||||
if (taskAutoSentRef.current) return;
|
||||
taskAutoSentRef.current = true;
|
||||
handleSubmit({ text: taskAutoSendText, files: [] });
|
||||
}, [taskAutoSendText, isNewThread, streamThreadId, handleSubmit]);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
(answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
@@ -223,8 +256,9 @@ export default function AgentChatPage({
|
||||
}, [thread]);
|
||||
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
// 任务闸门面板:仅在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
|
||||
const showTaskGate = !!taskId && isNewThread && !hasStartedConversation;
|
||||
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
|
||||
// 3qfx/xdfx(taskBind)不展示闸门,靠 taskAutoSendText 直接发出开场信息。
|
||||
const showTaskGate = !!taskGate && !!taskId && isNewThread && !hasStartedConversation;
|
||||
const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
||||
|
||||
const status = thread.error
|
||||
@@ -290,7 +324,11 @@ export default function AgentChatPage({
|
||||
</header>
|
||||
|
||||
{showTaskGate && (
|
||||
<TaskPurposePanel taskId={taskId} onStart={handleGateStart} />
|
||||
<TaskPurposePanel
|
||||
taskId={taskId}
|
||||
onStart={handleGateStart}
|
||||
autoStartWhenEmpty={autoStartWhenPurposeEmpty}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLandingState ? (
|
||||
@@ -403,6 +441,7 @@ export default function AgentChatPage({
|
||||
agentName={agentDisplayName}
|
||||
disabled={thread.isLoading || isDemoMode}
|
||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||||
defaultCollapsed={taskCtxOn}
|
||||
/>
|
||||
</div>
|
||||
</ThreadContext.Provider>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
fetchCopTaskDetail,
|
||||
persistCopTaskDetail,
|
||||
resolveDeeplinkTaskId,
|
||||
resolveTaskDeeplinkTarget,
|
||||
} from '@/core/auth/task-deeplink';
|
||||
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
|
||||
@@ -160,11 +161,14 @@ export default function LoginPage() {
|
||||
// 任一环节失败(无地址/拉取失败/未配置智能体/goPath 无法识别)都回退默认首页。
|
||||
if (taskId && goPath) {
|
||||
try {
|
||||
const detail = await fetchCopTaskDetail(taskId);
|
||||
// xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;其它业务原样。
|
||||
const realTaskId = await resolveDeeplinkTaskId(goPath, taskId);
|
||||
if (cancelled) return;
|
||||
const detail = await fetchCopTaskDetail(realTaskId);
|
||||
if (cancelled) return;
|
||||
if (detail) {
|
||||
persistCopTaskDetail(detail);
|
||||
const target = await resolveTaskDeeplinkTarget(goPath, detail, taskId);
|
||||
const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId);
|
||||
if (cancelled) return;
|
||||
if (target) {
|
||||
navigate(target.path, { replace: true, state: target.state });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Navigate, Route, Routes, useParams } from "react-router-dom";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
@@ -12,6 +12,7 @@ import TaskTyFrom from "@/strategy-components/components/task-components/TaskTyF
|
||||
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
|
||||
import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes";
|
||||
import {
|
||||
TaskBindChat,
|
||||
TaskFxChat,
|
||||
TaskFxIndexResolver,
|
||||
TaskFxWorkspace,
|
||||
@@ -39,6 +40,7 @@ import LightAppListPage from "./LightAppListPage";
|
||||
import LightAppManagementPage from "./LightAppManagementPage";
|
||||
import MenuManagementPage from "./MenuManagementPage";
|
||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||
import TaskButtonsPage from "./TaskButtonsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
import LightAppNoteQAPage from "./LightAppNoteQAPage";
|
||||
@@ -59,20 +61,6 @@ import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||
|
||||
/**
|
||||
* 任务深链:把智能体问答嵌进「任务管理」布局。读路由参数 agent_id,给 AgentChatPage
|
||||
* 传入以 taskBase 为前缀的对话路由(开新会话 / URL 改写都停留在任务管理上下文,
|
||||
* 侧边栏保持对应业务高亮)。
|
||||
*/
|
||||
function TaskEmbeddedAgentChat({ taskBase }: { taskBase: string }) {
|
||||
const { agent_id } = useParams<{ agent_id: string }>();
|
||||
return (
|
||||
<ChatRuntime>
|
||||
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} />
|
||||
</ChatRuntime>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StrategyRoutes() {
|
||||
return (
|
||||
<RouteBaseProvider value="/page/strategy">
|
||||
@@ -155,12 +143,26 @@ export default function StrategyRoutes() {
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
{/* 三情分析:任务工作区(左对话列表 1:N,无目的树闸门;新建对话直接带开场信息)。 */}
|
||||
<Route
|
||||
path="plan-task-sentiment/agent/:agent_id/chats/:thread_id"
|
||||
path="plan-task-sentiment/agent/:agent_id/chats"
|
||||
element={
|
||||
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task-sentiment" />
|
||||
<TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task-sentiment" business="3qfx" lite />
|
||||
}
|
||||
/>
|
||||
>
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<TaskFxIndexResolver taskBase="/page/strategy/task-management/plan-task-sentiment" />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path=":thread_id"
|
||||
element={
|
||||
<TaskBindChat taskBase="/page/strategy/task-management/plan-task-sentiment" />
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path="plan-task/plan-task-fx"
|
||||
element={
|
||||
@@ -184,7 +186,7 @@ export default function StrategyRoutes() {
|
||||
<Route
|
||||
path="plan-task/plan-task-fx/agent/:agent_id/chats"
|
||||
element={
|
||||
<TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
|
||||
<TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task/plan-task-fx" business="rwfx" />
|
||||
}
|
||||
>
|
||||
<Route
|
||||
@@ -196,7 +198,10 @@ export default function StrategyRoutes() {
|
||||
<Route
|
||||
path=":thread_id"
|
||||
element={
|
||||
<TaskFxChat taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
|
||||
<TaskFxChat
|
||||
taskBase="/page/strategy/task-management/plan-task/plan-task-fx"
|
||||
autoStartWhenPurposeEmpty
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
@@ -219,12 +224,27 @@ export default function StrategyRoutes() {
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
{/* 行动分析:任务工作区(左对话列表 1:N 精简版;右侧带目的树闸门,与 rwfx 同一查询/显示)。
|
||||
注意:xdfx 深链传入的是「行动id」,登录时已先经 resolveDeeplinkTaskId 换成任务id。 */}
|
||||
<Route
|
||||
path="plan-task/plan-task-action/agent/:agent_id/chats/:thread_id"
|
||||
path="plan-task/plan-task-action/agent/:agent_id/chats"
|
||||
element={
|
||||
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task/plan-task-action" />
|
||||
<TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task/plan-task-action" business="xdfx" lite />
|
||||
}
|
||||
/>
|
||||
>
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<TaskFxIndexResolver taskBase="/page/strategy/task-management/plan-task/plan-task-action" />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path=":thread_id"
|
||||
element={
|
||||
<TaskFxChat taskBase="/page/strategy/task-management/plan-task/plan-task-action" />
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="mnty-task-mechanism" element={<TaskTyFrom defaultPage="iframeXdty" title="XDTY任务" />} />
|
||||
<Route path="mnty-task-plan" element={<TaskTyFrom defaultPage="iframeFaty" title="方案TY任务" />} />
|
||||
<Route path="mnty-task-zsty" element={<TaskTyFrom defaultPage="iframeSysystem" title="XXTY任务" />} />
|
||||
@@ -362,6 +382,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/task-buttons"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<TaskButtonsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
"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 } 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,
|
||||
} from "@/core/auth/task-buttons";
|
||||
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",
|
||||
enabled: true,
|
||||
sortOrder: maxSort + 1,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
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">
|
||||
配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序)
|
||||
</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-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="url">外部链接</SelectItem>
|
||||
<SelectItem value="business">切换业务</SelectItem>
|
||||
<SelectItem value="purpose">目的树弹框</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 === "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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user