feat: harden tenant access and production operations

This commit is contained in:
xz
2026-08-24 22:02:22 +08:00
parent ffb27d845b
commit 9811469a64
18 changed files with 709 additions and 36 deletions
+10 -1
View File
@@ -8,10 +8,13 @@
- 多组织:一个用户可以属于多个组织,组织之间的项目、成员、模型和审计数据隔离。 - 多组织:一个用户可以属于多个组织,组织之间的项目、成员、模型和审计数据隔离。
- 多工作区:制作部、素材实验室、客户空间可以在同一组织下独立管理。 - 多工作区:制作部、素材实验室、客户空间可以在同一组织下独立管理。
- 多用户与角色:组织所有者、组织管理员、制片、编剧、资产美术、配音/字幕、审片、项目编辑和项目查看者。 - 多用户与角色:组织所有者、组织管理员、制片、编剧、资产美术、配音/字幕、审片、项目编辑和项目查看者。
- 项目级访问控制:项目邀请使用受限 `project_guest` 工作区身份和 `project-only` 访问模式,只能看到显式授权项目,不会因加入工作区而继承全部项目或生成权限。
- 真实 scope API:所有项目、任务、模型、审片、导出请求都经过 user → organization → workspace → project 检查。 - 真实 scope API:所有项目、任务、模型、审片、导出请求都经过 user → organization → workspace → project 检查。
- 平台级系统治理:系统配置、功能开关、通知渠道、API 客户端、服务健康和队列运营;系统权限独立于组织管理员。 - 平台级系统治理:系统配置、功能开关、通知渠道、API 客户端、服务健康和队列运营;系统权限独立于组织管理员。
- 实时运营概览:管理概览直接读取服务健康探针、Worker 心跳/队列和部署生产就绪度,不使用前端静态状态冒充在线运行状态。
- 企业身份中心:TOTP MFA、登录策略、OIDC/SAML 提供商登记与发现探测、SCIM 目录令牌轮换和受令牌保护的用户同步接口。 - 企业身份中心:TOTP MFA、登录策略、OIDC/SAML 提供商登记与发现探测、SCIM 目录令牌轮换和受令牌保护的用户同步接口。
- 用户通知中心:按用户、组织、工作区隔离的通知收件箱、未读计数、单条/全部已读、组织级通知偏好和服务端收件人过滤。 - 用户通知中心:按用户、组织、工作区隔离的通知收件箱、未读计数、单条/全部已读、组织级通知偏好和服务端收件人过滤。
- 全局生产搜索:支持 `⌘/Ctrl + K` 命令入口,在当前工作区或当前项目内定位项目、剧本、分集、镜头、资产、任务、生成任务和交付记录;结果由后端按租户作用域过滤。
- 协作任务中心:项目内任务创建、负责人分派、优先级、截止时间、状态、本人任务更新、任务通知和审计记录。 - 协作任务中心:项目内任务创建、负责人分派、优先级、截止时间、状态、本人任务更新、任务通知和审计记录。
- 资产版本台账:资产版本不可覆盖,支持当前版本切换、历史版本恢复、锁定状态和镜头绑定审计。 - 资产版本台账:资产版本不可覆盖,支持当前版本切换、历史版本恢复、锁定状态和镜头绑定审计。
- 本地持久化:Node 24 内置 SQLite 自动初始化到 `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/data/platform.sqlite`。 - 本地持久化:Node 24 内置 SQLite 自动初始化到 `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/data/platform.sqlite`。
@@ -40,7 +43,7 @@ npm run api
API:http://127.0.0.1:8787/api/health API:http://127.0.0.1:8787/api/health
``` ```
完整回归(必须串行执行,共 25 项,避免 SQLite 写入竞争): 完整回归(必须串行执行,共 32 项,避免 SQLite 写入竞争):
```bash ```bash
npm run smoke:all npm run smoke:all
@@ -77,6 +80,8 @@ POST http://127.0.0.1:8787/api/auth/sessions/:sessionId/revoke
POST http://127.0.0.1:8787/api/auth/sessions/revoke-others POST http://127.0.0.1:8787/api/auth/sessions/revoke-others
POST http://127.0.0.1:8787/api/auth/password POST http://127.0.0.1:8787/api/auth/password
GET http://127.0.0.1:8787/api/context GET http://127.0.0.1:8787/api/context
GET http://127.0.0.1:8787/api/search?q=雷雨&scope=workspace&limit=40
GET http://127.0.0.1:8787/api/search?q=雷雨&scope=project&limit=40
GET http://127.0.0.1:8787/api/tasks?status=all&assignedTo=me&limit=100 GET http://127.0.0.1:8787/api/tasks?status=all&assignedTo=me&limit=100
POST http://127.0.0.1:8787/api/tasks POST http://127.0.0.1:8787/api/tasks
PATCH http://127.0.0.1:8787/api/tasks/:taskId PATCH http://127.0.0.1:8787/api/tasks/:taskId
@@ -105,6 +110,9 @@ GET http://127.0.0.1:8787/api/billing
GET http://127.0.0.1:8787/api/audit GET http://127.0.0.1:8787/api/audit
GET http://127.0.0.1:8787/api/audit/:auditId GET http://127.0.0.1:8787/api/audit/:auditId
GET http://127.0.0.1:8787/api/audit/export GET http://127.0.0.1:8787/api/audit/export
GET http://127.0.0.1:8787/api/system/health
GET http://127.0.0.1:8787/api/system/worker
GET http://127.0.0.1:8787/api/system/readiness
GET http://127.0.0.1:8787/api/permissions GET http://127.0.0.1:8787/api/permissions
GET http://127.0.0.1:8787/api/assets?kind=voice GET http://127.0.0.1:8787/api/assets?kind=voice
POST http://127.0.0.1:8787/api/assets/upload POST http://127.0.0.1:8787/api/assets/upload
@@ -237,6 +245,7 @@ API 客户端 scope 由后端白名单强制执行:`jobs:read` 只能读取任
- 一次生成只允许一张完整单画面;分镜图、接触表、边界表只能审核,不能喂回生成。 - 一次生成只允许一张完整单画面;分镜图、接触表、边界表只能审核,不能喂回生成。
- 声音作为独立资产锁定,不把 MiniMax H3 随机原生声音作为最终角色声线。 - 声音作为独立资产锁定,不把 MiniMax H3 随机原生声音作为最终角色声线。
- 当前平台底座已具备真实任务合同、队列、重试、取消、租约、并发控制和 HTTP Runner 执行接口;API 启动时自动运行本地 Worker,只会领取本地 `ready` 连接器任务。若未配置可用的自有图片/视频/TTS/ASR Runner,任务会明确显示 `not-connected`/`blocked`,不会伪称已经生成真实媒体成片。 - 当前平台底座已具备真实任务合同、队列、重试、取消、租约、并发控制和 HTTP Runner 执行接口;API 启动时自动运行本地 Worker,只会领取本地 `ready` 连接器任务。若未配置可用的自有图片/视频/TTS/ASR Runner,任务会明确显示 `not-connected`/`blocked`,不会伪称已经生成真实媒体成片。
- 手动执行接口对已完成任务是幂等的:如果本地 Worker 已先完成任务,重复调用 `POST /api/jobs/:jobId/run` 会返回现有完成结果并标记 `idempotent: true`,不会再次调用模型或生成第二份媒体。
- 模型协议、Worker 环境变量、状态字段和接口返回约定见 `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/docs/API_REFERENCE.md`。 - 模型协议、Worker 环境变量、状态字段和接口返回约定见 `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/docs/API_REFERENCE.md`。
- 本地验证脚本会修改 SQLite,因此多个 smoke 脚本应顺序执行;并行写入会触发 SQLite 的正常写锁保护。 - 本地验证脚本会修改 SQLite,因此多个 smoke 脚本应顺序执行;并行写入会触发 SQLite 的正常写锁保护。
+20
View File
@@ -23,6 +23,7 @@ user → organization → workspace → project → permission
| 认证与会话 | `/api/auth/login`、`/api/auth/session`、`/api/auth/logout`、`/api/auth/password`、`/api/auth/sessions/*` | 登录用户 | | 认证与会话 | `/api/auth/login`、`/api/auth/session`、`/api/auth/logout`、`/api/auth/password`、`/api/auth/sessions/*` | 登录用户 |
| MFA 与企业身份 | `/api/auth/login/mfa`、`/api/auth/mfa/*`、`/api/auth/sso/*`、`/api/system/identity/*` | MFA 管理或 `system:settings:*` | | MFA 与企业身份 | `/api/auth/login/mfa`、`/api/auth/mfa/*`、`/api/auth/sso/*`、`/api/system/identity/*` | MFA 管理或 `system:settings:*` |
| 租户上下文 | `/api/context`、`/api/organizations/*`、`/api/workspaces/*`、`/api/projects/*` | 按组织 / 工作区 / 项目角色 | | 租户上下文 | `/api/context`、`/api/organizations/*`、`/api/workspaces/*`、`/api/projects/*` | 按组织 / 工作区 / 项目角色 |
| 全局生产搜索 | `/api/search?q=&scope=workspace|project` | 登录用户,结果严格受当前作用域限制 |
| 用户通知中心 | `/api/notifications`、`/api/notifications/:id`、`/api/notifications/read-all`、`/api/notification-preferences` | 登录用户 / 当前组织 | | 用户通知中心 | `/api/notifications`、`/api/notifications/:id`、`/api/notifications/read-all`、`/api/notification-preferences` | 登录用户 / 当前组织 |
| 内容生产 | `/api/production/catalog`、`/api/production/graph`、`/api/production/script/*`、`/api/production/bible`、`/api/production/shots/*` | `script:edit`、`asset:edit`、`job:create` | | 内容生产 | `/api/production/catalog`、`/api/production/graph`、`/api/production/script/*`、`/api/production/bible`、`/api/production/shots/*` | `script:edit`、`asset:edit`、`job:create` |
| 资产与声音 | `/api/assets/*`、`/api/assets/:assetId/versions/upload`、`/api/assets/:assetId/verify`、`/api/assets/:assetId/versions/:versionId/restore` | `asset:edit`、`voice:edit` | | 资产与声音 | `/api/assets/*`、`/api/assets/:assetId/versions/upload`、`/api/assets/:assetId/verify`、`/api/assets/:assetId/versions/:versionId/restore` | `asset:edit`、`voice:edit` |
@@ -35,6 +36,17 @@ user → organization → workspace → project → permission
未登录返回 `401`;已登录但没有作用域或权限返回 `403`。接口不会因为前端菜单隐藏就跳过后端检查。 未登录返回 `401`;已登录但没有作用域或权限返回 `403`。接口不会因为前端菜单隐藏就跳过后端检查。
### 全局生产搜索
```http
GET /api/search?q=雷雨&scope=workspace&limit=40
GET /api/search?q=雷雨&scope=project&limit=40
```
搜索接口只在当前登录会话的组织、工作区和可访问项目集合内查询,支持项目、分集、剧本、镜头、资产、协作任务、生成任务和交付记录。`scope=workspace` 搜索当前工作区内可访问项目;`scope=project` 只搜索当前项目。返回结果包含 `type`、`typeLabel`、`title`、`subtitle`、`status`、`targetTab` 和目标作用域 ID,前端命令面板可以据此直接切换项目并跳转到对应生产页面。空查询返回空结果,不会返回未经筛选的目录数据。
搜索不会把连接器 endpoint、API key、客户交付令牌或本地存储绝对路径作为可搜索字段返回;跨组织、跨工作区或无项目访问权的关键词不会出现在结果中。
### 审计与合规中心 ### 审计与合规中心
```http ```http
@@ -94,11 +106,15 @@ POST /api/projects/:projectId/lifecycle
系统管理员可读取部署边界和当前运行时状态: 系统管理员可读取部署边界和当前运行时状态:
```text ```text
GET /api/system/health
GET /api/system/worker
GET /api/system/readiness GET /api/system/readiness
GET /api/system/backups GET /api/system/backups
POST /api/system/backups POST /api/system/backups
``` ```
`/api/system/health` 返回数据库中的服务探针、Runner 状态、队列深度和最近心跳;`/api/system/worker` 返回本地 Worker 的租约、并发、心跳年龄和队列告警。管理概览直接使用这些服务端结果,组织管理员只能看到后端允许的健康范围,不能通过前端隐藏绕过权限。
`/api/system/readiness` 会明确区分当前激活运行时与目标 provider:业务数据库当前返回 `node:sqlite`,PostgreSQL、Redis、S3-compatible 对象存储只有在对应 `PLATFORM_*` 环境变量存在时标记为“已配置”,不会把 compose 注入误报成已经完成业务迁移。`POST /api/system/backups` 使用 SQLite `VACUUM INTO` 创建 `data/backups/` 下的快照,并写入 `system.database.backup_created` 审计事件;接口不提供在线恢复,恢复必须经过停机审查和人工确认。 `/api/system/readiness` 会明确区分当前激活运行时与目标 provider:业务数据库当前返回 `node:sqlite`,PostgreSQL、Redis、S3-compatible 对象存储只有在对应 `PLATFORM_*` 环境变量存在时标记为“已配置”,不会把 compose 注入误报成已经完成业务迁移。`POST /api/system/backups` 使用 SQLite `VACUUM INTO` 创建 `data/backups/` 下的快照,并写入 `system.database.backup_created` 审计事件;接口不提供在线恢复,恢复必须经过停机审查和人工确认。
### 商业运营、席位与配额 ### 商业运营、席位与配额
@@ -161,6 +177,8 @@ POST /api/auth/register
创建或重发邀请时,服务端只在当前响应返回一次性 `inviteToken` 和 `acceptUrl`;数据库保存的是令牌哈希和短提示,不保存明文令牌。邀请默认 7 天过期,过期邀请不会继续占用席位。重发会替换令牌哈希,使旧注册链接立即返回 `404 invitation_not_found`;撤销会清空令牌哈希、返回 `revoked`,并释放待入组席位。未登录预览仍可读取邀请范围,但注册时会再次校验令牌、邮箱和有效期。 创建或重发邀请时,服务端只在当前响应返回一次性 `inviteToken` 和 `acceptUrl`;数据库保存的是令牌哈希和短提示,不保存明文令牌。邀请默认 7 天过期,过期邀请不会继续占用席位。重发会替换令牌哈希,使旧注册链接立即返回 `404 invitation_not_found`;撤销会清空令牌哈希、返回 `revoked`,并释放待入组席位。未登录预览仍可读取邀请范围,但注册时会再次校验令牌、邮箱和有效期。
邀请角色必须匹配目标层级:不传 `workspaceId` 时只能使用组织角色 `org_member` / `org_admin`;传 `workspaceId` 时只能使用工作区角色 `producer` / `writer` / `art_director` / `voice_editor` / `reviewer`;同时传 `workspaceId` 与 `projectId` 时只能使用项目角色 `project_editor` / `project_viewer`。项目级邀请接受后会自动建立 `project_guest` 工作区成员身份和 `access_mode=project-only`,不会把项目角色升级为工作区制片,也不会展示工作区其他项目;组织所有者不能通过普通邀请授予。
普通成员调用组织邀请接口返回 `403 permission_denied`;重复邀请同一邮箱返回 `409 invitation_already_pending`,已是组织成员的邮箱返回 `409 invitation_recipient_already_member`。 普通成员调用组织邀请接口返回 `403 permission_denied`;重复邀请同一邮箱返回 `409 invitation_already_pending`,已是组织成员的邮箱返回 `409 invitation_recipient_already_member`。
### MFA ### MFA
@@ -259,6 +277,8 @@ AI_DRAMA_WORKER_STALE_MS=30000
如果设置 `AI_DRAMA_WORKER_ENABLED=0`,任务不会自动执行,后台仍会显示 Worker 已暂停。 如果设置 `AI_DRAMA_WORKER_ENABLED=0`,任务不会自动执行,后台仍会显示 Worker 已暂停。
`POST /api/jobs/:jobId/run` 允许管理员或拥有 `job:create` 的角色手动执行任务;如果请求到达时任务已经被本地 Worker 完成,服务端返回现有结果并带 `idempotent: true`,避免手动按钮与自动 Worker 竞态导致重复生成。
## 模型协议适配器 ## 模型协议适配器
模型连接器在模型中台注册,密钥只保存为环境变量名,不保存密钥本身: 模型连接器在模型中台注册,密钥只保存为环境变量名,不保存密钥本身:
+3 -2
View File
@@ -22,7 +22,7 @@
└── 模型连接器、Runner、资产存储策略 └── 模型连接器、Runner、资产存储策略
``` ```
组织解决客户和计费隔离,工作区解决团队协作隔离,项目解决内容访问和交付隔离。一个用户可以加入多个组织,在同一组织中进入多个工作区;项目默认继承工作区成员,但敏感项目可以额外配置 project member。 组织解决客户和计费隔离,工作区解决团队协作隔离,项目解决内容访问和交付隔离。一个用户可以加入多个组织,在同一组织中进入多个工作区;普通工作区成员默认可以访问工作区项目,项目级邀请则使用 `workspace_members.access_mode = project-only` 和 `project_guest` 角色,只能进入显式授权的项目。
## 角色模型 ## 角色模型
@@ -35,6 +35,7 @@
| 工作区 | `art_director` | 角色/场景/道具锁、参考图、prompt、连续性 | | 工作区 | `art_director` | 角色/场景/道具锁、参考图、prompt、连续性 |
| 工作区 | `voice_editor` | 声线锁、TTS、字幕、ASR 对齐 | | 工作区 | `voice_editor` | 声线锁、TTS、字幕、ASR 对齐 |
| 工作区 | `reviewer` | QA、审片意见、通过/驳回 | | 工作区 | `reviewer` | QA、审片意见、通过/驳回 |
| 工作区 | `project_guest` | 只进入被授权项目,不继承工作区其他项目和生成权限 |
| 项目 | `project_editor` | 指定项目内容编辑 | | 项目 | `project_editor` | 指定项目内容编辑 |
| 项目 | `project_viewer` | 只读查看、下载被授权的交付物 | | 项目 | `project_viewer` | 只读查看、下载被授权的交付物 |
@@ -48,7 +49,7 @@
- `organizations`:租户、slug、部署模式、所有者。 - `organizations`:租户、slug、部署模式、所有者。
- `organization_members`:用户加入组织的角色、邀请状态。 - `organization_members`:用户加入组织的角色、邀请状态。
- `workspaces`:组织下的生产空间。 - `workspaces`:组织下的生产空间。
- `workspace_members`:工作区角色。 - `workspace_members`:工作区角色和访问范围;`access_mode=project-only` 只允许显式 `project_members` 项目。
- `projects`:工作区下的系列、模板或客户项目。 - `projects`:工作区下的系列、模板或客户项目。
- `project_members`:项目级额外授权。 - `project_members`:项目级额外授权。
- `invitations`:待接受邀请,不把邀请误当作已加入成员。 - `invitations`:待接受邀请,不把邀请误当作已加入成员。
+1
View File
@@ -40,6 +40,7 @@
"smoke:tasks": "node scripts/smoke-tasks.mjs", "smoke:tasks": "node scripts/smoke-tasks.mjs",
"smoke:task-collaboration": "node scripts/smoke-task-collaboration.mjs", "smoke:task-collaboration": "node scripts/smoke-task-collaboration.mjs",
"smoke:notifications": "node scripts/smoke-notifications.mjs", "smoke:notifications": "node scripts/smoke-notifications.mjs",
"smoke:search": "node scripts/smoke-search.mjs",
"smoke:project-isolation": "node scripts/smoke-project-isolation.mjs", "smoke:project-isolation": "node scripts/smoke-project-isolation.mjs",
"smoke:project-lifecycle": "node scripts/smoke-project-lifecycle.mjs", "smoke:project-lifecycle": "node scripts/smoke-project-lifecycle.mjs",
"smoke:all": "node scripts/smoke-all.mjs" "smoke:all": "node scripts/smoke-all.mjs"
+1
View File
@@ -30,6 +30,7 @@ const scripts = [
"smoke:work-items", "smoke:work-items",
"smoke:tasks", "smoke:tasks",
"smoke:notifications", "smoke:notifications",
"smoke:search",
"smoke:project-isolation", "smoke:project-isolation",
"smoke:project-lifecycle" "smoke:project-lifecycle"
]; ];
+60
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { dbRun, withTransaction } from "../server/db.mjs";
const base = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; const base = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const scope = { const scope = {
@@ -33,8 +34,25 @@ async function login(email) {
const ownerHeaders = { ...(await login("producer@local.test")), ...scope }; const ownerHeaders = { ...(await login("producer@local.test")), ...scope };
const writerHeaders = { ...(await login("writer@local.test")), ...scope }; const writerHeaders = { ...(await login("writer@local.test")), ...scope };
const inviteEmail = `invitation-smoke-${Date.now()}@local.test`; const inviteEmail = `invitation-smoke-${Date.now()}@local.test`;
const projectInviteEmail = `project-invitation-smoke-${Date.now()}@local.test`;
let invitationId = ""; let invitationId = "";
let invitationState = ""; let invitationState = "";
let projectInvitationId = "";
let projectInvitationState = "";
let projectUserId = "";
function cleanupProjectUser() {
if (!projectUserId) return;
withTransaction(() => {
dbRun("DELETE FROM invitations WHERE email = ? OR accepted_user_id = ?", [projectInviteEmail, projectUserId]);
dbRun("UPDATE audit_logs SET actor_user_id = NULL WHERE actor_user_id = ?", [projectUserId]);
dbRun("UPDATE system_settings SET updated_by = NULL WHERE updated_by = ?", [projectUserId]);
dbRun("UPDATE feature_flags SET updated_by = NULL WHERE updated_by = ?", [projectUserId]);
dbRun("UPDATE notification_channels SET created_by = NULL WHERE created_by = ?", [projectUserId]);
dbRun("UPDATE api_clients SET created_by = NULL WHERE created_by = ?", [projectUserId]);
dbRun("DELETE FROM users WHERE id = ?", [projectUserId]);
});
}
try { try {
const writerAttempt = await request("/api/organizations/org-studio-lab/invitations", { const writerAttempt = await request("/api/organizations/org-studio-lab/invitations", {
@@ -99,6 +117,40 @@ try {
const afterRevoke = assertOk(await request("/api/organizations/org-studio-lab/commercial", { headers: ownerHeaders }), "读取撤销后商业席位"); const afterRevoke = assertOk(await request("/api/organizations/org-studio-lab/commercial", { headers: ownerHeaders }), "读取撤销后商业席位");
assert.equal(Number(afterRevoke.seat.pending), Number(before.seat.pending), "撤销邀请后必须释放待入组席位"); assert.equal(Number(afterRevoke.seat.pending), Number(before.seat.pending), "撤销邀请后必须释放待入组席位");
const mismatchedRole = await request("/api/organizations/org-studio-lab/invitations", {
method: "POST",
headers: ownerHeaders,
body: JSON.stringify({ email: `mismatched-role-${Date.now()}@local.test`, roleKey: "project_viewer", workspaceId: "ws-local-aidrama" })
});
assert.equal(mismatchedRole.response.status, 400, "项目角色不能用于工作区级邀请");
assert.equal(mismatchedRole.payload.error, "role_invalid", "邀请层级角色不匹配需要稳定错误码");
const projectInvite = assertOk(await request("/api/organizations/org-studio-lab/invitations", {
method: "POST",
headers: ownerHeaders,
body: JSON.stringify({ email: projectInviteEmail, roleKey: "project_viewer", workspaceId: "ws-local-aidrama", projectId: "thunder-mouth" })
}), "创建项目查看者邀请");
projectInvitationId = projectInvite.invitation.id;
projectInvitationState = projectInvite.invitation.status;
assert.equal(projectInvite.invitation.role_key, "project_viewer", "项目邀请必须保留项目查看者角色");
assert.equal(projectInvite.invitation.project_id, "thunder-mouth", "项目邀请必须绑定目标项目");
const projectRegistration = assertOk(await request("/api/auth/register", {
method: "POST",
body: JSON.stringify({ inviteToken: projectInvite.invitation.inviteToken, displayName: "项目访客测试用户", password: "ProjectInvite@123456" })
}), "注册项目查看者");
projectUserId = projectRegistration.user.id;
assert.equal(projectRegistration.context.currentWorkspace.id, "ws-local-aidrama", "项目邀请用户必须进入目标工作区");
assert.equal(projectRegistration.context.currentProject.id, "thunder-mouth", "项目邀请用户必须进入目标项目");
assert.equal(projectRegistration.context.workspaceRole, "project_guest", "项目邀请用户必须使用受限工作区角色");
assert.equal(projectRegistration.context.projectRole, "project_viewer", "项目邀请用户必须保留项目查看者角色");
assert.equal(projectRegistration.context.permissions.includes("job:create"), false, "项目查看者不能继承生成权限");
projectInvitationState = "accepted";
const projectUserProjects = assertOk(await request("/api/projects", {
headers: { authorization: `Bearer ${projectRegistration.session.token}`, "x-organization-id": "org-studio-lab", "x-workspace-id": "ws-local-aidrama" }
}), "读取项目查看者可访问项目");
assert.deepEqual(projectUserProjects.projects.map((item) => item.id), ["thunder-mouth"], "项目查看者不能继承工作区其他项目");
const listed = assertOk(await request("/api/organizations/org-studio-lab", { headers: ownerHeaders }), "读取组织邀请列表"); const listed = assertOk(await request("/api/organizations/org-studio-lab", { headers: ownerHeaders }), "读取组织邀请列表");
assert.ok(!listed.invitations.some((item) => item.id === invitationId), "撤销邀请不能继续出现在待处理列表"); assert.ok(!listed.invitations.some((item) => item.id === invitationId), "撤销邀请不能继续出现在待处理列表");
} finally { } finally {
@@ -109,6 +161,14 @@ try {
body: "{}" body: "{}"
}); });
} }
if (projectInvitationId && projectInvitationState === "pending") {
await request(`/api/organizations/org-studio-lab/invitations/${encodeURIComponent(projectInvitationId)}/revoke`, {
method: "POST",
headers: ownerHeaders,
body: "{}"
});
}
cleanupProjectUser();
} }
console.log(`invitations smoke passed: ${base}`); console.log(`invitations smoke passed: ${base}`);
+1 -1
View File
@@ -72,7 +72,7 @@ try {
const invitation = await request("/api/organizations/org-studio-lab/invitations", ownerHeaders, { const invitation = await request("/api/organizations/org-studio-lab/invitations", ownerHeaders, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ email, roleKey: "writer", workspaceId: "ws-local-aidrama", projectId: "thunder-mouth" }) body: JSON.stringify({ email, roleKey: "project_editor", workspaceId: "ws-local-aidrama", projectId: "thunder-mouth" })
}); });
assert(invitation.response.status === 201 && invitation.payload.invitation.inviteToken, "MFA 测试用户邀请创建失败"); assert(invitation.response.status === 201 && invitation.payload.invitation.inviteToken, "MFA 测试用户邀请创建失败");
+55
View File
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const scope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
async function request(path, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
async function login(email) {
const result = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password: "Demo@123456" })
});
assert.equal(result.response.status, 200, `${email} 登录失败`);
return { authorization: `Bearer ${result.payload.session.token}` };
}
const ownerHeaders = { ...(await login("producer@local.test")), ...scope };
const writerHeaders = { ...(await login("writer@local.test")), ...scope };
const unauthenticated = await request("/api/search?q=%E9%9B%B7%E9%9B%A8", { headers: scope });
assert.equal(unauthenticated.response.status, 401, "未登录搜索必须返回 401");
const ownerSearch = await request("/api/search?q=%E9%9B%B7%E9%9B%A8&scope=workspace", { headers: ownerHeaders });
assert.equal(ownerSearch.response.status, 200, "组织所有者搜索失败");
assert.ok(ownerSearch.payload.total >= 3, "搜索结果应覆盖项目、镜头和资产等多个生产对象");
assert.ok(ownerSearch.payload.results.some((item) => item.type === "project" && item.projectId === "thunder-mouth"), "搜索必须返回当前项目");
assert.ok(ownerSearch.payload.results.some((item) => item.type === "shot" && item.targetTab === "director"), "搜索必须返回可跳转的镜头结果");
assert.ok(ownerSearch.payload.results.every((item) => item.organizationId === "org-studio-lab"), "搜索结果必须返回当前组织作用域");
const projectSearch = await request("/api/search?q=%E9%9B%B7%E9%9B%A8&scope=project", { headers: ownerHeaders });
assert.equal(projectSearch.response.status, 200, "当前项目搜索失败");
assert.ok(projectSearch.payload.results.length > 0 && projectSearch.payload.results.every((item) => item.projectId === "thunder-mouth"), "当前项目搜索不能返回其他项目对象");
const crossOrganization = await request("/api/search?q=%E5%B1%B1%E6%B5%B7&scope=workspace", { headers: ownerHeaders });
assert.equal(crossOrganization.response.status, 200, "跨组织关键词搜索不应报错");
assert.equal(crossOrganization.payload.results.length, 0, "当前组织搜索不得泄露北辰组织项目");
const writerSearch = await request("/api/search?q=%E9%9B%B7%E9%9B%A8&scope=workspace", { headers: writerHeaders });
assert.equal(writerSearch.response.status, 200, "普通成员应能搜索自己可访问的生产内容");
assert.ok(writerSearch.payload.results.length > 0, "普通成员应能搜索当前项目内容");
assert.ok(writerSearch.payload.results.every((item) => item.projectId === "thunder-mouth"), "普通成员搜索不能越过项目边界");
assert.ok(writerSearch.payload.results.every((item) => item.organizationId === "org-studio-lab"), "普通成员搜索必须返回当前组织作用域");
console.log(`search smoke passed: owner=${ownerSearch.payload.total}, project=${projectSearch.payload.total}, writer=${writerSearch.payload.total}`);
+3
View File
@@ -92,6 +92,7 @@ ensureColumn("projects", "archived_at", "TEXT");
ensureColumn("projects", "archived_by", "TEXT"); ensureColumn("projects", "archived_by", "TEXT");
ensureColumn("projects", "archived_from_status", "TEXT"); ensureColumn("projects", "archived_from_status", "TEXT");
ensureColumn("projects", "template_id", "TEXT NOT NULL DEFAULT 'ai-manhua-drama'"); ensureColumn("projects", "template_id", "TEXT NOT NULL DEFAULT 'ai-manhua-drama'");
ensureColumn("workspace_members", "access_mode", "TEXT NOT NULL DEFAULT 'all'");
ensureColumn("auth_sessions", "device_id", "TEXT"); ensureColumn("auth_sessions", "device_id", "TEXT");
ensureColumn("auth_sessions", "risk_level", "TEXT NOT NULL DEFAULT 'medium'"); ensureColumn("auth_sessions", "risk_level", "TEXT NOT NULL DEFAULT 'medium'");
ensureColumn("auth_sessions", "risk_score", "INTEGER NOT NULL DEFAULT 50"); ensureColumn("auth_sessions", "risk_score", "INTEGER NOT NULL DEFAULT 50");
@@ -200,6 +201,7 @@ function seedRoles() {
["art_director", "workspace", "资产美术", "角色、场景、道具、prompt 和连续性"], ["art_director", "workspace", "资产美术", "角色、场景、道具、prompt 和连续性"],
["voice_editor", "workspace", "配音/字幕", "固定声线、TTS、字幕和 ASR"], ["voice_editor", "workspace", "配音/字幕", "固定声线、TTS、字幕和 ASR"],
["reviewer", "workspace", "审片", "QA、审片意见和通过/驳回"], ["reviewer", "workspace", "审片", "QA、审片意见和通过/驳回"],
["project_guest", "workspace", "项目受限成员", "只访问被授权项目,不继承工作区其他项目"],
["project_editor", "project", "项目编辑", "指定项目的内容编辑"], ["project_editor", "project", "项目编辑", "指定项目的内容编辑"],
["project_viewer", "project", "项目查看者", "只读查看和下载授权交付物"] ["project_viewer", "project", "项目查看者", "只读查看和下载授权交付物"]
]; ];
@@ -263,6 +265,7 @@ function seedRoles() {
art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "job:create"], art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "job:create"],
voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "job:create"], voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "job:create"],
reviewer: ["script:read", "task:view", "task:complete", "qa:review", "voice:approve", "delivery:view"], reviewer: ["script:read", "task:view", "task:complete", "qa:review", "voice:approve", "delivery:view"],
project_guest: ["script:read", "task:view", "delivery:view"],
project_editor: ["script:read", "script:edit", "asset:edit", "prompt:edit", "voice:edit", "task:view", "task:manage", "task:complete", "job:create", "delivery:view"], project_editor: ["script:read", "script:edit", "asset:edit", "prompt:edit", "voice:edit", "task:view", "task:manage", "task:complete", "job:create", "delivery:view"],
project_viewer: ["script:read", "task:view", "delivery:view"] project_viewer: ["script:read", "task:view", "delivery:view"]
}; };
+4
View File
@@ -491,6 +491,10 @@ function assertSingleFrameResult(job, result) {
export async function executeGenerationJob(context, jobId, body = {}) { export async function executeGenerationJob(context, jobId, body = {}) {
const job = jobRow(context, jobId); const job = jobRow(context, jobId);
if (!hasPermission(context, "job:create") && !hasPermission(context, "queue:manage")) throw httpError(403, "permission_denied", "没有执行生成任务的权限"); if (!hasPermission(context, "job:create") && !hasPermission(context, "queue:manage")) throw httpError(403, "permission_denied", "没有执行生成任务的权限");
// The local Worker and a manual run request can legitimately race. Once a
// job is complete, treating a second run request as an idempotent read keeps
// the operator action safe without executing the model twice.
if (job.status === "completed") return { job: jobPayload(job), jobs: listGenerationJobs(context), idempotent: true };
requireProjectWritable(context); requireProjectWritable(context);
if (!["queued", "blocked", "failed", "cancelled"].includes(job.status)) throw httpError(409, "job_not_runnable", "当前任务状态不能执行", { status: job.status }); if (!["queued", "blocked", "failed", "cancelled"].includes(job.status)) throw httpError(409, "job_not_runnable", "当前任务状态不能执行", { status: job.status });
const unresolved = unresolvedDependencies(jobId); const unresolved = unresolvedDependencies(jobId);
+21 -4
View File
@@ -141,6 +141,7 @@ import { createSsoTicket, handleOidcCallback, redeemSsoTicket, startOidcLogin }
import { handleSamlCallback, isSamlProvider, samlServiceProviderMetadata, startSamlLogin } from "./saml.mjs"; import { handleSamlCallback, isSamlProvider, samlServiceProviderMetadata, startSamlLogin } from "./saml.mjs";
import { listWorkItems } from "./work-items.mjs"; import { listWorkItems } from "./work-items.mjs";
import { addTaskLink, createProjectTask, createTaskComment, getProjectTask, listProjectActivity, listProjectTasks, listTaskComments, removeTaskLink, updateProjectTask } from "./tasks.mjs"; import { addTaskLink, createProjectTask, createTaskComment, getProjectTask, listProjectActivity, listProjectTasks, listTaskComments, removeTaskLink, updateProjectTask } from "./tasks.mjs";
import { searchPlatform } from "./search.mjs";
import { consumeRateLimit, rateLimitHeaders, rateLimitIdentity } from "./rate-limit.mjs"; import { consumeRateLimit, rateLimitHeaders, rateLimitIdentity } from "./rate-limit.mjs";
import { createDeliveryAccessLink, listDeliveryAccessFeedback, listDeliveryAccessLinks, readPublicDeliveryFile, resolvePublicDeliveryPortal, revokeDeliveryAccessLink, submitPublicDeliveryFeedback } from "./delivery-portal.mjs"; import { createDeliveryAccessLink, listDeliveryAccessFeedback, listDeliveryAccessLinks, readPublicDeliveryFile, resolvePublicDeliveryPortal, revokeDeliveryAccessLink, submitPublicDeliveryFeedback } from "./delivery-portal.mjs";
@@ -953,12 +954,17 @@ function createInvitation(context, organizationId, body) {
throw httpError(409, "invitation_already_pending", "该邮箱已有待处理邀请,请重新发送或先撤销旧邀请", { email }); throw httpError(409, "invitation_already_pending", "该邮箱已有待处理邀请,请重新发送或先撤销旧邀请", { email });
} }
assertOrganizationSeatAvailable(organizationId, { email }); assertOrganizationSeatAvailable(organizationId, { email });
const roleKey = body.roleKey || "writer";
if (!dbGet("SELECT key FROM roles WHERE key = ?", [roleKey])) throw httpError(400, "role_invalid", "邀请角色不存在", { roleKey });
const workspaceId = body.workspaceId || null; const workspaceId = body.workspaceId || null;
if (workspaceId && !dbGet("SELECT id FROM workspaces WHERE id = ? AND organization_id = ?", [workspaceId, organizationId])) throw httpError(400, "workspace_invalid", "邀请目标工作区不属于当前组织"); if (workspaceId && !dbGet("SELECT id FROM workspaces WHERE id = ? AND organization_id = ?", [workspaceId, organizationId])) throw httpError(400, "workspace_invalid", "邀请目标工作区不属于当前组织");
const projectId = body.projectId || null; const projectId = body.projectId || null;
if (projectId && !workspaceId) throw httpError(400, "workspace_required_for_project_invitation", "项目级邀请必须同时指定所属工作区");
if (projectId && !dbGet("SELECT id FROM projects WHERE id = ? AND workspace_id = ?", [projectId, workspaceId])) throw httpError(400, "project_invalid", "邀请目标项目不属于当前工作区"); if (projectId && !dbGet("SELECT id FROM projects WHERE id = ? AND workspace_id = ?", [projectId, workspaceId])) throw httpError(400, "project_invalid", "邀请目标项目不属于当前工作区");
const invitationScope = projectId ? "project" : workspaceId ? "workspace" : "organization";
const defaultRole = invitationScope === "project" ? "project_editor" : invitationScope === "workspace" ? "writer" : "org_member";
const requestedRoleKey = String(body.roleKey || defaultRole).trim();
const role = roleForScope(requestedRoleKey, invitationScope);
if (invitationScope === "organization" && role.key === "org_owner") throw httpError(400, "owner_invitation_not_allowed", "组织所有者不能通过普通邀请授予,请在成员管理中完成所有者转移");
const roleKey = role.key;
const id = createId("invite"); const id = createId("invite");
const inviteToken = `invite-${randomBytes(24).toString("base64url")}`; const inviteToken = `invite-${randomBytes(24).toString("base64url")}`;
const tokenHash = createHash("sha256").update(inviteToken).digest("hex"); const tokenHash = createHash("sha256").update(inviteToken).digest("hex");
@@ -1019,12 +1025,14 @@ function updateWorkspaceMember(context, workspaceId, userId, body) {
const member = dbGet("SELECT * FROM workspace_members WHERE workspace_id = ? AND user_id = ?", [workspaceId, userId]); const member = dbGet("SELECT * FROM workspace_members WHERE workspace_id = ? AND user_id = ?", [workspaceId, userId]);
if (!member) throw httpError(404, "member_not_found", "工作区成员不存在"); if (!member) throw httpError(404, "member_not_found", "工作区成员不存在");
const roleKey = String(body.roleKey || member.role_key); const roleKey = String(body.roleKey || member.role_key);
const accessMode = String(body.accessMode || member.access_mode || "all");
const status = String(body.status || member.status); const status = String(body.status || member.status);
roleForScope(roleKey, "workspace"); roleForScope(roleKey, "workspace");
if (!["all", "project-only"].includes(accessMode)) throw httpError(400, "workspace_access_mode_invalid", "工作区访问范围只能是 all 或 project-only");
if (!["active", "suspended"].includes(status)) throw httpError(400, "member_status_invalid", "成员状态无效"); if (!["active", "suspended"].includes(status)) throw httpError(400, "member_status_invalid", "成员状态无效");
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
dbRun("UPDATE workspace_members SET role_key = ?, status = ?, updated_at = ? WHERE workspace_id = ? AND user_id = ?", [roleKey, status, timestamp, workspaceId, userId]); dbRun("UPDATE workspace_members SET role_key = ?, access_mode = ?, status = ?, updated_at = ? WHERE workspace_id = ? AND user_id = ?", [roleKey, accessMode, status, timestamp, workspaceId, userId]);
addAudit({ context, action: "workspace.member.updated", targetType: "workspace_member", targetId: `${workspaceId}:${userId}`, metadata: { previousRole: member.role_key, roleKey, previousStatus: member.status, status } }); addAudit({ context, action: "workspace.member.updated", targetType: "workspace_member", targetId: `${workspaceId}:${userId}`, metadata: { previousRole: member.role_key, roleKey, previousAccessMode: member.access_mode || "all", accessMode, previousStatus: member.status, status } });
return { members: workspaceMembers(workspaceId) }; return { members: workspaceMembers(workspaceId) };
} }
@@ -1508,6 +1516,15 @@ createServer(async (req, res) => {
if (req.method === "GET" && pathname === "/api/context") return send(res, 200, platformPayload(resolveContext(req.headers, url.searchParams))); if (req.method === "GET" && pathname === "/api/context") return send(res, 200, platformPayload(resolveContext(req.headers, url.searchParams)));
if (req.method === "GET" && pathname === "/api/search") {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, searchPlatform(context, {
query: url.searchParams.get("q") || url.searchParams.get("query") || "",
scope: url.searchParams.get("scope") || "workspace",
limit: url.searchParams.get("limit") || 40
}));
}
if (req.method === "GET" && pathname === "/api/work-items") { if (req.method === "GET" && pathname === "/api/work-items") {
const context = resolveContext(req.headers, url.searchParams); const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, listWorkItems(context, { limit: url.searchParams.get("limit") })); return send(res, 200, listWorkItems(context, { limit: url.searchParams.get("limit") }));
+1
View File
@@ -290,6 +290,7 @@ CREATE TABLE IF NOT EXISTS workspace_members (
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_key TEXT NOT NULL, role_key TEXT NOT NULL,
access_mode TEXT NOT NULL DEFAULT 'all',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'invited', 'suspended')), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'invited', 'suspended')),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
+188
View File
@@ -0,0 +1,188 @@
import { dbAll } from "./db.mjs";
const SEARCH_LIMIT = 60;
function placeholders(values) {
return values.map(() => "?").join(",");
}
function likePattern(value) {
return `%${String(value || "").trim().replace(/[\\%_]/g, "\\$&").slice(0, 80)}%`;
}
function scopeProjects(context, scope) {
const accessible = Array.isArray(context?.projects) ? context.projects : [];
if (scope === "project" && context?.project?.id) return [context.project.id];
return accessible.map((project) => project.id).filter(Boolean);
}
function baseResult(type, typeLabel, row, targetTab, title, subtitle, status = "") {
return {
type,
typeLabel,
id: row.id,
title: String(title || row.name || row.title || row.id),
subtitle: String(subtitle || ""),
status: String(status || row.status || ""),
updatedAt: row.updated_at || row.updatedAt || row.created_at || "",
targetTab,
organizationId: row.organization_id || "",
workspaceId: row.workspace_id || "",
projectId: row.project_id || row.projectId || (type === "project" ? row.id : "")
};
}
function sortResults(results) {
return results.sort((left, right) => {
const leftTitle = left.title.toLocaleLowerCase();
const rightTitle = right.title.toLocaleLowerCase();
const leftStarts = leftTitle.startsWith(left.query) ? 1 : 0;
const rightStarts = rightTitle.startsWith(right.query) ? 1 : 0;
if (leftStarts !== rightStarts) return rightStarts - leftStarts;
return String(right.updatedAt).localeCompare(String(left.updatedAt));
});
}
export function searchPlatform(context, { query = "", scope = "workspace", limit = 40 } = {}) {
const normalizedQuery = String(query || "").trim().slice(0, 80);
if (!normalizedQuery) return { query: "", scope, total: 0, results: [] };
const projectIds = scopeProjects(context, scope === "project" ? "project" : "workspace");
if (!projectIds.length) return { query: normalizedQuery, scope, total: 0, results: [] };
const safeLimit = Math.min(SEARCH_LIMIT, Math.max(1, Number(limit || 40)));
const perType = Math.min(12, Math.max(4, Math.ceil(safeLimit / 8) + 2));
const ids = placeholders(projectIds);
const pattern = likePattern(normalizedQuery);
const results = [];
const projects = dbAll(
`SELECT p.*, w.name AS workspace_name, w.organization_id AS organization_id
FROM projects p
JOIN workspaces w ON w.id = p.workspace_id
WHERE p.id IN (${ids})
AND (p.name LIKE ? ESCAPE '\\' OR p.type LIKE ? ESCAPE '\\')
ORDER BY p.updated_at DESC LIMIT ?`,
[...projectIds, pattern, pattern, perType]
);
for (const row of projects) {
results.push(baseResult("project", "项目", row, "factory", row.name, `${row.workspace_name} · ${row.type}`, row.status));
}
const episodes = dbAll(
`SELECT e.*, se.season_number, se.title AS season_title, se.series_id,
p.id AS project_id, p.name AS project_name, w.organization_id AS organization_id, p.workspace_id, w.name AS workspace_name
FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
JOIN projects p ON p.id = s.project_id
JOIN workspaces w ON w.id = p.workspace_id
WHERE p.id IN (${ids})
AND (e.title LIKE ? ESCAPE '\\' OR e.hook LIKE ? ESCAPE '\\' OR e.cliffhanger LIKE ? ESCAPE '\\')
ORDER BY e.updated_at DESC LIMIT ?`,
[...projectIds, pattern, pattern, pattern, perType]
);
for (const row of episodes) {
results.push(baseResult("episode", "分集", row, "bible", row.title, `${row.project_name} · 第 ${row.episode_number} 集 · ${row.season_title}`, row.status));
}
const documents = dbAll(
`SELECT d.*, e.episode_number, e.title AS episode_title,
p.name AS project_name, w.organization_id AS organization_id, p.workspace_id
FROM script_documents d
LEFT JOIN episodes e ON e.id = d.episode_id
JOIN projects p ON p.id = d.project_id
JOIN workspaces w ON w.id = p.workspace_id
WHERE p.id IN (${ids})
AND (d.title LIKE ? ESCAPE '\\' OR d.content LIKE ? ESCAPE '\\')
ORDER BY d.updated_at DESC LIMIT ?`,
[...projectIds, pattern, pattern, perType]
);
for (const row of documents) {
results.push(baseResult("script", "剧本", row, "script", row.title, `${row.project_name}${row.episode_title ? ` · ${row.episode_title}` : ""} · v${row.version_number}`, row.status));
}
const shots = dbAll(
`SELECT sh.*, e.episode_number, e.title AS episode_title,
p.id AS project_id, p.name AS project_name, w.organization_id AS organization_id, p.workspace_id
FROM shots sh
JOIN episodes e ON e.id = sh.episode_id
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
JOIN projects p ON p.id = s.project_id
JOIN workspaces w ON w.id = p.workspace_id
WHERE p.id IN (${ids})
AND (sh.title LIKE ? ESCAPE '\\' OR sh.id LIKE ? ESCAPE '\\' OR sh.continuity_json LIKE ? ESCAPE '\\')
ORDER BY sh.updated_at DESC LIMIT ?`,
[...projectIds, pattern, pattern, pattern, perType]
);
for (const row of shots) {
results.push(baseResult("shot", "镜头", row, "director", row.title, `${row.project_name} · E${String(row.episode_number).padStart(2, "0")} · S${String(row.shot_number).padStart(2, "0")}`, row.status));
}
const assets = dbAll(
`SELECT a.*, p.id AS project_id, p.name AS project_name, w.organization_id AS organization_id, p.workspace_id
FROM assets a
JOIN projects p ON p.id = a.project_id
JOIN workspaces w ON w.id = p.workspace_id
WHERE p.id IN (${ids})
AND (a.name LIKE ? ESCAPE '\\' OR a.kind LIKE ? ESCAPE '\\')
ORDER BY a.updated_at DESC LIMIT ?`,
[...projectIds, pattern, pattern, perType]
);
for (const row of assets) {
results.push(baseResult("asset", "资产", row, "casting", row.name, `${row.project_name} · ${row.kind}`, row.lock_status));
}
const tasks = dbAll(
`SELECT t.*, p.id AS project_id, p.name AS project_name, w.organization_id AS organization_id, p.workspace_id,
u.display_name AS assignee_name
FROM project_tasks t
JOIN projects p ON p.id = t.project_id
JOIN workspaces w ON w.id = p.workspace_id
LEFT JOIN users u ON u.id = t.assignee_user_id
WHERE p.id IN (${ids})
AND (t.title LIKE ? ESCAPE '\\' OR t.description LIKE ? ESCAPE '\\')
ORDER BY t.updated_at DESC LIMIT ?`,
[...projectIds, pattern, pattern, perType]
);
for (const row of tasks) {
results.push(baseResult("task", "任务", row, "tasks", row.title, `${row.project_name} · ${row.assignee_name || "未分派"}`, row.status));
}
const jobs = dbAll(
`SELECT j.*, p.name AS project_name, w.organization_id AS organization_id
FROM generation_jobs j
JOIN projects p ON p.id = j.project_id
JOIN workspaces w ON w.id = p.workspace_id
WHERE p.id IN (${ids})
AND (j.kind LIKE ? ESCAPE '\\' OR j.status LIKE ? ESCAPE '\\' OR j.id LIKE ? ESCAPE '\\' OR j.error_message LIKE ? ESCAPE '\\')
ORDER BY j.updated_at DESC LIMIT ?`,
[...projectIds, pattern, pattern, pattern, pattern, perType]
);
for (const row of jobs) {
results.push(baseResult("job", "生成任务", row, "jobs", row.kind || row.id, `${row.project_name} · ${row.id}`, row.status));
}
const deliveries = dbAll(
`SELECT d.*, p.name AS project_name, w.organization_id AS organization_id
FROM deliveries d
JOIN projects p ON p.id = d.project_id
JOIN workspaces w ON w.id = p.workspace_id
WHERE p.id IN (${ids})
AND (d.version LIKE ? ESCAPE '\\' OR d.channel LIKE ? ESCAPE '\\' OR d.status LIKE ? ESCAPE '\\' OR d.id LIKE ? ESCAPE '\\')
ORDER BY d.updated_at DESC LIMIT ?`,
[...projectIds, pattern, pattern, pattern, pattern, perType]
);
for (const row of deliveries) {
results.push(baseResult("delivery", "交付", row, "export", `${row.project_name} · ${row.version}`, `${row.channel} · ${row.id}`, row.status));
}
const ranked = sortResults(results.map((result) => ({ ...result, query: normalizedQuery.toLocaleLowerCase() })));
return {
query: normalizedQuery,
scope: scope === "project" && context?.project?.id ? "project" : "workspace",
total: ranked.length,
results: ranked.slice(0, safeLimit).map(({ query: _query, ...result }) => result)
};
}
+15 -8
View File
@@ -114,7 +114,8 @@ function accessibleProjects(workspaceId, userId, organizationId) {
FROM projects p FROM projects p
LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = ? AND pm.status = 'active' LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = ? AND pm.status = 'active'
JOIN workspace_members wm ON wm.workspace_id = p.workspace_id AND wm.user_id = ? AND wm.status = 'active' JOIN workspace_members wm ON wm.workspace_id = p.workspace_id AND wm.user_id = ? AND wm.status = 'active'
WHERE p.workspace_id = ? AND (pm.user_id IS NOT NULL OR wm.user_id IS NOT NULL) WHERE p.workspace_id = ?
AND (wm.access_mode IS NULL OR wm.access_mode = 'all' OR (wm.access_mode = 'project-only' AND pm.user_id IS NOT NULL))
ORDER BY CASE WHEN p.status = 'archived' THEN 1 ELSE 0 END, p.updated_at DESC`, ORDER BY CASE WHEN p.status = 'archived' THEN 1 ELSE 0 END, p.updated_at DESC`,
[userId, userId, workspaceId] [userId, userId, workspaceId]
); );
@@ -179,7 +180,8 @@ function ensureProject(userId, organization, workspace, projectId) {
[projectId, userId] [projectId, userId]
); );
const workspaceMember = dbGet("SELECT * FROM workspace_members WHERE workspace_id = ? AND user_id = ? AND status = 'active'", [workspace.id, userId]); const workspaceMember = dbGet("SELECT * FROM workspace_members WHERE workspace_id = ? AND user_id = ? AND status = 'active'", [workspace.id, userId]);
if (!membership && !workspaceMember && !canSeeAllWorkspaceData(organization.id, userId)) { const workspaceCanSeeProject = workspaceMember && (workspaceMember.access_mode !== "project-only" || Boolean(membership));
if (!membership && !workspaceCanSeeProject && !canSeeAllWorkspaceData(organization.id, userId)) {
throw httpError(403, "project_forbidden", "当前用户没有该项目访问权", { projectId, userId }); throw httpError(403, "project_forbidden", "当前用户没有该项目访问权", { projectId, userId });
} }
return { project, membership }; return { project, membership };
@@ -304,7 +306,7 @@ export function orgMembers(organizationId) {
export function workspaceMembers(workspaceId) { export function workspaceMembers(workspaceId) {
return dbAll( return dbAll(
`SELECT wm.id, wm.user_id, u.display_name, u.email, u.avatar_color, wm.role_key, r.name AS role_name, wm.status `SELECT wm.id, wm.user_id, u.display_name, u.email, u.avatar_color, wm.role_key, r.name AS role_name, wm.access_mode, wm.status
FROM workspace_members wm FROM workspace_members wm
JOIN users u ON u.id = wm.user_id JOIN users u ON u.id = wm.user_id
LEFT JOIN roles r ON r.key = wm.role_key LEFT JOIN roles r ON r.key = wm.role_key
@@ -486,7 +488,10 @@ export function previewInvitation(token) {
} }
function invitationRoleForWorkspace(roleKey) { function invitationRoleForWorkspace(roleKey) {
return roleKey === "writer" || roleKey === "voice_editor" || roleKey === "art_director" ? roleKey : "producer"; if (["producer", "writer", "art_director", "voice_editor", "reviewer"].includes(roleKey)) return roleKey;
// Project invitations need a workspace membership for context resolution,
// but must not inherit workspace-wide production permissions.
return "project_guest";
} }
function applyInvitationMembership(invitation, userId, timestamp) { function applyInvitationMembership(invitation, userId, timestamp) {
@@ -497,15 +502,17 @@ function applyInvitationMembership(invitation, userId, timestamp) {
[`om-${invitation.organization_id}-${userId}`, invitation.organization_id, userId, organizationRole, timestamp, timestamp, timestamp] [`om-${invitation.organization_id}-${userId}`, invitation.organization_id, userId, organizationRole, timestamp, timestamp, timestamp]
); );
if (invitation.workspace_id) { if (invitation.workspace_id) {
const accessMode = invitation.project_id ? "project-only" : "all";
dbRun( dbRun(
"INSERT INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?) ON CONFLICT(workspace_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = 'active', updated_at = excluded.updated_at", "INSERT INTO workspace_members(id, workspace_id, user_id, role_key, access_mode, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'active', ?, ?) ON CONFLICT(workspace_id, user_id) DO UPDATE SET role_key = excluded.role_key, access_mode = CASE WHEN workspace_members.access_mode = 'all' THEN 'all' ELSE excluded.access_mode END, status = 'active', updated_at = excluded.updated_at",
[`wm-${invitation.workspace_id}-${userId}`, invitation.workspace_id, userId, invitationRoleForWorkspace(invitation.role_key), timestamp, timestamp] [`wm-${invitation.workspace_id}-${userId}`, invitation.workspace_id, userId, invitationRoleForWorkspace(invitation.role_key), accessMode, timestamp, timestamp]
); );
} }
if (invitation.project_id) { if (invitation.project_id) {
const projectRole = ["project_editor", "project_viewer"].includes(invitation.role_key) ? invitation.role_key : "project_editor";
dbRun( dbRun(
"INSERT INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'project_editor', 'active', ?, ?) ON CONFLICT(project_id, user_id) DO UPDATE SET status = 'active', updated_at = excluded.updated_at", "INSERT INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?) ON CONFLICT(project_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = 'active', updated_at = excluded.updated_at",
[`pm-${invitation.project_id}-${userId}`, invitation.project_id, userId, timestamp, timestamp] [`pm-${invitation.project_id}-${userId}`, invitation.project_id, userId, projectRole, timestamp, timestamp]
); );
} }
} }
+209 -10
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { import {
AlertTriangle, AlertTriangle,
Archive, Archive,
@@ -11,6 +11,7 @@ import {
Clapperboard, Clapperboard,
Coins, Coins,
Copy, Copy,
ChevronRight,
Download, Download,
FileJson2, FileJson2,
Film, Film,
@@ -83,7 +84,7 @@ import {
ScriptProductionPage, ScriptProductionPage,
SeriesBiblePage SeriesBiblePage
} from "./components/ProductionPages"; } from "./components/ProductionPages";
import { addProjectMember, changeProjectLifecycle, completeMfaEnrollment, createJob, createOrganization, createProject, createWorkspace, fetchAuthSession, fetchPlatformContext, fetchProject, getAuthSession, getClientContext, inviteMember, login, loginWithMfa, logout, redeemSsoTicket, resendOrganizationInvitation, revokeOrganizationInvitation, startMfaEnrollment, updateOrganization, updateOrganizationMember, updateOrganizationRolePolicy, updateProject, updateProjectMember, updateWorkspace, updateWorkspaceMember } from "./lib/api"; import { addProjectMember, changeProjectLifecycle, completeMfaEnrollment, createJob, createOrganization, createProject, createWorkspace, fetchAuthSession, fetchPlatformContext, fetchProject, getAuthSession, getClientContext, inviteMember, login, loginWithMfa, logout, redeemSsoTicket, resendOrganizationInvitation, revokeOrganizationInvitation, searchPlatform, startMfaEnrollment, updateOrganization, updateOrganizationMember, updateOrganizationRolePolicy, updateProject, updateProjectMember, updateWorkspace, updateWorkspaceMember } from "./lib/api";
import { buildAllExports, buildModelRequest, downloadJson } from "./lib/exporters"; import { buildAllExports, buildModelRequest, downloadJson } from "./lib/exporters";
import { projectQa } from "./lib/qa"; import { projectQa } from "./lib/qa";
import { getPlatformSummary, platformData } from "./platform/platformData"; import { getPlatformSummary, platformData } from "./platform/platformData";
@@ -688,7 +689,9 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
const usage = platform.usage; const usage = platform.usage;
const [inviteEmail, setInviteEmail] = useState(""); const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState("writer"); const [inviteRole, setInviteRole] = useState("writer");
const [inviteScope, setInviteScope] = useState(workspaces.length ? "workspace" : "organization");
const [inviteWorkspaceId, setInviteWorkspaceId] = useState(workspaces[0]?.id || ""); const [inviteWorkspaceId, setInviteWorkspaceId] = useState(workspaces[0]?.id || "");
const [inviteProjectId, setInviteProjectId] = useState("");
const [organizationName, setOrganizationName] = useState(""); const [organizationName, setOrganizationName] = useState("");
const [workspaceName, setWorkspaceName] = useState(""); const [workspaceName, setWorkspaceName] = useState("");
const [projectName, setProjectName] = useState(""); const [projectName, setProjectName] = useState("");
@@ -705,6 +708,13 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
const [inviteLink, setInviteLink] = useState(""); const [inviteLink, setInviteLink] = useState("");
const [inviteActionId, setInviteActionId] = useState(""); const [inviteActionId, setInviteActionId] = useState("");
const inviteProjects = projects.filter((item) => !inviteWorkspaceId || item.workspace_id === inviteWorkspaceId || item.workspaceId === inviteWorkspaceId);
const inviteRoleOptions = inviteScope === "organization"
? [["org_member", "组织成员"], ["org_admin", "组织管理员"]]
: inviteScope === "project"
? [["project_editor", "项目编辑"], ["project_viewer", "项目查看者"]]
: [["writer", "编剧"], ["producer", "制片"], ["art_director", "资产美术"], ["voice_editor", "配音/字幕"], ["reviewer", "审片"]];
useEffect(() => { useEffect(() => {
setMemberDrafts(Object.fromEntries(members.map((member) => [member.user_id || member.id, { roleKey: member.role_key || "org_member", status: member.status || "active" }]))); setMemberDrafts(Object.fromEntries(members.map((member) => [member.user_id || member.id, { roleKey: member.role_key || "org_member", status: member.status || "active" }])));
}, [platform.members]); }, [platform.members]);
@@ -723,9 +733,17 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
}, [context?.currentOrganization?.id, context?.currentWorkspace?.id, context?.currentProject?.id]); }, [context?.currentOrganization?.id, context?.currentWorkspace?.id, context?.currentProject?.id]);
useEffect(() => { useEffect(() => {
setWorkspaceMemberDrafts(Object.fromEntries((platform.workspaceMembers || []).map((member) => [member.user_id || member.id, { roleKey: member.role_key || "producer", status: member.status || "active" }]))); setWorkspaceMemberDrafts(Object.fromEntries((platform.workspaceMembers || []).map((member) => [member.user_id || member.id, { roleKey: member.role_key || "producer", accessMode: member.access_mode || "all", status: member.status || "active" }])));
}, [platform.workspaceMembers]); }, [platform.workspaceMembers]);
useEffect(() => {
if (!workspaces.some((workspace) => workspace.id === inviteWorkspaceId)) setInviteWorkspaceId(workspaces[0]?.id || "");
}, [platform.workspaces]);
useEffect(() => {
if (!inviteProjects.some((project) => project.id === inviteProjectId)) setInviteProjectId(inviteProjects[0]?.id || "");
}, [platform.projects, inviteWorkspaceId]);
useEffect(() => { useEffect(() => {
const availableRole = rolePolicies.roles.find((role) => role.key === selectedPolicyRoleKey) || rolePolicies.roles[0]; const availableRole = rolePolicies.roles.find((role) => role.key === selectedPolicyRoleKey) || rolePolicies.roles[0];
if (availableRole && availableRole.key !== selectedPolicyRoleKey) setSelectedPolicyRoleKey(availableRole.key); if (availableRole && availableRole.key !== selectedPolicyRoleKey) setSelectedPolicyRoleKey(availableRole.key);
@@ -743,6 +761,13 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
} }
} }
function changeInviteScope(scope) {
setInviteScope(scope);
if (scope === "organization") setInviteRole("org_member");
else if (scope === "project") setInviteRole("project_editor");
else setInviteRole("writer");
}
function inviteUrl(invitation) { function inviteUrl(invitation) {
if (!invitation) return ""; if (!invitation) return "";
const path = invitation.acceptUrl || (invitation.inviteToken ? `/register?invite=${encodeURIComponent(invitation.inviteToken)}` : ""); const path = invitation.acceptUrl || (invitation.inviteToken ? `/register?invite=${encodeURIComponent(invitation.inviteToken)}` : "");
@@ -753,7 +778,18 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
const handleInvite = async (event) => { const handleInvite = async (event) => {
event.preventDefault(); event.preventDefault();
const result = await runAction(() => onInvite({ email: inviteEmail, roleKey: inviteRole, workspaceId: inviteWorkspaceId || undefined }), "邀请已创建;注册链接仅在当前管理会话中显示"); if (inviteScope !== "organization" && !inviteWorkspaceId) {
setNotice("工作区级或项目级邀请必须选择工作区");
return;
}
if (inviteScope === "project" && !inviteProjectId) {
setNotice("项目级邀请必须选择项目");
return;
}
const body = { email: inviteEmail, roleKey: inviteRole };
if (inviteScope !== "organization") body.workspaceId = inviteWorkspaceId;
if (inviteScope === "project") body.projectId = inviteProjectId;
const result = await runAction(() => onInvite(body), "邀请已创建;注册链接仅在当前管理会话中显示");
if (result?.invitation) { if (result?.invitation) {
setInviteLink(inviteUrl(result.invitation)); setInviteLink(inviteUrl(result.invitation));
setInviteEmail(""); setInviteEmail("");
@@ -978,8 +1014,10 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
</div> </div>
<form className="invite-form" onSubmit={handleInvite}> <form className="invite-form" onSubmit={handleInvite}>
<div className="form-title"><UserPlus size={16} />邀请成员</div> <div className="form-title"><UserPlus size={16} />邀请成员</div>
<div className="form-grid"><input type="email" required value={inviteEmail} onChange={(event) => setInviteEmail(event.target.value)} placeholder="name@studio.local" aria-label="成员邮箱" /><select value={inviteRole} onChange={(event) => setInviteRole(event.target.value)} aria-label="成员角色"><option value="writer">编剧</option><option value="art_director">资产美术</option><option value="voice_editor">配音/字幕</option><option value="reviewer">审片</option><option value="producer">制片</option></select></div> <div className="form-grid"><input type="email" required value={inviteEmail} onChange={(event) => setInviteEmail(event.target.value)} placeholder="name@studio.local" aria-label="成员邮箱" /><select value={inviteScope} onChange={(event) => changeInviteScope(event.target.value)} aria-label="邀请范围"><option value="organization">组织级</option><option value="workspace" disabled={!workspaces.length}>工作区级</option><option value="project" disabled={!inviteProjects.length}>项目级</option></select></div>
<select value={inviteWorkspaceId} onChange={(event) => setInviteWorkspaceId(event.target.value)} aria-label="邀请工作区"><option value="">仅加入组织</option>{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}</select> {inviteScope !== "organization" && <select value={inviteWorkspaceId} onChange={(event) => { setInviteWorkspaceId(event.target.value); if (inviteScope === "project") setInviteProjectId(""); }} aria-label="邀请工作区"><option value="">选择工作区</option>{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}</select>}
{inviteScope === "project" && <select value={inviteProjectId} onChange={(event) => setInviteProjectId(event.target.value)} aria-label="邀请项目"><option value="">选择项目</option>{inviteProjects.map((project) => <option key={project.id} value={project.id}>{project.name || project.title}</option>)}</select>}
<select value={inviteRole} onChange={(event) => setInviteRole(event.target.value)} aria-label="成员角色">{inviteRoleOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select>
<button className="subtle" type="submit"><Mail size={15} />发送邀请</button> <button className="subtle" type="submit"><Mail size={15} />发送邀请</button>
</form> </form>
{inviteLink && <div className="invite-link-card"> {inviteLink && <div className="invite-link-card">
@@ -1000,7 +1038,7 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
<div className="invite-list"> <div className="invite-list">
{invitations.length ? invitations.map((invite) => <div className="invite-item" key={invite.id}> {invitations.length ? invitations.map((invite) => <div className="invite-item" key={invite.id}>
<Mail size={15} /> <Mail size={15} />
<div className="invite-main"><strong>{invite.email}</strong><span>{invite.role_name || invite.role_key} · {invite.workspace_name || "组织级"}</span><small>有效期至 {invite.expires_at ? new Date(invite.expires_at).toLocaleString("zh-CN", { dateStyle: "short", timeStyle: "short" }) : "-"} · {invite.token_hint || "token hidden"}</small></div> <div className="invite-main"><strong>{invite.email}</strong><span>{invite.role_name || invite.role_key} · {invite.project_name ? `${invite.workspace_name} / ${invite.project_name}` : invite.workspace_name || "组织级"}</span><small>有效期至 {invite.expires_at ? new Date(invite.expires_at).toLocaleString("zh-CN", { dateStyle: "short", timeStyle: "short" }) : "-"} · {invite.token_hint || "token hidden"}</small></div>
<div className="invite-actions"><StatusPill tone="warn">pending</StatusPill><button type="button" className="icon-text-button" onClick={() => handleResendInvitation(invite)} disabled={inviteActionId === invite.id}><RefreshCw size={13} />重发</button><button type="button" className="icon-text-button danger" onClick={() => handleRevokeInvitation(invite)} disabled={inviteActionId === invite.id}><XCircle size={13} />撤销</button></div> <div className="invite-actions"><StatusPill tone="warn">pending</StatusPill><button type="button" className="icon-text-button" onClick={() => handleResendInvitation(invite)} disabled={inviteActionId === invite.id}><RefreshCw size={13} />重发</button><button type="button" className="icon-text-button danger" onClick={() => handleRevokeInvitation(invite)} disabled={inviteActionId === invite.id}><XCircle size={13} />撤销</button></div>
</div>) : <p className="empty-state">当前组织没有待处理邀请。</p>} </div>) : <p className="empty-state">当前组织没有待处理邀请。</p>}
</div> </div>
@@ -1011,8 +1049,8 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
<div className="member-list workspace-member-list"> <div className="member-list workspace-member-list">
{workspaceMembers.length ? workspaceMembers.map((member) => { {workspaceMembers.length ? workspaceMembers.map((member) => {
const userId = member.user_id || member.id; const userId = member.user_id || member.id;
const draft = workspaceMemberDrafts[userId] || { roleKey: member.role_key || "producer", status: member.status || "active" }; const draft = workspaceMemberDrafts[userId] || { roleKey: member.role_key || "producer", accessMode: member.access_mode || "all", status: member.status || "active" };
return <div key={member.id || userId}><span className="member-avatar">{member.display_name?.slice(0, 1)}</span><div><strong>{member.display_name}</strong><span>{member.email}</span></div><div className="member-role-editor"><select aria-label={`${member.display_name} 工作区角色`} value={draft.roleKey} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, roleKey: event.target.value } }))}><option value="producer">制片</option><option value="writer">编剧</option><option value="art_director">资产美术</option><option value="voice_editor">配音/字幕</option><option value="reviewer">审片</option></select><select aria-label={`${member.display_name} 工作区状态`} value={draft.status} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, status: event.target.value } }))}><option value="active">启用</option><option value="suspended">停用</option></select><button className="icon-text-button" onClick={() => saveWorkspaceMember(member)}><Save size={13} />保存</button></div></div>; return <div key={member.id || userId}><span className="member-avatar">{member.display_name?.slice(0, 1)}</span><div><strong>{member.display_name}</strong><span>{member.email}</span></div><div className="member-role-editor"><select aria-label={`${member.display_name} 工作区角色`} value={draft.roleKey} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, roleKey: event.target.value } }))}><option value="producer">制片</option><option value="writer">编剧</option><option value="art_director">资产美术</option><option value="voice_editor">配音/字幕</option><option value="reviewer">审片</option><option value="project_guest">项目受限成员</option></select><select aria-label={`${member.display_name} 项目访问范围`} value={draft.accessMode} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, accessMode: event.target.value } }))}><option value="all">全部项目</option><option value="project-only">仅授权项目</option></select><select aria-label={`${member.display_name} 工作区状态`} value={draft.status} onChange={(event) => setWorkspaceMemberDrafts((current) => ({ ...current, [userId]: { ...draft, status: event.target.value } }))}><option value="active">启用</option><option value="suspended">停用</option></select><button className="icon-text-button" onClick={() => saveWorkspaceMember(member)}><Save size={13} />保存</button></div></div>;
}) : <p className="empty-state">当前角色没有工作区成员管理权限。</p>} }) : <p className="empty-state">当前角色没有工作区成员管理权限。</p>}
</div> </div>
</section> </section>
@@ -1141,6 +1179,140 @@ function DirectorDesk({ project, activeShot, setActiveShotId }) {
); );
} }
const searchTypeIcons = {
project: FolderKanban,
episode: BookOpen,
script: BookOpen,
shot: Clapperboard,
asset: Images,
task: ListChecks,
job: Wand2,
delivery: Download
};
const searchStatusLabels = {
production: "制作中",
draft: "草稿",
ready: "就绪",
open: "待处理",
in_progress: "进行中",
blocked: "阻塞",
done: "已完成",
queued: "排队中",
running: "运行中",
completed: "已完成",
failed: "失败",
published: "已发布",
submitted: "待审批",
approved: "已批准"
};
function GlobalSearchPalette({ open, onClose, contextOverrides, onNavigate }) {
const inputRef = useRef(null);
const [query, setQuery] = useState("");
const [scope, setScope] = useState("workspace");
const [selectedIndex, setSelectedIndex] = useState(0);
const [payload, setPayload] = useState({ results: [], total: 0 });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setTimeout(() => inputRef.current?.focus(), 0);
}, [open]);
useEffect(() => {
if (!open) return undefined;
const normalized = query.trim();
if (!normalized) {
setPayload({ results: [], total: 0 });
setSelectedIndex(0);
setError("");
return undefined;
}
let cancelled = false;
const timer = setTimeout(async () => {
setLoading(true);
try {
const next = await searchPlatform(normalized, { scope, limit: 40 }, contextOverrides);
if (!cancelled) {
setPayload(next);
setSelectedIndex(0);
setError("");
}
} catch (requestError) {
if (!cancelled) {
setPayload({ results: [], total: 0 });
setError(requestError.message || "搜索失败");
}
} finally {
if (!cancelled) setLoading(false);
}
}, 180);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [open, query, scope, contextOverrides?.organizationId, contextOverrides?.workspaceId, contextOverrides?.projectId]);
useEffect(() => {
if (!open) return undefined;
function handleKeyDown(event) {
if (event.key === "Escape") {
event.preventDefault();
onClose();
} else if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex((current) => Math.min(current + 1, Math.max(0, payload.results.length - 1)));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex((current) => Math.max(0, current - 1));
} else if (event.key === "Enter" && payload.results[selectedIndex]) {
event.preventDefault();
onNavigate(payload.results[selectedIndex]);
onClose();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, onClose, onNavigate, payload.results, selectedIndex]);
if (!open) return null;
const results = payload.results || [];
return (
<div className="global-search-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && onClose()}>
<section className="global-search-dialog" role="dialog" aria-modal="true" aria-label="全局搜索">
<div className="global-search-head">
<div className="global-search-input-wrap"><Search size={18} /><input ref={inputRef} value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、剧本、镜头、资产、任务…" aria-label="搜索项目、剧本、镜头、资产、任务" /></div>
<button className="icon-only-button" type="button" onClick={onClose} title="关闭搜索" aria-label="关闭搜索"><XCircle size={17} /></button>
</div>
<div className="global-search-toolbar">
<div className="global-search-scope" role="group" aria-label="搜索范围">
<button type="button" className={scope === "project" ? "selected" : ""} onClick={() => setScope("project")} disabled={!contextOverrides?.projectId}>当前项目</button>
<button type="button" className={scope === "workspace" ? "selected" : ""} onClick={() => setScope("workspace")}>当前工作区</button>
</div>
<span>{loading ? "搜索中…" : query.trim() ? `${payload.total || 0} 条结果` : "输入关键词开始搜索"}</span>
</div>
{error && <div className="global-search-error"><AlertTriangle size={15} />{error}</div>}
<div className="global-search-results">
{!query.trim() && <div className="global-search-empty"><Search size={24} /><strong>搜索整个生产工作区</strong><span>项目、剧本、分集、镜头、资产、任务、生成任务和交付记录都支持快速定位。</span></div>}
{query.trim() && !loading && !error && !results.length && <div className="global-search-empty"><Search size={24} /><strong>没有找到匹配内容</strong><span>试试项目名、镜头编号、角色名或任务关键词。</span></div>}
{results.map((result, index) => {
const Icon = searchTypeIcons[result.type] || Search;
return <button key={`${result.type}-${result.id}`} type="button" className={`global-search-result ${index === selectedIndex ? "selected" : ""}`} onMouseEnter={() => setSelectedIndex(index)} onClick={() => { onNavigate(result); onClose(); }}>
<span className="global-search-result-icon"><Icon size={16} /></span>
<span className="global-search-result-copy"><strong>{result.title}</strong><small><b>{result.typeLabel}</b>{result.subtitle ? ` · ${result.subtitle}` : ""}</small></span>
{result.status && <span className="global-search-result-status">{searchStatusLabels[result.status] || result.status}</span>}
<ChevronRight size={15} />
</button>;
})}
</div>
<div className="global-search-foot"><span><kbd>↑</kbd><kbd>↓</kbd>选择</span><span><kbd>Enter</kbd>打开</span><span><kbd>Esc</kbd>关闭</span></div>
</section>
</div>
);
}
function App() { function App() {
const storedContext = getClientContext(); const storedContext = getClientContext();
const storedAuthSession = getAuthSession(); const storedAuthSession = getAuthSession();
@@ -1160,6 +1332,7 @@ function App() {
const [activeEpisodeId, setActiveEpisodeId] = useState(storedContext.episodeId || ""); const [activeEpisodeId, setActiveEpisodeId] = useState(storedContext.episodeId || "");
const [activeTabState, setActiveTabState] = useState(tabFromLocation); const [activeTabState, setActiveTabState] = useState(tabFromLocation);
const [mobileNavOpen, setMobileNavOpen] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [activeShotId, setActiveShotId] = useState(""); const [activeShotId, setActiveShotId] = useState("");
const [adapterId, setAdapterId] = useState(emptyProject.production.defaultAdapter); const [adapterId, setAdapterId] = useState(emptyProject.production.defaultAdapter);
const [taskLog, setTaskLog] = useState([ const [taskLog, setTaskLog] = useState([
@@ -1187,6 +1360,17 @@ function App() {
return () => window.removeEventListener("hashchange", handleHashChange); return () => window.removeEventListener("hashchange", handleHashChange);
}, []); }, []);
useEffect(() => {
function handleGlobalShortcut(event) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setSearchOpen(true);
}
}
window.addEventListener("keydown", handleGlobalShortcut);
return () => window.removeEventListener("keydown", handleGlobalShortcut);
}, []);
const activeTab = activeTabState; const activeTab = activeTabState;
const adapterCatalog = useMemo(() => adapterCatalogForContext(platformContext), [platformContext?.platform?.adapterCatalog, platformContext?.platform?.modelRegistry]); const adapterCatalog = useMemo(() => adapterCatalogForContext(platformContext), [platformContext?.platform?.adapterCatalog, platformContext?.platform?.modelRegistry]);
@@ -1352,6 +1536,19 @@ function App() {
refreshContext({ organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId }); refreshContext({ organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId });
} }
async function navigateFromSearch(result) {
const nextProjectId = result.projectId || (result.type === "project" ? result.id : activeProjectId);
await refreshContext({
organizationId: result.organizationId || activeOrgId,
workspaceId: result.workspaceId || activeWorkspaceId,
projectId: nextProjectId || ""
});
if (result.type === "shot" && typeof window !== "undefined") {
window.setTimeout(() => setActiveShotId(result.id), 0);
}
setActiveTab(result.targetTab || "creator-home");
}
async function inviteFromAdmin(body) { async function inviteFromAdmin(body) {
const result = await inviteMember(activeOrgId, body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId }); const result = await inviteMember(activeOrgId, body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId });
await refreshContext(); await refreshContext();
@@ -1597,6 +1794,7 @@ function App() {
<label><FolderKanban size={14} /><span>工作区</span><select value={activeWorkspaceId} onChange={(event) => switchWorkspace(event.target.value)} disabled={contextLoading || !workspaces.length} aria-label="工作区切换"><option value="">选择工作区</option>{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}</select></label> <label><FolderKanban size={14} /><span>工作区</span><select value={activeWorkspaceId} onChange={(event) => switchWorkspace(event.target.value)} disabled={contextLoading || !workspaces.length} aria-label="工作区切换"><option value="">选择工作区</option>{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}</select></label>
<label><Film size={14} /><span>项目</span><select value={activeProjectId} onChange={(event) => switchProject(event.target.value)} disabled={contextLoading || !projects.length} aria-label="项目切换"><option value="">选择项目</option>{projects.map((projectItem) => <option key={projectItem.id} value={projectItem.id}>{projectItem.title || projectItem.name}</option>)}</select></label> <label><Film size={14} /><span>项目</span><select value={activeProjectId} onChange={(event) => switchProject(event.target.value)} disabled={contextLoading || !projects.length} aria-label="项目切换"><option value="">选择项目</option>{projects.map((projectItem) => <option key={projectItem.id} value={projectItem.id}>{projectItem.title || projectItem.name}</option>)}</select></label>
</div> </div>
<button className="topbar-search-trigger" type="button" onClick={() => setSearchOpen(true)} title="打开全局搜索"><Search size={15} /><span>搜索生产内容</span><kbd>⌘K</kbd></button>
<NotificationBell contextOverrides={contextOverrides} onOpen={setActiveTab} /> <NotificationBell contextOverrides={contextOverrides} onOpen={setActiveTab} />
<div className="user-context"><span className="user-avatar">{currentUser?.display_name?.slice(0, 1) || "林"}</span><div><strong>{currentUser?.display_name || "本地用户"}</strong><span>{currentRole}</span></div><button className="icon-only-button" title="账号与安全" aria-label="账号与安全" onClick={() => setActiveTab("account")}><KeyRound size={15} /></button><button className="icon-only-button" title="退出登录" aria-label="退出登录" onClick={handleLogout}><LogOut size={15} /></button></div> <div className="user-context"><span className="user-avatar">{currentUser?.display_name?.slice(0, 1) || "林"}</span><div><strong>{currentUser?.display_name || "本地用户"}</strong><span>{currentRole}</span></div><button className="icon-only-button" title="账号与安全" aria-label="账号与安全" onClick={() => setActiveTab("account")}><KeyRound size={15} /></button><button className="icon-only-button" title="退出登录" aria-label="退出登录" onClick={handleLogout}><LogOut size={15} /></button></div>
{canCreateJobNow && <select value={adapterId} onChange={(event) => setAdapterId(event.target.value)} aria-label="生成适配器"> {canCreateJobNow && <select value={adapterId} onChange={(event) => setAdapterId(event.target.value)} aria-label="生成适配器">
@@ -1679,7 +1877,7 @@ function App() {
{activeTab === "delivery-portal" && <DeliveryAccessPage contextOverrides={contextOverrides} canApproveDelivery={canApproveDelivery} />} {activeTab === "delivery-portal" && <DeliveryAccessPage contextOverrides={contextOverrides} canApproveDelivery={canApproveDelivery} />}
{activeTab === "admin" && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />} {activeTab === "admin" && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
{activeTab === "admin-overview" && <AdminOverviewPage platformContext={platformContext} setActiveTab={setActiveTab} />} {activeTab === "admin-overview" && <AdminOverviewPage platformContext={platformContext} contextOverrides={contextOverrides} setActiveTab={setActiveTab} />}
{(activeTab === "admin-organizations" || activeTab === "admin-members") && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />} {(activeTab === "admin-organizations" || activeTab === "admin-members") && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
{activeTab === "admin-models" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />} {activeTab === "admin-models" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />}
{activeTab === "admin-queue" && <AdminQueuePage platformContext={platformContext} contextOverrides={contextOverrides} />} {activeTab === "admin-queue" && <AdminQueuePage platformContext={platformContext} contextOverrides={contextOverrides} />}
@@ -1732,6 +1930,7 @@ function App() {
</aside>} </aside>}
</div> </div>
</main> </main>
<GlobalSearchPalette open={searchOpen} onClose={() => setSearchOpen(false)} contextOverrides={contextOverrides} onNavigate={navigateFromSearch} />
</div> </div>
); );
} }
+62 -9
View File
@@ -1119,31 +1119,77 @@ function UsageTrendCard({ rows }) {
); );
} }
export function AdminOverviewPage({ platformContext, setActiveTab }) { export function AdminOverviewPage({ platformContext, contextOverrides = {}, setActiveTab }) {
const platform = platformContext?.platform || {}; const platform = platformContext?.platform || {};
const summary = platformContext?.summary || {}; const summary = platformContext?.summary || {};
const permissions = new Set(platformContext?.context?.permissions || []); const permissions = new Set(platformContext?.context?.permissions || []);
const canViewSystem = Boolean(platformContext?.context?.systemAdmin) || permissions.has("system:settings:view"); const canViewSystem = Boolean(platformContext?.context?.systemAdmin) || permissions.has("system:settings:view");
const canManageQueue = permissions.has("queue:manage"); const canManageQueue = permissions.has("queue:manage");
const runners = platform.runnerHealth || [];
const members = platform.members || []; const members = platform.members || [];
const projects = platform.projects || []; const projects = platform.projects || [];
const usage = platform.usage || {}; const usage = platform.usage || {};
const attention = runners.filter((runner) => runner.status !== "ready").length; const [health, setHealth] = useState({ services: [], summary: {} });
const [worker, setWorker] = useState(null);
const [readiness, setReadiness] = useState(null);
const [loading, setLoading] = useState(true);
const [notice, setNotice] = useState("");
const scopeKey = [contextOverrides.userId, contextOverrides.organizationId, contextOverrides.workspaceId, contextOverrides.projectId].join("|");
async function loadOperations() {
setLoading(true);
setNotice("");
const [healthResult, workerResult, readinessResult] = await Promise.allSettled([
fetchSystemHealth(contextOverrides),
fetchWorkerStatus(contextOverrides),
canViewSystem ? fetchSystemReadiness(contextOverrides) : Promise.resolve(null)
]);
const errors = [];
if (healthResult.status === "fulfilled") setHealth(healthResult.value);
else errors.push(`系统健康:${healthResult.reason?.message || "读取失败"}`);
if (workerResult.status === "fulfilled") setWorker(workerResult.value.worker || workerResult.value);
else errors.push(`Worker:${workerResult.reason?.message || "读取失败"}`);
if (readinessResult.status === "fulfilled" && readinessResult.value) setReadiness(readinessResult.value);
else if (canViewSystem && readinessResult.status === "rejected") errors.push(`生产就绪度:${readinessResult.reason?.message || "读取失败"}`);
if (errors.length) setNotice(errors.join(";"));
setLoading(false);
}
useEffect(() => { loadOperations(); }, [scopeKey, canViewSystem]);
const services = health.services?.length ? health.services : (platform.runnerHealth || []).map((runner) => ({
id: runner.id,
service_key: runner.id,
label: runner.name,
kind: "runner",
status: runner.status,
queue_depth: runner.queueDepth,
last_heartbeat: runner.lastHeartbeat,
metadata: {}
}));
const liveSummary = health.summary?.total ? health.summary : summary;
const queueDepth = Number(worker?.queueDepth ?? liveSummary.queueDepth ?? summary.queueDepth ?? 0);
const attention = Number(liveSummary.attention ?? services.filter((service) => service.status !== "ready").length);
const workerStatus = worker?.healthStatus || worker?.status || "unknown";
const readinessChecks = readiness?.checks || [];
const statusLabel = (status) => ({ ready: "正常", configured: "已配置", "active-local": "本地运行", "not-configured": "未配置", "needs-config": "需配置", missing: "缺失", unsafe: "不安全", paused: "已暂停", stale: "已过期", starting: "启动中", draining: "排空中", "waiting-model": "等待模型" }[status] || status || "未知");
const statusTone = (status) => ["ready", "configured", "active-local"].includes(status) ? "active" : ["not-configured", "needs-config", "missing", "unsafe", "stale", "failed"].includes(status) ? "failed" : "needs-evidence";
return ( return (
<div className="enterprise-page"> <div className="enterprise-page">
<AdminHeader eyebrow="ADMIN CONSOLE / OVERVIEW" title="管理概览" description="组织管理员在这里查看平台运行状态、团队协作、队列容量、用量和需要处理的治理事项。" action={<button className="subtle" onClick={() => window.location.reload()}><RefreshCw size={15} />刷新概览</button>} /> <AdminHeader eyebrow="ADMIN CONSOLE / OVERVIEW" title="管理概览" description="组织管理员在这里查看平台运行状态、团队协作、队列容量、用量和需要处理的治理事项。" action={<div className="header-actions"><StatusBadge status={loading ? "needs-evidence" : attention ? "needs-evidence" : "active"} label={loading ? "同步中" : attention ? `${attention} 项需要关注` : "运行正常"} /><button className="subtle" onClick={loadOperations} disabled={loading}><RefreshCw size={15} />{loading ? "同步中…" : "刷新概览"}</button></div>} />
{notice && <div className="inline-notice warn"><AlertTriangle size={15} /><span>{notice}</span></div>}
<div className="admin-kpi-grid"> <div className="admin-kpi-grid">
<AdminKpi icon={Users} label="组织成员" value={members.length} detail="活跃成员与待邀请" tone="ok" /> <AdminKpi icon={Users} label="组织成员" value={members.length} detail="活跃成员与待邀请" tone="ok" />
<AdminKpi icon={Workflow} label="生产项目" value={projects.length} detail={`${summary.activeProjects ?? 0} 个制作中`} tone="neutral" /> <AdminKpi icon={Workflow} label="生产项目" value={projects.length} detail={`${summary.activeProjects ?? 0} 个制作中`} tone="neutral" />
<AdminKpi icon={Network} label="模型 / Runner" value={`${summary.modelCount || platform.modelRegistry?.length || 0} / ${summary.runnerCount || runners.length}`} detail={`${attention} 个需要关注`} tone={attention ? "warn" : "ok"} /> <AdminKpi icon={Network} label="模型 / 服务" value={`${summary.modelCount || platform.modelRegistry?.length || 0} / ${liveSummary.total || services.length}`} detail={`${attention} 个需要关注`} tone={attention ? "warn" : "ok"} />
<AdminKpi icon={Gauge} label="队列深度" value={summary.queueDepth || 0} detail="本地任务等待处理" tone={summary.queueDepth ? "warn" : "ok"} /> <AdminKpi icon={Gauge} label="队列深度" value={queueDepth} detail={worker ? `Worker ${statusLabel(workerStatus)}` : "本地任务等待处理"} tone={queueDepth ? "warn" : "ok"} />
</div> </div>
<div className="enterprise-grid enterprise-grid-main"> <div className="enterprise-grid enterprise-grid-main">
<section className="studio-card wide-card"> <section className="studio-card wide-card">
<SectionBar title="平台运营状态" detail="当前组织 · 当前工作区" action={canManageQueue && <ContextLink onClick={() => setActiveTab("admin-queue")}>打开队列</ContextLink>} /> <SectionBar title="平台运营状态" detail={health.checkedAt ? `真实服务探针 · ${formatAdminDate(health.checkedAt)}` : "当前组织 · 当前工作区"} action={canManageQueue && <ContextLink onClick={() => setActiveTab("admin-queue")}>打开队列</ContextLink>} />
<div className="service-overview-list"> <div className="service-overview-list">
{runners.map((runner) => <div className="service-overview-row" key={runner.id}><div className="service-name"><span className={`service-dot ${runner.status === "ready" ? "ok" : "warn"}`} /><div><strong>{runner.name}</strong><span>{runner.lastHeartbeat || "local"}</span></div></div><div className="service-meter"><div><span style={{ width: `${Math.min(100, 18 + runner.queueDepth * 16)}%` }} /></div><small>queue {runner.queueDepth}</small></div><StatusBadge status={runner.status} label={runner.status === "ready" ? "正常" : runner.status === "planned" ? "规划中" : "待接入模型"} /></div>)} {services.map((service) => <div className="service-overview-row" key={service.id || service.service_key}><div className="service-name"><span className={`service-dot ${service.status === "ready" ? "ok" : "warn"}`} /><div><strong>{service.label || service.service_key}</strong><span>{service.kind} · {service.last_heartbeat || service.updated_at || "未上报心跳"}</span></div></div><div className="service-meter"><div><span style={{ width: `${Math.min(100, 18 + Number(service.queue_depth || 0) * 16)}%` }} /></div><small>queue {Number(service.queue_depth || 0)}</small></div><StatusBadge status={statusTone(service.status)} label={statusLabel(service.status)} /></div>)}
{worker && <div className="service-overview-row"><div className="service-name"><span className={`service-dot ${workerStatus === "ready" ? "ok" : "warn"}`} /><div><strong>本地 Worker · {worker.workerId || "default"}</strong><span>并发 {worker.maxConcurrency || 0} · 心跳 {worker.lastHeartbeatAt || "未上报"}</span></div></div><div className="service-meter"><div><span style={{ width: `${Math.min(100, Number(worker.inFlight || 0) * 34 + 12)}%` }} /></div><small>{worker.inFlight || 0} 执行中</small></div><StatusBadge status={statusTone(workerStatus)} label={statusLabel(workerStatus)} /></div>}
{!services.length && !worker && <div className="empty-table">还没有可读取的服务健康记录。</div>}
</div> </div>
</section> </section>
<section className="studio-card"> <section className="studio-card">
@@ -1151,7 +1197,7 @@ export function AdminOverviewPage({ platformContext, setActiveTab }) {
<div className="attention-list"> <div className="attention-list">
<div><ShieldAlert size={17} /><div><strong>外部云连接已禁用</strong><span>符合 local-runner-only 策略</span></div></div> <div><ShieldAlert size={17} /><div><strong>外部云连接已禁用</strong><span>符合 local-runner-only 策略</span></div></div>
<div><AlertTriangle size={17} /><div><strong>声音授权证据缺失</strong><span>1 个策略需要补充材料</span></div></div> <div><AlertTriangle size={17} /><div><strong>声音授权证据缺失</strong><span>1 个策略需要补充材料</span></div></div>
<div><Clock3 size={17} /><div><strong>{summary.queueDepth || 0} 个任务排队</strong><span>可以调整队列并发或优先级</span></div></div> <div><Clock3 size={17} /><div><strong>{queueDepth} 个任务排队</strong><span>可以调整队列并发或优先级</span></div></div>
</div> </div>
{canViewSystem && <button className="subtle full-width" onClick={() => setActiveTab("system-generation")}><Settings2 size={15} />查看系统策略</button>} {canViewSystem && <button className="subtle full-width" onClick={() => setActiveTab("system-generation")}><Settings2 size={15} />查看系统策略</button>}
</section> </section>
@@ -1160,6 +1206,13 @@ export function AdminOverviewPage({ platformContext, setActiveTab }) {
<div className="usage-hero"><strong>{usage.totalCost ?? 86}</strong><span>本月本地计量成本</span></div> <div className="usage-hero"><strong>{usage.totalCost ?? 86}</strong><span>本月本地计量成本</span></div>
{(usage.quotas || []).slice(0, 3).map((quota) => <div className="quota-row" key={`${quota.metric}-${quota.unit}`}><div><span>{quota.metric}</span><b>{quota.used_value}/{quota.limit_value} {quota.unit}</b></div><div className="quota-track"><span style={{ width: `${Math.min(100, Math.round((quota.used_value / quota.limit_value) * 100))}%` }} /></div></div>)} {(usage.quotas || []).slice(0, 3).map((quota) => <div className="quota-row" key={`${quota.metric}-${quota.unit}`}><div><span>{quota.metric}</span><b>{quota.used_value}/{quota.limit_value} {quota.unit}</b></div><div className="quota-track"><span style={{ width: `${Math.min(100, Math.round((quota.used_value / quota.limit_value) * 100))}%` }} /></div></div>)}
</section> </section>
{canViewSystem && <section className="studio-card wide-card">
<SectionBar title="生产就绪度" detail={readiness ? `${readiness.profile} · 检查于 ${formatAdminDate(readiness.checkedAt)}` : "系统管理员可查看部署前置条件"} action={<ContextLink onClick={() => setActiveTab("system-storage")}>查看系统设置</ContextLink>} />
<div className="policy-check-list">
{readinessChecks.slice(0, 8).map((check) => <div key={check.key}><ShieldCheck size={17} /><span>{check.label}<small>{check.detail}</small></span><StatusBadge status={statusTone(check.status)} label={statusLabel(check.status)} /></div>)}
{!readiness && !loading && <div className="empty-table">当前没有生产就绪度结果。</div>}
</div>
</section>}
</div> </div>
</div> </div>
); );
+5
View File
@@ -118,6 +118,11 @@ export async function fetchPlatformContext(overrides = {}) {
return payload; return payload;
} }
export async function searchPlatform(query, { scope = "workspace", limit = 40 } = {}, overrides = {}) {
const params = new URLSearchParams({ q: String(query || ""), scope, limit: String(limit) });
return apiFetch(`/api/search?${params.toString()}`, {}, overrides);
}
export async function fetchWorkItems(overrides = {}) { export async function fetchWorkItems(overrides = {}) {
return apiFetch("/api/work-items", {}, overrides); return apiFetch("/api/work-items", {}, overrides);
} }
+50 -1
View File
@@ -197,7 +197,9 @@ button {
.top-actions { .top-actions {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 10px; gap: 10px;
flex-wrap: wrap;
} }
.scope-switchers { .scope-switchers {
@@ -238,7 +240,8 @@ button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-width: 112px; min-width: 142px;
flex: 0 0 auto;
padding: 4px 8px 4px 4px; padding: 4px 8px 4px 4px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
@@ -247,6 +250,7 @@ button {
.user-context > div { .user-context > div {
display: grid; display: grid;
min-width: 0;
gap: 2px; gap: 2px;
} }
@@ -268,12 +272,18 @@ button {
} }
.user-context strong { .user-context strong {
overflow: hidden;
font-size: 12px; font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
} }
.user-context span:last-child { .user-context span:last-child {
overflow: hidden;
color: var(--faint); color: var(--faint);
font-size: 10px; font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
} }
.user-avatar, .user-avatar,
@@ -6006,6 +6016,39 @@ body {
.form-grid-two .span-two { grid-column: 1 / -1; } .form-grid-two .span-two { grid-column: 1 / -1; }
.identity-form .subsection-heading { margin-bottom: 2px; } .identity-form .subsection-heading { margin-bottom: 2px; }
/* Cross-module command search */
.topbar-search-trigger { display: inline-flex; align-items: center; gap: 7px; min-height: 38px; padding: 0 10px; color: var(--muted); background: #ffffff; border: 1px solid var(--line); border-radius: 8px; white-space: nowrap; }
.topbar-search-trigger:hover { color: var(--accent); border-color: #a8c5b6; background: #f7fbf8; }
.topbar-search-trigger kbd, .global-search-foot kbd { min-width: 22px; padding: 2px 5px; color: var(--faint); background: #f1f4f1; border: 1px solid #dce3de; border-radius: 4px; font-size: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; text-align: center; }
.global-search-backdrop { position: fixed; z-index: 120; inset: 0; display: grid; place-items: start center; padding: 10vh 16px 24px; background: rgba(17, 29, 24, .42); }
.global-search-dialog { display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; width: min(780px, 100%); max-height: min(720px, 82vh); overflow: hidden; background: #ffffff; border: 1px solid #c9d7ce; border-radius: 10px; box-shadow: 0 28px 80px rgba(15, 31, 23, .24); }
.global-search-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid #e5ebe7; }
.global-search-input-wrap { display: flex; align-items: center; gap: 9px; min-width: 0; flex: 1; color: var(--accent); }
.global-search-input-wrap input { width: 100%; min-height: 36px; padding: 0; color: var(--ink); background: transparent; border: 0; outline: 0; font-size: 15px; }
.global-search-input-wrap input::placeholder { color: #9aa59f; }
.global-search-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 14px; color: var(--faint); background: #fafcfb; border-bottom: 1px solid #edf1ee; font-size: 10px; }
.global-search-scope { display: flex; gap: 4px; padding: 3px; background: #eef3ef; border-radius: 6px; }
.global-search-scope button { min-height: 27px; padding: 0 9px; color: var(--muted); background: transparent; border: 0; border-radius: 5px; font-size: 10px; }
.global-search-scope button.selected { color: var(--accent); background: #ffffff; box-shadow: 0 1px 4px rgba(33, 70, 51, .1); font-weight: 800; }
.global-search-scope button:disabled { color: #b2bbb5; cursor: not-allowed; }
.global-search-error { display: flex; align-items: center; gap: 7px; margin: 10px 14px 0; padding: 9px 10px; color: #8d4f25; background: #fff4e8; border: 1px solid #efd4b8; border-radius: 6px; font-size: 11px; }
.global-search-results { min-height: 170px; overflow: auto; padding: 8px; }
.global-search-result { display: grid; grid-template-columns: 32px minmax(0, 1fr) auto 16px; gap: 9px; align-items: center; width: 100%; min-height: 54px; padding: 8px 9px; color: var(--ink); background: transparent; border: 0; border-radius: 7px; text-align: left; }
.global-search-result:hover, .global-search-result.selected { background: #eef6f0; }
.global-search-result-icon { display: grid; width: 30px; height: 30px; place-items: center; color: var(--accent); background: #e7f2ea; border-radius: 7px; }
.global-search-result-copy { display: grid; gap: 4px; min-width: 0; }
.global-search-result-copy strong { overflow: hidden; color: var(--ink); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.global-search-result-copy small { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.global-search-result-copy b { color: var(--accent); font-weight: 800; }
.global-search-result-status { max-width: 80px; overflow: hidden; color: var(--faint); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.global-search-result > svg { color: #9baa9f; }
.global-search-empty { display: grid; place-items: center; gap: 9px; min-height: 220px; padding: 32px; color: var(--faint); text-align: center; }
.global-search-empty svg { color: #a9b9ae; }
.global-search-empty strong { color: var(--ink); font-size: 13px; }
.global-search-empty span { max-width: 390px; font-size: 11px; line-height: 1.6; }
.global-search-foot { display: flex; justify-content: flex-end; gap: 14px; padding: 9px 14px; color: var(--faint); border-top: 1px solid #e5ebe7; font-size: 10px; }
.global-search-foot span { display: inline-flex; align-items: center; gap: 4px; }
@media (max-width: 980px) { @media (max-width: 980px) {
.tenant-settings-grid { grid-template-columns: 1fr; } .tenant-settings-grid { grid-template-columns: 1fr; }
.identity-policy-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .identity-policy-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -6017,6 +6060,12 @@ body {
} }
@media (max-width: 680px) { @media (max-width: 680px) {
.global-search-backdrop { padding: 16px 10px; }
.global-search-dialog { max-height: calc(100vh - 32px); }
.global-search-toolbar { align-items: flex-start; flex-direction: column; }
.global-search-foot { justify-content: space-between; gap: 6px; }
.topbar-search-trigger span, .topbar-search-trigger kbd { display: none; }
.topbar-search-trigger { min-width: 38px; justify-content: center; padding: 0 9px; }
.mfa-status-row { grid-template-columns: 36px minmax(0, 1fr); } .mfa-status-row { grid-template-columns: 36px minmax(0, 1fr); }
.mfa-status-row > .status-badge { grid-column: 2; justify-self: start; } .mfa-status-row > .status-badge { grid-column: 2; justify-self: start; }
.mfa-disable-form { grid-template-columns: 1fr; } .mfa-disable-form { grid-template-columns: 1fr; }