feat: harden tenant access and production operations
This commit is contained in:
@@ -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 的正常写锁保护。
|
||||
|
||||
|
||||
@@ -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 竞态导致重复生成。
|
||||
|
||||
## 模型协议适配器
|
||||
|
||||
模型连接器在模型中台注册,密钥只保存为环境变量名,不保存密钥本身:
|
||||
|
||||
@@ -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`:待接受邀请,不把邀请误当作已加入成员。
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -30,6 +30,7 @@ const scripts = [
|
||||
"smoke:work-items",
|
||||
"smoke:tasks",
|
||||
"smoke:notifications",
|
||||
"smoke:search",
|
||||
"smoke:project-isolation",
|
||||
"smoke:project-lifecycle"
|
||||
];
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 测试用户邀请创建失败");
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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"]
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
+21
-4
@@ -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") }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+209
-10
@@ -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
|
||||
</div>
|
||||
<form className="invite-form" onSubmit={handleInvite}>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
{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>
|
||||
</form>
|
||||
{inviteLink && <div className="invite-link-card">
|
||||
@@ -1000,7 +1038,7 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
|
||||
<div className="invite-list">
|
||||
{invitations.length ? invitations.map((invite) => <div className="invite-item" key={invite.id}>
|
||||
<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>) : <p className="empty-state">当前组织没有待处理邀请。</p>}
|
||||
</div>
|
||||
@@ -1011,8 +1049,8 @@ function AdminCenter({ platformContext, onRefresh, onSwitchOrganization, onInvit
|
||||
<div className="member-list workspace-member-list">
|
||||
{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 <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>;
|
||||
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><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>}
|
||||
</div>
|
||||
</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() {
|
||||
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() {
|
||||
<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>
|
||||
</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} />
|
||||
<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="生成适配器">
|
||||
@@ -1679,7 +1877,7 @@ function App() {
|
||||
{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-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-models" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />}
|
||||
{activeTab === "admin-queue" && <AdminQueuePage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
||||
@@ -1732,6 +1930,7 @@ function App() {
|
||||
</aside>}
|
||||
</div>
|
||||
</main>
|
||||
<GlobalSearchPalette open={searchOpen} onClose={() => setSearchOpen(false)} contextOverrides={contextOverrides} onNavigate={navigateFromSearch} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1119,31 +1119,77 @@ function UsageTrendCard({ rows }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminOverviewPage({ platformContext, setActiveTab }) {
|
||||
export function AdminOverviewPage({ platformContext, contextOverrides = {}, setActiveTab }) {
|
||||
const platform = platformContext?.platform || {};
|
||||
const summary = platformContext?.summary || {};
|
||||
const permissions = new Set(platformContext?.context?.permissions || []);
|
||||
const canViewSystem = Boolean(platformContext?.context?.systemAdmin) || permissions.has("system:settings:view");
|
||||
const canManageQueue = permissions.has("queue:manage");
|
||||
const runners = platform.runnerHealth || [];
|
||||
const members = platform.members || [];
|
||||
const projects = platform.projects || [];
|
||||
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 (
|
||||
<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">
|
||||
<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={Network} label="模型 / Runner" value={`${summary.modelCount || platform.modelRegistry?.length || 0} / ${summary.runnerCount || runners.length}`} detail={`${attention} 个需要关注`} tone={attention ? "warn" : "ok"} />
|
||||
<AdminKpi icon={Gauge} label="队列深度" value={summary.queueDepth || 0} detail="本地任务等待处理" tone={summary.queueDepth ? "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={queueDepth} detail={worker ? `Worker ${statusLabel(workerStatus)}` : "本地任务等待处理"} tone={queueDepth ? "warn" : "ok"} />
|
||||
</div>
|
||||
<div className="enterprise-grid enterprise-grid-main">
|
||||
<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">
|
||||
{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>
|
||||
</section>
|
||||
<section className="studio-card">
|
||||
@@ -1151,7 +1197,7 @@ export function AdminOverviewPage({ platformContext, setActiveTab }) {
|
||||
<div className="attention-list">
|
||||
<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><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>
|
||||
{canViewSystem && <button className="subtle full-width" onClick={() => setActiveTab("system-generation")}><Settings2 size={15} />查看系统策略</button>}
|
||||
</section>
|
||||
@@ -1160,6 +1206,13 @@ export function AdminOverviewPage({ platformContext, setActiveTab }) {
|
||||
<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>)}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -118,6 +118,11 @@ export async function fetchPlatformContext(overrides = {}) {
|
||||
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 = {}) {
|
||||
return apiFetch("/api/work-items", {}, overrides);
|
||||
}
|
||||
|
||||
+50
-1
@@ -197,7 +197,9 @@ button {
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scope-switchers {
|
||||
@@ -238,7 +240,8 @@ button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 112px;
|
||||
min-width: 142px;
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 8px 4px 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
@@ -247,6 +250,7 @@ button {
|
||||
|
||||
.user-context > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@@ -268,12 +272,18 @@ button {
|
||||
}
|
||||
|
||||
.user-context strong {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-context span:last-child {
|
||||
overflow: hidden;
|
||||
color: var(--faint);
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-avatar,
|
||||
@@ -6006,6 +6016,39 @@ body {
|
||||
.form-grid-two .span-two { grid-column: 1 / -1; }
|
||||
.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) {
|
||||
.tenant-settings-grid { grid-template-columns: 1fr; }
|
||||
.identity-policy-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@@ -6017,6 +6060,12 @@ body {
|
||||
}
|
||||
|
||||
@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 > .status-badge { grid-column: 2; justify-self: start; }
|
||||
.mfa-disable-form { grid-template-columns: 1fr; }
|
||||
|
||||
Reference in New Issue
Block a user