sync update from LAN repo (source master @ ca48c4ec)
This commit is contained in:
1 parent
8bc8b0e082
commit
ea9dcff9c0
873 files changed
+107866
-14011
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { fetchWithBindingRetry } from "@/core/auth/login-transport";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { LoginByUsernameResponse } from "./index";
|
||||
@@ -29,10 +30,10 @@ async function accountRequest<T>(path: string, init: RequestInit = {}): Promise<
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 20_000);
|
||||
try {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/v1/auth${path}`, {
|
||||
const response = await fetchWithBindingRetry(() => apiFetch(`${getBackendBaseURL()}/api/v1/auth${path}`, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
}));
|
||||
const body: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(accountError(body, "请求失败,请稍后重试"));
|
||||
return body as T;
|
||||
@@ -45,15 +46,6 @@ async function accountRequest<T>(path: string, init: RequestInit = {}): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAuthenticationOptions(): Promise<AuthenticationOptions> {
|
||||
const options = await accountRequest<AuthenticationOptions>("/options", { cache: "no-store" });
|
||||
if (!options || [options.password_login_enabled, options.registration_enabled, options.sso_enabled]
|
||||
.some((value) => typeof value !== "boolean")) {
|
||||
throw new Error("无法读取登录设置,请联系管理员或重试");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export function loginWithPassword(username: string, password: string): Promise<LoginByUsernameResponse> {
|
||||
return accountRequest("/login/local", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { fetchWithBindingRetry } from "@/core/auth/login-transport";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { LoginByUsernameResponse } from "./index";
|
||||
@@ -9,29 +10,78 @@ export interface UserListItem {
|
||||
system_role: "admin" | "user";
|
||||
skill_count: number;
|
||||
agent_count: number;
|
||||
/** 问答次数:用户提问的次数(一次提问算一次)。 */
|
||||
/** 近 30 天问答次数:用户提问的次数(一次提问算一次)。 */
|
||||
qa_count: number;
|
||||
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
|
||||
approved: boolean;
|
||||
/**
|
||||
* 仅兼容尚未迁移的历史口令。新密码使用散列存储,不回传明文或散列,
|
||||
* 是否已设置凭据以 has_login_password 为准。
|
||||
* 仅兼容尚未迁移的历史口令。新密码使用散列存储,不回传明文或散列,
|
||||
* 是否已设置凭据以 has_login_password 为准。
|
||||
*/
|
||||
login_password: string | null;
|
||||
/** 已设置密码/口令,散列凭据不会返回给客户端。 */
|
||||
has_login_password: boolean;
|
||||
/** 管理员重置自己的密码时,服务器同步刷新当前会话。 */
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
/** 已设置密码/口令,散列凭据不会返回给客户端。 */
|
||||
has_login_password: boolean;
|
||||
/** 管理员重置自己的密码时,服务器同步刷新当前会话。 */
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`);
|
||||
/**
|
||||
* 拉全部注册用户。`includeCounts=false` 时后端只查 users 表、跳过问答次数
|
||||
* 统计与 skill/agent 计数(返回 0)——白名单管理页用,避免 runs 表慢查询。
|
||||
*/
|
||||
export async function listAllUsers(includeCounts = true): Promise<UserListItem[]> {
|
||||
const url = new URL(`${getBackendBaseURL()}/api/v1/auth/users`);
|
||||
if (!includeCounts) url.searchParams.set("include_counts", "false");
|
||||
const res = await apiFetch(url.toString());
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json() as Promise<UserListItem[]>;
|
||||
}
|
||||
|
||||
/** 分页用户列表(后端传 limit 时返回的 UserListPage 形状)。 */
|
||||
export interface UserListPage {
|
||||
items: UserListItem[];
|
||||
total: number;
|
||||
approved_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** 服务端分页拉用户(白名单管理页):只查一页 users 表 + 两个 COUNT。 */
|
||||
export async function listUsersPaged(opts: {
|
||||
q?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}, signal?: AbortSignal): Promise<UserListPage> {
|
||||
const url = new URL(`${getBackendBaseURL()}/api/v1/auth/users`);
|
||||
url.searchParams.set("limit", String(opts.limit));
|
||||
url.searchParams.set("offset", String(opts.offset));
|
||||
url.searchParams.set("include_counts", "false");
|
||||
if (opts.q) url.searchParams.set("q", opts.q);
|
||||
const controller = new AbortController();
|
||||
const cancel = () => controller.abort();
|
||||
if (signal?.aborted) cancel();
|
||||
else signal?.addEventListener("abort", cancel, { once: true });
|
||||
const timer = setTimeout(cancel, 20_000);
|
||||
try {
|
||||
const res = await apiFetch(url.toString(), { signal: controller.signal });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(extractErrorMessage(data, `用户列表加载失败(HTTP ${res.status})`));
|
||||
}
|
||||
return await res.json() as UserListPage;
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted && !signal?.aborted) {
|
||||
throw new Error("用户列表加载超时,请重试");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", cancel);
|
||||
}
|
||||
}
|
||||
|
||||
/** 放行 / 取消放行某个注册用户(登录白名单)。Admin only。 */
|
||||
export async function setUserApproval(userId: string, approved: boolean): Promise<UserListItem> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/approval`, {
|
||||
@@ -47,7 +97,7 @@ export async function setUserApproval(userId: string, approved: boolean): Promis
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员设置 / 重置统一密码与登录口令。服务器不再允许清除以回退免密登录。
|
||||
* 管理员设置 / 重置统一密码与登录口令。服务器不再允许清除以回退免密登录。
|
||||
*/
|
||||
export async function setUserLoginPassword(userId: string, password: string | null): Promise<UserListItem> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/login-password`, {
|
||||
@@ -95,13 +145,15 @@ export async function loginByUsername(
|
||||
password != null && password !== ""
|
||||
? `${base}?password=${encodeURIComponent(password)}`
|
||||
: base;
|
||||
const response = await apiFetch(url, {
|
||||
// application/x-www-form-urlencoded is a CORS simple content type. A JSON
|
||||
// body makes old Firefox preflight the login and then abort it.
|
||||
const response = await fetchWithBindingRetry(() => apiFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
body: new URLSearchParams({ username }),
|
||||
}));
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => null);
|
||||
throw new Error(extractErrorMessage(error));
|
||||
@@ -122,6 +174,7 @@ const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve,
|
||||
|
||||
export async function loginByToken(
|
||||
token: string,
|
||||
isCurrent: () => boolean = () => true,
|
||||
): Promise<LoginByUsernameResponse> {
|
||||
// Token-exchange login: the gateway resolves the upstream token to a username
|
||||
// and signs the matching local account in.
|
||||
@@ -129,13 +182,18 @@ export async function loginByToken(
|
||||
let lastError = "Login failed.";
|
||||
|
||||
for (let attempt = 1; attempt <= TOKEN_LOGIN_MAX_ATTEMPTS; attempt += 1) {
|
||||
const response = await apiFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
|
||||
let response: Response;
|
||||
try {
|
||||
// No JSON content type and no custom header: old Firefox can send this
|
||||
// POST without a preflight. A dropped connection is retried once.
|
||||
response = await fetchWithBindingRetry(() => apiFetch(url, { method: "POST" }));
|
||||
} catch (error) {
|
||||
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
|
||||
throw error;
|
||||
}
|
||||
if (response.ok) {
|
||||
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
|
||||
return response.json() as Promise<LoginByUsernameResponse>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* b5-1「任务分析」单智能体深链。
|
||||
*
|
||||
* 与既有 3qfx/rwfx/xdfx 的区别是:任务详情由接入系统预先写入 DeerFlow 的
|
||||
* ``/api/task-deeplink-details/b5-1/{id}``,登录后只从本系统读取,不依赖对方的
|
||||
* 详情接口。智能体默认由「业务链条管理」中的 b5-1 行配置,但深链 URL 可用
|
||||
* ``?agentId=`` 直接指定(优先于配置行);``?type=`` 业务类型与 taskId 一起决定
|
||||
* 聊天记录的独立分区。
|
||||
*/
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose";
|
||||
import { persistB51Theme } from "@/core/auth/b5-1-theme";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import type { TaskDeeplinkTarget } from "@/core/auth/task-deeplink";
|
||||
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
|
||||
|
||||
export const B5_1_GOPATH = "b5-1";
|
||||
export const B5_1_TASK_BASE = "/page/strategy/task-management/plan-task-b5-1";
|
||||
export const B5_1_DETAIL_STORAGE_KEY = "task.b5-1.detail";
|
||||
|
||||
/** 与后端索引列 task_deeplink_threads.sub_type 同宽。 */
|
||||
const B51_TASK_TYPE_MAX_LENGTH = 64;
|
||||
|
||||
export interface B51TaskDetail {
|
||||
type: "b5-1";
|
||||
id: string;
|
||||
taskName: string;
|
||||
taskContent: string;
|
||||
overview: string;
|
||||
extra: Record<string, unknown>;
|
||||
/** 深链 ``?type=`` 业务类型。不来自后端详情接口,由前端随快照一并登记。 */
|
||||
taskType?: string;
|
||||
}
|
||||
|
||||
function asText(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function asExtra(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
/** 归一化 ``?type=``:去空白并截到索引列宽,空值返回 ""(无类型分区)。 */
|
||||
export function normalizeB51TaskType(value: string | null | undefined): string {
|
||||
return (value ?? "").trim().slice(0, B51_TASK_TYPE_MAX_LENGTH);
|
||||
}
|
||||
|
||||
/** 判断 goPath 是否精确为 b5-1(大小写不敏感)。 */
|
||||
export function isB51GoPath(goPath: string): boolean {
|
||||
return goPath.trim().toLowerCase() === B5_1_GOPATH;
|
||||
}
|
||||
|
||||
/** 读取已由接入方写入的任务详情(按 taskId + 业务类型分区取)。
|
||||
* 404 表示该 (id, type) 尚未同步,不应带空详情进入问答。 */
|
||||
export async function fetchB51TaskDetail(
|
||||
taskId: string,
|
||||
taskType?: string,
|
||||
): Promise<B51TaskDetail | null> {
|
||||
const id = taskId.trim();
|
||||
if (!id) return null;
|
||||
const normalizedType = normalizeB51TaskType(taskType);
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
if (normalizedType) query.set("type", normalizedType);
|
||||
const suffix = normalizedType ? `?${query.toString()}` : "";
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/task-deeplink-details/${B5_1_GOPATH}/${encodeURIComponent(id)}${suffix}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
console.warn("[b5-1-deeplink] task detail unavailable", { id, normalizedType, status: response.status });
|
||||
return null;
|
||||
}
|
||||
const body = (await response.json().catch(() => null)) as { data?: unknown } | null;
|
||||
const data = body?.data;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const row = data as Record<string, unknown>;
|
||||
const detail: B51TaskDetail = {
|
||||
type: B5_1_GOPATH,
|
||||
id: asText(row.id).trim() || id,
|
||||
taskName: asText(row.taskName),
|
||||
taskContent: asText(row.taskContent),
|
||||
overview: asText(row.overview),
|
||||
extra: asExtra(row.extra),
|
||||
taskType: normalizeB51TaskType(asText(row.taskType)) || normalizedType || undefined,
|
||||
};
|
||||
return detail.taskName && detail.taskContent ? detail : null;
|
||||
} catch (error) {
|
||||
console.warn("[b5-1-deeplink] task detail request failed", { id, normalizedType, error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function persistB51TaskDetail(detail: B51TaskDetail): void {
|
||||
try {
|
||||
sessionStorage.setItem(B5_1_DETAIL_STORAGE_KEY, JSON.stringify(detail));
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
export function readB51TaskDetail(): B51TaskDetail | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(B5_1_DETAIL_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<B51TaskDetail>;
|
||||
const id = asText(parsed.id).trim();
|
||||
if (!id) return null;
|
||||
return {
|
||||
type: B5_1_GOPATH,
|
||||
id,
|
||||
taskName: asText(parsed.taskName),
|
||||
taskContent: asText(parsed.taskContent),
|
||||
overview: asText(parsed.overview),
|
||||
extra: asExtra(parsed.extra),
|
||||
taskType: normalizeB51TaskType(asText(parsed.taskType)) || undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildB51OpeningText(detail: B51TaskDetail, taskId: string): string {
|
||||
return [
|
||||
detail.taskName ? `任务名称:${detail.taskName}` : "",
|
||||
detail.taskContent ? `任务详情:${detail.taskContent}` : "",
|
||||
detail.overview ? `任务概述:${detail.overview}` : "",
|
||||
taskId ? `任务id(task_id)为${taskId}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** 「业务链条管理」b5-1 行配置的智能体(旧链接未带 ?agentId= 时的回退)。 */
|
||||
async function resolveB51MappedAgentId(): Promise<string> {
|
||||
const mappings = await listBusinessMappings();
|
||||
return mappings
|
||||
.find((item) => item.businessCode.trim().toLowerCase() === B5_1_GOPATH)
|
||||
?.agentId?.trim() ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立任务上下文后进入独立的任务分析工作区。
|
||||
* 智能体优先取深链 ``?agentId=``,未传时回退配置管理中 b5-1 行;
|
||||
* ``?type=`` 业务类型随上下文与快照登记,聊天记录按 taskId + type 分区;
|
||||
* ``?theme=`` 主题色随深链会话登记(不传则由 b5-1-theme 默认深蓝)。
|
||||
*/
|
||||
export async function resolveB51DeeplinkTarget(
|
||||
detail: B51TaskDetail,
|
||||
taskId: string,
|
||||
opts?: { agentId?: string; taskType?: string; theme?: string },
|
||||
): Promise<TaskDeeplinkTarget | null> {
|
||||
const urlAgentId = (opts?.agentId ?? "").trim();
|
||||
const agentId = urlAgentId || (await resolveB51MappedAgentId());
|
||||
if (!agentId) return null;
|
||||
|
||||
const taskType = normalizeB51TaskType(opts?.taskType);
|
||||
persistB51Theme(opts?.theme ?? "");
|
||||
clearTaskContext();
|
||||
persistB51TaskDetail(taskType ? { ...detail, taskType } : detail);
|
||||
persistTaskContext({
|
||||
taskId,
|
||||
agentId,
|
||||
openingText: buildB51OpeningText(detail, taskId),
|
||||
goPath: B5_1_GOPATH,
|
||||
...(taskType ? { taskType } : {}),
|
||||
});
|
||||
return { path: `${B5_1_TASK_BASE}/agent/${agentId}/chats` };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
B51_DEFAULT_THEME,
|
||||
persistB51Theme,
|
||||
resolveB51ForcedTheme,
|
||||
resolveB51ThemeName,
|
||||
} from "./b5-1-theme.ts";
|
||||
|
||||
const WORKSPACE = "/page/strategy/task-management/plan-task-b5-1/agent/analyst/chats";
|
||||
|
||||
test("non-b5-1 routes are untouched", () => {
|
||||
assert.equal(resolveB51ForcedTheme("/page/workspace/chats/new"), undefined);
|
||||
assert.equal(resolveB51ForcedTheme("/login"), undefined);
|
||||
assert.equal(resolveB51ForcedTheme("/login?goPath=3qfx&taskId=1"), undefined);
|
||||
assert.equal(resolveB51ForcedTheme("/login?goPath=agentfx"), undefined);
|
||||
});
|
||||
|
||||
test("workspace defaults to deep blue (dark) without a theme param", () => {
|
||||
assert.equal(resolveB51ForcedTheme(WORKSPACE), "dark");
|
||||
assert.equal(resolveB51ForcedTheme(WORKSPACE, "?threadId=t1"), "dark");
|
||||
assert.equal(B51_DEFAULT_THEME, "dark-blue");
|
||||
});
|
||||
|
||||
test("supplied theme wins over the default", () => {
|
||||
assert.equal(resolveB51ForcedTheme(WORKSPACE, "?theme=lightblue"), "light");
|
||||
assert.equal(resolveB51ForcedTheme(WORKSPACE, "?theme=light"), "light");
|
||||
assert.equal(resolveB51ForcedTheme(WORKSPACE, "?theme=dark-blue"), "dark");
|
||||
assert.equal(resolveB51ForcedTheme(WORKSPACE, "?theme=%E6%B7%B1%E8%93%9D"), "dark");
|
||||
});
|
||||
|
||||
test("b5-1 login page is forced too, case-insensitive on goPath", () => {
|
||||
assert.equal(
|
||||
resolveB51ForcedTheme("/login", "?taskId=1&goPath=b5-1"),
|
||||
"dark",
|
||||
);
|
||||
assert.equal(
|
||||
resolveB51ForcedTheme("/login", "?taskId=1&goPath=B5-1&theme=lightblue"),
|
||||
"light",
|
||||
);
|
||||
});
|
||||
|
||||
test("theme resolution order falls back to the default without storage", () => {
|
||||
assert.equal(resolveB51ThemeName(), "dark-blue");
|
||||
assert.equal(resolveB51ThemeName("?theme=iceblue"), "iceblue");
|
||||
// persistB51Theme 在无 sessionStorage 的环境(node 测试)里静默跳过,
|
||||
// 浏览器里则登记后由 resolveB51ThemeName() 优先读 URL、其次读登记值。
|
||||
persistB51Theme("lightgreen");
|
||||
assert.equal(resolveB51ThemeName("?theme=dark"), "dark");
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* b5-1「任务分析」深链的主题色规则。
|
||||
*
|
||||
* 产品约定:接入方不传 ``?theme=`` 时**默认深蓝**(映射为 dark 主题),传了
|
||||
* (如 ``dark-blue``/``lightblue``…)则用传入值经既有嵌入映射解析明暗。
|
||||
* 主题随深链会话存 sessionStorage(按标签页),登录跳转/刷新后仍生效,
|
||||
* 且只在 b5-1 工作区路径与 goPath=b5-1 的登录页强制——离开即恢复用户
|
||||
* 自己的外观偏好,不写 localStorage、不影响其它页面。
|
||||
*/
|
||||
|
||||
import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
|
||||
|
||||
export const B51_THEME_STORAGE_KEY = "task.b5-1.theme";
|
||||
/** 不传 theme 时的缺省主题名(经 resolveEmbedThemeMode 映射为深色)。 */
|
||||
export const B51_DEFAULT_THEME = "dark-blue";
|
||||
|
||||
const B51_WORKSPACE_PATH_PREFIX = "/page/strategy/task-management/plan-task-b5-1";
|
||||
|
||||
function readThemeParam(search: string): string {
|
||||
const value = new URLSearchParams(
|
||||
search.startsWith("?") ? search.slice(1) : search,
|
||||
).get("theme");
|
||||
return (value ?? "").trim();
|
||||
}
|
||||
|
||||
function readGoPathParam(search: string): string {
|
||||
const value = new URLSearchParams(
|
||||
search.startsWith("?") ? search.slice(1) : search,
|
||||
).get("goPath");
|
||||
return (value ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** 深链进入任务分析工作区时登记接入方传入的主题(空串 = 用默认)。 */
|
||||
export function persistB51Theme(rawTheme: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(B51_THEME_STORAGE_KEY, (rawTheme ?? "").trim());
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredB51Theme(): string {
|
||||
try {
|
||||
return (sessionStorage.getItem(B51_THEME_STORAGE_KEY) ?? "").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 b5-1 深链应使用的原始主题名:URL 参数 > 登记值 > 默认深蓝。 */
|
||||
export function resolveB51ThemeName(search?: string): string {
|
||||
const fromUrl = search === undefined ? "" : readThemeParam(search);
|
||||
if (fromUrl) return fromUrl;
|
||||
return readStoredB51Theme() || B51_DEFAULT_THEME;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由级强制主题:仅 b5-1 工作区路径与 goPath=b5-1 的登录页返回明/暗,
|
||||
* 其它路径返回 undefined(不影响既有主题行为)。
|
||||
*/
|
||||
export function resolveB51ForcedTheme(
|
||||
pathname: string,
|
||||
search = "",
|
||||
): "light" | "dark" | undefined {
|
||||
const isWorkspace = pathname.startsWith(B51_WORKSPACE_PATH_PREFIX);
|
||||
const isB51Login =
|
||||
(pathname === "/login" || pathname.startsWith("/login/")) &&
|
||||
readGoPathParam(search) === "b5-1";
|
||||
if (!isWorkspace && !isB51Login) return undefined;
|
||||
return resolveEmbedThemeMode(resolveB51ThemeName(search));
|
||||
}
|
||||
@@ -1,17 +1,37 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
import { listAllUsers, setUserApproval, setUserLoginPassword } from "./api";
|
||||
import { listAllUsers, listUsersPaged, setUserApproval, setUserLoginPassword } from "./api";
|
||||
|
||||
export function useAllUsers(enabled: boolean) {
|
||||
export function useAllUsers(enabled: boolean, includeCounts = true) {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "users"],
|
||||
queryFn: listAllUsers,
|
||||
// "full"/"lite" 后缀区分缓存;放行/改密后按 ["admin","users"] 前缀失效,两种都刷新。
|
||||
queryKey: ["admin", "users", includeCounts ? "full" : "lite"],
|
||||
queryFn: () => listAllUsers(includeCounts),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** 服务端分页的用户列表(白名单管理页)。q 为搜索词,page 从 1 开始。 */
|
||||
export function useUsersPage(
|
||||
enabled: boolean,
|
||||
params: { q: string; page: number; pageSize: number },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "users", "page", params.q, params.page, params.pageSize],
|
||||
queryFn: ({ signal }) =>
|
||||
listUsersPaged({
|
||||
q: params.q.trim() || undefined,
|
||||
limit: params.pageSize,
|
||||
offset: (params.page - 1) * params.pageSize,
|
||||
}, signal),
|
||||
enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** 放行 / 取消放行某个用户(登录白名单),成功后刷新用户列表缓存。 */
|
||||
export function useSetUserApproval() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { beginLoginAttempt, holdLoginAttempt } from "./scoped-storage";
|
||||
|
||||
class MemoryStorage {
|
||||
data = new Map<string, string>();
|
||||
get length() { return this.data.size; }
|
||||
key(index: number) { return [...this.data.keys()][index] ?? null; }
|
||||
getItem(key: string) { return this.data.get(key) ?? null; }
|
||||
setItem(key: string, value: string) { this.data.set(key, value); }
|
||||
removeItem(key: string) { this.data.delete(key); }
|
||||
clear() { this.data.clear(); }
|
||||
}
|
||||
|
||||
function browser() {
|
||||
const localStorage = new MemoryStorage();
|
||||
const sessionStorage = new MemoryStorage();
|
||||
const listeners = new Map<string, Set<(event: Event) => void>>();
|
||||
const window = {
|
||||
localStorage,
|
||||
sessionStorage,
|
||||
addEventListener(type: string, listener: (event: Event) => void) {
|
||||
const set = listeners.get(type) ?? new Set();
|
||||
set.add(listener);
|
||||
listeners.set(type, set);
|
||||
},
|
||||
removeEventListener(type: string, listener: (event: Event) => void) {
|
||||
listeners.get(type)?.delete(listener);
|
||||
},
|
||||
dispatchEvent(event: Event) {
|
||||
listeners.get(event.type)?.forEach((listener) => listener(event));
|
||||
return true;
|
||||
},
|
||||
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
||||
} as Window & typeof globalThis;
|
||||
window.self = window;
|
||||
window.top = window;
|
||||
Object.defineProperty(globalThis, "window", { value: window, configurable: true });
|
||||
return { localStorage, sessionStorage, window };
|
||||
}
|
||||
|
||||
test("a newer login invalidates the previous response without clearing the current session", async () => {
|
||||
const { localStorage } = browser();
|
||||
localStorage.setItem("deerflow.auth", "admin");
|
||||
localStorage.setItem("userInfo", "admin-profile");
|
||||
const old = beginLoginAttempt();
|
||||
const pending = Promise.resolve().then(() => {
|
||||
if (old()) localStorage.setItem("deerflow.auth", "old-token-user");
|
||||
});
|
||||
const latest = beginLoginAttempt();
|
||||
localStorage.setItem("deerflow.auth", "new-token-user");
|
||||
await pending;
|
||||
assert.equal(old(), false);
|
||||
assert.equal(latest(), true);
|
||||
assert.equal(localStorage.getItem("deerflow.auth"), "new-token-user");
|
||||
assert.equal(localStorage.getItem("userInfo"), "admin-profile");
|
||||
});
|
||||
|
||||
test("another normal tab starting a login invalidates this tab's pending login", () => {
|
||||
const { localStorage } = browser();
|
||||
const pending = beginLoginAttempt();
|
||||
localStorage.setItem("deerflow.login-attempt", "another-tab-login");
|
||||
assert.equal(pending(), false);
|
||||
});
|
||||
|
||||
test("another window's late account write is put back while this login still owns the attempt", () => {
|
||||
const { localStorage, window } = browser();
|
||||
const current = beginLoginAttempt();
|
||||
localStorage.setItem("deerflow.auth", "admin");
|
||||
localStorage.setItem("userInfo", "{\"userName\":\"admin\"}");
|
||||
holdLoginAttempt(current);
|
||||
localStorage.setItem("deerflow.auth", "old-token-user");
|
||||
localStorage.setItem("userInfo", "{\"userName\":\"external-user\"}");
|
||||
window.dispatchEvent({
|
||||
type: "storage",
|
||||
key: "deerflow.auth",
|
||||
newValue: "old-token-user",
|
||||
storageArea: localStorage,
|
||||
} as unknown as Event);
|
||||
assert.equal(localStorage.getItem("deerflow.auth"), "admin");
|
||||
assert.equal(localStorage.getItem("userInfo"), "{\"userName\":\"admin\"}");
|
||||
});
|
||||
|
||||
test("a newer login in the other window is kept", () => {
|
||||
const { localStorage, window } = browser();
|
||||
const first = beginLoginAttempt();
|
||||
localStorage.setItem("deerflow.auth", "admin");
|
||||
holdLoginAttempt(first);
|
||||
beginLoginAttempt();
|
||||
localStorage.setItem("deerflow.auth", "new-token-user");
|
||||
window.dispatchEvent({
|
||||
type: "storage",
|
||||
key: "deerflow.auth",
|
||||
newValue: "new-token-user",
|
||||
storageArea: localStorage,
|
||||
} as unknown as Event);
|
||||
assert.equal(localStorage.getItem("deerflow.auth"), "new-token-user");
|
||||
});
|
||||
|
||||
test("storage failure stops the login instead of keeping a stale writer", () => {
|
||||
const { localStorage } = browser();
|
||||
localStorage.setItem("deerflow.auth", "old-user");
|
||||
localStorage.setItem = () => { throw new Error("storage unavailable"); };
|
||||
assert.throws(() => beginLoginAttempt(), /storage unavailable/);
|
||||
assert.equal(localStorage.getItem("deerflow.auth"), "old-user");
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { accountPageSearch, mergeLoginSearch, shouldStartSso, shouldUseStoredTicket } from "./login-entry";
|
||||
import { accountPageSearch, claimSsoLoginStart, loginParamsForEntry, mergeLoginSearch, shouldStartSso, shouldUseStoredTicket } from "./login-entry";
|
||||
|
||||
const options = { password_login_enabled: true, registration_enabled: true, sso_enabled: true };
|
||||
|
||||
@@ -12,6 +12,18 @@ test("password entry never starts a guest login; explicit SSO still works", () =
|
||||
assert.equal(shouldStartSso({ ...options, password_login_enabled: false }, true, false), true);
|
||||
});
|
||||
|
||||
test("repeated automatic-login effect starts one request; explicit retry can claim the guard again", () => {
|
||||
const started = { current: false };
|
||||
assert.equal(claimSsoLoginStart(started), true);
|
||||
// React StrictMode and dependency-driven effect reruns use the same ref.
|
||||
assert.equal(claimSsoLoginStart(started), false);
|
||||
assert.equal(claimSsoLoginStart(started), false);
|
||||
|
||||
// LoginPage resets the ref only when the user explicitly retries.
|
||||
started.current = false;
|
||||
assert.equal(claimSsoLoginStart(started), true);
|
||||
});
|
||||
|
||||
test("expired sessions wait for an explicit re-login instead of repeatedly kicking the other client", () => {
|
||||
const legacy = { ...options, password_login_enabled: false };
|
||||
assert.equal(shouldStartSso(legacy, false, false, true), false);
|
||||
@@ -19,6 +31,35 @@ test("expired sessions wait for an explicit re-login instead of repeatedly kicki
|
||||
assert.equal(shouldStartSso(legacy, false, false, false), true);
|
||||
});
|
||||
|
||||
test("username password entry drops an earlier external token left before the hash", () => {
|
||||
const params = loginParamsForEntry(
|
||||
"password=123ewq",
|
||||
"?authToken=old-token&yUserId=old-user&ticket=old-ticket&token=old-tools&access_token=old-access&userId=old-id&theme=dark-blue",
|
||||
"admin",
|
||||
);
|
||||
assert.equal(params.get("password"), "123ewq");
|
||||
assert.equal(params.get("theme"), "dark-blue");
|
||||
for (const key of ["authToken", "yUserId", "ticket", "token", "access_token", "userId"]) {
|
||||
assert.equal(params.get(key), null, key);
|
||||
}
|
||||
});
|
||||
|
||||
test("identity params inside the password route itself are kept", () => {
|
||||
const params = loginParamsForEntry(
|
||||
"password=123ewq&authToken=this-entry&yUserId=this-user",
|
||||
"?authToken=old-token&yUserId=old-user",
|
||||
"admin",
|
||||
);
|
||||
assert.equal(params.get("authToken"), "this-entry");
|
||||
assert.equal(params.get("yUserId"), "this-user");
|
||||
});
|
||||
|
||||
test("outer token stays for a bare entry, and a path username only drops it when a password is present", () => {
|
||||
assert.equal(loginParamsForEntry("", "?authToken=outer", undefined).get("authToken"), "outer");
|
||||
assert.equal(loginParamsForEntry("", "?authToken=outer", "admin").get("authToken"), "outer");
|
||||
assert.equal(loginParamsForEntry("", "?authToken=outer&password=123ewq", "admin").get("authToken"), null);
|
||||
});
|
||||
|
||||
test("external login credentials before the hash are read, and route values take precedence", () => {
|
||||
const params = mergeLoginSearch("?authToken=current&password=&taskId=42", "?authToken=stale&password=old&ticket=t&theme=dark-blue");
|
||||
assert.equal(params.get("authToken"), "current");
|
||||
@@ -32,6 +73,7 @@ test("external login credentials before the hash are read, and route values take
|
||||
test("stored tickets cannot override explicit login links or password entry", () => {
|
||||
assert.equal(shouldUseStoredTicket(options, false), false);
|
||||
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false }, true), false);
|
||||
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false }, false, true), false);
|
||||
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false }, false), true);
|
||||
assert.equal(shouldUseStoredTicket({ ...options, password_login_enabled: false, sso_enabled: false }, false), false);
|
||||
assert.equal(shouldUseStoredTicket(null, false), false);
|
||||
|
||||
@@ -11,6 +11,13 @@ export function shouldStartSso(
|
||||
(explicitSso || !options.password_login_enabled));
|
||||
}
|
||||
|
||||
/** Claim the one automatic SSO login started by a mounted LoginPage. */
|
||||
export function claimSsoLoginStart(started: { current: boolean }): boolean {
|
||||
if (started.current) return false;
|
||||
started.current = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** External callers put login parameters before or after the hash route. */
|
||||
export function mergeLoginSearch(routeSearch: string, outerSearch: string): URLSearchParams {
|
||||
const params = new URLSearchParams(outerSearch);
|
||||
@@ -20,6 +27,31 @@ export function mergeLoginSearch(routeSearch: string, outerSearch: string): URLS
|
||||
return params;
|
||||
}
|
||||
|
||||
const OUTER_IDENTITY_PARAMS = ["authToken", "yUserId", "ticket", "token", "access_token", "userId"] as const;
|
||||
|
||||
/**
|
||||
* `/login/:username?password=` names the account. Identity params left in front
|
||||
* of the hash by an earlier external-token entry must not sign that user back
|
||||
* in. The same keys written inside the hash route are still honored.
|
||||
*/
|
||||
export function loginParamsForEntry(
|
||||
routeSearch: string,
|
||||
outerSearch: string,
|
||||
routeUsername?: string | null,
|
||||
): URLSearchParams {
|
||||
const params = mergeLoginSearch(routeSearch, outerSearch);
|
||||
const route = new URLSearchParams(routeSearch);
|
||||
const username = routeUsername?.trim() ?? "";
|
||||
const password = route.has("password")
|
||||
? (route.get("password") ?? "")
|
||||
: (new URLSearchParams(outerSearch).get("password") ?? "");
|
||||
if (!username || !password) return params;
|
||||
for (const key of OUTER_IDENTITY_PARAMS) {
|
||||
if (!route.has(key)) params.delete(key);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export const LOGIN_CREDENTIAL_PARAMS = [
|
||||
"ticket", "userId", "yUserId", "token", "access_token", "password", "authToken", "routeKey",
|
||||
] as const;
|
||||
@@ -28,8 +60,9 @@ export const LOGIN_CREDENTIAL_PARAMS = [
|
||||
export function shouldUseStoredTicket(
|
||||
options: AuthenticationOptions | null,
|
||||
explicitSso: boolean,
|
||||
hasCurrentSession = false,
|
||||
): boolean {
|
||||
return Boolean(options?.sso_enabled && !options.password_login_enabled && !explicitSso);
|
||||
return Boolean(options?.sso_enabled && !options.password_login_enabled && !explicitSso && !hasCurrentSession);
|
||||
}
|
||||
|
||||
/** Keep the destination/presentation when switching forms; discard credentials. */
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { fetchWithBindingRetry, isBrowserBindingAbort } from "./login-transport";
|
||||
|
||||
test("recognizes the Firefox binding abort and a dropped fetch", () => {
|
||||
assert.equal(isBrowserBindingAbort(new TypeError("NetworkError when attempting to fetch resource.")), true);
|
||||
assert.equal(isBrowserBindingAbort(Object.assign(new Error("NS_BINDING_ABORTED"), { name: "NS_ERROR_NET_RESET" })), true);
|
||||
const timeout = new Error("The operation was aborted");
|
||||
timeout.name = "AbortError";
|
||||
assert.equal(isBrowserBindingAbort(timeout), false);
|
||||
assert.equal(isBrowserBindingAbort(new Error("口令错误")), false);
|
||||
});
|
||||
|
||||
test("retries a binding abort once", async () => {
|
||||
let calls = 0;
|
||||
const response = await fetchWithBindingRetry(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new TypeError("Failed to fetch");
|
||||
return new Response("ok");
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(await response.text(), "ok");
|
||||
});
|
||||
|
||||
test("does not retry an application error", async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
() => fetchWithBindingRetry(async () => {
|
||||
calls += 1;
|
||||
throw new Error("口令错误");
|
||||
}),
|
||||
/口令错误/,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Old Firefox cancels a credentialed POST that needed a CORS preflight and
|
||||
* reports NS_BINDING_ABORTED. Fetch rejects that as a TypeError, not an
|
||||
* HTTP response. A caller-owned AbortController uses AbortError instead, so
|
||||
* an intentional timeout is not retried here.
|
||||
*/
|
||||
export function isBrowserBindingAbort(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const text = `${error.name} ${error.message}`.toLowerCase();
|
||||
if (text.includes("ns_binding_aborted") || text.includes("binding aborted")) return true;
|
||||
if (error.name !== "TypeError") return false;
|
||||
return /networkerror|failed to fetch|load failed|network error/.test(text);
|
||||
}
|
||||
|
||||
export async function fetchWithBindingRetry(run: () => Promise<Response>): Promise<Response> {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
if (!isBrowserBindingAbort(error)) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
return await run();
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
getStoredAuth,
|
||||
setStoredAuth,
|
||||
} from "./index";
|
||||
import { checkAccountAccess, isSameAccountSession, type AccountAccess } from "./session-validation";
|
||||
import {
|
||||
checkAccountAccess,
|
||||
isRecentlyAuthenticatedLogin,
|
||||
isSameAccountIdentity,
|
||||
isSameAccountSession,
|
||||
type AccountAccess,
|
||||
} from "./session-validation";
|
||||
|
||||
type GateState = (Extract<AccountAccess, { status: "allowed" }> & { warning?: string })
|
||||
| Extract<AccountAccess, { status: "unauthorized" }>
|
||||
@@ -24,7 +30,6 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [state, setState] = useState<GateState>({ status: "checking" });
|
||||
const writingVerifiedAccount = useRef(false);
|
||||
const requestInFlight = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
@@ -34,21 +39,11 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
|
||||
if (!isEmbedActive() && !isIsolatedSession()
|
||||
&& (event.key === "deerflow.auth" || event.key === null)) refresh();
|
||||
};
|
||||
const recheck = () => { if (!requestInFlight.current) refresh(); };
|
||||
const onVisibility = () => { if (document.visibilityState === "visible") recheck(); };
|
||||
window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener("focus", recheck);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
// A different browser can revoke this account's session. Also refresh the
|
||||
// policy so enabling login or single-session mode takes effect in open tabs.
|
||||
const timer = setInterval(onVisibility, 15_000);
|
||||
return () => {
|
||||
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener("focus", recheck);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -56,12 +51,16 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
|
||||
const controller = new AbortController();
|
||||
const requestedAuth = getStoredAuth();
|
||||
let disposed = false;
|
||||
requestInFlight.current = true;
|
||||
if (isRecentlyAuthenticatedLogin(requestedAuth)) {
|
||||
setState({ status: "allowed", auth: requestedAuth });
|
||||
return () => controller.abort();
|
||||
}
|
||||
const timeout = setTimeout(() => controller.abort(), 20_000);
|
||||
// A background recheck must not unmount an active editor on every poll.
|
||||
// A changed account/token still blocks immediately until its own check ends.
|
||||
// Keep the protected tree mounted while a renewed token for the same
|
||||
// account is verified. Unmounting here destroys nested iframes and resets
|
||||
// an active chat even though the user's identity did not change.
|
||||
setState((previous) => previous.status === "allowed"
|
||||
&& JSON.stringify(previous.auth) === JSON.stringify(requestedAuth)
|
||||
&& isSameAccountIdentity(previous.auth, requestedAuth)
|
||||
? previous : { status: "checking" });
|
||||
|
||||
void checkAccountAccess({
|
||||
@@ -92,16 +91,14 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
|
||||
: error instanceof TypeError ? "无法连接服务器,请检查网络后重试"
|
||||
: error instanceof Error ? error.message : "暂时无法验证登录状态,请稍后重试";
|
||||
setState((previous) => previous.status === "allowed"
|
||||
&& JSON.stringify(previous.auth) === JSON.stringify(requestedAuth)
|
||||
&& isSameAccountIdentity(previous.auth, requestedAuth)
|
||||
&& isSameAccountSession(getStoredAuth(), requestedAuth)
|
||||
? { ...previous, warning: message } : { status: "error", message });
|
||||
}).finally(() => {
|
||||
clearTimeout(timeout);
|
||||
if (!disposed) requestInFlight.current = false;
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
requestInFlight.current = false;
|
||||
controller.abort();
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
@@ -119,7 +116,7 @@ export function RequireAccount({ children, allowLegacyAnonymous = false }: {
|
||||
onClick={() => setAttempt((value) => value + 1)}>重新验证</button>
|
||||
</div>;
|
||||
}
|
||||
if (state.status === "allowed" && JSON.stringify(state.auth) === JSON.stringify(getStoredAuth())) {
|
||||
if (state.status === "allowed" && isSameAccountIdentity(state.auth, getStoredAuth())) {
|
||||
return <div className="contents">
|
||||
{children}
|
||||
{state.warning && <div role="alert" className="fixed right-4 bottom-4 z-50 flex max-w-md items-center gap-3 rounded-md border bg-background p-4 text-sm shadow-lg">
|
||||
|
||||
@@ -69,6 +69,80 @@ function isProtectedAuthKey(key: string): boolean {
|
||||
return PROTECTED_AUTH_EXACT_KEYS.includes(key) || key.startsWith(PROTECTED_AUTH_PREFIX);
|
||||
}
|
||||
|
||||
const LOGIN_ATTEMPT_KEY = "deerflow.login-attempt";
|
||||
|
||||
/**
|
||||
* Mark the login that is allowed to write this tab's auth storage.
|
||||
*
|
||||
* A newer call, including one from another normal tab, replaces the marker.
|
||||
* The previous login keeps its already issued session until it is actually
|
||||
* replaced; callers must consult the returned check immediately before writing
|
||||
* so a late token or profile response cannot put the old user back.
|
||||
* Embed and isolated tabs use their own sessionStorage marker and do not
|
||||
* cancel a normal tab's login.
|
||||
*/
|
||||
export function beginLoginAttempt(): () => boolean {
|
||||
const storage = authStorage();
|
||||
if (!storage) {
|
||||
throw new Error("浏览器无法保存登录信息,请允许网站存储后重试。");
|
||||
}
|
||||
const attempt = `${Date.now()}-${Math.random()}-${Math.random()}`;
|
||||
storage.setItem(LOGIN_ATTEMPT_KEY, attempt);
|
||||
return () => authStorage() === storage && storage.getItem(LOGIN_ATTEMPT_KEY) === attempt;
|
||||
}
|
||||
|
||||
const LOGIN_HOLD_MS = 8_000;
|
||||
const EXTRA_HELD_KEYS = ["ylog.token1"];
|
||||
|
||||
function isHeldAuthKey(key: string): boolean {
|
||||
return isProtectedAuthKey(key) || EXTRA_HELD_KEYS.includes(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the account just written by this login if another window's late response
|
||||
* puts the previous account back. A newer login replaces the attempt marker and
|
||||
* this hold then steps aside.
|
||||
*/
|
||||
export function holdLoginAttempt(isCurrent: () => boolean): void {
|
||||
const storage = authStorage();
|
||||
if (!storage || typeof window === "undefined" || !isCurrent()) return;
|
||||
const snapshot = new Map<string, string | null>();
|
||||
for (let i = 0; i < storage.length; i += 1) {
|
||||
const key = storage.key(i);
|
||||
if (key && isHeldAuthKey(key)) snapshot.set(key, storage.getItem(key));
|
||||
}
|
||||
for (const key of [...PROTECTED_AUTH_EXACT_KEYS, ...EXTRA_HELD_KEYS]) {
|
||||
if (!snapshot.has(key)) snapshot.set(key, null);
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
if (!isCurrent()) return;
|
||||
for (let i = storage.length - 1; i >= 0; i -= 1) {
|
||||
const key = storage.key(i);
|
||||
if (!key || !isHeldAuthKey(key)) continue;
|
||||
const expected = snapshot.has(key) ? snapshot.get(key) ?? null : null;
|
||||
if (expected === null) storage.removeItem(key);
|
||||
else if (storage.getItem(key) !== expected) storage.setItem(key, expected);
|
||||
}
|
||||
snapshot.forEach((value, key) => {
|
||||
if (value !== null && storage.getItem(key) !== value) storage.setItem(key, value);
|
||||
});
|
||||
};
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (!isCurrent()) {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
return;
|
||||
}
|
||||
if (event.storageArea !== storage || !event.key || !isHeldAuthKey(event.key)) return;
|
||||
const expected = snapshot.has(event.key) ? snapshot.get(event.key) ?? null : null;
|
||||
if (event.newValue === expected) return;
|
||||
restore();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
const timer = window.setTimeout(() => window.removeEventListener("storage", onStorage), LOGIN_HOLD_MS);
|
||||
(timer as unknown as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 localStorage 里的鉴权 / 身份键**快照复制**进 sessionStorage(覆盖式)。
|
||||
*
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { checkAccountAccess, isSameAccountSession } from "./session-validation";
|
||||
import {
|
||||
checkAccountAccess,
|
||||
isRecentlyAuthenticatedLogin,
|
||||
isSameAccountIdentity,
|
||||
isSameAccountSession,
|
||||
rememberSuccessfulLogin,
|
||||
} from "./session-validation";
|
||||
|
||||
const auth = {
|
||||
access_token: "browser-token", token_type: "bearer", expires_in: 3600,
|
||||
@@ -9,63 +15,63 @@ const auth = {
|
||||
};
|
||||
const user = { id: "user-1", email: "actual@example.com", system_role: "user", needs_setup: false };
|
||||
|
||||
function server(enabled: boolean, me: Response = Response.json(user), singleSession = false) {
|
||||
function server(me: Response = Response.json(user)) {
|
||||
const calls: { url: string; init?: RequestInit }[] = [];
|
||||
const fetcher: typeof fetch = async (input, init) => {
|
||||
calls.push({ url: String(input), init });
|
||||
return String(input).endsWith("/options")
|
||||
? Response.json({ password_login_enabled: enabled, single_session_enabled: singleSession }) : me;
|
||||
return me;
|
||||
};
|
||||
return { calls, fetcher };
|
||||
}
|
||||
|
||||
test("enabled login rejects anonymous accounts including standalone mode before /me", async () => {
|
||||
const { fetcher, calls } = server(true);
|
||||
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", allowLegacyAnonymous: true, fetcher }), { status: "unauthorized" });
|
||||
assert.equal(calls.length, 1);
|
||||
test("anonymous access is decided locally without fetching login options", async () => {
|
||||
const { fetcher, calls } = server();
|
||||
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", fetcher }), { status: "unauthorized" });
|
||||
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", allowLegacyAnonymous: true, fetcher }), { status: "allowed", auth: null });
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("a cached token never grants access before /me validates it", async () => {
|
||||
const { fetcher, calls } = server(true, new Response(null, { status: 401 }));
|
||||
const { fetcher, calls } = server(new Response(null, { status: 401 }));
|
||||
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "https://gateway", fetcher }), { status: "unauthorized", reason: "session-expired" });
|
||||
assert.equal(calls[1]?.url, "https://gateway/api/v1/auth/me");
|
||||
assert.equal(new Headers(calls[1]?.init?.headers).get("Authorization"), "Bearer browser-token");
|
||||
assert.equal(calls[1]?.init?.cache, "no-store");
|
||||
assert.equal(calls[0]?.url, "https://gateway/api/v1/auth/me");
|
||||
assert.equal(new Headers(calls[0]?.init?.headers).get("Authorization"), "Bearer browser-token");
|
||||
assert.equal(calls[0]?.init?.cache, "no-store");
|
||||
});
|
||||
|
||||
test("verified server permissions replace claimed local permissions", async () => {
|
||||
const { fetcher } = server(true);
|
||||
const { fetcher } = server();
|
||||
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), {
|
||||
status: "allowed", auth: { ...auth, email: user.email, system_role: user.system_role, needs_setup: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("a token for another account cannot authenticate the cached account", async () => {
|
||||
const { fetcher } = server(true, Response.json({ ...user, id: "another-user" }));
|
||||
const { fetcher } = server(Response.json({ ...user, id: "another-user" }));
|
||||
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), { status: "unauthorized", reason: "session-expired" });
|
||||
});
|
||||
|
||||
test("disabled login preserves legacy sessions and the standalone anonymous entry", async () => {
|
||||
const { fetcher, calls } = server(false);
|
||||
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), { status: "allowed", auth });
|
||||
test("legacy anonymous access does not make a session request", async () => {
|
||||
const { fetcher, calls } = server();
|
||||
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", fetcher }), { status: "unauthorized" });
|
||||
assert.deepEqual(await checkAccountAccess({ auth: null, baseURL: "", allowLegacyAnonymous: true, fetcher }), { status: "allowed", auth: null });
|
||||
assert.ok(calls.every(({ url }) => url.endsWith("/options")));
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("outages and invalid policy responses fail closed without treating the session as invalid", async () => {
|
||||
const { fetcher } = server(true, new Response(null, { status: 503 }));
|
||||
test("outages and invalid user responses fail closed without treating the session as invalid", async () => {
|
||||
const { fetcher } = server(new Response(null, { status: 503 }));
|
||||
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher }), /无法验证登录/);
|
||||
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher: async () => Response.json({}) }), /无法读取登录设置/);
|
||||
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher: async () => Response.json({}) }), /登录信息不完整/);
|
||||
await assert.rejects(checkAccountAccess({ auth, baseURL: "", fetcher: async () => { throw new TypeError("offline"); } }), /offline/);
|
||||
});
|
||||
|
||||
test("single-session mode detects revoked sessions even with the password page disabled", async () => {
|
||||
const { fetcher, calls } = server(false, new Response(null, { status: 401 }), true);
|
||||
test("revoked sessions are detected through the existing session endpoint", async () => {
|
||||
const { fetcher, calls } = server(new Response(null, { status: 401 }));
|
||||
assert.deepEqual(await checkAccountAccess({ auth, baseURL: "", fetcher }), {
|
||||
status: "unauthorized", reason: "session-expired",
|
||||
});
|
||||
assert.ok(calls.some(({ url }) => url.endsWith("/me")));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.ok(calls[0].url.endsWith("/me"));
|
||||
});
|
||||
|
||||
test("late failures cannot clear a renewed token or a different account", () => {
|
||||
@@ -74,3 +80,33 @@ test("late failures cannot clear a renewed token or a different account", () =>
|
||||
assert.equal(isSameAccountSession({ ...auth, user_id: "another-user" }, auth), false);
|
||||
assert.equal(isSameAccountSession(null, auth), false);
|
||||
});
|
||||
|
||||
test("a successful login response avoids a redundant immediate /me round trip only briefly", () => {
|
||||
const login = { ...auth, access_token: "fresh-login-token" };
|
||||
const now = 1_000_000;
|
||||
rememberSuccessfulLogin(login, now);
|
||||
assert.equal(isRecentlyAuthenticatedLogin(login, now + 1), true);
|
||||
assert.equal(isRecentlyAuthenticatedLogin({ ...login, user_id: "another-user" }, now + 1), false);
|
||||
assert.equal(isRecentlyAuthenticatedLogin(login, now + 15_001), false);
|
||||
});
|
||||
|
||||
test("same-account token renewal preserves the mounted account identity", () => {
|
||||
assert.equal(isSameAccountIdentity({ ...auth }, auth), true);
|
||||
assert.equal(isSameAccountIdentity({ ...auth, access_token: "renewed" }, auth), true);
|
||||
assert.equal(
|
||||
isSameAccountIdentity(
|
||||
{ ...auth, user_id: "migrated-user", email: "claimed@new-domain.example" },
|
||||
auth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isSameAccountIdentity(
|
||||
{ ...auth, access_token: "other", email: "another@example.com" },
|
||||
auth,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(isSameAccountIdentity(null, null), true);
|
||||
assert.equal(isSameAccountIdentity(null, auth), false);
|
||||
});
|
||||
@@ -1,7 +1,28 @@
|
||||
import type { LoginByUsernameResponse } from "./index";
|
||||
import { getAccountIdentityKey, type LoginByUsernameResponse } from "./index";
|
||||
|
||||
type StoredAccount = LoginByUsernameResponse | null;
|
||||
|
||||
const RECENT_LOGIN_TTL_MS = 15_000;
|
||||
const recentSuccessfulLogins = new Map<string, { userId: string; expiresAt: number }>();
|
||||
|
||||
/** A fresh login response is already server-validated; avoid an immediate duplicate `/me`. */
|
||||
export function rememberSuccessfulLogin(auth: LoginByUsernameResponse, now = Date.now()): void {
|
||||
if (!hasAccountSession(auth)) return;
|
||||
recentSuccessfulLogins.set(auth.access_token, {
|
||||
userId: auth.user_id,
|
||||
expiresAt: now + RECENT_LOGIN_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function isRecentlyAuthenticatedLogin(auth: StoredAccount, now = Date.now()): boolean {
|
||||
if (!hasAccountSession(auth)) return false;
|
||||
for (const [token, record] of recentSuccessfulLogins) {
|
||||
if (record.expiresAt <= now) recentSuccessfulLogins.delete(token);
|
||||
}
|
||||
const record = recentSuccessfulLogins.get(auth.access_token);
|
||||
return Boolean(record && record.expiresAt > now && record.userId === auth.user_id);
|
||||
}
|
||||
|
||||
export type AccountAccess =
|
||||
| { status: "allowed"; auth: StoredAccount }
|
||||
| { status: "unauthorized"; reason?: "session-expired" };
|
||||
@@ -11,12 +32,27 @@ export function isSameAccountSession(current: StoredAccount, requested: StoredAc
|
||||
return current?.access_token === requested?.access_token && current?.user_id === requested?.user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the stable account principal while deliberately ignoring a renewed
|
||||
* access token. A same-account login in another tab must be revalidated, but
|
||||
* it must not tear down the already mounted workspace (and its iframe tree)
|
||||
* while that validation is in flight.
|
||||
*/
|
||||
export function isSameAccountIdentity(current: StoredAccount, requested: StoredAccount): boolean {
|
||||
if (current === requested) return true;
|
||||
if (!current || !requested) return false;
|
||||
|
||||
const currentIdentity = getAccountIdentityKey(current);
|
||||
const requestedIdentity = getAccountIdentityKey(requested);
|
||||
return currentIdentity !== null && currentIdentity === requestedIdentity;
|
||||
}
|
||||
|
||||
export function hasAccountSession(auth: StoredAccount): auth is LoginByUsernameResponse {
|
||||
return Boolean(typeof auth?.user_id === "string" && auth.user_id.trim()
|
||||
&& typeof auth.access_token === "string" && auth.access_token.trim());
|
||||
}
|
||||
|
||||
/** The server owns both the login policy and the account's current permissions. */
|
||||
/** Validate an existing session without fetching login options on page entry. */
|
||||
export async function checkAccountAccess({
|
||||
auth,
|
||||
baseURL,
|
||||
@@ -30,25 +66,10 @@ export async function checkAccountAccess({
|
||||
allowLegacyAnonymous?: boolean;
|
||||
fetcher?: typeof fetch;
|
||||
}): Promise<AccountAccess> {
|
||||
const optionsResponse = await fetcher(`${baseURL}/api/v1/auth/options`, {
|
||||
credentials: "include", cache: "no-store", signal,
|
||||
});
|
||||
if (!optionsResponse.ok) throw new Error("无法读取登录设置,请稍后重试");
|
||||
const options: unknown = await optionsResponse.json();
|
||||
if (!options || typeof options !== "object"
|
||||
|| !("password_login_enabled" in options)
|
||||
|| typeof options.password_login_enabled !== "boolean") {
|
||||
throw new Error("无法读取登录设置,请稍后重试");
|
||||
if (!hasAccountSession(auth)) {
|
||||
return allowLegacyAnonymous ? { status: "allowed", auth: null } : { status: "unauthorized" };
|
||||
}
|
||||
|
||||
const singleSessionEnabled = "single_session_enabled" in options && options.single_session_enabled === true;
|
||||
if (!options.password_login_enabled && !singleSessionEnabled) {
|
||||
return allowLegacyAnonymous || hasAccountSession(auth)
|
||||
? { status: "allowed", auth }
|
||||
: { status: "unauthorized" };
|
||||
}
|
||||
if (!hasAccountSession(auth)) return { status: "unauthorized" };
|
||||
|
||||
const response = await fetcher(`${baseURL}/api/v1/auth/me`, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
|
||||
@@ -75,7 +75,10 @@ export function AuthSyncWatcher({
|
||||
id: IDENTITY_TOAST_ID,
|
||||
description: "当前页面已同步退出状态。",
|
||||
});
|
||||
navigate("/login", { replace: true });
|
||||
// A bare legacy /login entry auto-starts the guest SSO flow. Cross-tab
|
||||
// logout must stop at the explicit re-login screen instead of silently
|
||||
// creating a guest session that can overwrite the account in another tab.
|
||||
navigate("/login?reason=session-expired", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
import {
|
||||
buildTaskOpeningText,
|
||||
ensureNarrativeSummary,
|
||||
fetchTaskActionStructure,
|
||||
} from "./task-deeplink";
|
||||
|
||||
const CONSUMER = "https://ch1.b.uat.4.cn/consumer";
|
||||
|
||||
const memory = new Map<string, string>();
|
||||
Object.assign(globalThis, {
|
||||
window: {
|
||||
__CM_RUNTIME_CONFIG__: { VITE_AIPI_CONSUMER_URL: CONSUMER },
|
||||
},
|
||||
sessionStorage: {
|
||||
getItem: (key: string) => memory.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
memory.set(key, String(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
memory.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
memory.clear();
|
||||
},
|
||||
key: (index: number) => [...memory.keys()][index] ?? null,
|
||||
get length() {
|
||||
return memory.size;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
interface RecordedCall {
|
||||
method: "GET";
|
||||
endpoint: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const calls: RecordedCall[] = [];
|
||||
|
||||
const actionDetails: Record<string, unknown> = {
|
||||
"9001": {
|
||||
state: 200,
|
||||
msg: "操作成功",
|
||||
data: {
|
||||
actionId: "9001",
|
||||
purpose: null,
|
||||
actor: null,
|
||||
expectedBehavior: null,
|
||||
drivingFactor: null,
|
||||
narrationList: null,
|
||||
},
|
||||
},
|
||||
"9002": {
|
||||
state: 200,
|
||||
msg: "操作成功",
|
||||
data: {
|
||||
actionId: "9002",
|
||||
purpose: "扩大影响",
|
||||
actor: "媒体账号",
|
||||
expectedBehavior: "转发评论",
|
||||
drivingFactor: "认同感",
|
||||
narrationList: ["叙事03"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
requestCrudService.get = async (endpoint: string, params: Record<string, unknown> = {}) => {
|
||||
calls.push({ method: "GET", endpoint, params: { ...params } });
|
||||
if (endpoint.endsWith("/task-action/actionDetail")) {
|
||||
const actionId = String(params.actionId ?? "");
|
||||
const body = actionDetails[actionId];
|
||||
if (!body) throw new Error(`no mock for actionId=${actionId}`);
|
||||
return body;
|
||||
}
|
||||
if (endpoint.endsWith("/mainnets/narratives-by-name")) {
|
||||
return {
|
||||
state: 200,
|
||||
msg: "操作成功",
|
||||
data: { summary: "这条传播网主要讨论暑期文旅消费。" },
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected GET ${endpoint}`);
|
||||
};
|
||||
|
||||
function queryString(params: Record<string, unknown>): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
search.set(key, String(value));
|
||||
}
|
||||
const text = search.toString();
|
||||
return text ? `?${text}` : "";
|
||||
}
|
||||
|
||||
test("模拟 xdfx 行动结构与传播网简介的 consumer 调用", async () => {
|
||||
const task = {
|
||||
id: "2055",
|
||||
overview: "",
|
||||
taskDirection: "",
|
||||
taskName: "任务01:暑期文旅舆情综合研判",
|
||||
taskContent: "围绕暑期文旅消费高峰做综合研判。",
|
||||
taskType: "1",
|
||||
cbwName: "暑期文旅传播网",
|
||||
};
|
||||
|
||||
await fetchTaskActionStructure("9001");
|
||||
await ensureNarrativeSummary("9001", task.cbwName);
|
||||
const missingPurpose = buildTaskOpeningText(task, "2055", {
|
||||
actionId: "9001",
|
||||
actionName: "暑期引导行动",
|
||||
narration: "引导公众讨论文旅消费。",
|
||||
cbwId: "88",
|
||||
cbwName: task.cbwName,
|
||||
});
|
||||
|
||||
await fetchTaskActionStructure("9002");
|
||||
await ensureNarrativeSummary("9002", task.cbwName);
|
||||
const complete = buildTaskOpeningText(task, "2055", {
|
||||
actionId: "9002",
|
||||
actionName: "暑期引导行动",
|
||||
});
|
||||
|
||||
console.log("\n--- 模拟调用 ---");
|
||||
for (const call of calls) {
|
||||
console.log(`${call.method} ${call.endpoint}${queryString(call.params)}`);
|
||||
console.log(`params ${JSON.stringify(call.params)}`);
|
||||
}
|
||||
console.log("\n--- purpose 为 null 时的用户消息 ---");
|
||||
console.log(missingPurpose);
|
||||
console.log("\n--- purpose 和 actor 都有值时的用户消息 ---");
|
||||
console.log(complete);
|
||||
console.log("---");
|
||||
|
||||
assert.deepEqual(
|
||||
calls.map((call) => ({ endpoint: call.endpoint, params: call.params })),
|
||||
[
|
||||
{ endpoint: `${CONSUMER}/task-action/actionDetail`, params: { actionId: "9001" } },
|
||||
{
|
||||
endpoint: `${CONSUMER}/mainnets/narratives-by-name`,
|
||||
params: { cbwName: "暑期文旅传播网" },
|
||||
},
|
||||
{ endpoint: `${CONSUMER}/task-action/actionDetail`, params: { actionId: "9002" } },
|
||||
],
|
||||
);
|
||||
for (const label of ["目的:", "行为体:", "预期行为:", "驱动因素:", "叙事:"]) {
|
||||
assert.equal(missingPurpose.includes(label), false, label);
|
||||
}
|
||||
assert.match(missingPurpose, /传播网简介:这条传播网主要讨论暑期文旅消费。/);
|
||||
assert.equal(complete.includes("传播网简介"), false);
|
||||
assert.match(complete, /目的:扩大影响/);
|
||||
assert.match(complete, /叙事:叙事03/);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
actionNeedsNarrative,
|
||||
parseNarrativeSummary,
|
||||
parseTaskActionStructure,
|
||||
taskActionStructureLines,
|
||||
} from "./task-deeplink";
|
||||
|
||||
test("purpose 或 actor 为 null 时需要传播网简介", () => {
|
||||
const missingPurpose = parseTaskActionStructure(
|
||||
{
|
||||
state: 200,
|
||||
msg: "操作成功",
|
||||
data: {
|
||||
actionId: "9",
|
||||
purpose: null,
|
||||
actor: "媒体",
|
||||
expectedBehavior: "转发",
|
||||
drivingFactor: "认同",
|
||||
narrationList: ["叙事01", "叙事02"],
|
||||
},
|
||||
},
|
||||
"9",
|
||||
);
|
||||
assert.equal(missingPurpose?.purpose, null);
|
||||
assert.equal(missingPurpose?.actor, "媒体");
|
||||
assert.equal(actionNeedsNarrative(missingPurpose), true);
|
||||
assert.equal(missingPurpose?.narration, "叙事01;叙事02");
|
||||
|
||||
const bothPresent = parseTaskActionStructure(
|
||||
{
|
||||
state: 200,
|
||||
data: { purpose: "扩大影响", actor: "媒体", narrationList: [] },
|
||||
},
|
||||
"9",
|
||||
);
|
||||
assert.equal(actionNeedsNarrative(bothPresent), false);
|
||||
});
|
||||
|
||||
test("目的和行为体等字段为 null 时不写入用户消息", () => {
|
||||
const detail = parseTaskActionStructure(
|
||||
{
|
||||
state: 200,
|
||||
data: {
|
||||
actionId: "9",
|
||||
purpose: null,
|
||||
actor: null,
|
||||
expectedBehavior: null,
|
||||
drivingFactor: null,
|
||||
narrationList: null,
|
||||
},
|
||||
},
|
||||
"9",
|
||||
);
|
||||
assert.ok(detail);
|
||||
detail.narrativeSummary = "这条传播网主要讨论暑期文旅。";
|
||||
assert.deepEqual(taskActionStructureLines(detail), ["传播网简介:这条传播网主要讨论暑期文旅。"]);
|
||||
});
|
||||
|
||||
test("传播网简介拼进用户消息,空简介不占行", () => {
|
||||
const summary = parseNarrativeSummary({
|
||||
state: "200",
|
||||
msg: "操作成功",
|
||||
data: { summary: "这条传播网主要讨论暑期文旅。" },
|
||||
});
|
||||
assert.equal(summary, "这条传播网主要讨论暑期文旅。");
|
||||
const lines = taskActionStructureLines({
|
||||
actionId: "9",
|
||||
purpose: null,
|
||||
actor: "媒体",
|
||||
expectedBehavior: "",
|
||||
drivingFactor: "",
|
||||
narration: "",
|
||||
narrativeSummary: summary,
|
||||
});
|
||||
assert.deepEqual(lines, ["行为体:媒体", "传播网简介:这条传播网主要讨论暑期文旅。"]);
|
||||
});
|
||||
@@ -61,7 +61,7 @@ export const TASK_BUSINESSES: {
|
||||
},
|
||||
{
|
||||
code: "3qfx",
|
||||
name: "三情分析",
|
||||
name: "三情研判",
|
||||
goPath: "3qfx",
|
||||
desc: "精简版工作区(无目的树闸门),通过 ?goPath=3qfx 进入。",
|
||||
},
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* 流程:
|
||||
* 1. 调 consumer 接口 ``GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=``,
|
||||
* 请求头 ``Authorization: Bearer admin``(由 service 拦截器统一注入)。
|
||||
* 2. 把返回的 data(id/overview/taskDirection/taskName/taskContent)整包存到 sessionStorage。
|
||||
* 2. 把返回的 data(id/overview/taskDirection/taskName/taskContent/taskType)整包存到 sessionStorage。
|
||||
* ``taskType`` 是字典码,拼开发送的开场信息时映射为 1 打击 / 2 塑势 / 3 防御。
|
||||
* 3. 按 goPath 解析出业务码 + 模式:
|
||||
* - ``3qfx`` / ``rwfx`` / ``xdfx`` → 单智能体问答(用「配置管理」里该业务配置的智能体)
|
||||
* - ``3qfx-A`` / ``rwfx-A`` / ``xdfx-A`` → 多智能体会商(用该业务配置的业务链条)
|
||||
@@ -19,7 +20,10 @@ import { requestCrudService } from "@/service/request-axios-service";
|
||||
import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose";
|
||||
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
|
||||
import { readRuntimeEnv, readRuntimeFlag } from "@/env";
|
||||
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
|
||||
import {
|
||||
listBusinessMappings,
|
||||
type BusinessMapping,
|
||||
} from "@/strategy-components/api/business-mapping";
|
||||
|
||||
/** xdfx 业务码:深链传入的是「行动id」,需先调 action 接口解析出真正的「任务id」。 */
|
||||
const XDFX_BUSINESS_CODE = "7BF";
|
||||
@@ -31,6 +35,41 @@ export interface CopTaskDetail {
|
||||
taskDirection: string;
|
||||
taskName: string;
|
||||
taskContent: string;
|
||||
/**
|
||||
* 任务类型字典码(cop-task-detail 的 ``taskType``)。
|
||||
* 1 打击 / 2 塑势 / 3 防御;缺省或无法识别时为空,发送时省略该行。
|
||||
*/
|
||||
taskType?: string;
|
||||
/** 传播网名称。xdfx 在目的或行为体为 null 时,用它请求传播网简介。 */
|
||||
cbwName?: string;
|
||||
}
|
||||
|
||||
/** cop-task-detail ``taskType`` 字典码 → 发给模型的任务类型。 */
|
||||
const COP_TASK_TYPE_LABELS: Record<string, string> = {
|
||||
"1": "打击",
|
||||
"2": "塑势",
|
||||
"3": "防御",
|
||||
};
|
||||
|
||||
const COP_TASK_TYPE_LABEL_VALUES = new Set(Object.values(COP_TASK_TYPE_LABELS));
|
||||
|
||||
/** 把任务类型字典码映射成中文;数字、数字字符串和已是中文的值都接受。无法识别时返回空串。 */
|
||||
export function mapCopTaskTypeLabel(value: unknown): string {
|
||||
let key = "";
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
key = String(value);
|
||||
} else if (typeof value === "string") {
|
||||
key = value.trim();
|
||||
}
|
||||
if (!key) return "";
|
||||
if (COP_TASK_TYPE_LABEL_VALUES.has(key)) return key;
|
||||
return COP_TASK_TYPE_LABELS[key] ?? "";
|
||||
}
|
||||
|
||||
function readTaskTypeCode(value: unknown): string {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === "string") return value.trim();
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 任务详情整包暂存的 sessionStorage key(目标页/后续可读取)。 */
|
||||
@@ -39,6 +78,12 @@ export const COP_TASK_DETAIL_STORAGE_KEY = "login.copTaskDetail";
|
||||
/** 行动详情整包暂存的 sessionStorage key(xdfx 行动分析:会商/单智能体读取行动名称/叙事)。 */
|
||||
export const ACTION_DETAIL_STORAGE_KEY = "login.actionDetail";
|
||||
|
||||
/**
|
||||
* consumer ``GET /task-action/actionDetail`` 的结构化行动详情。
|
||||
* 与 ``login.actionDetail``(行动名称/任务 id/传播网)不是同一份数据。
|
||||
*/
|
||||
export const TASK_ACTION_STRUCTURE_STORAGE_KEY = "login.taskActionStructure";
|
||||
|
||||
/** goPath 基础码 → 业务码(配置管理 businessCode)。 */
|
||||
const GOPATH_BUSINESS_CODE: Record<string, string> = {
|
||||
"3qfx": "3Q", // 三情分析
|
||||
@@ -58,20 +103,33 @@ const BUSINESS_TASK_BASE: Record<string, string> = {
|
||||
|
||||
export interface ParsedGoPath {
|
||||
businessCode: string;
|
||||
goPath: "3qfx" | "rwfx" | "xdfx";
|
||||
/** Base goPath, normalized to lowercase and without the optional `-A`. */
|
||||
goPath: string;
|
||||
/** true = 多智能体会商(``-A`` 后缀);false = 单智能体问答。 */
|
||||
roundtable: boolean;
|
||||
}
|
||||
|
||||
/** 解析 goPath(大小写不敏感);``-A`` 后缀表示多智能体会商。无法识别返回 null。 */
|
||||
export function parseGoPath(goPath: string): ParsedGoPath | null {
|
||||
/** Normalize any syntactically valid goPath before resolving the mapping. */
|
||||
function parseGoPathToken(goPath: string): Pick<ParsedGoPath, "goPath" | "roundtable"> | null {
|
||||
const raw = goPath.trim().toLowerCase();
|
||||
if (!raw) return null;
|
||||
const roundtable = raw.endsWith("-a");
|
||||
const base = roundtable ? raw.slice(0, -2) : raw;
|
||||
if (!base || base.length > 32) return null;
|
||||
return { goPath: base, roundtable };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the built-in goPaths (case-insensitive). Custom goPaths are resolved
|
||||
* from the persisted business-mapping catalogue by `resolveTaskDeeplinkTarget`.
|
||||
*/
|
||||
export function parseGoPath(goPath: string): ParsedGoPath | null {
|
||||
const token = parseGoPathToken(goPath);
|
||||
if (!token) return null;
|
||||
const { goPath: base, roundtable } = token;
|
||||
const businessCode = GOPATH_BUSINESS_CODE[base];
|
||||
if (!businessCode) return null;
|
||||
return { businessCode, goPath: base as ParsedGoPath["goPath"], roundtable };
|
||||
return { businessCode, goPath: base, roundtable };
|
||||
}
|
||||
|
||||
function consumerBaseUrl(): string {
|
||||
@@ -93,6 +151,8 @@ function mockCopTaskDetail(taskId: string): CopTaskDetail {
|
||||
taskName: "任务01:暑期文旅舆情综合研判",
|
||||
taskContent:
|
||||
"围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。",
|
||||
taskType: "1",
|
||||
cbwName: "暑期文旅传播网",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +164,7 @@ export function stubCopTaskDetail(taskId: string): CopTaskDetail {
|
||||
taskDirection: "",
|
||||
taskName: "",
|
||||
taskContent: "",
|
||||
taskType: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,6 +187,8 @@ export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail
|
||||
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
|
||||
taskName: typeof d.taskName === "string" ? d.taskName : "",
|
||||
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
|
||||
taskType: readTaskTypeCode(d.taskType),
|
||||
cbwName: normalizeCbwName(d.cbwName),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[task-deeplink] cop-task-detail failed", { taskId, error });
|
||||
@@ -171,6 +234,236 @@ export function readActionDetail(): ActionDetail | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 传播网 id 是否可下发给模型。JSON ``null``、缺字段、空串、字面量 ``null`` 都视为不传。
|
||||
*/
|
||||
export function normalizeCbwId(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
if (typeof value === "number") return Number.isFinite(value) ? String(value) : null;
|
||||
if (typeof value !== "string") return null;
|
||||
const text = value.trim();
|
||||
if (!text || text.toLowerCase() === "null" || text.toLowerCase() === "undefined") return null;
|
||||
return text;
|
||||
}
|
||||
|
||||
/** 传播网名称。空值不下发;没有有效传播网 id 时调用方应整段省略。 */
|
||||
export function normalizeCbwName(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
const text = value.trim();
|
||||
if (!text || text.toLowerCase() === "null" || text.toLowerCase() === "undefined") return "";
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* consumer ``GET {VITE_AIPI_CONSUMER_URL}/task-action/actionDetail?actionId=`` 的 data。
|
||||
* 写入 xdfx 用户消息时:purpose→目的,actor→行为体,expectedBehavior→预期行为,
|
||||
* drivingFactor→驱动因素,narrationList→叙事(列表元素拼成一句)。
|
||||
*/
|
||||
export interface TaskActionStructure {
|
||||
/** 本次请求使用的行动 id(深链 URL 的 actionId)。 */
|
||||
actionId: string;
|
||||
/** JSON null / 缺字段保持 null,用来决定要不要补拉传播网简介。 */
|
||||
purpose: string | null;
|
||||
actor: string | null;
|
||||
expectedBehavior: string;
|
||||
drivingFactor: string;
|
||||
/** ``narrationList`` 拼成的一句话。 */
|
||||
narration: string;
|
||||
/** ``mainnets/narratives-by-name`` 的 data.summary。仅在目的或行为体为 null 时才有。 */
|
||||
narrativeSummary: string;
|
||||
}
|
||||
|
||||
function textField(value: unknown): string {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/** null / 缺字段保持 null;空串仍是空串,不触发传播网简介。 */
|
||||
function nullableText(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
return textField(value);
|
||||
}
|
||||
|
||||
/** purpose 或 actor 有一个为 null,就补拉传播网简介。 */
|
||||
export function actionNeedsNarrative(detail: {
|
||||
purpose: string | null;
|
||||
actor: string | null;
|
||||
} | null | undefined): boolean {
|
||||
if (!detail) return false;
|
||||
return detail.purpose === null || detail.actor === null;
|
||||
}
|
||||
|
||||
/** 把叙事列表收成一句。空元素丢掉,条目之间用分号连接。 */
|
||||
export function joinNarrationList(value: unknown): string {
|
||||
if (!Array.isArray(value)) return "";
|
||||
return value.map((item) => textField(item)).filter(Boolean).join(";");
|
||||
}
|
||||
|
||||
function isConsumerOk(state: unknown): boolean {
|
||||
return state === 200 || state === "200";
|
||||
}
|
||||
|
||||
/** 解析 ``{state, msg, data}``。state 不是 200、或 data 不是对象时返回 null。 */
|
||||
export function parseTaskActionStructure(body: unknown, actionId: string): TaskActionStructure | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const envelope = body as { state?: unknown; data?: unknown };
|
||||
if (!isConsumerOk(envelope.state)) return null;
|
||||
const data = envelope.data;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const row = data as Record<string, unknown>;
|
||||
return {
|
||||
actionId,
|
||||
purpose: nullableText(row.purpose),
|
||||
actor: nullableText(row.actor),
|
||||
expectedBehavior: textField(row.expectedBehavior),
|
||||
drivingFactor: textField(row.drivingFactor),
|
||||
narration: joinNarrationList(row.narrationList),
|
||||
narrativeSummary: "",
|
||||
};
|
||||
}
|
||||
|
||||
/** 解析 ``mainnets/narratives-by-name`` 的 ``data.summary``。失败或无简介返回空串。 */
|
||||
export function parseNarrativeSummary(body: unknown): string {
|
||||
if (!body || typeof body !== "object") return "";
|
||||
const envelope = body as { state?: unknown; data?: unknown };
|
||||
if (!isConsumerOk(envelope.state)) return "";
|
||||
const data = envelope.data;
|
||||
if (!data || typeof data !== "object") return "";
|
||||
return textField((data as { summary?: unknown }).summary);
|
||||
}
|
||||
|
||||
export function taskActionStructureLines(detail: TaskActionStructure | null | undefined): string[] {
|
||||
if (!detail) return [];
|
||||
return [
|
||||
detail.purpose ? `目的:${detail.purpose}` : "",
|
||||
detail.actor ? `行为体:${detail.actor}` : "",
|
||||
detail.expectedBehavior ? `预期行为:${detail.expectedBehavior}` : "",
|
||||
detail.drivingFactor ? `驱动因素:${detail.drivingFactor}` : "",
|
||||
detail.narration ? `叙事:${detail.narration}` : "",
|
||||
detail.narrativeSummary ? `传播网简介:${detail.narrativeSummary}` : "",
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function persistTaskActionStructure(detail: TaskActionStructure): void {
|
||||
try {
|
||||
sessionStorage.setItem(TASK_ACTION_STRUCTURE_STORAGE_KEY, JSON.stringify(detail));
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
export function readTaskActionStructure(): TaskActionStructure | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(TASK_ACTION_STRUCTURE_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<TaskActionStructure>;
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
return {
|
||||
actionId: typeof parsed.actionId === "string" ? parsed.actionId : "",
|
||||
purpose: parsed.purpose == null ? null : typeof parsed.purpose === "string" ? parsed.purpose : "",
|
||||
actor: parsed.actor == null ? null : typeof parsed.actor === "string" ? parsed.actor : "",
|
||||
expectedBehavior: typeof parsed.expectedBehavior === "string" ? parsed.expectedBehavior : "",
|
||||
drivingFactor: typeof parsed.drivingFactor === "string" ? parsed.drivingFactor : "",
|
||||
narration: typeof parsed.narration === "string" ? parsed.narration : "",
|
||||
narrativeSummary: typeof parsed.narrativeSummary === "string" ? parsed.narrativeSummary : "",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 只取与当前行动 id 匹配的结构化详情,避免串到上一次深链。 */
|
||||
export function readTaskActionStructureFor(actionId: string): TaskActionStructure | null {
|
||||
const id = actionId.trim();
|
||||
if (!id) return null;
|
||||
const stored = readTaskActionStructure();
|
||||
if (!stored || stored.actionId !== id) return null;
|
||||
return stored;
|
||||
}
|
||||
|
||||
const taskActionStructureRequests = new Map<string, Promise<TaskActionStructure | null>>();
|
||||
const narrativeSummaryRequests = new Map<string, Promise<string>>();
|
||||
|
||||
function narrativeCbwName(task: { cbwName?: string | null } | null | undefined, action: { cbwName?: string | null } | null | undefined): string {
|
||||
return normalizeCbwName(task?.cbwName) || normalizeCbwName(action?.cbwName);
|
||||
}
|
||||
|
||||
async function loadNarrativeSummary(cbwName: string): Promise<string> {
|
||||
const base = consumerBaseUrl();
|
||||
if (!base) return "";
|
||||
try {
|
||||
const body = await requestCrudService.get(`${base}/mainnets/narratives-by-name`, { cbwName });
|
||||
return parseNarrativeSummary(body);
|
||||
} catch (error) {
|
||||
console.warn("[task-deeplink] narratives-by-name failed", { cbwName, error });
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 按传播网名称取简介。同一名称的在途请求会复用。 */
|
||||
export function fetchNarrativeSummary(cbwName: string): Promise<string> {
|
||||
const name = normalizeCbwName(cbwName);
|
||||
if (!name) return Promise.resolve("");
|
||||
const existing = narrativeSummaryRequests.get(name);
|
||||
if (existing) return existing;
|
||||
const pending = loadNarrativeSummary(name);
|
||||
narrativeSummaryRequests.set(name, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行动结构里目的或行为体为 null 时,用任务详情(没有则用行动详情)的 cbwName
|
||||
* 拉传播网简介,写回同一份行动结构,供用户消息拼接。
|
||||
*/
|
||||
export async function ensureNarrativeSummary(actionId: string, cbwName: string): Promise<void> {
|
||||
const id = actionId.trim();
|
||||
const name = normalizeCbwName(cbwName);
|
||||
if (!id || !name) return;
|
||||
const structure = readTaskActionStructureFor(id) ?? (await fetchTaskActionStructure(id));
|
||||
if (!actionNeedsNarrative(structure)) return;
|
||||
const summary = await fetchNarrativeSummary(name);
|
||||
const latest = readTaskActionStructureFor(id) ?? structure;
|
||||
if (!latest) return;
|
||||
persistTaskActionStructure({ ...latest, narrativeSummary: summary });
|
||||
}
|
||||
|
||||
async function loadTaskActionStructure(actionId: string): Promise<TaskActionStructure | null> {
|
||||
const base = consumerBaseUrl();
|
||||
if (!base) return null;
|
||||
try {
|
||||
const body = await requestCrudService.get(`${base}/task-action/actionDetail`, { actionId });
|
||||
const detail = parseTaskActionStructure(body, actionId);
|
||||
if (detail) persistTaskActionStructure(detail);
|
||||
return detail;
|
||||
} catch (error) {
|
||||
console.warn("[task-deeplink] task-action/actionDetail failed", { actionId, error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* xdfx 深链:按行动 id 拉 consumer 行动结构。同一 actionId 的在途/已完成请求会复用,
|
||||
* 登录预取和开场词拼装不会各打一次。失败返回 null,不阻断跳转。
|
||||
*/
|
||||
export function fetchTaskActionStructure(actionId: string): Promise<TaskActionStructure | null> {
|
||||
const id = actionId.trim();
|
||||
if (!id) return Promise.resolve(null);
|
||||
const existing = taskActionStructureRequests.get(id);
|
||||
if (existing) return existing;
|
||||
const pending = loadTaskActionStructure(id);
|
||||
taskActionStructureRequests.set(id, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** xdfx 发给模型的传播网两行。``cbwId`` 无效时返回空数组,名称也不带。 */
|
||||
export function cbwModelLines(cbwId: unknown, cbwName: unknown): string[] {
|
||||
const id = normalizeCbwId(cbwId);
|
||||
if (!id) return [];
|
||||
const name = normalizeCbwName(cbwName);
|
||||
return [`传播网id(cbw_id)为${id}`, ...(name ? [`传播网名称:${name}`] : [])];
|
||||
}
|
||||
|
||||
/** 行动详情接口 ``{action_detail_url}/{actionId}`` 返回的 ``data`` 字段(xdfx 行动分析用)。 */
|
||||
export interface ActionDetail {
|
||||
/** 行动id(深链 ?taskId= 传入的就是它)。 */
|
||||
@@ -185,6 +478,10 @@ export interface ActionDetail {
|
||||
actionStatus: number | string;
|
||||
/** 行动叙事 / 描述。 */
|
||||
narration: string;
|
||||
/** 传播网 id。接口为 null / 空时保持 null,开场信息不带传播网。 */
|
||||
cbwId: string | null;
|
||||
/** 传播网名称。仅在 ``cbwId`` 有值时随开场信息下发。 */
|
||||
cbwName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,6 +513,8 @@ export async function fetchActionDetail(actionId: string): Promise<ActionDetail
|
||||
actionStatus:
|
||||
typeof d.actionStatus === "number" || typeof d.actionStatus === "string" ? d.actionStatus : "",
|
||||
narration: typeof d.narration === "string" ? d.narration : "",
|
||||
cbwId: normalizeCbwId(d.cbwId),
|
||||
cbwName: normalizeCbwName(d.cbwName),
|
||||
};
|
||||
// 取到即整包暂存:登录解析任务id 时(resolveDeeplinkTaskId→fetchTaskIdFromAction)就会缓存它,
|
||||
// 之后会商左上角「当前行动」、单智能体行动详情卡都能直接 readActionDetail(),避免重复请求。
|
||||
@@ -250,13 +549,61 @@ export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Prom
|
||||
return rawId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深链预取:登录**进行中**就把不依赖 DeerFlow 登录态的深链数据并行拉好——
|
||||
* 深链配置(公开端点)、xdfx 行动详情(含其内部缓存的配置)、任务详情
|
||||
* (consumer 接口走静态 admin Bearer,与登录无关)。登录成功后登录页的
|
||||
* 深链解析直接消费这些在途/已完成的结果,省掉「配置→行动→任务详情」
|
||||
* 的整段登录后串行等待。
|
||||
*
|
||||
* 失败语义与登录页原逻辑一致:行动详情失败按原始 id 回退拉任务详情、
|
||||
* realTaskId 给 ""(调用方回退原始 id);任务详情失败为 null(调用方
|
||||
* 走 stub)。xdfx 同时预取 consumer ``task-action/actionDetail``,失败只是
|
||||
* 开场词里不带目的/行为体等行。这些 promise 均不 reject。
|
||||
*/
|
||||
export interface TaskDeeplinkPrefetch {
|
||||
/** 真实任务 id(xdfx:行动→任务;其余原样返回)。 */
|
||||
realTaskId: Promise<string>;
|
||||
/** 任务详情(xdfx 已串好「行动详情→任务详情」链;无数据/失败为 null)。 */
|
||||
taskDetail: Promise<CopTaskDetail | null>;
|
||||
/** xdfx:consumer 行动结构。其它业务为已完成的 null。失败不 reject。 */
|
||||
actionStructure: Promise<TaskActionStructure | null>;
|
||||
}
|
||||
|
||||
export function prefetchTaskDeeplink(taskId: string, goPath: string): TaskDeeplinkPrefetch {
|
||||
const parsed = parseGoPath(goPath);
|
||||
if (parsed?.businessCode === XDFX_BUSINESS_CODE) {
|
||||
const actionDetail = fetchActionDetail(taskId).catch((error: unknown) => {
|
||||
console.warn("[task-deeplink] prefetch action detail failed", { taskId, error });
|
||||
return null;
|
||||
});
|
||||
const taskDetail = actionDetail.then((ad) => fetchCopTaskDetail(ad?.taskId || taskId));
|
||||
const actionStructure = fetchTaskActionStructure(taskId).then(async (structure) => {
|
||||
if (!actionNeedsNarrative(structure)) return structure;
|
||||
const [ad, task] = await Promise.all([actionDetail, taskDetail]);
|
||||
await ensureNarrativeSummary(taskId, narrativeCbwName(task, ad));
|
||||
return readTaskActionStructureFor(taskId) ?? structure;
|
||||
});
|
||||
return {
|
||||
realTaskId: actionDetail.then((ad) => ad?.taskId ?? ""),
|
||||
taskDetail,
|
||||
actionStructure,
|
||||
};
|
||||
}
|
||||
return {
|
||||
realTaskId: Promise.resolve(taskId),
|
||||
taskDetail: fetchCopTaskDetail(taskId),
|
||||
actionStructure: Promise.resolve(null),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TaskDeeplinkTarget {
|
||||
path: string;
|
||||
/** react-router navigate 的 state(多智能体会商用 roundtablePrefill)。 */
|
||||
state?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** xdfx(行动分析)开场信息里要追加的行动信息:行动标题 + 行动内容 + 行动id。 */
|
||||
/** xdfx(行动分析)开场信息里要追加的行动信息:行动标题 + 行动内容 + 行动id + 传播网。 */
|
||||
export interface ActionOpeningInfo {
|
||||
/** 行动id(action_id)。 */
|
||||
actionId?: string;
|
||||
@@ -264,20 +611,32 @@ export interface ActionOpeningInfo {
|
||||
actionName?: string;
|
||||
/** 行动内容(行动叙事 narration)。 */
|
||||
narration?: string;
|
||||
/** 传播网 id。为 null / 空时不写入开场信息,名称一并省略。 */
|
||||
cbwId?: string | null;
|
||||
/** 传播网名称。仅在有传播网 id 时写入。 */
|
||||
cbwName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 taskName / taskContent 拼出开场信息文本;末尾附上任务 id(task_id),供后端识别任务。
|
||||
* ``taskType`` 能映射时插入「任务类型:打击/塑势/防御」。
|
||||
* xdfx(行动分析)额外附上「行动标题 / 行动内容 / 行动id(action_id)」——传入 action 时追加这几行。
|
||||
* 同一行动已取到的 consumer 行动结构会再追加「目的 / 行为体 / 预期行为 / 驱动因素 / 叙事」。
|
||||
* 目的或行为体为 null 时,再追加「传播网简介」。
|
||||
* 传播网 id、传播网名称仅在 ``cbwId`` 有值时追加;为 null 时两行都不写。
|
||||
*/
|
||||
function buildOpeningText(detail: CopTaskDetail, taskId: string, action?: ActionOpeningInfo): string {
|
||||
const taskTypeLabel = mapCopTaskTypeLabel(detail.taskType);
|
||||
return [
|
||||
detail.taskName ? `任务名称:${detail.taskName}` : "",
|
||||
detail.taskContent ? `任务内容:${detail.taskContent}` : "",
|
||||
taskTypeLabel ? `任务类型:${taskTypeLabel}` : "",
|
||||
taskId ? `任务id(task_id)为${taskId}` : "",
|
||||
action?.actionName ? `行动标题:${action.actionName}` : "",
|
||||
action?.narration ? `行动内容:${action.narration}` : "",
|
||||
action?.actionId ? `行动id(action_id)为${action.actionId}` : "",
|
||||
...taskActionStructureLines(action?.actionId ? readTaskActionStructureFor(action.actionId) : null),
|
||||
...cbwModelLines(action?.cbwId, action?.cbwName),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
@@ -314,10 +673,36 @@ export async function resolveTaskDeeplinkTarget(
|
||||
taskId: string,
|
||||
rawId?: string,
|
||||
): Promise<TaskDeeplinkTarget | null> {
|
||||
const parsed = parseGoPath(goPath);
|
||||
if (!parsed) return null;
|
||||
const builtIn = parseGoPath(goPath);
|
||||
const token = parseGoPathToken(goPath);
|
||||
if (!token) return null;
|
||||
|
||||
// Built-ins retain their task-workspace routes. Any other valid base is a
|
||||
// custom mapping only when it exists in the globally configured catalogue.
|
||||
let mapping: BusinessMapping | null = null;
|
||||
const isCustom = !builtIn;
|
||||
const parsed: ParsedGoPath = builtIn ?? {
|
||||
businessCode: "",
|
||||
goPath: token.goPath,
|
||||
roundtable: token.roundtable,
|
||||
};
|
||||
if (isCustom) {
|
||||
try {
|
||||
const mappings = await listBusinessMappings();
|
||||
mapping = mappings.find(
|
||||
(row) => row.isCustom && row.businessCode.trim().toLowerCase() === token.goPath,
|
||||
) ?? null;
|
||||
} catch (error) {
|
||||
console.warn("[task-deeplink] custom business mapping failed", error);
|
||||
}
|
||||
if (!mapping) {
|
||||
console.warn("[task-deeplink] custom goPath is not configured", token.goPath);
|
||||
return null;
|
||||
}
|
||||
parsed.businessCode = mapping.businessCode;
|
||||
}
|
||||
const taskBase = BUSINESS_TASK_BASE[parsed.businessCode];
|
||||
if (!taskBase) return null;
|
||||
if (!isCustom && !taskBase) return null;
|
||||
|
||||
// 清掉上一次深链可能残留的任务上下文(单智能体分支会用本次任务重新写入)。
|
||||
clearTaskContext();
|
||||
@@ -331,11 +716,19 @@ export async function resolveTaskDeeplinkTarget(
|
||||
let actionId = "";
|
||||
if (parsed.businessCode === XDFX_BUSINESS_CODE && rawId) {
|
||||
actionId = rawId;
|
||||
const ad = readActionDetail() ?? (await fetchActionDetail(rawId));
|
||||
const [, ad] = await Promise.all([
|
||||
fetchTaskActionStructure(rawId),
|
||||
readActionDetail() ? Promise.resolve(readActionDetail()) : fetchActionDetail(rawId),
|
||||
]);
|
||||
await ensureNarrativeSummary(rawId, narrativeCbwName(detail, ad));
|
||||
actionName = ad?.actionName ?? "";
|
||||
}
|
||||
return {
|
||||
path: `${taskBase}/roundtable`,
|
||||
// Custom mappings have no host business page. They intentionally open a
|
||||
// clean surface; hideChrome survives the subsequent in-page navigation.
|
||||
path: isCustom
|
||||
? "/page/strategy/qa/multi-agent-roundtable?hideChrome=1"
|
||||
: `${taskBase}/roundtable`,
|
||||
state: {
|
||||
roundtablePrefill: {
|
||||
taskTitle: detail.taskName,
|
||||
@@ -350,14 +743,15 @@ export async function resolveTaskDeeplinkTarget(
|
||||
};
|
||||
}
|
||||
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const mappings = await listBusinessMappings();
|
||||
agentId = mappings.find((m) => m.businessCode === parsed.businessCode)?.agentId ?? null;
|
||||
} catch (error) {
|
||||
console.warn("[task-deeplink] business mapping failed", error);
|
||||
agentId = null;
|
||||
if (!mapping) {
|
||||
try {
|
||||
const mappings = await listBusinessMappings();
|
||||
mapping = mappings.find((row) => row.businessCode === parsed.businessCode) ?? null;
|
||||
} catch (error) {
|
||||
console.warn("[task-deeplink] business mapping failed", error);
|
||||
}
|
||||
}
|
||||
const agentId = mapping?.agentId ?? null;
|
||||
if (!agentId) {
|
||||
console.warn("[task-deeplink] no agent mapped for", parsed.businessCode);
|
||||
return null;
|
||||
@@ -368,13 +762,24 @@ export async function resolveTaskDeeplinkTarget(
|
||||
// 开最新历史对话 / 新建。rwfx 在工作区里额外过「目的树闸门」;3qfx/xdfx 仅 1:N 列表,
|
||||
// 新建对话直接带开场信息自动发出(见 task-purpose / AgentChatPage 的 taskGate / taskBind)。
|
||||
// xdfx(行动分析):聊天记录按「行动id」划分(partitionByAction),且开场信息追加
|
||||
// 行动标题 / 行动内容 / action_id(行动详情登录解析任务id 时已缓存,优先读缓存避免重复请求)。
|
||||
// 行动标题 / 行动内容 / action_id / 传播网(行动详情登录解析任务id 时已缓存,优先读缓存避免重复请求)。
|
||||
const isXdfx = parsed.businessCode === XDFX_BUSINESS_CODE;
|
||||
const actionId = rawId || taskId;
|
||||
let actionInfo: ActionOpeningInfo | undefined;
|
||||
if (isXdfx) {
|
||||
const ad = rawId ? (readActionDetail() ?? (await fetchActionDetail(rawId))) : null;
|
||||
actionInfo = { actionId, actionName: ad?.actionName, narration: ad?.narration };
|
||||
const structureId = rawId || actionId;
|
||||
const [, ad] = await Promise.all([
|
||||
structureId ? fetchTaskActionStructure(structureId) : Promise.resolve(null),
|
||||
rawId ? (readActionDetail() ? Promise.resolve(readActionDetail()) : fetchActionDetail(rawId)) : Promise.resolve(null),
|
||||
]);
|
||||
if (structureId) await ensureNarrativeSummary(structureId, narrativeCbwName(detail, ad));
|
||||
actionInfo = {
|
||||
actionId,
|
||||
actionName: ad?.actionName,
|
||||
narration: ad?.narration,
|
||||
cbwId: ad?.cbwId,
|
||||
cbwName: ad?.cbwName,
|
||||
};
|
||||
}
|
||||
persistTaskContext({
|
||||
taskId,
|
||||
@@ -384,5 +789,12 @@ export async function resolveTaskDeeplinkTarget(
|
||||
partitionByAction: isXdfx,
|
||||
goPath: parsed.goPath,
|
||||
});
|
||||
if (isCustom) {
|
||||
// This route deliberately renders AgentChatPage without its left agent
|
||||
// list. The query also removes the global header and PageSidebar.
|
||||
return {
|
||||
path: `/page/strategy/custom-deeplink/${encodeURIComponent(agentId)}/chats/new?hideChrome=1`,
|
||||
};
|
||||
}
|
||||
return { path: `${taskBase}/agent/${agentId}/chats` };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
TaskEntryInputError,
|
||||
buildMainAppUrl,
|
||||
buildTaskEntryDestination,
|
||||
buildTaskEntrySessionSearch,
|
||||
parseTaskEntryParams,
|
||||
} from "./task-entry.ts";
|
||||
|
||||
test("parses the dedicated single-agent task entry", () => {
|
||||
const params = parseTaskEntryParams(
|
||||
"?username=admin&taskId=108&goPath=XDFX&theme=dark-blue&hideJump=1",
|
||||
);
|
||||
assert.deepEqual(params, {
|
||||
username: "admin",
|
||||
password: "",
|
||||
taskId: "108",
|
||||
goPath: "xdfx",
|
||||
theme: "dark-blue",
|
||||
hideJump: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects missing identity, task, and roundtable routes", () => {
|
||||
assert.throws(() => parseTaskEntryParams("?taskId=108&goPath=xdfx"), TaskEntryInputError);
|
||||
assert.throws(() => parseTaskEntryParams("?username=admin&goPath=xdfx"), TaskEntryInputError);
|
||||
assert.throws(
|
||||
() => parseTaskEntryParams("?username=admin&taskId=108&goPath=xdfx-A"),
|
||||
TaskEntryInputError,
|
||||
);
|
||||
});
|
||||
|
||||
test("forces isolated embed state and an allowlisted task target", () => {
|
||||
const params = parseTaskEntryParams(
|
||||
"?username=admin&taskId=108&goPath=xdfx&hideJump=true",
|
||||
);
|
||||
const session = new URLSearchParams(buildTaskEntrySessionSearch(params));
|
||||
assert.equal(session.get("embed"), "1");
|
||||
assert.equal(session.get("hideChrome"), "1");
|
||||
assert.equal(session.get("isolate"), "1");
|
||||
assert.equal(session.get("hideJump"), "1");
|
||||
|
||||
const destination = buildTaskEntryDestination(
|
||||
"/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats",
|
||||
params,
|
||||
);
|
||||
assert.equal(
|
||||
destination,
|
||||
"/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats?embed=1&hideChrome=1&isolate=1&hideJump=1",
|
||||
);
|
||||
assert.throws(() => buildTaskEntryDestination("https://example.com", params));
|
||||
assert.throws(() => buildTaskEntryDestination("/page/workspace/chats/new", params));
|
||||
});
|
||||
|
||||
test("returns to the main app at the same deployment prefix", () => {
|
||||
assert.equal(
|
||||
buildMainAppUrl(
|
||||
"https://example.com/deerflow/task-entry.html?username=admin",
|
||||
"/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats",
|
||||
),
|
||||
"https://example.com/deerflow/#/page/strategy/task-management/plan-task/plan-task-action/agent/a-1/chats",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
export const TASK_ENTRY_SUPPORTED_GOPATHS = ["3qfx", "rwfx", "xdfx"] as const;
|
||||
|
||||
export type TaskEntryGoPath = (typeof TASK_ENTRY_SUPPORTED_GOPATHS)[number];
|
||||
|
||||
export interface TaskEntryParams {
|
||||
username: string;
|
||||
password: string;
|
||||
taskId: string;
|
||||
goPath: TaskEntryGoPath;
|
||||
theme: string;
|
||||
hideJump: boolean;
|
||||
}
|
||||
|
||||
export class TaskEntryInputError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TaskEntryInputError";
|
||||
}
|
||||
}
|
||||
|
||||
function readFlag(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function parseTaskEntryParams(search: string): TaskEntryParams {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
const username = (params.get("username") ?? "").trim();
|
||||
const password = params.get("password") ?? "";
|
||||
const taskId = (params.get("taskId") ?? "").trim();
|
||||
const rawGoPath = (params.get("goPath") ?? "").trim().toLowerCase();
|
||||
const theme = (params.get("theme") ?? "").trim();
|
||||
|
||||
if (!username) throw new TaskEntryInputError("入口缺少 username,无法登录任务问答。");
|
||||
if (!taskId) throw new TaskEntryInputError("入口缺少 taskId,无法定位任务。");
|
||||
if (!TASK_ENTRY_SUPPORTED_GOPATHS.includes(rawGoPath as TaskEntryGoPath)) {
|
||||
throw new TaskEntryInputError("轻量入口仅支持 3qfx、rwfx 或 xdfx 单智能体任务问答。");
|
||||
}
|
||||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
taskId,
|
||||
goPath: rawGoPath as TaskEntryGoPath,
|
||||
theme,
|
||||
hideJump: readFlag(params.get("hideJump")),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTaskEntrySessionSearch(params: TaskEntryParams): string {
|
||||
const session = new URLSearchParams({
|
||||
embed: "1",
|
||||
hideChrome: "1",
|
||||
isolate: "1",
|
||||
});
|
||||
if (params.hideJump) session.set("hideJump", "1");
|
||||
if (params.theme) session.set("theme", params.theme);
|
||||
return `?${session.toString()}`;
|
||||
}
|
||||
|
||||
export function buildTaskEntryDestination(path: string, params: TaskEntryParams): string {
|
||||
if (!path.startsWith("/page/strategy/task-management/") || path.includes("\\")) {
|
||||
throw new Error("任务深链解析出了不受允许的目标页面。");
|
||||
}
|
||||
const url = new URL(path, "https://task-entry.invalid");
|
||||
if (url.origin !== "https://task-entry.invalid") {
|
||||
throw new Error("任务深链目标必须是系统内部页面。");
|
||||
}
|
||||
url.searchParams.set("embed", "1");
|
||||
url.searchParams.set("hideChrome", "1");
|
||||
url.searchParams.set("isolate", "1");
|
||||
if (params.hideJump) url.searchParams.set("hideJump", "1");
|
||||
if (params.theme) url.searchParams.set("theme", params.theme);
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
export function buildMainAppUrl(currentHref: string, destination: string): string {
|
||||
const appUrl = new URL(".", currentHref);
|
||||
appUrl.search = "";
|
||||
appUrl.hash = destination;
|
||||
return appUrl.toString();
|
||||
}
|
||||
@@ -33,7 +33,22 @@ export interface PurposeTreeNode {
|
||||
children: PurposeTreeNode[];
|
||||
}
|
||||
|
||||
export type TaskDeeplinkGoPath = "3qfx" | "rwfx" | "xdfx";
|
||||
/**
|
||||
* The base (without `-A`) of a task deep-link goPath.
|
||||
*
|
||||
* Besides the four built-ins, administrators may now create a short custom
|
||||
* mapping name. Keeping this a string lets its dedicated conversation history
|
||||
* partition follow the configured goPath without adding frontend code again.
|
||||
*/
|
||||
export type TaskDeeplinkGoPath = string;
|
||||
|
||||
function readTaskDeeplinkGoPath(value: unknown): TaskDeeplinkGoPath | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized && normalized.length <= 32 && !normalized.endsWith("-a")
|
||||
? normalized
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** 当前任务上下文(深链进入或「新建对话」时暂存,供闸门面板与绑定使用)。 */
|
||||
export interface TaskContext {
|
||||
@@ -51,6 +66,12 @@ export interface TaskContext {
|
||||
partitionByAction?: boolean;
|
||||
/** 单智能体深链接类型;用于写入专用 taskId + goPath 聊天记录索引。 */
|
||||
goPath?: TaskDeeplinkGoPath;
|
||||
/**
|
||||
* b5-1 深链 ``?type=`` 业务类型。聊天记录在 taskId + goPath 之上再按该类型
|
||||
* 细分分区(新对话打 `metadata.taskType` 标签、列表按 sub_type 过滤),
|
||||
* 不同类型的对话互不可见。仅 b5-1 使用,其它深链不设置。
|
||||
*/
|
||||
taskType?: string;
|
||||
}
|
||||
|
||||
const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext";
|
||||
@@ -369,10 +390,8 @@ export function readTaskContext(): TaskContext | null {
|
||||
openingText: typeof parsed.openingText === "string" ? parsed.openingText : "",
|
||||
actionId: typeof parsed.actionId === "string" ? parsed.actionId : undefined,
|
||||
partitionByAction: parsed.partitionByAction === true,
|
||||
goPath:
|
||||
parsed.goPath === "3qfx" || parsed.goPath === "rwfx" || parsed.goPath === "xdfx"
|
||||
? parsed.goPath
|
||||
: undefined,
|
||||
taskType: typeof parsed.taskType === "string" ? parsed.taskType.trim() : undefined,
|
||||
goPath: readTaskDeeplinkGoPath(parsed.goPath),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -399,6 +418,8 @@ export function taskThreadMetadata(
|
||||
? {
|
||||
taskRecordId,
|
||||
taskGoPath: resolvedGoPath,
|
||||
// b5-1 ?type=:同一 taskId 下不同业务类型的聊天记录分区。
|
||||
...(ctx.taskType ? { taskType: ctx.taskType } : {}),
|
||||
thread_type: "task_deeplink",
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -70,6 +70,7 @@ function persistUserDetailToken(data: UserDetailTokenPayload): void {
|
||||
*/
|
||||
export async function resolveUsernameByOnlineTicket(
|
||||
ticket: string,
|
||||
isCurrent: () => boolean = () => true,
|
||||
): Promise<string> {
|
||||
const onlineTicket = ticket.trim();
|
||||
if (!onlineTicket) {
|
||||
@@ -81,13 +82,23 @@ export async function resolveUsernameByOnlineTicket(
|
||||
if (!CONSUMER_URL) {
|
||||
throw new Error("未配置 VITE_AIPI_CONSUMER_URL,无法获取用户详情。");
|
||||
}
|
||||
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
|
||||
|
||||
persistExternalLoginParams({ ticket: onlineTicket });
|
||||
|
||||
const tokenRes = (await requestCrudService.get(
|
||||
`${AUTH_BASE_URL}/maxkey-mgt-api/sys/getTokenByOnlineTicket`,
|
||||
{ onlineTicket },
|
||||
)) as unknown as MaxkeyTokenByTicketResponse;
|
||||
// 两个接口都只依赖 onlineTicket(MaxKey token 仅供后续请求使用,不参与
|
||||
// 用户名解析),并行发起,把两轮外部串行往返压缩为一轮。
|
||||
const [tokenRes, detailRes] = (await Promise.all([
|
||||
requestCrudService.get(
|
||||
`${AUTH_BASE_URL}/maxkey-mgt-api/sys/getTokenByOnlineTicket`,
|
||||
{ onlineTicket },
|
||||
),
|
||||
requestCrudService.get(`${CONSUMER_URL}/cop/userDetailToken`, {
|
||||
ticket: onlineTicket,
|
||||
}),
|
||||
])) as unknown as [MaxkeyTokenByTicketResponse, UserDetailTokenResponse];
|
||||
|
||||
if (!isCurrent()) throw new Error("登录请求已被新的登录替代");
|
||||
|
||||
console.log("[wujie] getTokenByOnlineTicket:", tokenRes);
|
||||
|
||||
@@ -98,11 +109,6 @@ export async function resolveUsernameByOnlineTicket(
|
||||
}
|
||||
persistMaxkeyOnlineToken(tokenRes.data);
|
||||
|
||||
const detailRes = (await requestCrudService.get(
|
||||
`${CONSUMER_URL}/cop/userDetailToken`,
|
||||
{ ticket: onlineTicket },
|
||||
)) as unknown as UserDetailTokenResponse;
|
||||
|
||||
console.log("[wujie] userDetailToken:", detailRes);
|
||||
|
||||
const userData = detailRes.data;
|
||||
|
||||
@@ -175,7 +175,9 @@ export async function loginByYUserId(
|
||||
yUserId: string,
|
||||
token?: string | null,
|
||||
access_token?: string | null,
|
||||
isCurrent: () => boolean = () => true,
|
||||
): Promise<void> {
|
||||
if (!isCurrent()) return;
|
||||
if (token) {
|
||||
authStorageSet(TOOLS_TOKEN_STORAGE_KEY, token);
|
||||
}
|
||||
@@ -199,6 +201,7 @@ export async function loginByYUserId(
|
||||
// 注意:接口返回的 accessToken 优先;登录 URL 的 access_token 只在接口值为空时兜底。
|
||||
// The consumer API may return accessToken. URL access_token is only used as
|
||||
// a fallback when that API token is empty.
|
||||
if (!isCurrent()) return;
|
||||
const result = res.data ?? {};
|
||||
logYUserLogin("response", {
|
||||
keys: Object.keys(result),
|
||||
|
||||
Reference in new issue
Block a user