Merge branch 'ji' into master (操作日志 Y-Log 上报)
# Conflicts: # CLAUDE.md
This commit is contained in:
commit
053d3e300e
35 files changed
+1182
-125
No files matched your search
@@ -16,6 +16,7 @@ import {
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import { parseThemeMap, stringifyThemeMap } from "@/strategy-components/lib/light-app";
|
||||
|
||||
export type LightAppType = "window_open" | "iframe";
|
||||
@@ -156,7 +157,15 @@ export async function listLightApps(params: ListLightAppsParams = {}): Promise<L
|
||||
const query = qs.toString();
|
||||
const res = await apiFetch(`${BASE()}${query ? `?${query}` : ""}`, { method: "GET" });
|
||||
const data = await readJson<{ apps: ServerApp[] }>(res, "list light apps");
|
||||
return (data.apps ?? []).map(toApp);
|
||||
const apps = (data.apps ?? []).map(toApp);
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-轻应用-列表",
|
||||
url: `${BASE()}${query ? `?${query}` : ""}`,
|
||||
reqParam: params,
|
||||
returnParam: apps,
|
||||
});
|
||||
return apps;
|
||||
}
|
||||
|
||||
export async function getLightApp(appId: string): Promise<LightApp> {
|
||||
@@ -170,7 +179,15 @@ export async function createLightApp(input: LightAppInput): Promise<LightApp> {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toBody(input)),
|
||||
});
|
||||
return toApp(await readJson<ServerApp>(res, "create light app"));
|
||||
const app = toApp(await readJson<ServerApp>(res, "create light app"));
|
||||
writeYLog({
|
||||
optType: OptType.create,
|
||||
optDetail: "B1-CM助手-轻应用-注册",
|
||||
url: BASE(),
|
||||
reqParam: input,
|
||||
returnParam: app,
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function updateLightApp(
|
||||
@@ -182,12 +199,26 @@ export async function updateLightApp(
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toBody(patch)),
|
||||
});
|
||||
return toApp(await readJson<ServerApp>(res, "update light app"));
|
||||
const app = toApp(await readJson<ServerApp>(res, "update light app"));
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-轻应用-修改",
|
||||
url: `${BASE()}/${encodeURIComponent(appId)}`,
|
||||
reqParam: { appId, patch },
|
||||
returnParam: app,
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function deleteLightApp(appId: string): Promise<void> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(appId)}`, { method: "DELETE" });
|
||||
await readJson<{ success: boolean }>(res, "delete light app");
|
||||
writeYLog({
|
||||
optType: OptType.delete,
|
||||
optDetail: "B1-CM助手-轻应用-删除",
|
||||
url: `${BASE()}/${encodeURIComponent(appId)}`,
|
||||
reqParam: { appId },
|
||||
});
|
||||
}
|
||||
|
||||
// ── React Query hooks(管理页 / 列表页 / 侧边栏共用缓存) ───────────────────
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import type { MenuOverride } from "@/core/page-layout/menu-overrides";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/menu-overrides`;
|
||||
@@ -67,7 +68,15 @@ export async function replaceMenuOverrides(overrides: MenuOverride[]): Promise<M
|
||||
body: JSON.stringify({ overrides: overrides.map(toBody) }),
|
||||
});
|
||||
const data = await readJson<{ overrides: ServerOverride[] }>(res, "save menu overrides");
|
||||
return (data.overrides ?? []).map(toOverride);
|
||||
const saved = (data.overrides ?? []).map(toOverride);
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-菜单管理-保存",
|
||||
url: "/api/menu-overrides",
|
||||
reqParam: { overrides },
|
||||
returnParam: saved,
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
// ── React Query hooks(侧边栏 + 管理页共用缓存) ────────────────────────────
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/sensitive-words`;
|
||||
|
||||
@@ -71,7 +72,14 @@ async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
export async function listSensitiveWords(): Promise<SensitiveWord[]> {
|
||||
const res = await apiFetch(BASE(), { method: "GET" });
|
||||
const data = await readJson<{ words: ServerWord[] }>(res, "list sensitive words");
|
||||
return (data.words ?? []).map(toWord);
|
||||
const words = (data.words ?? []).map(toWord);
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-敏感词管理-列表",
|
||||
url: "/api/sensitive-words",
|
||||
returnParam: words,
|
||||
});
|
||||
return words;
|
||||
}
|
||||
|
||||
export async function replaceSensitiveWords(words: SensitiveWord[]): Promise<SensitiveWord[]> {
|
||||
@@ -81,7 +89,15 @@ export async function replaceSensitiveWords(words: SensitiveWord[]): Promise<Sen
|
||||
body: JSON.stringify({ words: words.map(toBody) }),
|
||||
});
|
||||
const data = await readJson<{ words: ServerWord[] }>(res, "save sensitive words");
|
||||
return (data.words ?? []).map(toWord);
|
||||
const saved = (data.words ?? []).map(toWord);
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-敏感词管理-保存",
|
||||
url: "/api/sensitive-words",
|
||||
reqParam: { words },
|
||||
returnParam: saved,
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** 把「启用」的词条折叠成运行时脱敏所需的 `原始词 → 替换词` 映射。 */
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/skill-addresses`;
|
||||
|
||||
@@ -197,7 +198,15 @@ export async function scanAddresses(kinds?: AddressKind[], skill?: string): Prom
|
||||
if (skill) params.set("skill", skill);
|
||||
const qs = params.toString();
|
||||
const res = await apiFetch(`${BASE()}/scan${qs ? `?${qs}` : ""}`, { method: "GET" });
|
||||
return toScanResult(await readJson<any>(res, "扫描"));
|
||||
const result = toScanResult(await readJson<any>(res, "扫描"));
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-技能地址管理-扫描",
|
||||
url: `${BASE()}/scan${qs ? `?${qs}` : ""}`,
|
||||
reqParam: { kinds, skill },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 流式扫描:逐技能上报进度,返回最终聚合结果。 */
|
||||
@@ -216,6 +225,13 @@ export async function scanAddressesStream(
|
||||
else if (ev.type === "result") result = toScanResult(ev);
|
||||
});
|
||||
if (!result) throw new Error("扫描未返回结果");
|
||||
writeYLog({
|
||||
optType: OptType.search,
|
||||
optDetail: "B1-CM助手-技能地址管理-扫描",
|
||||
url: `${BASE()}/scan/stream${qs ? `?${qs}` : ""}`,
|
||||
reqParam: { kinds: opts.kinds, skill: opts.skill },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -251,6 +267,13 @@ export async function applyReplacementsStream(
|
||||
else if (ev.type === "result") result = toApplyResult(ev);
|
||||
});
|
||||
if (!result) throw new Error("应用未返回结果");
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-技能地址管理-应用替换",
|
||||
url: `${BASE()}/apply/stream`,
|
||||
reqParam: body,
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -269,7 +292,15 @@ export async function listBackups(): Promise<BackupEntry[]> {
|
||||
export async function rollbackBackup(backupId: string): Promise<{ restored: number }> {
|
||||
const res = await apiFetch(`${BASE()}/rollback/${encodeURIComponent(backupId)}`, { method: "POST" });
|
||||
const data = await readJson<{ restored_files: string[] }>(res, "撤销");
|
||||
return { restored: (data.restored_files ?? []).length };
|
||||
const result = { restored: (data.restored_files ?? []).length };
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-技能地址管理-回退",
|
||||
url: `${BASE()}/rollback/${encodeURIComponent(backupId)}`,
|
||||
reqParam: { backupId },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── React Query ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import type {
|
||||
TaskBusiness,
|
||||
TaskButton,
|
||||
@@ -91,7 +92,15 @@ export async function replaceTaskButtons(buttons: TaskButton[]): Promise<TaskBut
|
||||
body: JSON.stringify({ buttons: buttons.map(toBody) }),
|
||||
});
|
||||
const data = await readJson<{ buttons: ServerButton[] }>(res, "save task buttons");
|
||||
return (data.buttons ?? []).map(toButton);
|
||||
const saved = (data.buttons ?? []).map(toButton);
|
||||
writeYLog({
|
||||
optType: OptType.edit,
|
||||
optDetail: "B1-CM助手-按钮管理-保存",
|
||||
url: "/api/task-buttons",
|
||||
reqParam: { buttons },
|
||||
returnParam: saved,
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
// ── React Query hooks(工作区 + 管理页共用缓存) ────────────────────────────
|
||||
|
||||
+93
-62
@@ -29,14 +29,17 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose";
|
||||
import {
|
||||
deleteTaskPurpose,
|
||||
fetchActionHistoryExists,
|
||||
readTaskContext,
|
||||
type ActionHistoryResult,
|
||||
} from "@/core/auth/task-purpose";
|
||||
import {
|
||||
fetchActionDetail,
|
||||
readCopTaskDetail,
|
||||
resolveTaskDeeplinkTarget,
|
||||
type ActionDetail,
|
||||
} from "@/core/auth/task-deeplink";
|
||||
import { fetchSqReportStatus } from "@/core/auth/dashboard-input";
|
||||
import { TASK_BUSINESSES, type TaskBusiness } from "@/core/auth/task-buttons";
|
||||
import { useTaskThreads } from "@/core/threads/use-task-threads";
|
||||
import { titleOfThread } from "@/core/threads/utils";
|
||||
@@ -45,6 +48,7 @@ import { cn } from "@/lib/utils";
|
||||
import AgentChatPage from "@/pages/AgentChatPage";
|
||||
import { ChatRuntime } from "@/pages/WorkspaceRoutes";
|
||||
import { TaskJumpBar } from "@/strategy-components/components/task-components/TaskJumpBar";
|
||||
import { RwfxSqGateProvider } from "@/strategy-components/components/task-components/rwfx-sq-gate";
|
||||
|
||||
function useChatsBase(taskBase: string): {
|
||||
taskId: string;
|
||||
@@ -196,6 +200,8 @@ function TaskConversationsList({
|
||||
<PlusSquare className="h-4 w-4" /> 新建对话
|
||||
</Button>
|
||||
{!lite && <TaskClearButton taskId={taskId} />}
|
||||
{/* xdfx:按「行动id」清除行动记录(删除前查历史是否存在;与 rwfx 共用同一删除接口)。 */}
|
||||
{business === "xdfx" && actionId && <ActionClearButton actionId={actionId} />}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
@@ -294,78 +300,99 @@ function TaskClearButton({ taskId }: { taskId: string }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* rwfx 专用「3q 完成预检」闸门:进入任务分析工作区时调研判情报报告接口
|
||||
* (``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``)。若该任务的报告为空
|
||||
* (数组为空 / 内容全为 null,即 3q 未完成)→ 弹框提示「3q 未完成,是否进行 3qfx」,
|
||||
* 并提供「前往 3qfx」按钮切换到**同一任务**的三情分析工作区(复用深链解析重绑任务上下文)。
|
||||
* 报告正常 / 接口不可判定(缺地址、请求失败)时不打扰用户。见 [[dashboard-input]] / [[task-deeplink]]。
|
||||
* 「清除记录」按钮(xdfx 行动分析):放在「新建对话」下面,按「行动id」清除该行动在 B 系统里的
|
||||
* 策划数据(聊天对话保留),与 rwfx 共用同一个删除接口(仅传 ``actionId``、不传 taskId)。
|
||||
*
|
||||
* 打开弹框时先「查询历史是否存在」(``fetchActionHistoryExists``):
|
||||
* - 接口未就绪(地址未配置)→ ``known=false``:当作未知,仍可删除(不拦截);
|
||||
* - 查到「无历史」(``known && !exists``)→ 禁用「确认清除」(没有可删的数据)。
|
||||
*/
|
||||
function RwfxSqReportGate({ taskId }: { taskId: string }) {
|
||||
const navigate = useNavigate();
|
||||
function ActionClearButton({ actionId }: { actionId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [jumping, setJumping] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [history, setHistory] = useState<ActionHistoryResult | null>(null);
|
||||
|
||||
// 打开弹框时查历史是否存在(接口未配置 → known=false,不影响删除)。
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
if (!open) return;
|
||||
let alive = true;
|
||||
void fetchSqReportStatus(taskId).then((status) => {
|
||||
// 仅「明确为空(3q 未完成)」才弹框;complete / unknown 静默放行。
|
||||
if (alive && status === "incomplete") setOpen(true);
|
||||
});
|
||||
setChecking(true);
|
||||
setHistory(null);
|
||||
void fetchActionHistoryExists(actionId)
|
||||
.then((r) => {
|
||||
if (alive) setHistory(r);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setChecking(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [taskId]);
|
||||
}, [open, actionId]);
|
||||
|
||||
const handleGo3qfx = async () => {
|
||||
const detail = readCopTaskDetail();
|
||||
if (!detail || !taskId) {
|
||||
toast.error("缺少任务信息,无法跳转");
|
||||
return;
|
||||
}
|
||||
setJumping(true);
|
||||
const handleClear = async () => {
|
||||
setClearing(true);
|
||||
try {
|
||||
// 复用深链解析:会清掉当前 rwfx 任务上下文并按 3qfx 业务重新绑定(同一 taskId)。
|
||||
const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId);
|
||||
if (target) {
|
||||
setOpen(false);
|
||||
navigate(target.path, { state: target.state });
|
||||
} else {
|
||||
toast.error("未配置三情分析(3qfx)对应的业务智能体");
|
||||
}
|
||||
const r = await deleteTaskPurpose(actionId, { idKind: "action" });
|
||||
if (r.success) toast.success("已清除行动记录");
|
||||
else toast.error(r.message || "清除失败");
|
||||
} catch {
|
||||
toast.error("跳转失败,请稍后再试");
|
||||
toast.error("清除失败,请稍后再试");
|
||||
} finally {
|
||||
setJumping(false);
|
||||
setClearing(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 仅当「明确查到无历史」时禁用删除;接口未就绪(known=false)仍允许删除。
|
||||
const noHistory = history?.known === true && history.exists === false;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !jumping && setOpen(o)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>3q 未完成</DialogTitle>
|
||||
<DialogDescription>
|
||||
未查询到该任务的研判情报报告(3q 未完成),是否前往该任务的三情分析(3qfx)?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={jumping}>
|
||||
暂不
|
||||
</Button>
|
||||
<Button
|
||||
// 弹框经 Radix Portal 渲染到 body,在 `.strategy-data` 作用域之外,故不能用
|
||||
// 局部的 `--color-main`(红主题侥幸有值、蓝主题失效→按钮消失)。改用全局按主题
|
||||
// 定义的品牌色 token(:root/.dark/.lightblue 都有),三套主题都能正确取色。
|
||||
className="bg-[var(--workspace-brand)] text-[var(--workspace-brand-foreground)] hover:opacity-90"
|
||||
onClick={handleGo3qfx}
|
||||
disabled={jumping}
|
||||
>
|
||||
{jumping ? <Loader2 className="h-4 w-4 animate-spin" /> : "前往 3qfx"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<>
|
||||
<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={() => setOpen(true)}
|
||||
disabled={!actionId}
|
||||
title="清除行动记录"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> 清除记录
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={(o) => !clearing && setOpen(o)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>清除行动记录</DialogTitle>
|
||||
<DialogDescription>
|
||||
将清除该行动在 B 系统中的策划数据(聊天对话保留),是否确认?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{checking ? (
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> 正在查询历史…
|
||||
</div>
|
||||
) : history?.known === true ? (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{history.exists ? "检测到该行动的历史记录。" : "未检测到该行动的历史记录,无需清除。"}
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={clearing}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleClear}
|
||||
disabled={clearing || checking || noHistory}
|
||||
>
|
||||
{clearing ? <Loader2 className="h-4 w-4 animate-spin" /> : "确认清除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,7 +410,7 @@ export function TaskFxWorkspace({
|
||||
lite?: boolean;
|
||||
}) {
|
||||
const { taskId, agentId, actionId, chatsBase } = useChatsBase(taskBase);
|
||||
return (
|
||||
const content = (
|
||||
<div className="flex size-full min-h-0 flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<TaskConversationsList
|
||||
@@ -399,10 +426,14 @@ export function TaskFxWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
<TaskJumpBar business={business} taskId={taskId} />
|
||||
{/* rwfx 专属:进入时预检 3q 是否完成,未完成则弹框引导去 3qfx。 */}
|
||||
{business === "rwfx" && <RwfxSqReportGate taskId={taskId} />}
|
||||
</div>
|
||||
);
|
||||
// rwfx 专属:3q 预检门控。进入时若 3q 未完成弹「前往 3qfx」框;并门控目的树为空时的
|
||||
// 自动发送——3q 无数据时不默认发送,仅当用户点「暂不(不去 3q 分析)」才发送。
|
||||
if (business === "rwfx") {
|
||||
return <RwfxSqGateProvider taskId={taskId}>{content}</RwfxSqGateProvider>;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。 */
|
||||
|
||||
+5
-1
@@ -31,6 +31,7 @@ import {
|
||||
type TaskPurposeResult,
|
||||
} from "@/core/auth/task-purpose";
|
||||
import { fetchTaskIdFromAction } from "@/core/auth/task-deeplink";
|
||||
import { useRwfxSqGate } from "@/strategy-components/components/task-components/rwfx-sq-gate";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -176,6 +177,8 @@ export function TaskPurposePanel({
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
// rwfx 空数据自动开始:防止重复触发。
|
||||
const autoStartedRef = useRef(false);
|
||||
// rwfx 3q 预检门控:3q 无数据时不默认发送,交由门控(仅「暂不」才发送)。
|
||||
const { gateStart } = useRwfxSqGate();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -185,9 +188,10 @@ export function TaskPurposePanel({
|
||||
if (cancelled) return;
|
||||
setResult(r);
|
||||
// 查询任务目的为空数据 → 默认直接开始第一次问答(跳过手动「开始问答」)。
|
||||
// 经 3q 门控:3q 完成/未知立即发送;3q 未完成则挂起,仅当用户「暂不(不去 3q 分析)」才发送。
|
||||
if (autoStartWhenEmpty && !r.hasData && !autoStartedRef.current) {
|
||||
autoStartedRef.current = true;
|
||||
onStart();
|
||||
gateStart(onStart);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* rwfx(任务分析)专用「3q 预检门控」。
|
||||
*
|
||||
* 进入任务分析工作区时调研判情报报告接口(``fetchSqReportStatus``)判断 3q 是否完成:
|
||||
* - ``incomplete``(3q 未完成 / 无数据)→ 弹「3q 未完成,是否前往 3qfx」框(暂不 / 前往 3qfx)。
|
||||
* - ``complete`` / ``unknown`` → 不打扰。
|
||||
*
|
||||
* 关键点:rwfx「目的树为空时自动发送第一次问题」必须**经本门控**(``gateStart``),不能在 3q
|
||||
* 无数据时还默认发送:
|
||||
* - 3q 完成 / 无法判定 → 立即发送;
|
||||
* - 3q 未完成 → 挂起,**仅当用户点「暂不」(不去 3q 分析)才发送**;点「前往 3qfx」则跳转、丢弃发送。
|
||||
*
|
||||
* Provider 由 ``TaskFxWorkspace``(仅 rwfx)包裹整个工作区;``TaskPurposePanel`` 经 ``useRwfxSqGate``
|
||||
* 读取门控。非 rwfx / 无 Provider → ``gateStart`` 直接发送(默认值)。见 [[dashboard-input]] / [[task-deeplink]]。
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { fetchSqReportStatus } from "@/core/auth/dashboard-input";
|
||||
import { readCopTaskDetail, resolveTaskDeeplinkTarget } from "@/core/auth/task-deeplink";
|
||||
|
||||
type GateStatus = "loading" | "incomplete" | "ok";
|
||||
|
||||
interface RwfxSqGateValue {
|
||||
/**
|
||||
* rwfx 自动开始第一次问答的门控。
|
||||
* - 3q 完成 / 无法判定 → 立即 ``start()``。
|
||||
* - 3q 未完成 → 挂起 ``start``,弹「前往 3qfx」框;用户点「暂不」才 ``start()``,点「前往 3qfx」跳转并丢弃。
|
||||
* 非 rwfx / 无 Provider → 直接 ``start()``(默认值)。
|
||||
*/
|
||||
gateStart: (start: () => void) => void;
|
||||
}
|
||||
|
||||
const RwfxSqGateContext = createContext<RwfxSqGateValue>({
|
||||
gateStart: (start) => start(),
|
||||
});
|
||||
|
||||
export function useRwfxSqGate(): RwfxSqGateValue {
|
||||
return useContext(RwfxSqGateContext);
|
||||
}
|
||||
|
||||
export function RwfxSqGateProvider({
|
||||
taskId,
|
||||
children,
|
||||
}: {
|
||||
taskId: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [jumping, setJumping] = useState(false);
|
||||
|
||||
const statusRef = useRef<GateStatus>(taskId ? "loading" : "ok");
|
||||
// 待触发的「开始问答」回调:3q 未完成时挂起,点「暂不」/关闭弹框后触发。
|
||||
const pendingStartRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// taskId 变化:重置门控状态并预检 3q。
|
||||
useEffect(() => {
|
||||
pendingStartRef.current = null;
|
||||
setOpen(false);
|
||||
if (!taskId) {
|
||||
statusRef.current = "ok";
|
||||
return;
|
||||
}
|
||||
statusRef.current = "loading";
|
||||
let alive = true;
|
||||
void fetchSqReportStatus(taskId).then((s) => {
|
||||
if (!alive) return;
|
||||
// complete / unknown 都按「放行」(不打扰);仅明确 incomplete 才门控。
|
||||
const next: GateStatus = s === "incomplete" ? "incomplete" : "ok";
|
||||
statusRef.current = next;
|
||||
if (next === "incomplete") {
|
||||
setOpen(true); // 进入即提醒(无论是否有挂起的自动发送)
|
||||
} else if (pendingStartRef.current) {
|
||||
// 加载期间已请求自动开始 → 放行后补发。
|
||||
const start = pendingStartRef.current;
|
||||
pendingStartRef.current = null;
|
||||
start();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
const gateStart = useCallback((start: () => void) => {
|
||||
const status = statusRef.current;
|
||||
if (status === "ok") {
|
||||
start();
|
||||
return;
|
||||
}
|
||||
// loading / incomplete:挂起。loading 解析为 ok 时补发;incomplete 时等用户「暂不」。
|
||||
pendingStartRef.current = start;
|
||||
if (status === "incomplete") setOpen(true);
|
||||
}, []);
|
||||
|
||||
/** 暂不(不去 3q 分析)/ 关闭弹框 → 补发挂起的「开始问答」(无则无操作)。 */
|
||||
const stayAndStart = useCallback(() => {
|
||||
setOpen(false);
|
||||
const start = pendingStartRef.current;
|
||||
pendingStartRef.current = null;
|
||||
start?.();
|
||||
}, []);
|
||||
|
||||
const handleGo3qfx = async () => {
|
||||
const detail = readCopTaskDetail();
|
||||
if (!detail || !taskId) {
|
||||
toast.error("缺少任务信息,无法跳转");
|
||||
return;
|
||||
}
|
||||
setJumping(true);
|
||||
try {
|
||||
// 复用深链解析:清掉当前 rwfx 任务上下文并按 3qfx 业务重新绑定(同一 taskId)。
|
||||
const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId);
|
||||
if (target) {
|
||||
pendingStartRef.current = null; // 去 3q 分析 → 丢弃自动发送
|
||||
setOpen(false);
|
||||
navigate(target.path, { state: target.state });
|
||||
} else {
|
||||
toast.error("未配置三情分析(3qfx)对应的业务智能体");
|
||||
}
|
||||
} catch {
|
||||
toast.error("跳转失败,请稍后再试");
|
||||
} finally {
|
||||
setJumping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const value = useMemo<RwfxSqGateValue>(() => ({ gateStart }), [gateStart]);
|
||||
|
||||
return (
|
||||
<RwfxSqGateContext.Provider value={value}>
|
||||
{children}
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (jumping) return;
|
||||
if (o) setOpen(true);
|
||||
else stayAndStart(); // 关闭(暂不/ESC/点外部)= 不去 3q 分析 → 发送
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>3q 未完成</DialogTitle>
|
||||
<DialogDescription>
|
||||
未查询到该任务的研判情报报告(3q 未完成),是否前往该任务的三情分析(3qfx)?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={stayAndStart} disabled={jumping}>
|
||||
暂不
|
||||
</Button>
|
||||
<Button
|
||||
// 弹框经 Radix Portal 渲染到 body,在 `.strategy-data` 作用域之外,故用全局按主题
|
||||
// 定义的品牌色 token(:root/.dark/.lightblue 都有),三套主题都能正确取色。
|
||||
className="bg-[var(--workspace-brand)] text-[var(--workspace-brand-foreground)] hover:opacity-90"
|
||||
onClick={handleGo3qfx}
|
||||
disabled={jumping}
|
||||
>
|
||||
{jumping ? <Loader2 className="h-4 w-4 animate-spin" /> : "前往 3qfx"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</RwfxSqGateContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user