This commit is contained in:
董宏凯 committed 2026-06-27 16:00:47 +08:00
commit 44d7304f8e
14 files changed
+436 -49

No files matched your search

+1 -1
View File
@@ -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. - **定时任务「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). - **轻应用中心 (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-<appId>`). 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). - **菜单管理 (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-<appId>`). 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. - **敏感词管理 (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/`。 - **技能地址管理 (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=<JWT>&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 切换到三Line truncated - **按钮管理 (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=<JWT>&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 切换到三Line truncated
+25
View File
@@ -13,6 +13,12 @@ export interface UserListItem {
qa_count: number; qa_count: number;
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */ /** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
approved: boolean; approved: boolean;
/**
* 账号级「登录口令」(明文,仅管理员可见)。非空 → 该账号用户名直登必须带匹配的
* `?password=`;为空/null → 走原有共享口令门/免口令逻辑。管理员据此拼出登录链接
* `…/login/<用户名>?password=xxxx` 发给用户。
*/
login_password: string | null;
} }
export async function listAllUsers(): Promise<UserListItem[]> { export async function listAllUsers(): Promise<UserListItem[]> {
@@ -35,6 +41,25 @@ export async function setUserApproval(userId: string, approved: boolean): Promis
return res.json() as Promise<UserListItem>; return res.json() as Promise<UserListItem>;
} }
/**
* 设置 / 清除某个账号的「登录口令」(明文)。Admin only。
*
* 传非空字符串 → 设置;传 `null` 或空字符串 → 清除。设置后该账号用户名直登需带匹配的
* `?password=`。返回更新后的用户项(含明文 `login_password`)。
*/
export async function setUserLoginPassword(userId: string, password: string | null): Promise<UserListItem> {
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<UserListItem>;
}
// Normalize a FastAPI error body into a human-readable string. `detail` may be // Normalize a FastAPI error body into a human-readable string. `detail` may be
// a plain string, an object like AuthErrorResponse ({ code, message }), or an // a plain string, an object like AuthErrorResponse ({ code, message }), or an
// array of validation errors — anything but a string would otherwise render as // array of validation errors — anything but a string would otherwise render as
+19 -1
View File
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { OptType, writeYLog } from "@/core/log/y-log"; import { OptType, writeYLog } from "@/core/log/y-log";
import { listAllUsers, setUserApproval } from "./api"; import { listAllUsers, setUserApproval, setUserLoginPassword } from "./api";
export function useAllUsers(enabled: boolean) { export function useAllUsers(enabled: boolean) {
return useQuery({ 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"] });
},
});
}
@@ -260,6 +260,14 @@ function buildOpeningText(detail: CopTaskDetail, taskId: string, action?: Action
.join("\n"); .join("\n");
} }
/**
* 3qfx/rwfx 这类“按任务id 直接问答”的任务工作区,在 ``TaskContext`` 丢失但
* ``login.copTaskDetail`` 仍存在时,可用它重新拼出开场词做前端自恢复。
*/
export function buildTaskOpeningText(detail: CopTaskDetail, taskId: string): string {
return buildOpeningText(detail, taskId);
}
/** /**
* 据 goPath + 任务详情解析跳转目标。 * 据 goPath + 任务详情解析跳转目标。
* - 多智能体会商:返回带 ``roundtablePrefill``(businessCode 让会商页自动选用配置的业务链条; * - 多智能体会商:返回带 ``roundtablePrefill``(businessCode 让会商页自动选用配置的业务链条;
+20 -4
View File
@@ -53,6 +53,7 @@ import { env } from "@/env";
import { readTaskContext, taskThreadMetadata } from "@/core/auth/task-purpose"; import { readTaskContext, taskThreadMetadata } from "@/core/auth/task-purpose";
import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel"; import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { buildTaskOpeningText, readCopTaskDetail } from "@/core/auth/task-deeplink";
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */ /** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder"; const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
@@ -100,10 +101,25 @@ export default function AgentChatPage({
// 任务深链上下文(taskId + 开场信息):闸门(rwfx)与纯绑定(3qfx/xdfx)都要读取。 // 任务深链上下文(taskId + 开场信息):闸门(rwfx)与纯绑定(3qfx/xdfx)都要读取。
const taskCtxOn = !!(taskGate || taskBind); const taskCtxOn = !!(taskGate || taskBind);
const taskContext = useMemo( const taskContext = useMemo(() => {
() => (taskCtxOn ? readTaskContext() : null), if (!taskCtxOn) return null;
[taskCtxOn], 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 = const taskId =
taskCtxOn && taskContext && taskContext.agentId === agent_id taskCtxOn && taskContext && taskContext.agentId === agent_id
? taskContext.taskId ? taskContext.taskId
+189 -36
View File
@@ -1,13 +1,14 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { Navigate } from "react-router-dom"; 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 { toast } from "sonner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import type { UserListItem } from "@/core/auth/api";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useAllUsers, useSetUserApproval } from "@/core/auth/hooks"; import { useAllUsers, useSetUserApproval, useSetUserLoginPassword } from "@/core/auth/hooks";
import { import {
useLoginWhitelistEnabled, useLoginWhitelistEnabled,
useSetLoginWhitelistEnabled, useSetLoginWhitelistEnabled,
@@ -28,9 +29,13 @@ export default function LoginWhitelistPage() {
const { data: users = [], isLoading: loadingUsers } = useAllUsers(isAdmin); const { data: users = [], isLoading: loadingUsers } = useAllUsers(isAdmin);
const setApproval = useSetUserApproval(); const setApproval = useSetUserApproval();
const setLoginPassword = useSetUserLoginPassword();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [pendingId, setPendingId] = useState<string | null>(null); const [pendingId, setPendingId] = useState<string | null>(null);
// 口令编辑:当前展开编辑的用户 id + 输入草稿(明文,便于管理员直接查看/复制)。
const [editingId, setEditingId] = useState<string | null>(null);
const [pwdDraft, setPwdDraft] = useState("");
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const visibleUsers = useMemo( 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 <Navigate to="/page/workspace/chats/new" replace />; if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
return ( return (
@@ -126,9 +181,10 @@ export default function LoginWhitelistPage() {
{/* 用户列表 */} {/* 用户列表 */}
<div className="overflow-hidden rounded-2xl border border-slate-200/60 bg-white shadow-sm"> <div className="overflow-hidden rounded-2xl border border-slate-200/60 bg-white shadow-sm">
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-3 border-b border-slate-200/60 bg-slate-50/70 px-4 py-2.5 text-xs font-semibold text-slate-500"> <div className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-3 border-b border-slate-200/60 bg-slate-50/70 px-4 py-2.5 text-xs font-semibold text-slate-500">
<span>用户</span> <span>用户</span>
<span className="px-1 text-center">问答</span> <span className="px-1 text-center">问答</span>
<span className="px-1 text-center">登录口令</span>
<span className="px-1 text-center">放行</span> <span className="px-1 text-center">放行</span>
</div> </div>
@@ -143,40 +199,137 @@ export default function LoginWhitelistPage() {
{visibleUsers.map((user) => { {visibleUsers.map((user) => {
const isUserAdmin = user.system_role === "admin"; const isUserAdmin = user.system_role === "admin";
const allowed = user.approved || isUserAdmin; const allowed = user.approved || isUserAdmin;
const hasPwd = Boolean(user.login_password);
const isEditing = editingId === user.id;
return ( return (
<li <Fragment key={user.id}>
key={user.id} <li className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-3 px-4 py-3 transition-colors hover:bg-slate-50/60">
className="grid grid-cols-[1fr_auto_auto] items-center gap-3 px-4 py-3 transition-colors hover:bg-slate-50/60" <div className="min-w-0">
> <div className="flex items-center gap-2">
<div className="min-w-0"> <span className="truncate text-sm font-medium text-slate-800">{user.email}</span>
<div className="flex items-center gap-2"> {isUserAdmin ? (
<span className="truncate text-sm font-medium text-slate-800">{user.email}</span> <span className="inline-flex items-center gap-1 rounded-md border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[11px] font-semibold text-amber-600">
{isUserAdmin ? ( <UserCheck className="size-3" />
<span className="inline-flex items-center gap-1 rounded-md border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[11px] font-semibold text-amber-600"> 管理员
<UserCheck className="size-3" /> </span>
管理员 ) : allowed ? (
</span> <span className="rounded-md border border-emerald-200 bg-emerald-50 px-1.5 py-0.5 text-[11px] font-semibold text-emerald-600">
) : allowed ? ( 已放行
<span className="rounded-md border border-emerald-200 bg-emerald-50 px-1.5 py-0.5 text-[11px] font-semibold text-emerald-600"> </span>
已放行 ) : (
</span> <span className="rounded-md border border-rose-200 bg-rose-50 px-1.5 py-0.5 text-[11px] font-semibold text-rose-600">
) : ( 已拦截
<span className="rounded-md border border-rose-200 bg-rose-50 px-1.5 py-0.5 text-[11px] font-semibold text-rose-600"> </span>
已拦截 )}
</span> </div>
)} <div className="truncate font-mono text-xs text-slate-400">{user.id}</div>
</div> </div>
<div className="truncate font-mono text-xs text-slate-400">{user.id}</div> <div className="px-1 text-center text-sm tabular-nums text-slate-500">{user.qa_count}</div>
</div> {/* 登录口令:设置 / 修改 + 复制登录链接 */}
<div className="px-1 text-center text-sm tabular-nums text-slate-500">{user.qa_count}</div> <div className="px-1">
<div className="px-1"> <div className="flex items-center justify-center gap-1.5">
<Switch <button
checked={allowed} type="button"
disabled={isUserAdmin || (setApproval.isPending && pendingId === user.id)} onClick={() => (isEditing ? closeEditor() : openEditor(user))}
onCheckedChange={(v) => handleToggleApproval(user.id, v)} title={hasPwd ? "已设登录口令,点击修改" : "设置登录口令"}
/> className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-semibold transition-colors ${
</div> hasPwd
</li> ? "border-emerald-200 bg-emerald-50 text-emerald-600 hover:bg-emerald-100"
: "border-slate-200 bg-slate-50 text-slate-500 hover:bg-slate-100"
}`}
>
<KeyRound className="size-3" />
{hasPwd ? "已设" : "设置"}
</button>
{hasPwd && (
<button
type="button"
onClick={() => copyText(buildLoginLink(user, user.login_password ?? ""), "已复制登录链接")}
title="复制登录链接"
className="inline-flex items-center rounded-md border border-slate-200 bg-white px-1.5 py-1 text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-700"
>
<Link2 className="size-3" />
</button>
)}
</div>
</div>
<div className="px-1">
<Switch
checked={allowed}
disabled={isUserAdmin || (setApproval.isPending && pendingId === user.id)}
onCheckedChange={(v) => handleToggleApproval(user.id, v)}
/>
</div>
</li>
{isEditing && (
<li className="bg-slate-50/70 px-4 py-4">
<div className="rounded-xl border border-slate-200/70 bg-white p-4 shadow-sm">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
<KeyRound className="size-3.5 text-slate-400" />
为 <span className="font-mono text-slate-800">{user.email}</span> 设置登录口令
</div>
<p className="mb-3 text-[11px] leading-relaxed text-slate-400">
设置后该账号需用此口令登录:
<code className="rounded bg-slate-100 px-1 text-slate-500">/login/用户名?password=口令</code>
。口令以明文随链接传输,仅用于临时开通,请妥善保管。
</p>
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
value={pwdDraft}
onChange={(e) => 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"
/>
<button
type="button"
disabled={!pwdDraft.trim() || setLoginPassword.isPending}
onClick={() => void handleSavePassword(user)}
className="inline-flex items-center gap-1 rounded-lg bg-red-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
>
保存
</button>
{hasPwd && (
<button
type="button"
disabled={setLoginPassword.isPending}
onClick={() => void handleClearPassword(user)}
className="inline-flex items-center gap-1 rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-sm font-semibold text-rose-600 transition-colors hover:bg-rose-100 disabled:opacity-50"
>
清除
</button>
)}
<button
type="button"
onClick={closeEditor}
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-500 transition-colors hover:bg-slate-50"
>
<X className="size-3.5" />
取消
</button>
</div>
{pwdDraft.trim() && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-slate-200/70 bg-slate-50/70 p-2">
<Link2 className="size-3.5 shrink-0 text-slate-400" />
<span className="truncate font-mono text-[11px] text-slate-500">{buildLoginLink(user, pwdDraft.trim())}</span>
<button
type="button"
onClick={() => copyText(buildLoginLink(user, pwdDraft.trim()), "已复制登录链接")}
className="ml-auto inline-flex shrink-0 items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-[11px] font-medium text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-700"
>
<Copy className="size-3" />
复制链接
</button>
</div>
)}
</div>
</li>
)}
</Fragment>
); );
})} })}
</ul> </ul>
@@ -60,7 +60,12 @@ function useChatsBase(taskBase: string): {
const { agent_id } = useParams<{ agent_id: string }>(); const { agent_id } = useParams<{ agent_id: string }>();
const agentId = agent_id ?? ""; const agentId = agent_id ?? "";
const ctx = readTaskContext(); 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 ?? "") : ""; const actionId = ctx?.partitionByAction ? (ctx.actionId ?? "") : "";
return { taskId, agentId, actionId, chatsBase: `${taskBase}/agent/${agentId}/chats` }; return { taskId, agentId, actionId, chatsBase: `${taskBase}/agent/${agentId}/chats` };
} }
+1 -1
View File
@@ -816,7 +816,7 @@ Per-user runtime overrides (whitelisted fields) live at `{base_dir}/users/{user_
- `subagents.enabled` - Master switch for subagent delegation - `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) - `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`): - `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=<shared-secret>`; 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=<shared-secret>`; 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/<username>?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 <token>` 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. - `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 <token>` 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`. 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`.
@@ -31,6 +31,7 @@ class User(BaseModel):
needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes") 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") 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)封禁个别用户。") approved: bool = Field(default=True, description="登录白名单放行标记(黑名单模式);仅当系统设置 login_whitelist.enabled 打开时拦截被取消放行的用户。新账号默认放行(True),管理员可「取消放行」(False)封禁个别用户。")
login_password: str | None = Field(default=None, description="管理员在白名单管理页给该账号设置的「登录口令」(明文,仅 admin 可见)。非空 → 该账号用户名直登必须带匹配的 ?password=;为空 → 走原有共享口令门/免口令逻辑。")
class UserResponse(BaseModel): class UserResponse(BaseModel):
@@ -47,6 +47,7 @@ class SQLiteUserRepository(UserRepository):
needs_setup=row.needs_setup, needs_setup=row.needs_setup,
token_version=row.token_version, token_version=row.token_version,
approved=row.approved, approved=row.approved,
login_password=row.login_password,
) )
@staticmethod @staticmethod
@@ -62,6 +63,7 @@ class SQLiteUserRepository(UserRepository):
needs_setup=user.needs_setup, needs_setup=user.needs_setup,
token_version=user.token_version, token_version=user.token_version,
approved=user.approved, approved=user.approved,
login_password=user.login_password,
) )
# ── CRUD ────────────────────────────────────────────────────────── # ── CRUD ──────────────────────────────────────────────────────────
@@ -157,6 +159,7 @@ class SQLiteUserRepository(UserRepository):
row.needs_setup = user.needs_setup row.needs_setup = user.needs_setup
row.token_version = user.token_version row.token_version = user.token_version
row.approved = user.approved row.approved = user.approved
row.login_password = user.login_password
await session.commit() await session.commit()
return user return user
@@ -20,6 +20,7 @@ from app.gateway.auth import (
UserResponse, UserResponse,
create_access_token, create_access_token,
) )
from app.gateway.auth.models import User
from app.gateway.auth.config import get_auth_config from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.csrf_middleware import is_secure_request 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 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
``<username>@<domain>`` 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: 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 mapped account exists, sign in directly (no password required).
- If account does not exist, create a regular user and sign in. - If account does not exist, create a regular user and sign in.
Password gate: when ``auth_login.username_login.require_password`` is Password gates (checked in this order):
enabled, callers must additionally pass ``?password=`` matching the
configured shared secret (default ``123ewq`` when unset). The gate is a 1. **Per-account 登录口令** — if an admin assigned this specific account a
single shared password — it does not change which account is logged in. 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 Returns the JWT access token in the response body and also sets it as
a session cookie for browser callers. a session cookie for browser callers.
""" """
username_login_config = get_app_config().auth_login.username_login 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) client_ip = _get_client_ip(request)
_check_rate_limit(client_ip) _check_rate_limit(client_ip)
if password != username_login_config.effective_password: if password != username_login_config.effective_password:
@@ -830,6 +874,7 @@ class UserListItem(BaseModel):
agent_count: int agent_count: int
qa_count: int = 0 # 问答次数: number of runs (one run = one question) qa_count: int = 0 # 问答次数: number of runs (one run = one question)
approved: bool = True # 登录白名单放行标记(白名单管理页据此放行/取消) approved: bool = True # 登录白名单放行标记(白名单管理页据此放行/取消)
login_password: str | None = None # 账号级登录口令(明文,管理员可见/可复制登录链接)
@router.get("/users", response_model=list[UserListItem]) @router.get("/users", response_model=list[UserListItem])
@@ -863,6 +908,7 @@ async def list_all_users(
agent_count=await agent_store.count_owned(uid), agent_count=await agent_store.count_owned(uid),
qa_count=qa_counts.get(uid, 0), qa_count=qa_counts.get(uid, 0),
approved=getattr(u, "approved", True), approved=getattr(u, "approved", True),
login_password=getattr(u, "login_password", None),
) )
) )
return result return result
@@ -897,6 +943,48 @@ async def set_user_approval(user_id: str, body: UserApprovalRequest, request: Re
agent_count=0, agent_count=0,
qa_count=0, qa_count=0,
approved=saved.approved, 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,
) )
@@ -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/<username>?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")
@@ -56,6 +56,13 @@ class UserRow(Base):
# 时把已存在行置为已放行。 # 时把已存在行置为已放行。
approved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=text("1")) 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 is assigned to (1:1). NULL = no explicit
# position → the user inherits the ``is_default`` position's grants. Drives # position → the user inherits the ``is_default`` position's grants. Drives
# the subscription sync engine (see ``deerflow.services.position_sync``). # the subscription sync engine (see ``deerflow.services.position_sync``).
@@ -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'。 -- ORM: UserRow.approved = Boolean, NOT NULL, default True, server_default '1'。
CALL _add_column_if_missing('users', 'approved', 'TINYINT(1) NOT NULL 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; DROP PROCEDURE IF EXISTS _add_column_if_missing;