From 9811469a64f87664e59d6500f9384a06063a4053 Mon Sep 17 00:00:00 2001 From: xz Date: Mon, 24 Aug 2026 22:02:22 +0800 Subject: [PATCH] feat: harden tenant access and production operations --- README.md | 11 +- docs/API_REFERENCE.md | 20 +++ docs/MULTI_TENANT_DESIGN.md | 5 +- package.json | 1 + scripts/smoke-all.mjs | 1 + scripts/smoke-invitations.mjs | 60 ++++++++ scripts/smoke-mfa.mjs | 2 +- scripts/smoke-search.mjs | 55 ++++++++ server/db.mjs | 3 + server/execution.mjs | 4 + server/local-api.mjs | 25 +++- server/schema.sql | 1 + server/search.mjs | 188 +++++++++++++++++++++++++ server/tenant.mjs | 23 +-- src/App.jsx | 219 +++++++++++++++++++++++++++-- src/components/EnterprisePages.jsx | 71 ++++++++-- src/lib/api.js | 5 + src/styles.css | 51 ++++++- 18 files changed, 709 insertions(+), 36 deletions(-) create mode 100644 scripts/smoke-search.mjs create mode 100644 server/search.mjs diff --git a/README.md b/README.md index 0538735..8dd2ed8 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,13 @@ - 多组织:一个用户可以属于多个组织,组织之间的项目、成员、模型和审计数据隔离。 - 多工作区:制作部、素材实验室、客户空间可以在同一组织下独立管理。 - 多用户与角色:组织所有者、组织管理员、制片、编剧、资产美术、配音/字幕、审片、项目编辑和项目查看者。 +- 项目级访问控制:项目邀请使用受限 `project_guest` 工作区身份和 `project-only` 访问模式,只能看到显式授权项目,不会因加入工作区而继承全部项目或生成权限。 - 真实 scope API:所有项目、任务、模型、审片、导出请求都经过 user → organization → workspace → project 检查。 - 平台级系统治理:系统配置、功能开关、通知渠道、API 客户端、服务健康和队列运营;系统权限独立于组织管理员。 +- 实时运营概览:管理概览直接读取服务健康探针、Worker 心跳/队列和部署生产就绪度,不使用前端静态状态冒充在线运行状态。 - 企业身份中心:TOTP MFA、登录策略、OIDC/SAML 提供商登记与发现探测、SCIM 目录令牌轮换和受令牌保护的用户同步接口。 - 用户通知中心:按用户、组织、工作区隔离的通知收件箱、未读计数、单条/全部已读、组织级通知偏好和服务端收件人过滤。 +- 全局生产搜索:支持 `⌘/Ctrl + K` 命令入口,在当前工作区或当前项目内定位项目、剧本、分集、镜头、资产、任务、生成任务和交付记录;结果由后端按租户作用域过滤。 - 协作任务中心:项目内任务创建、负责人分派、优先级、截止时间、状态、本人任务更新、任务通知和审计记录。 - 资产版本台账:资产版本不可覆盖,支持当前版本切换、历史版本恢复、锁定状态和镜头绑定审计。 - 本地持久化: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 ``` -完整回归(必须串行执行,共 25 项,避免 SQLite 写入竞争): +完整回归(必须串行执行,共 32 项,避免 SQLite 写入竞争): ```bash 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/password 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 POST http://127.0.0.1:8787/api/tasks 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/:auditId 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/assets?kind=voice POST http://127.0.0.1:8787/api/assets/upload @@ -237,6 +245,7 @@ API 客户端 scope 由后端白名单强制执行:`jobs:read` 只能读取任 - 一次生成只允许一张完整单画面;分镜图、接触表、边界表只能审核,不能喂回生成。 - 声音作为独立资产锁定,不把 MiniMax H3 随机原生声音作为最终角色声线。 - 当前平台底座已具备真实任务合同、队列、重试、取消、租约、并发控制和 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`。 - 本地验证脚本会修改 SQLite,因此多个 smoke 脚本应顺序执行;并行写入会触发 SQLite 的正常写锁保护。 diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index e2f7cc6..1b62235 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -23,6 +23,7 @@ user → organization → workspace → project → permission | 认证与会话 | `/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:*` | | 租户上下文 | `/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/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` | @@ -35,6 +36,17 @@ user → organization → workspace → project → permission 未登录返回 `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 @@ -94,11 +106,15 @@ POST /api/projects/:projectId/lifecycle 系统管理员可读取部署边界和当前运行时状态: ```text +GET /api/system/health +GET /api/system/worker GET /api/system/readiness GET /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` 审计事件;接口不提供在线恢复,恢复必须经过停机审查和人工确认。 ### 商业运营、席位与配额 @@ -161,6 +177,8 @@ POST /api/auth/register 创建或重发邀请时,服务端只在当前响应返回一次性 `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`。 ### MFA @@ -259,6 +277,8 @@ AI_DRAMA_WORKER_STALE_MS=30000 如果设置 `AI_DRAMA_WORKER_ENABLED=0`,任务不会自动执行,后台仍会显示 Worker 已暂停。 +`POST /api/jobs/:jobId/run` 允许管理员或拥有 `job:create` 的角色手动执行任务;如果请求到达时任务已经被本地 Worker 完成,服务端返回现有结果并带 `idempotent: true`,避免手动按钮与自动 Worker 竞态导致重复生成。 + ## 模型协议适配器 模型连接器在模型中台注册,密钥只保存为环境变量名,不保存密钥本身: diff --git a/docs/MULTI_TENANT_DESIGN.md b/docs/MULTI_TENANT_DESIGN.md index 08be623..4456dde 100644 --- a/docs/MULTI_TENANT_DESIGN.md +++ b/docs/MULTI_TENANT_DESIGN.md @@ -22,7 +22,7 @@ └── 模型连接器、Runner、资产存储策略 ``` -组织解决客户和计费隔离,工作区解决团队协作隔离,项目解决内容访问和交付隔离。一个用户可以加入多个组织,在同一组织中进入多个工作区;项目默认继承工作区成员,但敏感项目可以额外配置 project member。 +组织解决客户和计费隔离,工作区解决团队协作隔离,项目解决内容访问和交付隔离。一个用户可以加入多个组织,在同一组织中进入多个工作区;普通工作区成员默认可以访问工作区项目,项目级邀请则使用 `workspace_members.access_mode = project-only` 和 `project_guest` 角色,只能进入显式授权的项目。 ## 角色模型 @@ -35,6 +35,7 @@ | 工作区 | `art_director` | 角色/场景/道具锁、参考图、prompt、连续性 | | 工作区 | `voice_editor` | 声线锁、TTS、字幕、ASR 对齐 | | 工作区 | `reviewer` | QA、审片意见、通过/驳回 | +| 工作区 | `project_guest` | 只进入被授权项目,不继承工作区其他项目和生成权限 | | 项目 | `project_editor` | 指定项目内容编辑 | | 项目 | `project_viewer` | 只读查看、下载被授权的交付物 | @@ -48,7 +49,7 @@ - `organizations`:租户、slug、部署模式、所有者。 - `organization_members`:用户加入组织的角色、邀请状态。 - `workspaces`:组织下的生产空间。 -- `workspace_members`:工作区角色。 +- `workspace_members`:工作区角色和访问范围;`access_mode=project-only` 只允许显式 `project_members` 项目。 - `projects`:工作区下的系列、模板或客户项目。 - `project_members`:项目级额外授权。 - `invitations`:待接受邀请,不把邀请误当作已加入成员。 diff --git a/package.json b/package.json index 08932ba..c25e4a2 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "smoke:tasks": "node scripts/smoke-tasks.mjs", "smoke:task-collaboration": "node scripts/smoke-task-collaboration.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-lifecycle": "node scripts/smoke-project-lifecycle.mjs", "smoke:all": "node scripts/smoke-all.mjs" diff --git a/scripts/smoke-all.mjs b/scripts/smoke-all.mjs index f45609f..957b4cd 100644 --- a/scripts/smoke-all.mjs +++ b/scripts/smoke-all.mjs @@ -30,6 +30,7 @@ const scripts = [ "smoke:work-items", "smoke:tasks", "smoke:notifications", + "smoke:search", "smoke:project-isolation", "smoke:project-lifecycle" ]; diff --git a/scripts/smoke-invitations.mjs b/scripts/smoke-invitations.mjs index 1939688..2a5b0e1 100644 --- a/scripts/smoke-invitations.mjs +++ b/scripts/smoke-invitations.mjs @@ -1,4 +1,5 @@ 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 scope = { @@ -33,8 +34,25 @@ async function login(email) { const ownerHeaders = { ...(await login("producer@local.test")), ...scope }; const writerHeaders = { ...(await login("writer@local.test")), ...scope }; const inviteEmail = `invitation-smoke-${Date.now()}@local.test`; +const projectInviteEmail = `project-invitation-smoke-${Date.now()}@local.test`; let invitationId = ""; 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 { 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 }), "读取撤销后商业席位"); 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 }), "读取组织邀请列表"); assert.ok(!listed.invitations.some((item) => item.id === invitationId), "撤销邀请不能继续出现在待处理列表"); } finally { @@ -109,6 +161,14 @@ try { 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}`); diff --git a/scripts/smoke-mfa.mjs b/scripts/smoke-mfa.mjs index fafdf16..812af6b 100644 --- a/scripts/smoke-mfa.mjs +++ b/scripts/smoke-mfa.mjs @@ -72,7 +72,7 @@ try { const invitation = await request("/api/organizations/org-studio-lab/invitations", ownerHeaders, { method: "POST", 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 测试用户邀请创建失败"); diff --git a/scripts/smoke-search.mjs b/scripts/smoke-search.mjs new file mode 100644 index 0000000..6725119 --- /dev/null +++ b/scripts/smoke-search.mjs @@ -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}`); diff --git a/server/db.mjs b/server/db.mjs index 4e2f394..da79bbd 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -92,6 +92,7 @@ ensureColumn("projects", "archived_at", "TEXT"); ensureColumn("projects", "archived_by", "TEXT"); ensureColumn("projects", "archived_from_status", "TEXT"); 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", "risk_level", "TEXT NOT NULL DEFAULT 'medium'"); ensureColumn("auth_sessions", "risk_score", "INTEGER NOT NULL DEFAULT 50"); @@ -200,6 +201,7 @@ function seedRoles() { ["art_director", "workspace", "资产美术", "角色、场景、道具、prompt 和连续性"], ["voice_editor", "workspace", "配音/字幕", "固定声线、TTS、字幕和 ASR"], ["reviewer", "workspace", "审片", "QA、审片意见和通过/驳回"], + ["project_guest", "workspace", "项目受限成员", "只访问被授权项目,不继承工作区其他项目"], ["project_editor", "project", "项目编辑", "指定项目的内容编辑"], ["project_viewer", "project", "项目查看者", "只读查看和下载授权交付物"] ]; @@ -263,6 +265,7 @@ function seedRoles() { art_director: ["asset:edit", "prompt:edit", "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"], + 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_viewer: ["script:read", "task:view", "delivery:view"] }; diff --git a/server/execution.mjs b/server/execution.mjs index 07f62d0..86b8528 100644 --- a/server/execution.mjs +++ b/server/execution.mjs @@ -491,6 +491,10 @@ function assertSingleFrameResult(job, result) { export async function executeGenerationJob(context, jobId, body = {}) { const job = jobRow(context, jobId); 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); if (!["queued", "blocked", "failed", "cancelled"].includes(job.status)) throw httpError(409, "job_not_runnable", "当前任务状态不能执行", { status: job.status }); const unresolved = unresolvedDependencies(jobId); diff --git a/server/local-api.mjs b/server/local-api.mjs index efdb9bd..f28d763 100644 --- a/server/local-api.mjs +++ b/server/local-api.mjs @@ -141,6 +141,7 @@ import { createSsoTicket, handleOidcCallback, redeemSsoTicket, startOidcLogin } import { handleSamlCallback, isSamlProvider, samlServiceProviderMetadata, startSamlLogin } from "./saml.mjs"; import { listWorkItems } from "./work-items.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 { 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 }); } 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; if (workspaceId && !dbGet("SELECT id FROM workspaces WHERE id = ? AND organization_id = ?", [workspaceId, organizationId])) throw httpError(400, "workspace_invalid", "邀请目标工作区不属于当前组织"); 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", "邀请目标项目不属于当前工作区"); + 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 inviteToken = `invite-${randomBytes(24).toString("base64url")}`; 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]); if (!member) throw httpError(404, "member_not_found", "工作区成员不存在"); const roleKey = String(body.roleKey || member.role_key); + const accessMode = String(body.accessMode || member.access_mode || "all"); const status = String(body.status || member.status); 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", "成员状态无效"); 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]); - addAudit({ context, action: "workspace.member.updated", targetType: "workspace_member", targetId: `${workspaceId}:${userId}`, metadata: { previousRole: member.role_key, roleKey, previousStatus: member.status, status } }); + 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, previousAccessMode: member.access_mode || "all", accessMode, previousStatus: member.status, status } }); 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/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") { const context = resolveContext(req.headers, url.searchParams); return send(res, 200, listWorkItems(context, { limit: url.searchParams.get("limit") })); diff --git a/server/schema.sql b/server/schema.sql index a623063..698efb5 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -290,6 +290,7 @@ CREATE TABLE IF NOT EXISTS workspace_members ( workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_key TEXT NOT NULL, + access_mode TEXT NOT NULL DEFAULT 'all', status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'invited', 'suspended')), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, diff --git a/server/search.mjs b/server/search.mjs new file mode 100644 index 0000000..935b49c --- /dev/null +++ b/server/search.mjs @@ -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) + }; +} diff --git a/server/tenant.mjs b/server/tenant.mjs index 0b19750..a278b63 100644 --- a/server/tenant.mjs +++ b/server/tenant.mjs @@ -114,7 +114,8 @@ function accessibleProjects(workspaceId, userId, organizationId) { FROM projects p 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' - 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`, [userId, userId, workspaceId] ); @@ -179,7 +180,8 @@ function ensureProject(userId, organization, workspace, projectId) { [projectId, 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 }); } return { project, membership }; @@ -304,7 +306,7 @@ export function orgMembers(organizationId) { export function workspaceMembers(workspaceId) { 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 JOIN users u ON u.id = wm.user_id LEFT JOIN roles r ON r.key = wm.role_key @@ -486,7 +488,10 @@ export function previewInvitation(token) { } 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) { @@ -497,15 +502,17 @@ function applyInvitationMembership(invitation, userId, timestamp) { [`om-${invitation.organization_id}-${userId}`, invitation.organization_id, userId, organizationRole, timestamp, timestamp, timestamp] ); if (invitation.workspace_id) { + const accessMode = invitation.project_id ? "project-only" : "all"; 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", - [`wm-${invitation.workspace_id}-${userId}`, invitation.workspace_id, userId, invitationRoleForWorkspace(invitation.role_key), timestamp, timestamp] + "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), accessMode, timestamp, timestamp] ); } if (invitation.project_id) { + const projectRole = ["project_editor", "project_viewer"].includes(invitation.role_key) ? invitation.role_key : "project_editor"; 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", - [`pm-${invitation.project_id}-${userId}`, invitation.project_id, userId, timestamp, timestamp] + "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, projectRole, timestamp, timestamp] ); } } diff --git a/src/App.jsx b/src/App.jsx index 710a4e8..885aea7 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, Archive, @@ -11,6 +11,7 @@ import { Clapperboard, Coins, Copy, + ChevronRight, Download, FileJson2, Film, @@ -83,7 +84,7 @@ import { ScriptProductionPage, SeriesBiblePage } 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 { projectQa } from "./lib/qa"; import { getPlatformSummary, platformData } from "./platform/platformData"; @@ -688,7 +689,9 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit const usage = platform.usage; const [inviteEmail, setInviteEmail] = useState(""); const [inviteRole, setInviteRole] = useState("writer"); + const [inviteScope, setInviteScope] = useState(workspaces.length ? "workspace" : "organization"); const [inviteWorkspaceId, setInviteWorkspaceId] = useState(workspaces[0]?.id || ""); + const [inviteProjectId, setInviteProjectId] = useState(""); const [organizationName, setOrganizationName] = useState(""); const [workspaceName, setWorkspaceName] = useState(""); const [projectName, setProjectName] = useState(""); @@ -705,6 +708,13 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit const [inviteLink, setInviteLink] = 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(() => { setMemberDrafts(Object.fromEntries(members.map((member) => [member.user_id || member.id, { roleKey: member.role_key || "org_member", status: member.status || "active" }]))); }, [platform.members]); @@ -723,9 +733,17 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit }, [context?.currentOrganization?.id, context?.currentWorkspace?.id, context?.currentProject?.id]); 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]); + 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(() => { const availableRole = rolePolicies.roles.find((role) => role.key === selectedPolicyRoleKey) || rolePolicies.roles[0]; 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) { if (!invitation) return ""; 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) => { 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) { setInviteLink(inviteUrl(result.invitation)); setInviteEmail(""); @@ -978,8 +1014,10 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
邀请成员
-
setInviteEmail(event.target.value)} placeholder="name@studio.local" aria-label="成员邮箱" />
- +
setInviteEmail(event.target.value)} placeholder="name@studio.local" aria-label="成员邮箱" />
+ {inviteScope !== "organization" && } + {inviteScope === "project" && } +
{inviteLink &&
@@ -1000,7 +1038,7 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
{invitations.length ? invitations.map((invite) =>
-
{invite.email}{invite.role_name || invite.role_key} · {invite.workspace_name || "组织级"}有效期至 {invite.expires_at ? new Date(invite.expires_at).toLocaleString("zh-CN", { dateStyle: "short", timeStyle: "short" }) : "-"} · {invite.token_hint || "token hidden"}
+
{invite.email}{invite.role_name || invite.role_key} · {invite.project_name ? `${invite.workspace_name} / ${invite.project_name}` : invite.workspace_name || "组织级"}有效期至 {invite.expires_at ? new Date(invite.expires_at).toLocaleString("zh-CN", { dateStyle: "short", timeStyle: "short" }) : "-"} · {invite.token_hint || "token hidden"}
pending
) :

当前组织没有待处理邀请。

}
@@ -1011,8 +1049,8 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
{workspaceMembers.length ? workspaceMembers.map((member) => { const userId = member.user_id || member.id; - const draft = workspaceMemberDrafts[userId] || { roleKey: member.role_key || "producer", status: member.status || "active" }; - return
{member.display_name?.slice(0, 1)}
{member.display_name}{member.email}
; + const draft = workspaceMemberDrafts[userId] || { roleKey: member.role_key || "producer", accessMode: member.access_mode || "all", status: member.status || "active" }; + return
{member.display_name?.slice(0, 1)}
{member.display_name}{member.email}
; }) :

当前角色没有工作区成员管理权限。

}
@@ -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 ( +
event.target === event.currentTarget && onClose()}> +
+
+
setQuery(event.target.value)} placeholder="搜索项目、剧本、镜头、资产、任务…" aria-label="搜索项目、剧本、镜头、资产、任务" />
+ +
+
+
+ + +
+ {loading ? "搜索中…" : query.trim() ? `${payload.total || 0} 条结果` : "输入关键词开始搜索"} +
+ {error &&
{error}
} +
+ {!query.trim() &&
搜索整个生产工作区项目、剧本、分集、镜头、资产、任务、生成任务和交付记录都支持快速定位。
} + {query.trim() && !loading && !error && !results.length &&
没有找到匹配内容试试项目名、镜头编号、角色名或任务关键词。
} + {results.map((result, index) => { + const Icon = searchTypeIcons[result.type] || Search; + return ; + })} +
+
↑↓选择Enter打开Esc关闭
+
+
+ ); +} + function App() { const storedContext = getClientContext(); const storedAuthSession = getAuthSession(); @@ -1160,6 +1332,7 @@ function App() { const [activeEpisodeId, setActiveEpisodeId] = useState(storedContext.episodeId || ""); const [activeTabState, setActiveTabState] = useState(tabFromLocation); const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); const [activeShotId, setActiveShotId] = useState(""); const [adapterId, setAdapterId] = useState(emptyProject.production.defaultAdapter); const [taskLog, setTaskLog] = useState([ @@ -1187,6 +1360,17 @@ function App() { 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 adapterCatalog = useMemo(() => adapterCatalogForContext(platformContext), [platformContext?.platform?.adapterCatalog, platformContext?.platform?.modelRegistry]); @@ -1352,6 +1536,19 @@ function App() { 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) { const result = await inviteMember(activeOrgId, body, { organizationId: activeOrgId, workspaceId: activeWorkspaceId, projectId: activeProjectId }); await refreshContext(); @@ -1597,6 +1794,7 @@ function App() {
+
{currentUser?.display_name?.slice(0, 1) || "林"}
{currentUser?.display_name || "本地用户"}{currentRole}
{canCreateJobNow &&