Merge branch 'ji' into master
# Conflicts: # offline-backend-20260512/backend/CLAUDE.md # offline-backend-20260512/backend/app/gateway/app.py # offline-backend-20260512/backend/app/gateway/deps.py
This commit is contained in:
commit
77aa895a70
30 files changed
+1710
-176
No files matched your search
@@ -8,6 +8,7 @@ import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ListTree,
|
||||
MousePointerClick,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
User,
|
||||
@@ -213,6 +214,14 @@ function SecondaryNavSidebar() {
|
||||
菜单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/task-buttons")}
|
||||
>
|
||||
<MousePointerClick className="size-4" />
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSensitiveWords() && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
|
||||
@@ -493,6 +502,12 @@ function LegacyAccordionSidebar({
|
||||
菜单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/task-buttons")}>
|
||||
<MousePointerClick className="size-4" />
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSensitiveWords() && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/sensitive-words")}>
|
||||
<ShieldAlert className="size-4" />
|
||||
|
||||
@@ -131,14 +131,17 @@ export function AgentDetailPanel({
|
||||
agentName,
|
||||
onAskQuestion,
|
||||
disabled,
|
||||
defaultCollapsed,
|
||||
}: {
|
||||
agent: Agent | null | undefined;
|
||||
agentName: string;
|
||||
onAskQuestion: (text: string) => void;
|
||||
disabled?: boolean;
|
||||
/** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */
|
||||
defaultCollapsed?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<AgentDraft | null>(null);
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 任务工作区(任务深链 rwfx / 3qfx / xdfx,见 ``TaskFxWorkspace``)左侧栏的
|
||||
* **可配置跳转按钮**。
|
||||
*
|
||||
* 数据源优先级:后端 ``/api/task-buttons``(全局共享、admin 整体替换)。**仅当整张表
|
||||
* 为空(全新部署、从未保存过)**时回退到代码种子默认(``defaultTaskButtons``,从实时
|
||||
* 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时
|
||||
* config 进种子,避免写死;一旦保存过,后端即唯一数据源。
|
||||
*
|
||||
* 按钮两类(``linkType``):``url`` 外链(``window.open``,可选追加 ``?id=任务id``)/
|
||||
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)。
|
||||
*/
|
||||
|
||||
import {
|
||||
appendQueryParam,
|
||||
type TaskDeeplinkConfig,
|
||||
} from "@/core/auth/task-deeplink-config";
|
||||
import {
|
||||
readCopTaskDetail,
|
||||
resolveTaskDeeplinkTarget,
|
||||
} from "@/core/auth/task-deeplink";
|
||||
|
||||
/** 任务工作区业务码(与路由 taskBase 一一对应)。 */
|
||||
export type TaskBusiness = "rwfx" | "3qfx" | "xdfx";
|
||||
|
||||
/** 业务元数据:每个 = 一个任务深链页面。``goPath`` 为深链 ``?goPath=`` 值;``desc`` 给管理页说明。 */
|
||||
export const TASK_BUSINESSES: {
|
||||
code: TaskBusiness;
|
||||
/** 页面名(tab 主标题)。 */
|
||||
name: string;
|
||||
/** 深链 goPath 值。 */
|
||||
goPath: string;
|
||||
/** 页面说明。 */
|
||||
desc: string;
|
||||
}[] = [
|
||||
{
|
||||
code: "rwfx",
|
||||
name: "任务分析",
|
||||
goPath: "rwfx",
|
||||
desc: "完整版任务工作区(带目的树闸门),通过 ?goPath=rwfx 进入。",
|
||||
},
|
||||
{
|
||||
code: "3qfx",
|
||||
name: "三情分析",
|
||||
goPath: "3qfx",
|
||||
desc: "精简版工作区(无目的树闸门),通过 ?goPath=3qfx 进入。",
|
||||
},
|
||||
{
|
||||
code: "xdfx",
|
||||
name: "行动分析",
|
||||
goPath: "xdfx",
|
||||
desc: "精简版工作区,通过 ?goPath=xdfx 进入(深链 taskId 实为行动id)。",
|
||||
},
|
||||
];
|
||||
|
||||
/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格(无需 target)。 */
|
||||
export type TaskButtonLinkType = "url" | "business" | "purpose";
|
||||
|
||||
/** 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同)。 */
|
||||
export type TaskButtonIdKind = "task" | "action";
|
||||
|
||||
/** 一个可配置跳转按钮。 */
|
||||
export interface TaskButton {
|
||||
id: string;
|
||||
business: TaskBusiness;
|
||||
label: string;
|
||||
linkType: TaskButtonLinkType;
|
||||
/** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``)。 */
|
||||
target: string;
|
||||
/** ``url`` 类型是否追加 id 查询参数。 */
|
||||
appendTaskId: boolean;
|
||||
/** 追加的查询参数名(如 ``id`` / ``task_id``);空则用 ``id``。 */
|
||||
idParam: string;
|
||||
/**
|
||||
* ``url`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始 ``?taskId=``
|
||||
* 实为行动id,任务id 由 action 接口解析。其它业务两者相同。
|
||||
*/
|
||||
idKind: TaskButtonIdKind;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/** 内部切换可选的目标业务(``business`` 类型按钮的下拉项)。 */
|
||||
export const TASK_BUSINESS_TARGETS: { code: TaskBusiness; label: string }[] = [
|
||||
{ code: "3qfx", label: "三情分析" },
|
||||
{ code: "rwfx", label: "任务分析" },
|
||||
{ code: "xdfx", label: "行动分析" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 代码种子默认按钮(仅在后端整张表为空时使用):
|
||||
* - rwfx:历史 4 个按钮(编辑/下一步/3q详情,地址取自实时任务深链配置)+ 切换到三情/行动分析;
|
||||
* - 3qfx / xdfx:互相切换到另外两个业务。
|
||||
* 即「三个业务都带切换按钮」。已保存过则用页面「导入默认」把缺失项补进来。
|
||||
*/
|
||||
export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
const sw = (
|
||||
id: string,
|
||||
business: TaskBusiness,
|
||||
label: string,
|
||||
target: TaskBusiness,
|
||||
sortOrder: number,
|
||||
): TaskButton => ({
|
||||
id,
|
||||
business,
|
||||
label,
|
||||
linkType: "business",
|
||||
target,
|
||||
appendTaskId: false,
|
||||
idParam: "id",
|
||||
idKind: "task",
|
||||
enabled: true,
|
||||
sortOrder,
|
||||
});
|
||||
// 目的树弹框按钮(无需 target)。
|
||||
const pz = (id: string, business: TaskBusiness, sortOrder: number): TaskButton => ({
|
||||
id,
|
||||
business,
|
||||
label: "弹框查看",
|
||||
linkType: "purpose",
|
||||
target: "",
|
||||
appendTaskId: false,
|
||||
idParam: "id",
|
||||
idKind: "task",
|
||||
enabled: true,
|
||||
sortOrder,
|
||||
});
|
||||
return [
|
||||
// rwfx(任务分析):外链 + 切换
|
||||
{
|
||||
id: "rwfx-edit",
|
||||
business: "rwfx",
|
||||
label: "编辑",
|
||||
linkType: "url",
|
||||
target: cfg.treemapEditUrl,
|
||||
appendTaskId: true,
|
||||
idParam: "id",
|
||||
idKind: "task",
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: "rwfx-next",
|
||||
business: "rwfx",
|
||||
label: "下一步",
|
||||
linkType: "url",
|
||||
target: cfg.treemapNextUrl,
|
||||
appendTaskId: true,
|
||||
idParam: "id",
|
||||
idKind: "task",
|
||||
enabled: true,
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
id: "rwfx-detail3q",
|
||||
business: "rwfx",
|
||||
label: "3q详情",
|
||||
linkType: "url",
|
||||
target: cfg.detail3qUrl,
|
||||
appendTaskId: true,
|
||||
idParam: "id",
|
||||
idKind: "task",
|
||||
enabled: true,
|
||||
sortOrder: 2,
|
||||
},
|
||||
sw("rwfx-switch3q", "rwfx", "切换到三情分析", "3qfx", 3),
|
||||
sw("rwfx-switchxd", "rwfx", "切换到行动分析", "xdfx", 4),
|
||||
pz("rwfx-purpose", "rwfx", 5),
|
||||
// 3qfx(三情分析):切换到任务/行动分析
|
||||
sw("3qfx-switchrw", "3qfx", "切换到任务分析", "rwfx", 0),
|
||||
sw("3qfx-switchxd", "3qfx", "切换到行动分析", "xdfx", 1),
|
||||
// xdfx(行动分析):切换到任务/三情分析 + 弹框查看
|
||||
sw("xdfx-switchrw", "xdfx", "切换到任务分析", "rwfx", 0),
|
||||
sw("xdfx-switch3q", "xdfx", "切换到三情分析", "3qfx", 1),
|
||||
pz("xdfx-purpose", "xdfx", 2),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际生效的按钮列表:后端有任意一条记录时即以后端为准;整张表为空才回退种子默认。
|
||||
* (与 [[敏感词管理]] 的种子语义一致:空=从未配置→用种子;非空=已配置→后端说了算,
|
||||
* 哪怕某业务被清空也不回退。)
|
||||
*/
|
||||
export function resolveTaskButtons(
|
||||
stored: TaskButton[],
|
||||
cfg: TaskDeeplinkConfig,
|
||||
): TaskButton[] {
|
||||
return stored.length > 0 ? stored : defaultTaskButtons(cfg);
|
||||
}
|
||||
|
||||
/** 取某业务下、启用的按钮,按 sortOrder 升序。 */
|
||||
export function buttonsForBusiness(
|
||||
buttons: TaskButton[],
|
||||
business: TaskBusiness,
|
||||
): TaskButton[] {
|
||||
return buttons
|
||||
.filter((b) => b.business === business && b.enabled)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}
|
||||
|
||||
export interface RunTaskButtonCtx {
|
||||
/** 任务id(xdfx 为 action 接口解析后的真实任务id;其它业务即深链原始 id)。 */
|
||||
taskId: string;
|
||||
/** 行动id(xdfx 深链原始 ?taskId=;其它业务通常等于 taskId)。 */
|
||||
actionId?: string;
|
||||
navigate: (path: string, opts?: { state?: Record<string, unknown> }) => void;
|
||||
/** 跳转/打开失败时的提示回调(默认 noop)。 */
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一个跳转按钮:
|
||||
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
|
||||
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
|
||||
*/
|
||||
export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> {
|
||||
const fail = (m: string) => ctx.onError?.(m);
|
||||
// purpose(目的树弹框)由组件层处理(需打开 React 弹框),此处不动作。
|
||||
if (btn.linkType === "purpose") return;
|
||||
if (btn.linkType === "url") {
|
||||
const base = btn.target.trim();
|
||||
if (!base) {
|
||||
fail(`未配置「${btn.label}」的跳转地址`);
|
||||
return;
|
||||
}
|
||||
// 行动分析(xdfx)可选追加行动id;否则用任务id。行动id 缺失时回退任务id。
|
||||
const id = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId;
|
||||
const param = (btn.idParam || "id").trim() || "id";
|
||||
const url = btn.appendTaskId && id ? appendQueryParam(base, param, id) : base;
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
// business:内部切换到另一业务
|
||||
const detail = readCopTaskDetail();
|
||||
if (!detail || !ctx.taskId) {
|
||||
fail("缺少任务信息,无法跳转");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const target = await resolveTaskDeeplinkTarget(btn.target, detail, ctx.taskId);
|
||||
if (target) ctx.navigate(target.path, { state: target.state });
|
||||
else fail(`未配置「${btn.label}」对应的业务智能体`);
|
||||
} catch {
|
||||
fail("跳转失败,请稍后再试");
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export interface TaskDeeplinkConfig {
|
||||
test: boolean;
|
||||
/** 删除接口走假数据(该接口未开发完时打开)。 */
|
||||
deleteTest: boolean;
|
||||
/** 行动→任务解析走假数据(xdfx 行动id→任务id)。 */
|
||||
actionTest: boolean;
|
||||
/** 目的树查询完整 URL(调用追加 ?taskId=)。 */
|
||||
purposeDetailUrl: string;
|
||||
/** 删除完整 URL(调用追加 ?taskId=)。 */
|
||||
@@ -27,10 +29,16 @@ export interface TaskDeeplinkConfig {
|
||||
treemapEditUrl: string;
|
||||
/** B 系统树形下一步页 URL(跳转追加 ?id=)。 */
|
||||
treemapNextUrl: string;
|
||||
/** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success}。 */
|
||||
/** 行动详情接口基址(xdfx 行动→任务):调用追加 /{actionId},返回 data.taskId 为真正任务id。 */
|
||||
actionDetailUrl: string;
|
||||
/** 「3q详情」详情页 URL(rwfx 目的树面板链接,跳转追加 ?id= 任务id);留空则前端提示未配置。 */
|
||||
detail3qUrl: string;
|
||||
/** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success};
|
||||
* actionDetail = 原始 {state,msg,data:{taskId,...}}。 */
|
||||
mock: {
|
||||
purposeDetail?: unknown;
|
||||
delete?: { message?: string; success?: boolean };
|
||||
actionDetail?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,10 +46,13 @@ export interface TaskDeeplinkConfig {
|
||||
export const DEFAULT_TASK_DEEPLINK_CONFIG: TaskDeeplinkConfig = {
|
||||
test: false,
|
||||
deleteTest: false,
|
||||
actionTest: false,
|
||||
purposeDetailUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail",
|
||||
deleteUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete",
|
||||
treemapEditUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit",
|
||||
treemapNextUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next",
|
||||
actionDetailUrl: "https://ch1.b.uat.4.cn/consumer/plan/action",
|
||||
detail3qUrl: "",
|
||||
mock: {},
|
||||
};
|
||||
|
||||
@@ -55,16 +66,21 @@ function normalize(body: unknown): TaskDeeplinkConfig {
|
||||
return {
|
||||
test: o.test === true,
|
||||
deleteTest: o.delete_test === true,
|
||||
actionTest: o.action_test === true,
|
||||
purposeDetailUrl: str(o.purpose_detail_url, d.purposeDetailUrl),
|
||||
deleteUrl: str(o.delete_url, d.deleteUrl),
|
||||
treemapEditUrl: str(o.treemap_edit_url, d.treemapEditUrl),
|
||||
treemapNextUrl: str(o.treemap_next_url, d.treemapNextUrl),
|
||||
actionDetailUrl: str(o.action_detail_url, d.actionDetailUrl),
|
||||
// detail_3q_url 允许为空(地址待定),不回退默认;只在配了非空时才生效。
|
||||
detail3qUrl: typeof o.detail_3q_url === "string" ? o.detail_3q_url : d.detail3qUrl,
|
||||
mock: {
|
||||
purposeDetail: mock.purpose_detail,
|
||||
delete:
|
||||
mock.delete && typeof mock.delete === "object"
|
||||
? (mock.delete as { message?: string; success?: boolean })
|
||||
: undefined,
|
||||
actionDetail: mock.action_detail,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose";
|
||||
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
|
||||
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
|
||||
|
||||
/** rwfx 业务码:单智能体走「任务工作区 + 目的树闸门」(其它业务保持原问答逻辑)。 */
|
||||
const RWFX_BUSINESS_CODE = "6BF";
|
||||
/** xdfx 业务码:深链传入的是「行动id」,需先调 action 接口解析出真正的「任务id」。 */
|
||||
const XDFX_BUSINESS_CODE = "7BF";
|
||||
|
||||
/** consumer 接口返回的任务详情(data 字段)。 */
|
||||
export interface CopTaskDetail {
|
||||
@@ -110,6 +111,44 @@ export function readCopTaskDetail(): CopTaskDetail | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* xdfx 深链:用「行动id」调行动详情接口 ``{action_detail_url}/{actionId}``,取出 ``data.taskId``
|
||||
* 作为真正的「任务id」。``action_test=true`` 时直接用后端配置里的假数据(接口未通时用)。
|
||||
* 缺地址 / 返回不合法 → 返回 ""(调用方据此回退)。
|
||||
*/
|
||||
export async function fetchTaskIdFromAction(actionId: string): Promise<string> {
|
||||
if (!actionId) return "";
|
||||
const cfg = await fetchTaskDeeplinkConfig();
|
||||
let body: unknown;
|
||||
if (cfg.actionTest) {
|
||||
body = cfg.mock.actionDetail;
|
||||
} else if (cfg.actionDetailUrl) {
|
||||
const base = cfg.actionDetailUrl.replace(/\/+$/, "");
|
||||
// service 拦截器已统一注入 Authorization: Bearer admin 并返回整包 body;行动id 走路径段。
|
||||
body = await requestCrudService.get(`${base}/${encodeURIComponent(actionId)}`);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
|
||||
if (!data || typeof data !== "object") return "";
|
||||
const taskId = (data as Record<string, unknown>).taskId;
|
||||
return taskId != null && taskId !== "" ? String(taskId) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 据 goPath 把深链 URL 里的原始 id 解析成真正的「任务id」:
|
||||
* - xdfx(行动分析):URL 传入的是「行动id」,调 action 接口换成 ``data.taskId``;
|
||||
* 解析失败返回 ""(让调用方回退默认首页,避免拿行动id当任务id污染后续查询/绑定)。
|
||||
* - 其它业务:URL 传入的本就是任务id,原样返回。
|
||||
*/
|
||||
export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Promise<string> {
|
||||
const parsed = parseGoPath(goPath);
|
||||
if (parsed?.businessCode === XDFX_BUSINESS_CODE) {
|
||||
return fetchTaskIdFromAction(rawId);
|
||||
}
|
||||
return rawId;
|
||||
}
|
||||
|
||||
export interface TaskDeeplinkTarget {
|
||||
path: string;
|
||||
/** react-router navigate 的 state(多智能体会商用 roundtablePrefill)。 */
|
||||
@@ -135,20 +174,23 @@ function buildOpeningText(detail: CopTaskDetail, taskId: string): string {
|
||||
* ``sessionStorage.pendingAgentMessage``(AgentChatPage 自动发出)。
|
||||
* 无法识别 goPath / 未配置智能体时返回 null(调用方回退到默认首页)。
|
||||
*
|
||||
* @param taskId 深链 URL 的原始 ``?taskId=``(外部任务 id,用于按任务隔离草稿;与
|
||||
* consumer 返回的 detail.id 区分——后者不保证等于外部任务 id)。
|
||||
* @param taskId 真实任务 id(xdfx 已由 action 接口解析;其它业务即深链原始 ?taskId=)。
|
||||
* 用于按任务隔离草稿/对话;与 consumer 返回的 detail.id 区分。
|
||||
* @param rawId 深链 URL 的原始 ``?taskId=``(xdfx 为「行动id」)。供需追加行动id 的按钮用;
|
||||
* 省略时回退 taskId。
|
||||
*/
|
||||
export async function resolveTaskDeeplinkTarget(
|
||||
goPath: string,
|
||||
detail: CopTaskDetail,
|
||||
taskId: string,
|
||||
rawId?: string,
|
||||
): Promise<TaskDeeplinkTarget | null> {
|
||||
const parsed = parseGoPath(goPath);
|
||||
if (!parsed) return null;
|
||||
const taskBase = BUSINESS_TASK_BASE[parsed.businessCode];
|
||||
if (!taskBase) return null;
|
||||
|
||||
// 清掉上一次深链可能残留的任务上下文(仅 rwfx 单智能体会重新写入)。
|
||||
// 清掉上一次深链可能残留的任务上下文(单智能体分支会用本次任务重新写入)。
|
||||
clearTaskContext();
|
||||
|
||||
if (parsed.roundtable) {
|
||||
@@ -177,22 +219,15 @@ export async function resolveTaskDeeplinkTarget(
|
||||
}
|
||||
if (!agentId) return null;
|
||||
|
||||
if (parsed.businessCode === RWFX_BUSINESS_CODE) {
|
||||
// rwfx:走「任务工作区」——暂存任务上下文(taskId + 开场信息),跳到 chats 索引,
|
||||
// 由工作区按 taskId 决定开最新历史对话 / 新建(新建走目的树闸门)。不设 pendingAgentMessage:
|
||||
// 开场信息在闸门通过后由目的树面板发出。
|
||||
persistTaskContext({ taskId, agentId, openingText: buildOpeningText(detail, taskId) });
|
||||
return { path: `${taskBase}/agent/${agentId}/chats` };
|
||||
}
|
||||
|
||||
// 其它单智能体问答:保持原逻辑(pendingAgentMessage 自动发出 + chats/new)。
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
"pendingAgentMessage",
|
||||
JSON.stringify({ agentId, text: buildOpeningText(detail, taskId) }),
|
||||
);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
return { path: `${taskBase}/agent/${agentId}/chats/new` };
|
||||
// 单智能体问答统一走「任务工作区」(任务↔对话 1:N,按 taskId + agentId 隔离不同业务的对话列表):
|
||||
// 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定
|
||||
// 开最新历史对话 / 新建。rwfx 在工作区里额外过「目的树闸门」;3qfx/xdfx 仅 1:N 列表,
|
||||
// 新建对话直接带开场信息自动发出(见 task-purpose / AgentChatPage 的 taskGate / taskBind)。
|
||||
persistTaskContext({
|
||||
taskId,
|
||||
agentId,
|
||||
openingText: buildOpeningText(detail, taskId),
|
||||
actionId: rawId || taskId,
|
||||
});
|
||||
return { path: `${taskBase}/agent/${agentId}/chats` };
|
||||
}
|
||||
@@ -33,6 +33,8 @@ export interface TaskContext {
|
||||
agentId: string;
|
||||
/** 自动发出的开场信息(任务名称/内容)。 */
|
||||
openingText: string;
|
||||
/** 深链原始 id(xdfx 为「行动id」,需追加行动id 的按钮用;其它业务等于 taskId)。 */
|
||||
actionId?: string;
|
||||
}
|
||||
|
||||
const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext";
|
||||
@@ -190,6 +192,12 @@ export function buildTreemapNextUrl(taskId: string): string {
|
||||
return url && taskId ? appendQueryParam(url, "id", taskId) : "";
|
||||
}
|
||||
|
||||
/** 「3q详情」详情页地址(目的树面板链接,新标签打开);追加 ?id=任务id。地址未配置时返回 ""。 */
|
||||
export function buildDetail3qUrl(taskId: string): string {
|
||||
const url = getCachedTaskDeeplinkConfig().detail3qUrl;
|
||||
return url && taskId ? appendQueryParam(url, "id", taskId) : "";
|
||||
}
|
||||
|
||||
/* --------------------------- 任务上下文暂存 --------------------------- */
|
||||
|
||||
export function persistTaskContext(ctx: TaskContext): void {
|
||||
@@ -210,6 +218,7 @@ export function readTaskContext(): TaskContext | null {
|
||||
taskId: String(parsed.taskId),
|
||||
agentId: String(parsed.agentId),
|
||||
openingText: typeof parsed.openingText === "string" ? parsed.openingText : "",
|
||||
actionId: typeof parsed.actionId === "string" ? parsed.actionId : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -4,19 +4,27 @@
|
||||
* 新建对话时由 [[task-purpose]] 流程给 thread 打 ``metadata.taskId`` 标签
|
||||
* (见 useThreadStream 的 ``threadMetadata``);这里复用服务端分页搜索按该标签过滤,
|
||||
* 后端默认按 ``updated_at desc`` 排序,所以 ``threads[0]`` 即最新一条。
|
||||
*
|
||||
* 同一个 taskId 可能挂在多条业务链上(rwfx / 3qfx / xdfx 共用任务 id),每条业务用各自的
|
||||
* 智能体。传入 ``agentId`` 时额外按 ``metadata.agent_id`` 过滤(后端 metadata 过滤是 AND/子集
|
||||
* 匹配),把不同业务的对话列表隔离开——否则三种业务的对话会混在一起。
|
||||
*/
|
||||
|
||||
import { useThreadsPage } from "@/core/threads/hooks";
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
|
||||
export function useTaskThreads(taskId: string | null | undefined) {
|
||||
export function useTaskThreads(
|
||||
taskId: string | null | undefined,
|
||||
agentId?: string | null | undefined,
|
||||
) {
|
||||
const enabled = !!taskId;
|
||||
// 任务工作区只在有 taskId 时渲染;无 taskId 时传一个不可能命中的过滤,返回空列表。
|
||||
const query = useThreadsPage({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
metadata: enabled ? { taskId } : { taskId: "__none__" },
|
||||
});
|
||||
const metadata = enabled
|
||||
? agentId
|
||||
? { taskId, agent_id: agentId }
|
||||
: { taskId }
|
||||
: { taskId: "__none__" };
|
||||
const query = useThreadsPage({ page: 1, pageSize: 100, metadata });
|
||||
const threads: AgentThread[] = enabled ? (query.data?.threads ?? []) : [];
|
||||
return {
|
||||
...query,
|
||||
|
||||
@@ -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,7 +122,9 @@ 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]);
|
||||
@@ -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,16 @@ export default function AgentChatPage({
|
||||
handleSubmit({ text: pending.text, files: [] });
|
||||
}, [agent_id, threadId, isNewThread, handleSubmit]);
|
||||
|
||||
// 纯绑定任务深链(3qfx/xdfx,无闸门):进入新建对话即自动发出任务开场信息。
|
||||
// 每次新建对话都带(与 rwfx 闸门「开始问答」发出等价);ref 防止同一会话内重复发出。
|
||||
useEffect(() => {
|
||||
if (!taskAutoSendText) return;
|
||||
if (!isNewThread) return;
|
||||
if (taskAutoSentRef.current) return;
|
||||
taskAutoSentRef.current = true;
|
||||
handleSubmit({ text: taskAutoSendText, files: [] });
|
||||
}, [taskAutoSendText, isNewThread, handleSubmit]);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
(answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
@@ -223,8 +250,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 +318,11 @@ export default function AgentChatPage({
|
||||
</header>
|
||||
|
||||
{showTaskGate && (
|
||||
<TaskPurposePanel taskId={taskId} onStart={handleGateStart} />
|
||||
<TaskPurposePanel
|
||||
taskId={taskId}
|
||||
onStart={handleGateStart}
|
||||
autoStartWhenEmpty={autoStartWhenPurposeEmpty}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLandingState ? (
|
||||
@@ -403,6 +435,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 任务工作区可配置跳转按钮(按钮管理)API 客户端 + React Query hooks。
|
||||
*
|
||||
* 后端:`/api/task-buttons`(见 app/gateway/routers/task_buttons.py)。
|
||||
* 全局共享、无用户隔离;读对所有登录用户开放(工作区要用),写仅 admin(整体替换)。
|
||||
* snake_case ⇄ camelCase 在此边界收敛。见 [[task-buttons]]。
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import type {
|
||||
TaskBusiness,
|
||||
TaskButton,
|
||||
TaskButtonIdKind,
|
||||
TaskButtonLinkType,
|
||||
} from "@/core/auth/task-buttons";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/task-buttons`;
|
||||
|
||||
interface ServerButton {
|
||||
id: string;
|
||||
business: string;
|
||||
label: string;
|
||||
link_type: string;
|
||||
target: string;
|
||||
append_task_id: boolean;
|
||||
id_param: string;
|
||||
id_kind: string;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
function toButton(s: ServerButton): TaskButton {
|
||||
return {
|
||||
id: s.id,
|
||||
business: (s.business as TaskBusiness) ?? "rwfx",
|
||||
label: s.label ?? "",
|
||||
linkType: (s.link_type as TaskButtonLinkType) ?? "url",
|
||||
target: s.target ?? "",
|
||||
appendTaskId: s.append_task_id ?? true,
|
||||
idParam: s.id_param || "id",
|
||||
idKind: (s.id_kind as TaskButtonIdKind) ?? "task",
|
||||
enabled: s.enabled ?? true,
|
||||
sortOrder: s.sort_order ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function toBody(b: TaskButton): ServerButton {
|
||||
return {
|
||||
id: b.id,
|
||||
business: b.business,
|
||||
label: b.label,
|
||||
link_type: b.linkType,
|
||||
target: b.target,
|
||||
append_task_id: b.appendTaskId,
|
||||
id_param: b.idParam,
|
||||
id_kind: b.idKind,
|
||||
enabled: b.enabled,
|
||||
sort_order: b.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null as unknown);
|
||||
const detail =
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? String((data as { detail: unknown }).detail)
|
||||
: `${ctx} failed (${res.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function listTaskButtons(): Promise<TaskButton[]> {
|
||||
const res = await apiFetch(BASE(), { method: "GET" });
|
||||
const data = await readJson<{ buttons: ServerButton[] }>(res, "list task buttons");
|
||||
return (data.buttons ?? []).map(toButton);
|
||||
}
|
||||
|
||||
export async function replaceTaskButtons(buttons: TaskButton[]): Promise<TaskButton[]> {
|
||||
const res = await apiFetch(BASE(), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ buttons: buttons.map(toBody) }),
|
||||
});
|
||||
const data = await readJson<{ buttons: ServerButton[] }>(res, "save task buttons");
|
||||
return (data.buttons ?? []).map(toButton);
|
||||
}
|
||||
|
||||
// ── React Query hooks(工作区 + 管理页共用缓存) ────────────────────────────
|
||||
|
||||
const TASK_BUTTONS_KEY = "task-buttons";
|
||||
|
||||
export function useTaskButtons() {
|
||||
return useQuery({
|
||||
queryKey: [TASK_BUTTONS_KEY],
|
||||
queryFn: listTaskButtons,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTaskButtonActions() {
|
||||
const queryClient = useQueryClient();
|
||||
return {
|
||||
save: useMutation({
|
||||
mutationFn: replaceTaskButtons,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: [TASK_BUTTONS_KEY] }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
+230
-85
@@ -1,20 +1,22 @@
|
||||
/**
|
||||
* 任务深链(rwfx / 任务FX)的「任务工作区」:一个任务挂多个对话(1:N)。
|
||||
* 任务深链的「任务工作区」:一个任务挂多个对话(1:N)。三种业务共用本布局:
|
||||
* - rwfx(任务FX,完整版):左对话列表 + 任务操作(编辑/下一步/清除记录)+ 跳转三情分析按钮;
|
||||
* 右侧问答带「目的树闸门」(AgentChatPage taskGate)。
|
||||
* - 3qfx(三情分析)/ xdfx(行动分析)(``lite`` 精简版):只有左对话列表(按 taskId + agentId 隔离),
|
||||
* 无任务操作、无闸门;新建对话直接带任务开场信息自动发出(AgentChatPage taskBind)。
|
||||
*
|
||||
* 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+「新建对话」;
|
||||
* 右下角 = 常驻浮动操作条(编辑 / 下一步 / 清除任务记录),absolute 定位,时刻可见、不被内容挤走;
|
||||
* 右侧 = <Outlet/> 渲染选中的对话(AgentChatPage,新建对话顶部带目的树闸门面板)。
|
||||
* 左侧顶部 = 当前任务卡片(任务名称 + 简介,来自登录深链暂存的 copTaskDetail)+「新建对话」;
|
||||
* 右侧 = <Outlet/> 渲染选中的对话。
|
||||
*
|
||||
* - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(=新建,走闸门)。
|
||||
* - 新建对话 = 直接跳入:navigate 到 chats/new;切换/新建时 AgentChatPage 会停掉老对话的服务端 run。
|
||||
* - 清除任务记录:仅调 delete 接口(聊天对话保留)。
|
||||
* - 编辑 / 下一步:window.open 配置里的 B 系统地址(?id=taskId)。
|
||||
* 见 [[task-purpose]] / [[task-deeplink-config]]。
|
||||
* - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。
|
||||
* - 切换/新建时 AgentChatPage 会停掉老对话的服务端 run。
|
||||
* - (rwfx)清除任务记录:仅调 delete 接口(聊天对话保留);编辑/下一步:window.open B 系统地址。
|
||||
* 见 [[task-purpose]] / [[task-deeplink]] / [[task-deeplink-config]]。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowRight, ExternalLink, Loader2, MessageSquare, PlusSquare, Trash2 } from "lucide-react";
|
||||
import { ArrowRight, ExternalLink, Loader2, Maximize2, MessageSquare, PlusSquare, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -26,24 +28,66 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose";
|
||||
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
|
||||
import {
|
||||
buildTreemapEditUrl,
|
||||
buildTreemapNextUrl,
|
||||
deleteTaskPurpose,
|
||||
readTaskContext,
|
||||
} from "@/core/auth/task-purpose";
|
||||
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
|
||||
DEFAULT_TASK_DEEPLINK_CONFIG,
|
||||
fetchTaskDeeplinkConfig,
|
||||
type TaskDeeplinkConfig,
|
||||
} from "@/core/auth/task-deeplink-config";
|
||||
import {
|
||||
TASK_BUSINESSES,
|
||||
buttonsForBusiness,
|
||||
resolveTaskButtons,
|
||||
runTaskButton,
|
||||
type TaskBusiness,
|
||||
} from "@/core/auth/task-buttons";
|
||||
import { useTaskButtons } from "@/strategy-components/api/task-buttons";
|
||||
import { useTaskThreads } from "@/core/threads/use-task-threads";
|
||||
import { titleOfThread } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import AgentChatPage from "@/pages/AgentChatPage";
|
||||
import { ChatRuntime } from "@/pages/WorkspaceRoutes";
|
||||
import { TaskPurposeDialog } from "@/strategy-components/components/task-components/TaskPurposePanel";
|
||||
|
||||
function useChatsBase(taskBase: string): { taskId: string; chatsBase: string } {
|
||||
function useChatsBase(taskBase: string): { taskId: string; agentId: string; chatsBase: string } {
|
||||
const { agent_id } = useParams<{ agent_id: string }>();
|
||||
const agentId = agent_id ?? "";
|
||||
const taskId = readTaskContext()?.taskId ?? "";
|
||||
return { taskId, chatsBase: `${taskBase}/agent/${agent_id}/chats` };
|
||||
return { taskId, agentId, chatsBase: `${taskBase}/agent/${agentId}/chats` };
|
||||
}
|
||||
|
||||
/**
|
||||
* 左侧顶部「当前任务」卡片:展示登录深链查询出的任务名称 + 简介(留存在 sessionStorage 的
|
||||
* copTaskDetail)。三种业务工作区都展示,放在「新建对话」上面。无任务详情时不渲染。
|
||||
*/
|
||||
function TaskDetailHeader() {
|
||||
const detail = useMemo(() => readCopTaskDetail(), []);
|
||||
const name = desensitize((detail?.taskName ?? "").trim());
|
||||
const brief = desensitize(((detail?.overview ?? "").trim() || (detail?.taskContent ?? "").trim()));
|
||||
if (!name && !brief) return null;
|
||||
return (
|
||||
<div className="mb-3 rounded-md border border-[var(--card-border)] bg-background/70 p-2.5 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0b2546]/70">
|
||||
<div className="text-muted-foreground mb-1 text-[11px] font-medium">当前任务</div>
|
||||
{name && (
|
||||
<div
|
||||
className="text-foreground line-clamp-2 text-sm font-semibold leading-snug"
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
)}
|
||||
{brief && (
|
||||
<div
|
||||
className="text-muted-foreground mt-1 line-clamp-3 text-xs leading-relaxed"
|
||||
title={brief}
|
||||
>
|
||||
{brief}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 从路由路径里解析当前打开的会话 id(布局组件读不到子路由的 :thread_id,故从 path 取)。 */
|
||||
@@ -53,16 +97,111 @@ function parseThreadId(pathname: string, chatsBase: string): string | null {
|
||||
return seg && seg !== "new" ? seg : null;
|
||||
}
|
||||
|
||||
/** 左侧任务对话列表 +「新建对话」。 */
|
||||
function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBase: string }) {
|
||||
/**
|
||||
* 底部「快捷跳转」栏:横跨整个工作区底部,按配置顺序渲染该业务启用的跳转按钮(按钮管理里排序)。
|
||||
* 后端为准,整张表为空才回退种子默认;地址依赖实时深链配置,故把配置拉进 state。见 [[task-buttons]]。
|
||||
*/
|
||||
function TaskJumpBar({ business, taskId }: { business: TaskBusiness; taskId: string }) {
|
||||
const navigate = useNavigate();
|
||||
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 { data: storedButtons = [] } = useTaskButtons();
|
||||
const jumpButtons = useMemo(
|
||||
() => buttonsForBusiness(resolveTaskButtons(storedButtons, cfg), business),
|
||||
[storedButtons, cfg, business],
|
||||
);
|
||||
|
||||
// 目的树弹框(purpose 类型按钮)由组件层打开。
|
||||
const [purposeOpen, setPurposeOpen] = useState(false);
|
||||
// xdfx:深链原始「行动id」(与解析后的任务id 不同才算真行动id),弹框据此换回任务id 再查目的。
|
||||
const ctxActionId = readTaskContext()?.actionId;
|
||||
const purposeActionId =
|
||||
business === "xdfx" && ctxActionId && ctxActionId !== taskId ? ctxActionId : undefined;
|
||||
|
||||
if (jumpButtons.length === 0) return null;
|
||||
|
||||
const handleRun = (btn: (typeof jumpButtons)[number]) => {
|
||||
if (btn.linkType === "purpose") {
|
||||
setPurposeOpen(true);
|
||||
return;
|
||||
}
|
||||
void runTaskButton(btn, {
|
||||
taskId,
|
||||
actionId: readTaskContext()?.actionId,
|
||||
navigate,
|
||||
onError: (m) => toast.error(m),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-border/70 bg-background/90 flex shrink-0 flex-wrap items-center justify-end gap-3 border-t px-5 py-3 backdrop-blur-xl dark:border-[#1b477e]/70 dark:bg-[#061b35]/85">
|
||||
{jumpButtons.map((btn) => (
|
||||
<Button
|
||||
key={btn.id}
|
||||
className="h-11 gap-2 border-transparent bg-[var(--color-main)] px-6 text-base text-white shadow-sm hover:bg-[var(--brand-950)]"
|
||||
onClick={() => handleRun(btn)}
|
||||
title={btn.label}
|
||||
>
|
||||
<span className="truncate">{btn.label}</span>
|
||||
{btn.linkType === "url" ? (
|
||||
<ExternalLink className="h-5 w-5 shrink-0" />
|
||||
) : btn.linkType === "purpose" ? (
|
||||
<Maximize2 className="h-5 w-5 shrink-0" />
|
||||
) : (
|
||||
<ArrowRight className="h-5 w-5 shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
<TaskPurposeDialog
|
||||
taskId={taskId}
|
||||
open={purposeOpen}
|
||||
onOpenChange={setPurposeOpen}
|
||||
resolveFromActionId={purposeActionId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 左侧任务对话列表:顶部「当前任务」卡片 +「新建对话」;下方对话列表(1:N)。
|
||||
* 跳转按钮已移到工作区底部「快捷跳转」栏(见 ``TaskJumpBar``)。
|
||||
*/
|
||||
function TaskConversationsList({
|
||||
taskId,
|
||||
agentId,
|
||||
chatsBase,
|
||||
business,
|
||||
lite,
|
||||
}: {
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
chatsBase: string;
|
||||
business: TaskBusiness;
|
||||
lite?: boolean;
|
||||
}) {
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { threads, isLoading } = useTaskThreads(taskId);
|
||||
const { threads, isLoading } = useTaskThreads(taskId, agentId);
|
||||
const currentThreadId = useMemo(() => parseThreadId(pathname, chatsBase), [pathname, chatsBase]);
|
||||
const businessName = TASK_BUSINESSES.find((b) => b.code === business)?.name ?? "";
|
||||
|
||||
return (
|
||||
<div className="bg-background/85 border-border/70 flex w-64 shrink-0 flex-col border-r shadow-[12px_0_30px_-30px_rgba(15,23,42,0.45)] backdrop-blur-xl dark:border-[#1b477e]/70 dark:bg-[#061b35]/82">
|
||||
<div className="border-border/70 border-b p-3 dark:border-[#1b477e]/70">
|
||||
{/* 左上角标明当前是哪个分析页面 */}
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="h-4 w-1 rounded-full bg-[var(--color-main)]" />
|
||||
<span className="text-foreground text-sm font-semibold tracking-wide">{businessName}</span>
|
||||
</div>
|
||||
<TaskDetailHeader />
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground text-sm font-semibold">任务对话</div>
|
||||
@@ -78,6 +217,7 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas
|
||||
>
|
||||
<PlusSquare className="h-4 w-4" /> 新建对话
|
||||
</Button>
|
||||
{!lite && <TaskClearButton taskId={taskId} />}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
@@ -114,24 +254,18 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<TaskActionsPanel taskId={taskId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 左侧任务栏底部操作区:编辑 / 下一步 / 清除任务记录。
|
||||
* 跟随任务对话面板布局,不再覆盖右侧聊天输入框。
|
||||
* 「清除记录」按钮(rwfx):放在「新建对话」下面,仅调 delete 接口清空 B 系统目的策划数据
|
||||
* (聊天对话保留)。带二次确认弹框。
|
||||
*/
|
||||
function TaskActionsPanel({ taskId }: { taskId: string }) {
|
||||
function TaskClearButton({ taskId }: { taskId: string }) {
|
||||
const [clearOpen, setClearOpen] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
|
||||
const openExternal = (url: string, label: string) => {
|
||||
if (url) window.open(url, "_blank", "noopener,noreferrer");
|
||||
else toast.error(`未配置${label}地址`);
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
setClearing(true);
|
||||
try {
|
||||
@@ -148,44 +282,16 @@ function TaskActionsPanel({ taskId }: { taskId: string }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-border/70 shrink-0 border-t p-3 dark:border-[#1b477e]/70">
|
||||
<div className="rounded-md border border-[var(--card-border)] bg-background/80 p-2 shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#0b2546]/72">
|
||||
<div className="text-muted-foreground mb-2 px-1 text-xs font-medium">
|
||||
任务操作
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 border-[var(--card-border)] bg-background/80 text-[var(--color-main)] hover:bg-[var(--bg-color)] hover:text-[var(--color-main)] dark:border-[#2b8ffe]/50 dark:bg-[#0b2546]/80 dark:text-[#bde9ff] dark:hover:bg-[#10375f]"
|
||||
onClick={() => openExternal(buildTreemapEditUrl(taskId), "编辑")}
|
||||
disabled={!taskId}
|
||||
title="编辑任务"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" /> 编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-[var(--color-main)] text-white shadow-[0_8px_18px_-12px_var(--color-main)] hover:bg-[var(--brand-950)]"
|
||||
onClick={() => openExternal(buildTreemapNextUrl(taskId), "下一步")}
|
||||
disabled={!taskId}
|
||||
title="进入下一步"
|
||||
>
|
||||
下一步 <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="col-span-2 h-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive dark:text-[#ccd2da] dark:hover:bg-[#5c0011]/35"
|
||||
onClick={() => setClearOpen(true)}
|
||||
disabled={!taskId}
|
||||
title="清除任务记录"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> 清除记录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive dark:text-[#ccd2da] dark:hover:bg-[#5c0011]/35 mt-2 h-9 w-full"
|
||||
onClick={() => setClearOpen(true)}
|
||||
disabled={!taskId}
|
||||
title="清除任务记录"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> 清除记录
|
||||
</Button>
|
||||
|
||||
<Dialog open={clearOpen} onOpenChange={(o) => !clearing && setClearOpen(o)}>
|
||||
<DialogContent>
|
||||
@@ -209,28 +315,44 @@ function TaskActionsPanel({ taskId }: { taskId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 任务工作区布局:左列表 + 右 Outlet。 */
|
||||
export function TaskFxWorkspace({ taskBase }: { taskBase: string }) {
|
||||
const { taskId, chatsBase } = useChatsBase(taskBase);
|
||||
// 预拉配置,保证「编辑/下一步」的同步 URL 构造能读到缓存。
|
||||
useEffect(() => {
|
||||
void fetchTaskDeeplinkConfig();
|
||||
}, []);
|
||||
/**
|
||||
* 任务工作区布局:左列表 + 右 Outlet。
|
||||
* ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。
|
||||
*/
|
||||
export function TaskFxWorkspace({
|
||||
taskBase,
|
||||
business,
|
||||
lite,
|
||||
}: {
|
||||
taskBase: string;
|
||||
business: TaskBusiness;
|
||||
lite?: boolean;
|
||||
}) {
|
||||
const { taskId, agentId, chatsBase } = useChatsBase(taskBase);
|
||||
return (
|
||||
<div className="flex size-full min-h-0 overflow-hidden">
|
||||
<TaskConversationsList taskId={taskId} chatsBase={chatsBase} />
|
||||
<div className="relative min-h-0 min-w-0 flex-1">
|
||||
<Outlet />
|
||||
<div className="flex size-full min-h-0 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<TaskConversationsList
|
||||
taskId={taskId}
|
||||
agentId={agentId}
|
||||
chatsBase={chatsBase}
|
||||
business={business}
|
||||
lite={lite}
|
||||
/>
|
||||
<div className="relative min-h-0 min-w-0 flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
<TaskJumpBar business={business} taskId={taskId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(走闸门)。 */
|
||||
/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。 */
|
||||
export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) {
|
||||
const { taskId, chatsBase } = useChatsBase(taskBase);
|
||||
const { taskId, agentId, chatsBase } = useChatsBase(taskBase);
|
||||
const navigate = useNavigate();
|
||||
const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId);
|
||||
const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId, agentId);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
@@ -248,12 +370,35 @@ export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 右侧对话宿主:AgentChatPage(taskGate 开启 → 新建对话顶部展示目的树闸门面板 + 老对话停止)。 */
|
||||
export function TaskFxChat({ taskBase }: { taskBase: string }) {
|
||||
/**
|
||||
* 右侧对话宿主(rwfx / xdfx):AgentChatPage taskGate → 新建对话顶部展示目的树闸门面板 + 老对话停止。
|
||||
* ``autoStartWhenPurposeEmpty``(rwfx):查询任务目的为空数据时自动开始第一次问答。
|
||||
*/
|
||||
export function TaskFxChat({
|
||||
taskBase,
|
||||
autoStartWhenPurposeEmpty,
|
||||
}: {
|
||||
taskBase: string;
|
||||
autoStartWhenPurposeEmpty?: boolean;
|
||||
}) {
|
||||
const { agent_id } = useParams<{ agent_id: string }>();
|
||||
return (
|
||||
<ChatRuntime>
|
||||
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} taskGate />
|
||||
<AgentChatPage
|
||||
chatBasePath={`${taskBase}/agent/${agent_id}/chats`}
|
||||
taskGate
|
||||
autoStartWhenPurposeEmpty={autoStartWhenPurposeEmpty}
|
||||
/>
|
||||
</ChatRuntime>
|
||||
);
|
||||
}
|
||||
|
||||
/** 右侧对话宿主(3qfx/xdfx):AgentChatPage taskBind → 仅 1:N 绑定,新建对话直接带开场信息自动发出。 */
|
||||
export function TaskBindChat({ taskBase }: { taskBase: string }) {
|
||||
const { agent_id } = useParams<{ agent_id: string }>();
|
||||
return (
|
||||
<ChatRuntime>
|
||||
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} taskBind />
|
||||
</ChatRuntime>
|
||||
);
|
||||
}
|
||||
+105
-12
@@ -10,8 +10,8 @@
|
||||
* onStart 由父组件(AgentChatPage 闸门态)实现:发出开场消息、开始问答。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Loader2, Maximize2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Loader2, Maximize2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -30,10 +30,11 @@ import {
|
||||
type PurposeTreeNode,
|
||||
type TaskPurposeResult,
|
||||
} from "@/core/auth/task-purpose";
|
||||
import { fetchTaskIdFromAction } from "@/core/auth/task-deeplink";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** 目的树表格:拍平成行,列数随数据自适应;层级缺失补空、字段变动不崩。 */
|
||||
function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?: string }) {
|
||||
export function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?: string }) {
|
||||
const rows = useMemo(() => flattenPurposeTree(tree), [tree]);
|
||||
const headers = useMemo(() => {
|
||||
const maxDepth = rows.reduce((m, r) => Math.max(m, r.length), 0);
|
||||
@@ -90,26 +91,103 @@ function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 独立的「任务目的弹框」:按需拉取目的树并放大展示。供底部「快捷跳转」栏的 ``purpose`` 类型
|
||||
* 按钮复用(任意页面可配置「弹框查看」)。打开时才拉取。
|
||||
*/
|
||||
export function TaskPurposeDialog({
|
||||
taskId,
|
||||
open,
|
||||
onOpenChange,
|
||||
resolveFromActionId,
|
||||
}: {
|
||||
taskId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** xdfx:深链原始「行动id」。提供时弹框先用它换出真正「任务id」再查目的树。 */
|
||||
resolveFromActionId?: string;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<TaskPurposeResult | null>(null);
|
||||
const [resolvedTaskId, setResolvedTaskId] = useState(taskId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
// xdfx:行动id 换任务id 再查目的树(与登录时的解析一致);无行动id 时直接用任务id。
|
||||
let tid = taskId;
|
||||
if (resolveFromActionId) {
|
||||
tid = (await fetchTaskIdFromAction(resolveFromActionId)) || taskId;
|
||||
}
|
||||
if (cancelled) return;
|
||||
setResolvedTaskId(tid);
|
||||
const r = await fetchTaskPurposeDetail(tid);
|
||||
if (!cancelled) setResult(r);
|
||||
} catch {
|
||||
if (!cancelled) setResult({ tree: [], hasData: false });
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, taskId, resolveFromActionId]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[90vw] !max-w-[90vw] sm:!max-w-[90vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>任务目的(任务 {resolvedTaskId})</DialogTitle>
|
||||
</DialogHeader>
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-6 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
|
||||
</div>
|
||||
) : (
|
||||
<PurposeTable tree={result?.tree ?? []} className="max-h-[70vh] rounded border border-border/60" />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskPurposePanel({
|
||||
taskId,
|
||||
onStart,
|
||||
autoStartWhenEmpty,
|
||||
}: {
|
||||
taskId: string;
|
||||
/** 闸门通过(无数据 / 覆盖成功),开始问答。 */
|
||||
onStart: () => void;
|
||||
/** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */
|
||||
autoStartWhenEmpty?: boolean;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [result, setResult] = useState<TaskPurposeResult | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [zoomOpen, setZoomOpen] = useState(false);
|
||||
// 任务目的默认收起(不展开),点标题切换。
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
// rwfx 空数据自动开始:防止重复触发。
|
||||
const autoStartedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchTaskPurposeDetail(taskId)
|
||||
.then((r) => {
|
||||
if (!cancelled) setResult(r);
|
||||
if (cancelled) return;
|
||||
setResult(r);
|
||||
// 查询任务目的为空数据 → 默认直接开始第一次问答(跳过手动「开始问答」)。
|
||||
if (autoStartWhenEmpty && !r.hasData && !autoStartedRef.current) {
|
||||
autoStartedRef.current = true;
|
||||
onStart();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setResult({ tree: [], hasData: false });
|
||||
@@ -120,6 +198,8 @@ export function TaskPurposePanel({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// onStart/autoStartWhenEmpty 在一次新建会话内稳定;仅按 taskId 重新拉取。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskId]);
|
||||
|
||||
const handleStart = () => {
|
||||
@@ -151,7 +231,19 @@ export function TaskPurposePanel({
|
||||
return (
|
||||
<div className="border-border/70 bg-card/60 shrink-0 border-b px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">任务目的(任务 {taskId})</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="flex items-center gap-1 text-sm font-medium hover:text-[var(--color-main)]"
|
||||
title={collapsed ? "展开任务目的" : "收起任务目的"}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
任务目的(任务 {taskId})
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasTree && (
|
||||
<Button size="sm" variant="outline" onClick={() => setZoomOpen(true)}>
|
||||
@@ -164,13 +256,14 @@ export function TaskPurposePanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
|
||||
</div>
|
||||
) : (
|
||||
<PurposeTable tree={result?.tree ?? []} className="max-h-48 rounded border border-border/60" />
|
||||
)}
|
||||
{!collapsed &&
|
||||
(loading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
|
||||
</div>
|
||||
) : (
|
||||
<PurposeTable tree={result?.tree ?? []} className="max-h-48 rounded border border-border/60" />
|
||||
))}
|
||||
|
||||
{/* 弹框查看:放大显示完整表格 */}
|
||||
<Dialog open={zoomOpen} onOpenChange={setZoomOpen}>
|
||||
|
||||
Reference in new issue
Block a user