From 9572b66ecb92392322718adfc009df13dc36be18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Sat, 27 Jun 2026 11:03:39 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(=E7=99=BD=E5=90=8D=E5=8D=95):=20?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E7=BA=A7=E7=99=BB=E5=BD=95=E5=8F=A3=E4=BB=A4?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=AE=A1=E7=90=86=E5=91=98=E8=AE=BE=E6=98=8E?= =?UTF-8?q?=E6=96=87=E5=8F=A3=E4=BB=A4,=E7=94=A8=E6=88=B7=E5=87=AD=20/logi?= =?UTF-8?q?n/<=E7=94=A8=E6=88=B7=E5=90=8D>=3Fpassword=3D=20=E7=9B=B4?= =?UTF-8?q?=E7=99=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 白名单管理页可给单个账号设明文登录口令(users.login_password,可空): 设置后该账号用户名直登(/login/username)须带匹配 ?password=,且优先于全局共享口令门; 未设口令的账号行为不变。前端登录 URL 已支持透传 ?password=,无需改登录页。 - 后端:UserRow/User 新增 login_password + 仓储映射;/login/username 账号级口令闸门 (_lookup_user_by_username 仅查不建);新管理员接口 PUT /users/{id}/login-password; UserListItem 暴露明文(仅 admin 列表);migration 20260625_04 + 幂等补列 SQL。 - 前端:白名单页新增「登录口令」列(设置/修改/清除 + 复制登录链接); core/auth api+hooks 增 setUserLoginPassword/useSetUserLoginPassword(含 Y-Log)。 - 文档:根 + 后端 CLAUDE.md 同步。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- frontend-web/src/core/auth/api.ts | 25 ++ frontend-web/src/core/auth/hooks.ts | 20 +- frontend-web/src/pages/LoginWhitelistPage.tsx | 225 +++++++++++++++--- offline-backend-20260512/backend/CLAUDE.md | 2 +- .../backend/app/gateway/auth/models.py | 1 + .../app/gateway/auth/repositories/sqlite.py | 3 + .../backend/app/gateway/routers/auth.py | 98 +++++++- .../20260625_04_users_login_password.py | 56 +++++ .../deerflow/persistence/user/model.py | 7 + .../scripts/sql/column_additions_mysql.sql | 7 + 11 files changed, 402 insertions(+), 44 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_04_users_login_password.py diff --git a/CLAUDE.md b/CLAUDE.md index d37524d5..52c64c90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API. - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). -- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. +- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`. - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 - **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, openMode, loginParams, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. diff --git a/frontend-web/src/core/auth/api.ts b/frontend-web/src/core/auth/api.ts index b2295ea8..d95ddd9f 100644 --- a/frontend-web/src/core/auth/api.ts +++ b/frontend-web/src/core/auth/api.ts @@ -13,6 +13,12 @@ export interface UserListItem { qa_count: number; /** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */ approved: boolean; + /** + * 账号级「登录口令」(明文,仅管理员可见)。非空 → 该账号用户名直登必须带匹配的 + * `?password=`;为空/null → 走原有共享口令门/免口令逻辑。管理员据此拼出登录链接 + * `…/login/<用户名>?password=xxxx` 发给用户。 + */ + login_password: string | null; } export async function listAllUsers(): Promise { @@ -35,6 +41,25 @@ export async function setUserApproval(userId: string, approved: boolean): Promis return res.json() as Promise; } +/** + * 设置 / 清除某个账号的「登录口令」(明文)。Admin only。 + * + * 传非空字符串 → 设置;传 `null` 或空字符串 → 清除。设置后该账号用户名直登需带匹配的 + * `?password=`。返回更新后的用户项(含明文 `login_password`)。 + */ +export async function setUserLoginPassword(userId: string, password: string | null): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/login-password`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(extractErrorMessage(data, "保存失败")); + } + return res.json() as Promise; +} + // Normalize a FastAPI error body into a human-readable string. `detail` may be // a plain string, an object like AuthErrorResponse ({ code, message }), or an // array of validation errors — anything but a string would otherwise render as diff --git a/frontend-web/src/core/auth/hooks.ts b/frontend-web/src/core/auth/hooks.ts index 0adf00e7..dee77404 100644 --- a/frontend-web/src/core/auth/hooks.ts +++ b/frontend-web/src/core/auth/hooks.ts @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { OptType, writeYLog } from "@/core/log/y-log"; -import { listAllUsers, setUserApproval } from "./api"; +import { listAllUsers, setUserApproval, setUserLoginPassword } from "./api"; export function useAllUsers(enabled: boolean) { return useQuery({ @@ -28,3 +28,21 @@ export function useSetUserApproval() { }, }); } + +/** 设置 / 清除某账号的「登录口令」(明文),成功后刷新用户列表缓存。 */ +export function useSetUserLoginPassword() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ userId, password }: { userId: string; password: string | null }) => setUserLoginPassword(userId, password), + onSuccess: (_data, vars) => { + const cleared = vars.password == null || vars.password === ""; + writeYLog({ + optType: cleared ? OptType.delete : OptType.edit, + optDetail: `CM助手-全量-白名单管理-${cleared ? "清除" : "设置"}账号登录口令`, + url: `/api/v1/auth/users/${vars.userId}/login-password`, + reqParam: { userId: vars.userId }, + }); + void queryClient.invalidateQueries({ queryKey: ["admin", "users"] }); + }, + }); +} diff --git a/frontend-web/src/pages/LoginWhitelistPage.tsx b/frontend-web/src/pages/LoginWhitelistPage.tsx index bebb066a..d65e1d3f 100644 --- a/frontend-web/src/pages/LoginWhitelistPage.tsx +++ b/frontend-web/src/pages/LoginWhitelistPage.tsx @@ -1,13 +1,14 @@ "use client"; -import { useMemo, useState } from "react"; +import { Fragment, useMemo, useState } from "react"; import { Navigate } from "react-router-dom"; -import { Info, Search, ShieldCheck, UserCheck } from "lucide-react"; +import { Copy, Info, KeyRound, Link2, Search, ShieldCheck, UserCheck, X } from "lucide-react"; import { toast } from "sonner"; import { Switch } from "@/components/ui/switch"; +import type { UserListItem } from "@/core/auth/api"; import { getStoredAuth } from "@/core/auth"; -import { useAllUsers, useSetUserApproval } from "@/core/auth/hooks"; +import { useAllUsers, useSetUserApproval, useSetUserLoginPassword } from "@/core/auth/hooks"; import { useLoginWhitelistEnabled, useSetLoginWhitelistEnabled, @@ -28,9 +29,13 @@ export default function LoginWhitelistPage() { const { data: users = [], isLoading: loadingUsers } = useAllUsers(isAdmin); const setApproval = useSetUserApproval(); + const setLoginPassword = useSetUserLoginPassword(); const [query, setQuery] = useState(""); const [pendingId, setPendingId] = useState(null); + // 口令编辑:当前展开编辑的用户 id + 输入草稿(明文,便于管理员直接查看/复制)。 + const [editingId, setEditingId] = useState(null); + const [pwdDraft, setPwdDraft] = useState(""); const q = query.trim().toLowerCase(); const visibleUsers = useMemo( @@ -61,6 +66,56 @@ export default function LoginWhitelistPage() { } }; + // 用户名直登链接:`<当前源>/#/login/<邮箱本地部分>?password=<口令>`。用户名取邮箱 + // `@` 前缀(用户名直登据此解析回同一账号);base 取去掉 hash 的当前地址,保留可能的 + // 部署子路径。 + const buildLoginLink = (user: UserListItem, password: string) => { + const localPart = (user.email.split("@")[0] || "").trim(); + const base = window.location.href.split("#")[0]; + return `${base}#/login/${encodeURIComponent(localPart)}?password=${encodeURIComponent(password)}`; + }; + + const copyText = async (text: string, okMsg: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success(okMsg); + } catch { + toast.error("复制失败,请手动复制"); + } + }; + + const openEditor = (user: UserListItem) => { + setEditingId(user.id); + setPwdDraft(user.login_password ?? ""); + }; + + const closeEditor = () => { + setEditingId(null); + setPwdDraft(""); + }; + + const handleSavePassword = async (user: UserListItem) => { + const pwd = pwdDraft.trim(); + if (!pwd) return; + try { + await setLoginPassword.mutateAsync({ userId: user.id, password: pwd }); + toast.success("已设置登录口令"); + closeEditor(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "保存失败"); + } + }; + + const handleClearPassword = async (user: UserListItem) => { + try { + await setLoginPassword.mutateAsync({ userId: user.id, password: null }); + toast.success("已清除登录口令"); + closeEditor(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "清除失败"); + } + }; + if (!isAdmin) return ; return ( @@ -126,9 +181,10 @@ export default function LoginWhitelistPage() { {/* 用户列表 */}
-
+
用户 问答 + 登录口令 放行
@@ -143,40 +199,137 @@ export default function LoginWhitelistPage() { {visibleUsers.map((user) => { const isUserAdmin = user.system_role === "admin"; const allowed = user.approved || isUserAdmin; + const hasPwd = Boolean(user.login_password); + const isEditing = editingId === user.id; return ( -
  • -
    -
    - {user.email} - {isUserAdmin ? ( - - - 管理员 - - ) : allowed ? ( - - 已放行 - - ) : ( - - 已拦截 - - )} + +
  • +
    +
    + {user.email} + {isUserAdmin ? ( + + + 管理员 + + ) : allowed ? ( + + 已放行 + + ) : ( + + 已拦截 + + )} +
    +
    {user.id}
    -
    {user.id}
    -
  • -
    {user.qa_count}
    -
    - handleToggleApproval(user.id, v)} - /> -
    - +
    {user.qa_count}
    + {/* 登录口令:设置 / 修改 + 复制登录链接 */} +
    +
    + + {hasPwd && ( + + )} +
    +
    +
    + handleToggleApproval(user.id, v)} + /> +
    + + + {isEditing && ( +
  • +
    +
    + + 为 {user.email} 设置登录口令 +
    +

    + 设置后该账号需用此口令登录: + /login/用户名?password=口令 + 。口令以明文随链接传输,仅用于临时开通,请妥善保管。 +

    +
    + setPwdDraft(e.target.value)} + placeholder="输入登录口令" + onKeyDown={(e) => { + if (e.key === "Enter") void handleSavePassword(user); + }} + className="w-56 rounded-lg border border-slate-200 bg-slate-50/50 px-3 py-1.5 text-sm transition-all hover:border-slate-300 focus:border-red-500 focus:outline-none focus:ring-4 focus:ring-red-500/5" + /> + + {hasPwd && ( + + )} + +
    + {pwdDraft.trim() && ( +
    + + {buildLoginLink(user, pwdDraft.trim())} + +
    + )} +
    +
  • + )} + ); })} diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 1d8b2e1d..c94fab02 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -816,7 +816,7 @@ Per-user runtime overrides (whitelisted fields) live at `{base_dir}/users/{user_ - `subagents.enabled` - Master switch for subagent delegation - `memory` - Memory system (enabled, storage_path, debounce_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens) - `auth_login` - Custom login modes layered on the base auth (`deerflow/config/login_config.py`): - - `username_login.{require_password, password}` - Password gate for `POST /api/v1/auth/login/username`. When `require_password=true`, the (otherwise passwordless) username login additionally requires `?password=`; empty `password` falls back to `123ewq`. Gate only ? it does not change which account logs in. + - `username_login.{require_password, password}` - **Shared** password gate for `POST /api/v1/auth/login/username`. When `require_password=true`, the (otherwise passwordless) username login additionally requires `?password=`; empty `password` falls back to `123ewq`. Gate only ? it does not change which account logs in. **Per-account override (账号级登录口令)**: independent of this shared gate, an admin can assign **one specific account** an individual **plaintext** password via the 白名单管理 page (`users.login_password` column, migration `20260625_04`, nullable). When the target account carries a `login_password`, `/login/username` **requires** `?password=` to match it **exactly** and that personal password **takes precedence over** the shared gate (the shared secret will not unlock that account); accounts with no personal password keep the shared-gate / passwordless behavior. The check happens before auto-register via the lookup-only helper `_lookup_user_by_username` (so a brand-new username is unaffected). Surfaced/managed admin-only: `GET /api/v1/auth/users` returns `UserListItem.login_password` (plaintext — the list is already admin-gated), and `PUT /api/v1/auth/users/{user_id}/login-password` `{password: str|null}` sets (non-empty) / clears (null·empty) it without touching `token_version`. Plaintext storage is intentional (the password travels in the login URL query anyway, so it stays re-copyable). The user logs in via the existing `#/login/?password=xxxx`. Tests: `tests/test_login_personal_password.py`. - `token_login.{enabled, token_info_url, service_authorization, timeout_seconds, verify_ssl, ca_cert_path, mock_tokens}` - Token-exchange login (`POST /api/v1/auth/login/token`). The gateway POSTs to `token_info_url` with the caller-supplied token placed directly in the `Authorization: Bearer ` header (no cookie; the static `service_authorization` field is deprecated/unused, kept only for backward compat), reads `data.username` from a `{"state":"200",...}` body, then signs the matching local user in (auto-registering if absent). `token_info_url` is HTTPS ? `verify_ssl: false` skips cert verification for self-signed/internal UAT hosts, or set `ca_cert_path` to a PEM bundle (takes precedence, keeps verification on). **`mock_tokens` (offline test mapping `{token: username}`)**: when an incoming token matches a key, `_fetch_username_from_token` returns the mapped username and **skips the `token_info_url` call entirely** — for environments where the upstream `getTokenInfo` host is unreachable (e.g. external networks). Empty in prod (no effect); seeded `{"123ewq": "admin"}` in this deployment so `/login/任意?authToken=123ewq` signs in as admin. **`UsernameLoginResponse` now also returns `username`** (the raw resolved login name, pre email-normalization) on both `/login/token` and `/login/username`, so the frontend can append a faithful `?username=` on jump links (按钮管理 追加登录态) instead of the lossy email local-part. See [docs/AUTH_LOGIN.md](docs/AUTH_LOGIN.md) for the full request/response contract. Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The shared `_get_or_create_user_by_username()` helper (in `routers/auth.py`) backs both `/login/username` and `/login/token`. diff --git a/offline-backend-20260512/backend/app/gateway/auth/models.py b/offline-backend-20260512/backend/app/gateway/auth/models.py index 4ec12d0c..dca99932 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/models.py +++ b/offline-backend-20260512/backend/app/gateway/auth/models.py @@ -31,6 +31,7 @@ class User(BaseModel): needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes") token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs") approved: bool = Field(default=True, description="登录白名单放行标记(黑名单模式);仅当系统设置 login_whitelist.enabled 打开时拦截被取消放行的用户。新账号默认放行(True),管理员可「取消放行」(False)封禁个别用户。") + login_password: str | None = Field(default=None, description="管理员在白名单管理页给该账号设置的「登录口令」(明文,仅 admin 可见)。非空 → 该账号用户名直登必须带匹配的 ?password=;为空 → 走原有共享口令门/免口令逻辑。") class UserResponse(BaseModel): diff --git a/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py b/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py index 0a37ddbe..fdfdac53 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py +++ b/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py @@ -47,6 +47,7 @@ class SQLiteUserRepository(UserRepository): needs_setup=row.needs_setup, token_version=row.token_version, approved=row.approved, + login_password=row.login_password, ) @staticmethod @@ -62,6 +63,7 @@ class SQLiteUserRepository(UserRepository): needs_setup=user.needs_setup, token_version=user.token_version, approved=user.approved, + login_password=user.login_password, ) # ── CRUD ────────────────────────────────────────────────────────── @@ -157,6 +159,7 @@ class SQLiteUserRepository(UserRepository): row.needs_setup = user.needs_setup row.token_version = user.token_version row.approved = user.approved + row.login_password = user.login_password await session.commit() return user diff --git a/offline-backend-20260512/backend/app/gateway/routers/auth.py b/offline-backend-20260512/backend/app/gateway/routers/auth.py index 943dd017..06cda848 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/auth.py +++ b/offline-backend-20260512/backend/app/gateway/routers/auth.py @@ -20,6 +20,7 @@ from app.gateway.auth import ( UserResponse, create_access_token, ) +from app.gateway.auth.models import User from app.gateway.auth.config import get_auth_config from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse from app.gateway.csrf_middleware import is_secure_request @@ -279,6 +280,25 @@ async def _get_or_create_user_by_username(username: str): return user, created +async def _lookup_user_by_username(username: str) -> User | None: + """Resolve a username to an **existing** local user, without creating one. + + Mirrors the lookup half of ``_get_or_create_user_by_username`` (canonical + ``@`` email + legacy local-part fallback) so the + per-account 登录口令 gate can read the target account's ``login_password`` + before deciding whether to admit the login. Returns ``None`` for a username + that has never registered (the personal-password gate then simply does not + apply, and ``_get_or_create_user_by_username`` later auto-registers it). + """ + provider = get_local_provider() + email = _username_to_email(username) + local_part = _normalize_username_local_part(username) + user = await provider.get_user_by_email(email) + if user is None and local_part is not None: + user = await provider.find_user_by_email_local_part(local_part) + return user + + def enforce_user_approved(user) -> None: """登录白名单拦截(**黑名单模式**):开关打开且用户被「取消放行」时拒绝登录。 @@ -566,16 +586,40 @@ async def login_by_username( - If mapped account exists, sign in directly (no password required). - If account does not exist, create a regular user and sign in. - Password gate: when ``auth_login.username_login.require_password`` is - enabled, callers must additionally pass ``?password=`` matching the - configured shared secret (default ``123ewq`` when unset). The gate is a - single shared password — it does not change which account is logged in. + Password gates (checked in this order): + + 1. **Per-account 登录口令** — if an admin assigned this specific account a + personal password (``users.login_password``, set on the 白名单管理 page), + the caller must pass ``?password=`` exactly matching it. This takes + precedence over the shared gate and is the「临时给某账号开通+设新口令」flow. + 2. **Shared gate** — otherwise, when + ``auth_login.username_login.require_password`` is enabled, callers must + pass ``?password=`` matching the configured shared secret (default + ``123ewq`` when unset). A single shared password; does not change which + account is logged in. Returns the JWT access token in the response body and also sets it as a session cookie for browser callers. """ username_login_config = get_app_config().auth_login.username_login - if username_login_config.require_password: + + # Per-account password takes precedence: look the account up (no create) and, + # if it carries a personal 登录口令, require an exact match — ignoring the + # shared gate entirely for that account. + existing = await _lookup_user_by_username(body.username) + personal_password = (getattr(existing, "login_password", None) or "").strip() if existing else "" + + if personal_password: + client_ip = _get_client_ip(request) + _check_rate_limit(client_ip) + if (password or "") != personal_password: + _record_login_failure(client_ip) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="口令错误").model_dump(), + ) + _record_login_success(client_ip) + elif username_login_config.require_password: client_ip = _get_client_ip(request) _check_rate_limit(client_ip) if password != username_login_config.effective_password: @@ -830,6 +874,7 @@ class UserListItem(BaseModel): agent_count: int qa_count: int = 0 # 问答次数: number of runs (one run = one question) approved: bool = True # 登录白名单放行标记(白名单管理页据此放行/取消) + login_password: str | None = None # 账号级登录口令(明文,管理员可见/可复制登录链接) @router.get("/users", response_model=list[UserListItem]) @@ -863,6 +908,7 @@ async def list_all_users( agent_count=await agent_store.count_owned(uid), qa_count=qa_counts.get(uid, 0), approved=getattr(u, "approved", True), + login_password=getattr(u, "login_password", None), ) ) return result @@ -897,6 +943,48 @@ async def set_user_approval(user_id: str, body: UserApprovalRequest, request: Re agent_count=0, qa_count=0, approved=saved.approved, + login_password=saved.login_password, + ) + + +class UserLoginPasswordRequest(BaseModel): + password: str | None = Field( + default=None, + max_length=255, + description="设为非空 → 该账号用户名直登需带此明文口令;传 null/空字符串 → 清除该口令(回到共享口令门/免口令)。", + ) + + +@router.put("/users/{user_id}/login-password", response_model=UserListItem) +async def set_user_login_password(user_id: str, body: UserLoginPasswordRequest, request: Request) -> UserListItem: + """设置 / 清除某个账号的「登录口令」(明文)。Admin only。 + + 设置后,该账号的用户名直登(``/login/username``)必须带匹配的 ``?password=``; + 管理员据返回的明文口令拼出登录链接 ``…/login/<用户名>?password=xxxx`` 发给用户。 + 不动 ``token_version``,现有会话不受影响(仅影响下次登录)。 + """ + admin = await get_current_user_from_request(request) + if admin.system_role != "admin": + raise HTTPException(status_code=403, detail="Admin only") + + provider = get_local_provider() + target = await provider.get_user(user_id) + if target is None: + raise HTTPException(status_code=404, detail="用户不存在") + + # 空字符串 / null / 纯空白 → 清除(None);否则存去掉首尾空白后的明文。 + pwd = (body.password or "").strip() + target.login_password = pwd or None + saved = await provider.update_user(target) + return UserListItem( + id=str(saved.id), + email=saved.email, + system_role=saved.system_role, + skill_count=0, + agent_count=0, + qa_count=0, + approved=saved.approved, + login_password=saved.login_password, ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_04_users_login_password.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_04_users_login_password.py new file mode 100644 index 00000000..90953e07 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_04_users_login_password.py @@ -0,0 +1,56 @@ +"""Add users.login_password (per-account 登录口令, 明文). + +Backs the 白名单管理「设置登录口令」feature: an admin assigns an individual +plaintext password to a specific account, and that account's username login +(``POST /api/v1/auth/login/username``) then requires a matching ``?password=`` +(it takes precedence over the global shared-password gate). The column is +nullable — NULL/empty means "no personal password" and the account keeps the +existing shared-gate / passwordless behavior. + +Stored in plaintext on purpose: the password rides in the login URL's query +string anyway, and keeping it readable lets the admin re-copy the full login +link ``…/login/?password=xxxx`` at any time. The ``/users`` list that +exposes it is admin-only. + +Revision ID: 20260625_04 +Revises: 20260625_03 +Create Date: 2026-06-27 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260625_04" +down_revision = "20260625_03" +branch_labels = None +depends_on = None + + +def _has_table(table_name: str) -> bool: + inspector = inspect(op.get_bind()) + return table_name in inspector.get_table_names() + + +def _has_column(table_name: str, column_name: str) -> bool: + if not _has_table(table_name): + return False + inspector = inspect(op.get_bind()) + return any(col["name"] == column_name for col in inspector.get_columns(table_name)) + + +def upgrade() -> None: + # A brand-new DB gets the column from create_all; only patch existing tables. + if _has_table("users") and not _has_column("users", "login_password"): + op.add_column( + "users", + sa.Column("login_password", sa.String(length=255), nullable=True), + ) + + +def downgrade() -> None: + if _has_column("users", "login_password"): + op.drop_column("users", "login_password") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py index 858be711..10e8e6bb 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py @@ -56,6 +56,13 @@ class UserRow(Base): # 时把已存在行置为已放行。 approved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=text("1")) + # 管理员在「白名单管理」页给个别账号设置的「登录口令」(**明文**)。非空 → 该账号的 + # 用户名直登(``POST /api/v1/auth/login/username``)必须带 ``?password=`` 且与此值 + # 完全一致才放行(优先于全局共享口令门);为空/NULL → 走原有逻辑(全局共享口令门 + # 或免口令)。明文存储是有意为之:口令本就以 URL query 明文传输,存明文才能让管理员 + # 随时复制完整登录链接 ``…/login/<用户名>?password=xxxx``。仅 admin 可读写。 + login_password: Mapped[str | None] = mapped_column(String(255), nullable=True) + # 岗位 (position) the user is assigned to (1:1). NULL = no explicit # position → the user inherits the ``is_default`` position's grants. Drives # the subscription sync engine (see ``deerflow.services.position_sync``). diff --git a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql index 4a7e4906..e8e6e0cc 100644 --- a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql +++ b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql @@ -130,6 +130,13 @@ CALL _add_column_if_missing('task_buttons', 'login_params_json', 'TEXT NULL DEFA -- ORM: UserRow.approved = Boolean, NOT NULL, default True, server_default '1'。 CALL _add_column_if_missing('users', 'approved', 'TINYINT(1) NOT NULL DEFAULT 1'); +-- ---- users.login_password: 账号级「登录口令」(明文),对应 migration 20260625_04 ---- +-- 管理员在白名单管理页给个别账号设置的明文登录口令。非空 → 该账号用户名直登 +-- (/api/v1/auth/login/username) 必须带匹配的 ?password=(优先于全局共享口令门); +-- NULL/空 → 走原有共享口令门/免口令逻辑。明文存储以便随时复制完整登录链接。 +-- ORM: UserRow.login_password = String(255), nullable。 +CALL _add_column_if_missing('users', 'login_password', 'VARCHAR(255) NULL DEFAULT NULL'); + -- ---- 清理临时存储过程 -------------------------------------------------------- DROP PROCEDURE IF EXISTS _add_column_if_missing; From c978a25b7fbb250884a3503e90abc7f3be9757ac Mon Sep 17 00:00:00 2001 From: Administrator Date: Sat, 27 Jun 2026 15:12:28 +0800 Subject: [PATCH 2/2] Fix 3qfx task context fallback --- frontend-web/src/core/auth/task-deeplink.ts | 8 +++++++ frontend-web/src/pages/AgentChatPage.tsx | 24 +++++++++++++++---- .../task-components/TaskFxWorkspace.tsx | 7 +++++- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/frontend-web/src/core/auth/task-deeplink.ts b/frontend-web/src/core/auth/task-deeplink.ts index 0ca46a84..673073d5 100644 --- a/frontend-web/src/core/auth/task-deeplink.ts +++ b/frontend-web/src/core/auth/task-deeplink.ts @@ -260,6 +260,14 @@ function buildOpeningText(detail: CopTaskDetail, taskId: string, action?: Action .join("\n"); } +/** + * 3qfx/rwfx 这类“按任务id 直接问答”的任务工作区,在 ``TaskContext`` 丢失但 + * ``login.copTaskDetail`` 仍存在时,可用它重新拼出开场词做前端自恢复。 + */ +export function buildTaskOpeningText(detail: CopTaskDetail, taskId: string): string { + return buildOpeningText(detail, taskId); +} + /** * 据 goPath + 任务详情解析跳转目标。 * - 多智能体会商:返回带 ``roundtablePrefill``(businessCode 让会商页自动选用配置的业务链条; diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index b5319a92..e5309bfe 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -53,6 +53,7 @@ import { env } from "@/env"; import { readTaskContext, taskThreadMetadata } from "@/core/auth/task-purpose"; import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel"; import { desensitize } from "@/lib/desensitize"; +import { buildTaskOpeningText, readCopTaskDetail } from "@/core/auth/task-deeplink"; /** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */ const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder"; @@ -100,10 +101,25 @@ export default function AgentChatPage({ // 任务深链上下文(taskId + 开场信息):闸门(rwfx)与纯绑定(3qfx/xdfx)都要读取。 const taskCtxOn = !!(taskGate || taskBind); - const taskContext = useMemo( - () => (taskCtxOn ? readTaskContext() : null), - [taskCtxOn], - ); + const taskContext = useMemo(() => { + if (!taskCtxOn) return null; + const stored = readTaskContext(); + if (stored && stored.agentId === agent_id) return stored; + // 3qfx:若任务上下文偶发丢失,但登录深链留下的任务详情仍在,则用它前端兜底重建 + // taskId + openingText,避免 landing 既不自动开始、也没有“开始回答”按钮。 + if (taskBind && !taskGate) { + const detail = readCopTaskDetail(); + const fallbackTaskId = detail?.id == null ? "" : String(detail.id).trim(); + if (detail && fallbackTaskId && agent_id) { + return { + taskId: fallbackTaskId, + agentId: agent_id, + openingText: buildTaskOpeningText(detail, fallbackTaskId), + }; + } + } + return stored; + }, [agent_id, taskBind, taskCtxOn, taskGate]); const taskId = taskCtxOn && taskContext && taskContext.agentId === agent_id ? taskContext.taskId diff --git a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx index bc2cd190..dfa1b7c2 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx @@ -60,7 +60,12 @@ function useChatsBase(taskBase: string): { const { agent_id } = useParams<{ agent_id: string }>(); const agentId = agent_id ?? ""; const ctx = readTaskContext(); - const taskId = ctx?.taskId ?? ""; + const detail = readCopTaskDetail(); + const fallbackTaskId = + detail?.id == null ? "" : String(detail.id).trim(); + // 3qfx 偶发会出现 task context 丢失,但任务详情仍在 sessionStorage 的情况。 + // 这里回退到任务详情里的 taskId,避免左侧对话列表直接失效成“空任务态”。 + const taskId = ctx?.taskId ?? fallbackTaskId; const actionId = ctx?.partitionByAction ? (ctx.actionId ?? "") : ""; return { taskId, agentId, actionId, chatsBase: `${taskBase}/agent/${agentId}/chats` }; }