feat: add workflow orchestration and media governance
This commit is contained in:
@@ -246,6 +246,9 @@ API 客户端 scope 由后端白名单强制执行:`jobs:read` 只能读取任
|
|||||||
- 声音作为独立资产锁定,不把 MiniMax H3 随机原生声音作为最终角色声线。
|
- 声音作为独立资产锁定,不把 MiniMax H3 随机原生声音作为最终角色声线。
|
||||||
- 当前平台底座已具备真实任务合同、队列、重试、取消、租约、并发控制和 HTTP Runner 执行接口;API 启动时自动运行本地 Worker,只会领取本地 `ready` 连接器任务。若未配置可用的自有图片/视频/TTS/ASR Runner,任务会明确显示 `not-connected`/`blocked`,不会伪称已经生成真实媒体成片。
|
- 当前平台底座已具备真实任务合同、队列、重试、取消、租约、并发控制和 HTTP Runner 执行接口;API 启动时自动运行本地 Worker,只会领取本地 `ready` 连接器任务。若未配置可用的自有图片/视频/TTS/ASR Runner,任务会明确显示 `not-connected`/`blocked`,不会伪称已经生成真实媒体成片。
|
||||||
- 手动执行接口对已完成任务是幂等的:如果本地 Worker 已先完成任务,重复调用 `POST /api/jobs/:jobId/run` 会返回现有完成结果并标记 `idempotent: true`,不会再次调用模型或生成第二份媒体。
|
- 手动执行接口对已完成任务是幂等的:如果本地 Worker 已先完成任务,重复调用 `POST /api/jobs/:jobId/run` 会返回现有完成结果并标记 `idempotent: true`,不会再次调用模型或生成第二份媒体。
|
||||||
|
- 流程模板已经是服务端版本化对象:全局内置模板可被组织 / 工作区新版本覆盖,生成队列支持“只生成计划”或“按步骤创建带依赖的 generation jobs”。
|
||||||
|
- 媒体证据支持按项目 scope 读取原始图片、视频、音频以及实际首帧 / 末帧;审片中心使用文件级预览、SHA-256、FFprobe 和 QA 证据共同验收,不把路径字符串当作已生成事实。
|
||||||
|
- 系统存储页支持保留周期和“仅清理无引用临时文件”的清理预览;回收动作只处理 `outputs/frames/tmp/cache` 中未被资产版本、媒体证据或合成清单引用的文件,并写入审计。
|
||||||
- 模型协议、Worker 环境变量、状态字段和接口返回约定见 `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/docs/API_REFERENCE.md`。
|
- 模型协议、Worker 环境变量、状态字段和接口返回约定见 `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/docs/API_REFERENCE.md`。
|
||||||
- 本地验证脚本会修改 SQLite,因此多个 smoke 脚本应顺序执行;并行写入会触发 SQLite 的正常写锁保护。
|
- 本地验证脚本会修改 SQLite,因此多个 smoke 脚本应顺序执行;并行写入会触发 SQLite 的正常写锁保护。
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ user → organization → workspace → project → permission
|
|||||||
| 内容生产 | `/api/production/catalog`、`/api/production/graph`、`/api/production/script/*`、`/api/production/bible`、`/api/production/shots/*` | `script:edit`、`asset:edit`、`job:create` |
|
| 内容生产 | `/api/production/catalog`、`/api/production/graph`、`/api/production/script/*`、`/api/production/bible`、`/api/production/shots/*` | `script:edit`、`asset:edit`、`job:create` |
|
||||||
| 资产与声音 | `/api/assets/*`、`/api/assets/:assetId/versions/upload`、`/api/assets/:assetId/verify`、`/api/assets/:assetId/versions/:versionId/restore` | `asset:edit`、`voice:edit` |
|
| 资产与声音 | `/api/assets/*`、`/api/assets/:assetId/versions/upload`、`/api/assets/:assetId/verify`、`/api/assets/:assetId/versions/:versionId/restore` | `asset:edit`、`voice:edit` |
|
||||||
| 生成任务 | `/api/jobs`、`/api/jobs/:jobId/*`、`/api/adapters/dry-run` | `job:create` 或 `queue:manage` |
|
| 生成任务 | `/api/jobs`、`/api/jobs/:jobId/*`、`/api/adapters/dry-run` | `job:create` 或 `queue:manage` |
|
||||||
|
| 流程模板编排 | `/api/workflows/templates`、`/api/workflows/templates/:id/instantiate`、`/api/workflows/runs` | 读取 / 实例化:`script:read`、`job:create` 或 `project:create`;管理:`workflow:manage` |
|
||||||
|
| 媒体证据与存储 | `/api/production/media-artifacts`、`/api/production/media-artifacts/:id/content`、`/api/usage/storage`、`/api/system/storage/*` | 媒体:`qa:review` 或 `delivery:view`;存储回收:系统设置权限 |
|
||||||
| 审片与交付 | `/api/production/reviews/*`、`/api/production/qa/run`、`/api/production/deliveries/*`、`/api/exports/write` | `qa:review`、`delivery:approve` |
|
| 审片与交付 | `/api/production/reviews/*`、`/api/production/qa/run`、`/api/production/deliveries/*`、`/api/exports/write` | `qa:review`、`delivery:approve` |
|
||||||
| 组织运营 | `/api/usage`、`/api/billing`、`/api/organizations/:id/commercial`、`/api/organizations/:id/usage`、`/api/organizations/:id/usage/export`、`/api/organizations/:id/billing`、`/api/organizations/:id/quotas/:quotaId`、`/api/audit`、`/api/audit/export` | `usage:view`、`billing:manage`、`quota:manage`、`audit:view` |
|
| 组织运营 | `/api/usage`、`/api/billing`、`/api/organizations/:id/commercial`、`/api/organizations/:id/usage`、`/api/organizations/:id/usage/export`、`/api/organizations/:id/billing`、`/api/organizations/:id/quotas/:quotaId`、`/api/audit`、`/api/audit/export` | `usage:view`、`billing:manage`、`quota:manage`、`audit:view` |
|
||||||
| 模型中台 | `/api/platform/models`、`/api/platform/models/register`、`/api/platform/models/:id/probe` | `model:manage` |
|
| 模型中台 | `/api/platform/models`、`/api/platform/models/register`、`/api/platform/models/:id/probe` | `model:manage` |
|
||||||
@@ -47,6 +49,71 @@ GET /api/search?q=雷雨&scope=project&limit=40
|
|||||||
|
|
||||||
搜索不会把连接器 endpoint、API key、客户交付令牌或本地存储绝对路径作为可搜索字段返回;跨组织、跨工作区或无项目访问权的关键词不会出现在结果中。
|
搜索不会把连接器 endpoint、API key、客户交付令牌或本地存储绝对路径作为可搜索字段返回;跨组织、跨工作区或无项目访问权的关键词不会出现在结果中。
|
||||||
|
|
||||||
|
### 流程模板与生产编排
|
||||||
|
|
||||||
|
流程模板是服务端版本化对象,不是前端写死的按钮配置。平台先提供全局内置模板,组织或工作区可以用同一 `templateKey` 创建自己的版本;列表按当前工作区优先、组织其次、全局最后合并同键模板。全局内置模板只能读取,不能直接修改。
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/workflows/templates?includeArchived=1
|
||||||
|
POST /api/workflows/templates
|
||||||
|
PATCH /api/workflows/templates/:templateId
|
||||||
|
POST /api/workflows/templates/:templateId/instantiate
|
||||||
|
GET /api/workflows/runs?status=&limit=50
|
||||||
|
```
|
||||||
|
|
||||||
|
创建或更新模板的核心结构如下:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "AI 漫剧单镜头生产",
|
||||||
|
"templateKey": "ai-manhua-drama",
|
||||||
|
"category": "ai-drama",
|
||||||
|
"status": "active",
|
||||||
|
"description": "关键帧 -> 图生视频 -> 固定配音 -> ASR -> 合成",
|
||||||
|
"defaultAdapterId": "owned-model-platform",
|
||||||
|
"steps": [
|
||||||
|
{ "key": "keyframe", "label": "单画面关键帧", "jobKind": "单画面关键帧", "requiresShot": true },
|
||||||
|
{ "key": "video", "label": "首尾帧图生视频", "jobKind": "首尾帧图生视频", "requiresShot": true, "dependsOnPrevious": true }
|
||||||
|
],
|
||||||
|
"gates": [
|
||||||
|
{ "key": "single-frame", "label": "一图一画面", "blocking": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`steps` 最多 32 个,模板状态只能是 `draft`、`active` 或 `archived`;只有 `active` 模板可以进入生产。`POST /instantiate` 要求当前项目上下文,支持:
|
||||||
|
|
||||||
|
- `mode=plan`:只生成流程计划,不创建生成任务,适合导演确认步骤和质检门。
|
||||||
|
- `mode=queue`:按步骤创建 `generation_jobs`,默认把前一步任务写入下一步 `depends_on`,失败步骤会使流程运行标记为 `blocked`。
|
||||||
|
- `shotId`:镜头级步骤必须绑定当前项目镜头;`episodeId`、`adapter`、`priority` 和 `approveExternal` 可作为运行参数传入。
|
||||||
|
|
||||||
|
运行记录保存模板版本、步骤状态、任务 ID、当前步骤、错误信息和创建人。模板创建、修改和实例化都会写审计;外部 / 混合成本适配器仍需显式审批,默认适配器为用户自有模型平台。
|
||||||
|
|
||||||
|
### 媒体证据、首末帧与存储治理
|
||||||
|
|
||||||
|
生成任务和合成任务完成后,平台会登记文件级媒体证据,保存相对路径、MIME、文件大小、SHA-256、时长、分辨率、音视频轨道状态,以及视频实际首帧和末帧。首末帧是 QA / 连续性依据,不接受 contact sheet、故事板拼图或前端临时缩略图替代。
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/production/media-artifacts?shotId=&jobId=&limit=200
|
||||||
|
GET /api/production/media-artifacts/:artifactId/content
|
||||||
|
GET /api/production/media-artifacts/:artifactId/content?frame=first
|
||||||
|
GET /api/production/media-artifacts/:artifactId/content?frame=last
|
||||||
|
```
|
||||||
|
|
||||||
|
媒体列表只返回当前组织、工作区和项目可见的证据;原始文件和首末帧内容接口返回二进制,带真实 `Content-Type`、`Content-Length`、内联文件名和基于内容 SHA-256 的 `ETag`。不存在、路径不安全或文件尚未落盘分别返回 `404` / `422` / `404`,不会把数据库里登记过的路径直接当成成功生成。
|
||||||
|
|
||||||
|
存储用量和回收接口如下:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/usage/storage
|
||||||
|
GET /api/system/storage/cleanup-preview?olderThanDays=30
|
||||||
|
POST /api/system/storage/reclaim
|
||||||
|
```
|
||||||
|
|
||||||
|
`/api/usage/storage` 返回当前作用域的已用空间、限额、剩余空间、占用率、项目拆分和最大文件。清理预览根据 `storage.retention_days`(默认 30 天)扫描 `outputs/`、`frames/`、`tmp/`、`cache/` 下超过保留期的临时文件,并明确列出候选文件和预计释放字节。`reclaim` 可接收 `{ "paths": ["storage/jobs/.../outputs/tmp.png"] }` 指定执行,也可以不传路径执行全部预览候选。
|
||||||
|
|
||||||
|
回收始终保护被 `media_artifacts`、`asset_versions` 或 `media_compositions` 引用的路径,并限制在 `storage/` 安全相对路径内;执行结果写入 `system.storage.reclaimed` 审计事件。`storage.provider` 当前默认是 `local-filesystem`,S3-compatible 等外部存储只作为后续适配层,不会在本地环境自动启用。
|
||||||
|
|
||||||
### 审计与合规中心
|
### 审计与合规中心
|
||||||
|
|
||||||
```http
|
```http
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"smoke:readiness": "node scripts/smoke-readiness.mjs",
|
"smoke:readiness": "node scripts/smoke-readiness.mjs",
|
||||||
"smoke:media-evidence": "node scripts/smoke-media-evidence.mjs",
|
"smoke:media-evidence": "node scripts/smoke-media-evidence.mjs",
|
||||||
"smoke:api-media": "node scripts/smoke-api-media.mjs",
|
"smoke:api-media": "node scripts/smoke-api-media.mjs",
|
||||||
|
"smoke:workflow-platform": "node scripts/smoke-workflow-platform.mjs",
|
||||||
"smoke:compose-evidence": "node scripts/smoke-compose-evidence.mjs",
|
"smoke:compose-evidence": "node scripts/smoke-compose-evidence.mjs",
|
||||||
"smoke:work-items": "node scripts/smoke-work-items.mjs",
|
"smoke:work-items": "node scripts/smoke-work-items.mjs",
|
||||||
"smoke:tasks": "node scripts/smoke-tasks.mjs",
|
"smoke:tasks": "node scripts/smoke-tasks.mjs",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const scripts = [
|
|||||||
"smoke:readiness",
|
"smoke:readiness",
|
||||||
"smoke:media-evidence",
|
"smoke:media-evidence",
|
||||||
"smoke:api-media",
|
"smoke:api-media",
|
||||||
|
"smoke:workflow-platform",
|
||||||
"smoke:compose-evidence",
|
"smoke:compose-evidence",
|
||||||
"smoke:work-items",
|
"smoke:work-items",
|
||||||
"smoke:tasks",
|
"smoke:tasks",
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ try {
|
|||||||
const artifacts = await request(`/api/production/media-artifacts?jobId=${encodeURIComponent(jobId)}`, { headers });
|
const artifacts = await request(`/api/production/media-artifacts?jobId=${encodeURIComponent(jobId)}`, { headers });
|
||||||
assert.equal(artifacts.response.ok, true, "media artifact API must be readable");
|
assert.equal(artifacts.response.ok, true, "media artifact API must be readable");
|
||||||
assert.equal(artifacts.payload.artifacts?.[0]?.job_id, jobId, "artifact must be linked to the generation job");
|
assert.equal(artifacts.payload.artifacts?.[0]?.job_id, jobId, "artifact must be linked to the generation job");
|
||||||
|
const artifactId = artifacts.payload.artifacts[0].id;
|
||||||
|
const contentResponse = await fetch(`${api}/api/production/media-artifacts/${encodeURIComponent(artifactId)}/content`, { headers });
|
||||||
|
assert.equal(contentResponse.ok, true, "media artifact content must be readable within the project scope");
|
||||||
|
assert.match(contentResponse.headers.get("content-type") || "", /video\/mp4/, "artifact preview must preserve video content type");
|
||||||
|
assert.ok((await contentResponse.arrayBuffer()).byteLength > 0, "artifact preview body must not be empty");
|
||||||
|
const frameResponse = await fetch(`${api}/api/production/media-artifacts/${encodeURIComponent(artifactId)}/content?frame=last`, { headers });
|
||||||
|
assert.equal(frameResponse.ok, true, "actual last-frame preview must be readable");
|
||||||
|
assert.match(frameResponse.headers.get("content-type") || "", /image\/jpeg/, "last-frame preview must be JPEG");
|
||||||
console.log(`api media smoke passed: ${jobId} -> ${artifacts.payload.artifacts[0].last_frame_path}`);
|
console.log(`api media smoke passed: ${jobId} -> ${artifacts.payload.artifacts[0].last_frame_path}`);
|
||||||
} finally {
|
} finally {
|
||||||
for (const jobId of createdJobIds) {
|
for (const jobId of createdJobIds) {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { dbRun } from "../server/db.mjs";
|
||||||
|
|
||||||
|
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, headers, options = {}) {
|
||||||
|
const response = await fetch(`${api}${path}`, { ...options, headers: { "content-type": "application/json", ...(headers || {}), ...(options.headers || {}) } });
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
return { response, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(email) {
|
||||||
|
const result = await request("/api/auth/login", null, { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) });
|
||||||
|
assert.equal(result.response.ok, true, `${email} 登录失败`);
|
||||||
|
return { authorization: `Bearer ${result.payload.session.token}`, ...scope };
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = await login("producer@local.test");
|
||||||
|
const writer = await login("writer@local.test");
|
||||||
|
let runId = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const templates = await request("/api/workflows/templates", owner);
|
||||||
|
assert.equal(templates.response.ok, true, "管理员读取流程模板失败");
|
||||||
|
const main = templates.payload.templates.find((template) => template.templateKey === "ai-manhua-drama");
|
||||||
|
assert.ok(main, "缺少 AI 漫剧主流程模板");
|
||||||
|
assert.equal(main.status, "active", "AI 漫剧主流程必须是 active");
|
||||||
|
assert.ok(main.steps.length >= 5, "流程模板必须包含可执行步骤");
|
||||||
|
|
||||||
|
const writerRead = await request("/api/workflows/templates", writer);
|
||||||
|
assert.equal(writerRead.response.ok, true, "普通生产成员应能读取可用模板");
|
||||||
|
const writerWrite = await request("/api/workflows/templates", writer, { method: "POST", body: JSON.stringify({ name: "越权模板", templateKey: "forbidden-template", steps: [{ label: "测试" }] }) });
|
||||||
|
assert.equal(writerWrite.response.status, 403, "普通生产成员不能创建工作区流程模板");
|
||||||
|
|
||||||
|
const plan = await request(`/api/workflows/templates/${encodeURIComponent(main.id)}/instantiate`, owner, { method: "POST", body: JSON.stringify({ mode: "plan", shotId: "shot-01" }) });
|
||||||
|
assert.equal(plan.response.status, 201, `流程计划创建失败:${plan.payload.detail || ""}`);
|
||||||
|
assert.equal(plan.payload.run.status, "planned", "计划模式不能直接排入任务队列");
|
||||||
|
assert.equal(plan.payload.jobs.length, 0, "计划模式不应创建 generation job");
|
||||||
|
assert.ok(plan.payload.run.steps.length >= 5, "流程运行记录必须保存步骤快照");
|
||||||
|
runId = plan.payload.run.id;
|
||||||
|
|
||||||
|
const runs = await request("/api/workflows/runs?limit=10", owner);
|
||||||
|
assert.equal(runs.response.ok, true, "流程运行记录读取失败");
|
||||||
|
assert.ok(runs.payload.runs.some((run) => run.id === runId), "流程运行记录没有按项目 scope 返回");
|
||||||
|
console.log(`workflow platform smoke passed: ${main.name} / ${plan.payload.run.steps.length} steps`);
|
||||||
|
} finally {
|
||||||
|
if (runId) dbRun("DELETE FROM workflow_runs WHERE id = ?", [runId]);
|
||||||
|
}
|
||||||
+38
-3
@@ -2,7 +2,7 @@ import { mkdir, readFile } from "node:fs/promises";
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { DatabaseSync } from "node:sqlite";
|
import { DatabaseSync } from "node:sqlite";
|
||||||
import { randomBytes, scryptSync } from "node:crypto";
|
import { randomBytes, scryptSync } from "node:crypto";
|
||||||
import { sampleProject } from "../src/data/sampleProject.js";
|
import { sampleProject, workflowTemplates } from "../src/data/sampleProject.js";
|
||||||
import { apiClientStorageMarker, hashApiClientKey } from "./api-client-secrets.mjs";
|
import { apiClientStorageMarker, hashApiClientKey } from "./api-client-secrets.mjs";
|
||||||
|
|
||||||
const serverRoot = resolve(import.meta.dirname);
|
const serverRoot = resolve(import.meta.dirname);
|
||||||
@@ -219,6 +219,7 @@ function seedRoles() {
|
|||||||
["project:create", "创建项目"],
|
["project:create", "创建项目"],
|
||||||
["project:manage", "管理项目设置"],
|
["project:manage", "管理项目设置"],
|
||||||
["project:members:manage", "管理项目成员"],
|
["project:members:manage", "管理项目成员"],
|
||||||
|
["workflow:manage", "管理生产流程模板和版本"],
|
||||||
["task:view", "查看项目协作任务"],
|
["task:view", "查看项目协作任务"],
|
||||||
["task:manage", "创建、分派和管理项目协作任务"],
|
["task:manage", "创建、分派和管理项目协作任务"],
|
||||||
["task:complete", "更新本人负责的协作任务状态"],
|
["task:complete", "更新本人负责的协作任务状态"],
|
||||||
@@ -257,10 +258,10 @@ function seedRoles() {
|
|||||||
org_admin: [
|
org_admin: [
|
||||||
"organization:manage", "organization:members:invite", "workspace:create", "workspace:manage",
|
"organization:manage", "organization:members:invite", "workspace:create", "workspace:manage",
|
||||||
"workspace:members:manage", "project:create", "project:manage", "project:members:manage",
|
"workspace:members:manage", "project:create", "project:manage", "project:members:manage",
|
||||||
"task:view", "task:manage", "task:complete", "model:manage", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
|
"workflow:manage", "task:view", "task:manage", "task:complete", "model:manage", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
|
||||||
"system:settings:view", "service:health:view", "organization:roles:manage"
|
"system:settings:view", "service:health:view", "organization:roles:manage"
|
||||||
],
|
],
|
||||||
producer: ["project:create", "project:manage", "project:members:manage", "task:view", "task:manage", "task:complete", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
|
producer: ["project:create", "project:manage", "project:members:manage", "workflow:manage", "task:view", "task:manage", "task:complete", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
|
||||||
writer: ["script:read", "script:edit", "task:view", "task:complete", "job:create"],
|
writer: ["script:read", "script:edit", "task:view", "task:complete", "job:create"],
|
||||||
art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "job:create"],
|
art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "job:create"],
|
||||||
voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "job:create"],
|
voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "job:create"],
|
||||||
@@ -384,8 +385,11 @@ function seedSystemGovernance() {
|
|||||||
["app.timezone", "general", "Asia/Shanghai", "string", "默认时区", 0],
|
["app.timezone", "general", "Asia/Shanghai", "string", "默认时区", 0],
|
||||||
["deployment.mode", "deployment", "private-local", "string", "部署模式", 0],
|
["deployment.mode", "deployment", "private-local", "string", "部署模式", 0],
|
||||||
["deployment.local_runner_only", "deployment", true, "boolean", "默认只允许本地 Runner", 0],
|
["deployment.local_runner_only", "deployment", true, "boolean", "默认只允许本地 Runner", 0],
|
||||||
|
["storage.provider", "storage", "local-filesystem", "string", "媒体文件存储提供方;生产可切换为 S3-compatible 适配层", 0],
|
||||||
["storage.root_path", "storage", resolve(projectRoot, "storage"), "path", "资产和生成产物根目录", 0],
|
["storage.root_path", "storage", resolve(projectRoot, "storage"), "path", "资产和生成产物根目录", 0],
|
||||||
["storage.max_upload_mb", "storage", 1024, "number", "单文件最大上传大小", 0],
|
["storage.max_upload_mb", "storage", 1024, "number", "单文件最大上传大小", 0],
|
||||||
|
["storage.retention_days", "storage", 30, "number", "无引用临时媒体的最短保留天数", 0],
|
||||||
|
["storage.cleanup_unreferenced_only", "storage", true, "boolean", "清理时只允许处理未被生产证据引用的临时文件", 0],
|
||||||
["queue.max_concurrency", "queue", 2, "number", "全局并发任务数", 0],
|
["queue.max_concurrency", "queue", 2, "number", "全局并发任务数", 0],
|
||||||
["generation.single_frame_only", "generation", true, "boolean", "一图一画面策略", 0],
|
["generation.single_frame_only", "generation", true, "boolean", "一图一画面策略", 0],
|
||||||
["generation.require_actual_last_frame", "generation", true, "boolean", "视频片段必须使用真实末帧连续", 0],
|
["generation.require_actual_last_frame", "generation", true, "boolean", "视频片段必须使用真实末帧连续", 0],
|
||||||
@@ -433,6 +437,36 @@ function seedSystemGovernance() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workflowStepDefinition(label, index) {
|
||||||
|
const normalized = String(label || "");
|
||||||
|
let jobKind = "";
|
||||||
|
let requiresShot = false;
|
||||||
|
if (/静态图|分镜图|关键帧|插画/.test(normalized)) { jobKind = "单画面关键帧"; requiresShot = true; }
|
||||||
|
else if (/图生视频|视频生成|成片/.test(normalized)) { jobKind = "首尾帧图生视频"; requiresShot = true; }
|
||||||
|
else if (/旁白|配音|TTS/.test(normalized)) { jobKind = "固定 TTS 配音"; requiresShot = true; }
|
||||||
|
else if (/ASR|字幕|对齐/.test(normalized)) { jobKind = "ASR 台词校验"; requiresShot = true; }
|
||||||
|
else if (/合成/.test(normalized)) jobKind = "剪辑合成清单";
|
||||||
|
return { key: `step-${index + 1}`, label: normalized, jobKind, requiresShot, dependsOnPrevious: index > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedWorkflowTemplates() {
|
||||||
|
const timestamp = now();
|
||||||
|
for (const template of workflowTemplates) {
|
||||||
|
const status = template.status === "主流程" || template.status === "可用" ? "active" : "draft";
|
||||||
|
const steps = template.steps.map((label, index) => workflowStepDefinition(label, index));
|
||||||
|
const gates = [
|
||||||
|
{ key: "single-frame", label: "一图一画面", blocking: true },
|
||||||
|
{ key: "continuity-lock", label: "角色 / 场景 / 道具连续性", blocking: true },
|
||||||
|
{ key: "voice-subtitle-asr", label: "声音 / 字幕 / ASR 对齐", blocking: true },
|
||||||
|
{ key: "clip-bridge", label: "片段衔接 / 实际末帧", blocking: true }
|
||||||
|
];
|
||||||
|
insertIgnore(
|
||||||
|
"INSERT OR IGNORE INTO workflow_templates(id, organization_id, workspace_id, template_key, version_number, name, category, status, description, steps_json, gates_json, default_adapter_id, created_by, created_at, updated_at) VALUES (?, NULL, NULL, ?, 1, ?, ?, ?, ?, ?, ?, 'owned-model-platform', 'u-owner', ?, ?)",
|
||||||
|
[`workflow-global-${template.id}-v1`, template.id, template.name, template.id, status, template.warning || "", JSON.stringify(steps), JSON.stringify(gates), timestamp, timestamp]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function seedUserNotifications() {
|
function seedUserNotifications() {
|
||||||
const timestamp = now();
|
const timestamp = now();
|
||||||
insertIgnore(
|
insertIgnore(
|
||||||
@@ -583,6 +617,7 @@ withTransaction(() => {
|
|||||||
seedMembersAndProjects();
|
seedMembersAndProjects();
|
||||||
seedModels();
|
seedModels();
|
||||||
seedSystemGovernance();
|
seedSystemGovernance();
|
||||||
|
seedWorkflowTemplates();
|
||||||
seedProductionGraph();
|
seedProductionGraph();
|
||||||
seedUserNotifications();
|
seedUserNotifications();
|
||||||
});
|
});
|
||||||
|
|||||||
+64
-2
@@ -130,16 +130,17 @@ import {
|
|||||||
probeModelConnector,
|
probeModelConnector,
|
||||||
updateModelConnector
|
updateModelConnector
|
||||||
} from "./execution.mjs";
|
} from "./execution.mjs";
|
||||||
import { requireStorageQuota, storageSummary } from "./storage.mjs";
|
import { reclaimStorage, requireStorageQuota, storageCleanupPreview, storageSummary } from "./storage.mjs";
|
||||||
import { backupSummary, createDatabaseBackup } from "./backup.mjs";
|
import { backupSummary, createDatabaseBackup } from "./backup.mjs";
|
||||||
import { systemReadiness } from "./readiness.mjs";
|
import { systemReadiness } from "./readiness.mjs";
|
||||||
import { dispatchNotificationEvent, listUserNotificationPreferences, listUserNotifications, markAllUserNotificationsRead, markUserNotificationRead, notificationDeliveries, updateUserNotificationPreference } from "./notifications.mjs";
|
import { dispatchNotificationEvent, listUserNotificationPreferences, listUserNotifications, markAllUserNotificationsRead, markUserNotificationRead, notificationDeliveries, updateUserNotificationPreference } from "./notifications.mjs";
|
||||||
import { composeProject, listCompositions } from "./composition.mjs";
|
import { composeProject, listCompositions } from "./composition.mjs";
|
||||||
import { listProjectArtifacts } from "./media-artifacts.mjs";
|
import { listProjectArtifacts, readArtifactContent } from "./media-artifacts.mjs";
|
||||||
import { runWorkerOnce, startLocalWorker, workerStatus } from "./worker.mjs";
|
import { runWorkerOnce, startLocalWorker, workerStatus } from "./worker.mjs";
|
||||||
import { createSsoTicket, handleOidcCallback, redeemSsoTicket, startOidcLogin } from "./oidc.mjs";
|
import { createSsoTicket, handleOidcCallback, redeemSsoTicket, startOidcLogin } from "./oidc.mjs";
|
||||||
import { handleSamlCallback, isSamlProvider, samlServiceProviderMetadata, startSamlLogin } from "./saml.mjs";
|
import { handleSamlCallback, isSamlProvider, samlServiceProviderMetadata, startSamlLogin } from "./saml.mjs";
|
||||||
import { listWorkItems } from "./work-items.mjs";
|
import { listWorkItems } from "./work-items.mjs";
|
||||||
|
import { createWorkflowTemplate, instantiateWorkflow, listWorkflowRuns, listWorkflowTemplates, updateWorkflowTemplate } from "./workflows.mjs";
|
||||||
import { addTaskLink, createProjectTask, createTaskComment, getProjectTask, listProjectActivity, listProjectTasks, listTaskComments, removeTaskLink, updateProjectTask } from "./tasks.mjs";
|
import { addTaskLink, createProjectTask, createTaskComment, getProjectTask, listProjectActivity, listProjectTasks, listTaskComments, removeTaskLink, updateProjectTask } from "./tasks.mjs";
|
||||||
import { searchPlatform } from "./search.mjs";
|
import { searchPlatform } from "./search.mjs";
|
||||||
import { consumeRateLimit, rateLimitHeaders, rateLimitIdentity } from "./rate-limit.mjs";
|
import { consumeRateLimit, rateLimitHeaders, rateLimitIdentity } from "./rate-limit.mjs";
|
||||||
@@ -1909,6 +1910,20 @@ createServer(async (req, res) => {
|
|||||||
return send(res, 200, { storage: await storageSummary(context) });
|
return send(res, 200, { storage: await storageSummary(context) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && pathname === "/api/system/storage/cleanup-preview") {
|
||||||
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
|
requirePermission(context, "system:settings:view");
|
||||||
|
return send(res, 200, { cleanup: await storageCleanupPreview(context, { olderThanDays: url.searchParams.get("olderThanDays") || "" }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && pathname === "/api/system/storage/reclaim") {
|
||||||
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
|
requirePermission(context, "system:settings:edit");
|
||||||
|
const result = await reclaimStorage(context, await readBody(req));
|
||||||
|
addAudit({ context, action: "system.storage.reclaimed", targetType: "storage_cleanup", targetId: `cleanup-${Date.now()}`, metadata: { deletedCount: result.deleted.length, deletedBytes: result.deletedBytes, retentionDays: result.policy.retentionDays } });
|
||||||
|
return send(res, 200, { cleanup: result, storage: await storageSummary(context) });
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && pathname === "/api/billing") {
|
if (req.method === "GET" && pathname === "/api/billing") {
|
||||||
const context = resolveContext(req.headers, url.searchParams);
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
requirePermission(context, "usage:view");
|
requirePermission(context, "usage:view");
|
||||||
@@ -2482,6 +2497,53 @@ createServer(async (req, res) => {
|
|||||||
return send(res, 200, { artifacts: listProjectArtifacts(context, { shotId: url.searchParams.get("shotId") || "", jobId: url.searchParams.get("jobId") || "", limit: url.searchParams.get("limit") || 200 }) });
|
return send(res, 200, { artifacts: listProjectArtifacts(context, { shotId: url.searchParams.get("shotId") || "", jobId: url.searchParams.get("jobId") || "", limit: url.searchParams.get("limit") || 200 }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mediaArtifactContentMatch = pathname.match(/^\/api\/production\/media-artifacts\/([^/]+)\/content$/);
|
||||||
|
if (req.method === "GET" && mediaArtifactContentMatch) {
|
||||||
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
|
if (!hasPermission(context, "delivery:view") && !hasPermission(context, "qa:review")) throw httpError(403, "permission_denied", "当前角色没有媒体证据访问权限");
|
||||||
|
try {
|
||||||
|
const payload = await readArtifactContent(context, decodeURIComponent(mediaArtifactContentMatch[1]), url.searchParams.get("frame") || "");
|
||||||
|
return sendBinary(res, 200, payload.content, payload.contentType, payload.fileName, { etag: `"${payload.contentSha256}"` });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message === "media_artifact_not_found") throw httpError(404, "media_artifact_not_found", "媒体证据不存在或不属于当前项目");
|
||||||
|
if (error.message === "media_artifact_path_invalid") throw httpError(422, "media_artifact_path_invalid", "媒体证据路径不是 storage/ 下的安全路径");
|
||||||
|
if (error.code === "ENOENT") throw httpError(404, "media_artifact_content_missing", "媒体文件尚未写入本地存储", { path: error.path || "" });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && pathname === "/api/workflows/templates") {
|
||||||
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
|
if (!hasPermission(context, "workflow:manage") && !hasPermission(context, "job:create") && !hasPermission(context, "script:read") && !hasPermission(context, "project:create")) throw httpError(403, "permission_denied", "当前角色没有流程模板访问权限");
|
||||||
|
return send(res, 200, { templates: listWorkflowTemplates(context, { includeArchived: url.searchParams.get("includeArchived") === "1" }), runs: context.project ? listWorkflowRuns(context, { limit: 20 }) : [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && pathname === "/api/workflows/templates") {
|
||||||
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
|
requirePermission(context, "workflow:manage");
|
||||||
|
return send(res, 201, { template: createWorkflowTemplate(context, await readBody(req)), templates: listWorkflowTemplates(context) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowTemplateMatch = pathname.match(/^\/api\/workflows\/templates\/([^/]+)$/);
|
||||||
|
if (req.method === "PATCH" && workflowTemplateMatch) {
|
||||||
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
|
requirePermission(context, "workflow:manage");
|
||||||
|
return send(res, 200, { template: updateWorkflowTemplate(context, decodeURIComponent(workflowTemplateMatch[1]), await readBody(req)), templates: listWorkflowTemplates(context) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowInstantiateMatch = pathname.match(/^\/api\/workflows\/templates\/([^/]+)\/instantiate$/);
|
||||||
|
if (req.method === "POST" && workflowInstantiateMatch) {
|
||||||
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
|
requirePermission(context, "job:create");
|
||||||
|
return send(res, 201, await instantiateWorkflow(context, decodeURIComponent(workflowInstantiateMatch[1]), await readBody(req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && pathname === "/api/workflows/runs") {
|
||||||
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
|
if (!hasPermission(context, "job:create") && !hasPermission(context, "workflow:manage")) throw httpError(403, "permission_denied", "当前角色没有流程运行记录访问权限");
|
||||||
|
return send(res, 200, { runs: listWorkflowRuns(context, { status: url.searchParams.get("status") || "", limit: url.searchParams.get("limit") || 50 }) });
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && pathname === "/api/production/compose") {
|
if (req.method === "POST" && pathname === "/api/production/compose") {
|
||||||
const context = resolveContext(req.headers, url.searchParams);
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
return send(res, 201, await composeProject(context, await readBody(req)));
|
return send(res, 201, await composeProject(context, await readBody(req)));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { access, mkdir, writeFile } from "node:fs/promises";
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { resolve, extname } from "node:path";
|
import { resolve, extname, basename } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { dbAll, dbGet, dbRun } from "./db.mjs";
|
import { dbAll, dbGet, dbRun } from "./db.mjs";
|
||||||
import { inspectStoragePath } from "./media-qa.mjs";
|
import { inspectStoragePath } from "./media-qa.mjs";
|
||||||
@@ -59,6 +59,24 @@ function safeOutputPath(value) {
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mimeForPath(pathname) {
|
||||||
|
const extension = extname(String(pathname || "")).toLowerCase();
|
||||||
|
return {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".wav": "audio/wav",
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
".m4a": "audio/mp4",
|
||||||
|
".mp4": "video/mp4",
|
||||||
|
".webm": "video/webm",
|
||||||
|
".json": "application/json",
|
||||||
|
".txt": "text/plain; charset=utf-8"
|
||||||
|
}[extension] || "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
async function materializeBase64(job, value, index, kind, mimeType = "") {
|
async function materializeBase64(job, value, index, kind, mimeType = "") {
|
||||||
const encoded = typeof value === "string" ? value : value?.b64_json || value?.base64 || "";
|
const encoded = typeof value === "string" ? value : value?.b64_json || value?.base64 || "";
|
||||||
if (!encoded) return "";
|
if (!encoded) return "";
|
||||||
@@ -289,3 +307,37 @@ export function latestArtifactForShot(context, shotId, kind = "video") {
|
|||||||
const row = dbGet("SELECT * FROM media_artifacts WHERE organization_id = ? AND workspace_id = ? AND project_id = ? AND shot_id = ? AND kind = ? ORDER BY CASE WHEN status = 'inspected' THEN 0 ELSE 1 END, created_at DESC LIMIT 1", [context.organization.id, context.workspace.id, context.project.id, shotId, kind]);
|
const row = dbGet("SELECT * FROM media_artifacts WHERE organization_id = ? AND workspace_id = ? AND project_id = ? AND shot_id = ? AND kind = ? ORDER BY CASE WHEN status = 'inspected' THEN 0 ELSE 1 END, created_at DESC LIMIT 1", [context.organization.id, context.workspace.id, context.project.id, shotId, kind]);
|
||||||
return artifactPayload(row);
|
return artifactPayload(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readArtifactContent(context, artifactId, frame = "") {
|
||||||
|
const artifact = dbGet(
|
||||||
|
`SELECT * FROM media_artifacts
|
||||||
|
WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?`,
|
||||||
|
[artifactId, context.organization.id, context.workspace.id, context.project.id]
|
||||||
|
);
|
||||||
|
if (!artifact) throw new Error("media_artifact_not_found");
|
||||||
|
const requestedFrame = frame === "first" ? artifact.first_frame_path : frame === "last" ? artifact.last_frame_path : "";
|
||||||
|
const relativePath = safeOutputPath(requestedFrame || artifact.path);
|
||||||
|
if (!relativePath) throw new Error("media_artifact_path_invalid");
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
content = await readFile(resolve(projectRoot, relativePath));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "ENOENT") {
|
||||||
|
const missing = new Error("media_artifact_content_missing");
|
||||||
|
missing.code = "ENOENT";
|
||||||
|
missing.path = relativePath;
|
||||||
|
throw missing;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const contentSha256 = createHash("sha256").update(content).digest("hex");
|
||||||
|
const sourceName = basename(relativePath) || basename(artifact.path) || artifact.id;
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
contentType: frame ? "image/jpeg" : artifact.mime_type || mimeForPath(relativePath),
|
||||||
|
fileName: sourceName,
|
||||||
|
contentSha256,
|
||||||
|
artifact: artifactPayload(artifact),
|
||||||
|
frame: frame || "source"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -990,6 +990,46 @@ CREATE TABLE IF NOT EXISTS system_settings (
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Workflow templates are versioned production contracts. Global templates can
|
||||||
|
-- be overridden at organization or workspace scope without changing project
|
||||||
|
-- content already in production.
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_templates (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
template_key TEXT NOT NULL,
|
||||||
|
version_number INTEGER NOT NULL DEFAULT 1,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'ai-manhua-drama',
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')),
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
steps_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
gates_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
default_adapter_id TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by TEXT REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (organization_id, workspace_id, template_key, version_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workflow_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
episode_id TEXT REFERENCES episodes(id) ON DELETE SET NULL,
|
||||||
|
shot_id TEXT REFERENCES shots(id) ON DELETE SET NULL,
|
||||||
|
template_id TEXT NOT NULL REFERENCES workflow_templates(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('planned', 'queued', 'running', 'blocked', 'completed', 'failed', 'cancelled')),
|
||||||
|
current_step_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
steps_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
job_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
error_message TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS feature_flags (
|
CREATE TABLE IF NOT EXISTS feature_flags (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
label TEXT NOT NULL,
|
label TEXT NOT NULL,
|
||||||
@@ -1161,6 +1201,8 @@ CREATE INDEX IF NOT EXISTS idx_audit_scope ON audit_logs(organization_id, worksp
|
|||||||
CREATE INDEX IF NOT EXISTS idx_invites_scope ON invitations(organization_id, status, created_at);
|
CREATE INDEX IF NOT EXISTS idx_invites_scope ON invitations(organization_id, status, created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_job_dependencies_dependency ON job_dependencies(depends_on_job_id, job_id);
|
CREATE INDEX IF NOT EXISTS idx_job_dependencies_dependency ON job_dependencies(depends_on_job_id, job_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_system_settings_category ON system_settings(category, updated_at);
|
CREATE INDEX IF NOT EXISTS idx_system_settings_category ON system_settings(category, updated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_templates_scope ON workflow_templates(organization_id, workspace_id, status, template_key, version_number DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workflow_runs_scope ON workflow_runs(organization_id, workspace_id, project_id, status, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_feature_flags_scope ON feature_flags(scope, enabled);
|
CREATE INDEX IF NOT EXISTS idx_feature_flags_scope ON feature_flags(scope, enabled);
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_channels_enabled ON notification_channels(enabled, kind);
|
CREATE INDEX IF NOT EXISTS idx_notification_channels_enabled ON notification_channels(enabled, kind);
|
||||||
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_scope ON notification_deliveries(organization_id, created_at);
|
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_scope ON notification_deliveries(organization_id, created_at);
|
||||||
|
|||||||
+86
-1
@@ -1,4 +1,4 @@
|
|||||||
import { lstat, readdir } from "node:fs/promises";
|
import { lstat, readdir, unlink } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { dbAll, dbGet, dbRun } from "./db.mjs";
|
import { dbAll, dbGet, dbRun } from "./db.mjs";
|
||||||
import { httpError } from "./tenant.mjs";
|
import { httpError } from "./tenant.mjs";
|
||||||
@@ -96,6 +96,91 @@ export async function requireStorageQuota(context, bytes) {
|
|||||||
return { ...summary, remainingBytes: Math.max(0, summary.remainingBytes - requestedBytes) };
|
return { ...summary, remainingBytes: Math.max(0, summary.remainingBytes - requestedBytes) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function configuredSetting(key, fallback) {
|
||||||
|
const row = dbGet("SELECT value_json FROM system_settings WHERE key = ?", [key]);
|
||||||
|
if (!row) return fallback;
|
||||||
|
try { return JSON.parse(row.value_json); } catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTemporaryStorageFile(relativePath) {
|
||||||
|
return /\/((outputs|frames|tmp|cache))\//i.test(`/${relativePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function protectedPaths(context) {
|
||||||
|
const protectedSet = new Set();
|
||||||
|
const add = (value) => {
|
||||||
|
const path = String(value || "").trim();
|
||||||
|
if (path.startsWith("storage/") && !path.includes("..")) protectedSet.add(path);
|
||||||
|
};
|
||||||
|
for (const row of dbAll("SELECT path, first_frame_path, last_frame_path FROM media_artifacts WHERE organization_id = ? AND workspace_id = ? AND project_id IN (SELECT id FROM projects WHERE workspace_id = ?)", [context.organization.id, context.workspace.id, context.workspace.id])) {
|
||||||
|
add(row.path); add(row.first_frame_path); add(row.last_frame_path);
|
||||||
|
}
|
||||||
|
for (const row of dbAll("SELECT av.storage_path FROM asset_versions av JOIN assets a ON a.id = av.asset_id JOIN projects p ON p.id = a.project_id JOIN workspaces w ON w.id = p.workspace_id WHERE w.organization_id = ? AND p.workspace_id = ?", [context.organization.id, context.workspace.id])) add(row.storage_path);
|
||||||
|
for (const row of dbAll("SELECT output_path, manifest_path FROM media_compositions WHERE organization_id = ? AND workspace_id = ?", [context.organization.id, context.workspace.id])) {
|
||||||
|
add(row.output_path); add(row.manifest_path);
|
||||||
|
}
|
||||||
|
return protectedSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storageCleanupPreview(context, options = {}) {
|
||||||
|
const configuredDays = Number(configuredSetting("storage.retention_days", 30));
|
||||||
|
const olderThanDays = Math.max(1, Math.min(3650, Number(options.olderThanDays || configuredDays || 30)));
|
||||||
|
const cutoff = Date.now() - olderThanDays * 86400000;
|
||||||
|
const protectedSet = protectedPaths(context);
|
||||||
|
const candidates = [];
|
||||||
|
for (const projectId of scopedProjectIds(context)) {
|
||||||
|
const usage = await projectUsage(projectId);
|
||||||
|
for (const file of usage.files) {
|
||||||
|
if (!isTemporaryStorageFile(file.relativePath)) continue;
|
||||||
|
if (protectedSet.has(file.relativePath)) continue;
|
||||||
|
const modifiedAt = new Date(file.updatedAt).getTime();
|
||||||
|
if (!Number.isFinite(modifiedAt) || modifiedAt > cutoff) continue;
|
||||||
|
candidates.push({
|
||||||
|
relativePath: file.relativePath,
|
||||||
|
projectId,
|
||||||
|
bytes: file.bytes,
|
||||||
|
updatedAt: file.updatedAt,
|
||||||
|
reason: "超过保留周期且未被媒体证据、资产版本或合成清单引用"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort((left, right) => right.bytes - left.bytes);
|
||||||
|
return {
|
||||||
|
policy: {
|
||||||
|
provider: configuredSetting("storage.provider", "local-filesystem"),
|
||||||
|
retentionDays: olderThanDays,
|
||||||
|
unreferencedOnly: Boolean(configuredSetting("storage.cleanup_unreferenced_only", true)),
|
||||||
|
protectedReferenceTypes: ["media_artifacts", "asset_versions", "media_compositions"]
|
||||||
|
},
|
||||||
|
cutoff: new Date(cutoff).toISOString(),
|
||||||
|
candidates,
|
||||||
|
totalBytes: candidates.reduce((sum, item) => sum + item.bytes, 0),
|
||||||
|
checkedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reclaimStorage(context, options = {}) {
|
||||||
|
const preview = await storageCleanupPreview(context, options);
|
||||||
|
const requested = new Set(Array.isArray(options.paths) ? options.paths.map((item) => String(item || "").trim()) : preview.candidates.map((item) => item.relativePath));
|
||||||
|
const targets = preview.candidates.filter((item) => requested.has(item.relativePath));
|
||||||
|
const deleted = [];
|
||||||
|
for (const target of targets) {
|
||||||
|
try {
|
||||||
|
await unlink(resolve(projectRoot, target.relativePath));
|
||||||
|
deleted.push(target);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...preview,
|
||||||
|
candidates: preview.candidates.filter((item) => !deleted.some((entry) => entry.relativePath === item.relativePath)),
|
||||||
|
deleted,
|
||||||
|
deletedBytes: deleted.reduce((sum, item) => sum + item.bytes, 0),
|
||||||
|
completedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function bytesToHuman(bytes) {
|
export function bytesToHuman(bytes) {
|
||||||
const value = Number(bytes || 0);
|
const value = Number(bytes || 0);
|
||||||
if (value < 1024) return `${value} B`;
|
if (value < 1024) return `${value} B`;
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { createGenerationJob } from "./execution.mjs";
|
||||||
|
import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
|
||||||
|
import { addAudit, httpError } from "./tenant.mjs";
|
||||||
|
|
||||||
|
const now = () => new Date().toISOString();
|
||||||
|
const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||||
|
|
||||||
|
function parseJson(value, fallback) {
|
||||||
|
try { return JSON.parse(value); } catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStep(step, index) {
|
||||||
|
if (typeof step === "string") return { key: `step-${index + 1}`, label: step, jobKind: "", requiresShot: false, dependsOnPrevious: index > 0 };
|
||||||
|
return {
|
||||||
|
key: String(step?.key || `step-${index + 1}`),
|
||||||
|
label: String(step?.label || step?.name || `步骤 ${index + 1}`),
|
||||||
|
jobKind: String(step?.jobKind || step?.job_kind || ""),
|
||||||
|
requiresShot: Boolean(step?.requiresShot ?? step?.requires_shot),
|
||||||
|
dependsOnPrevious: Boolean(step?.dependsOnPrevious ?? step?.depends_on_previous ?? index > 0),
|
||||||
|
optional: Boolean(step?.optional)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function templatePayload(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
organizationId: row.organization_id,
|
||||||
|
workspaceId: row.workspace_id,
|
||||||
|
templateKey: row.template_key,
|
||||||
|
version: Number(row.version_number || 1),
|
||||||
|
steps: parseJson(row.steps_json, []).map(normalizeStep),
|
||||||
|
gates: parseJson(row.gates_json, []),
|
||||||
|
defaultAdapterId: row.default_adapter_id,
|
||||||
|
scope: row.workspace_id ? "workspace" : row.organization_id ? "organization" : "global"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopedTemplateRows(context, includeArchived = false) {
|
||||||
|
const statusClause = includeArchived ? "1=1" : "status <> 'archived'";
|
||||||
|
return dbAll(
|
||||||
|
`SELECT * FROM workflow_templates
|
||||||
|
WHERE ${statusClause} AND
|
||||||
|
((organization_id IS NULL AND workspace_id IS NULL)
|
||||||
|
OR (organization_id = ? AND workspace_id IS NULL)
|
||||||
|
OR (organization_id = ? AND workspace_id = ?))
|
||||||
|
ORDER BY CASE WHEN workspace_id = ? THEN 0 WHEN organization_id = ? THEN 1 ELSE 2 END, version_number DESC, updated_at DESC`,
|
||||||
|
[context.organization.id, context.organization.id, context.workspace.id, context.workspace.id, context.organization.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWorkflowTemplates(context, options = {}) {
|
||||||
|
const seen = new Set();
|
||||||
|
return scopedTemplateRows(context, Boolean(options.includeArchived)).map(templatePayload).filter((template) => {
|
||||||
|
if (seen.has(template.templateKey)) return false;
|
||||||
|
seen.add(template.templateKey);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateForContext(context, templateId) {
|
||||||
|
const row = dbGet(
|
||||||
|
`SELECT * FROM workflow_templates
|
||||||
|
WHERE id = ? AND ((organization_id IS NULL AND workspace_id IS NULL) OR (organization_id = ? AND workspace_id IS NULL) OR (organization_id = ? AND workspace_id = ?))`,
|
||||||
|
[templateId, context.organization.id, context.organization.id, context.workspace.id]
|
||||||
|
);
|
||||||
|
if (!row) throw httpError(404, "workflow_template_not_found", "流程模板不存在或不属于当前工作区");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTemplateBody(body = {}) {
|
||||||
|
const name = String(body.name || "").trim();
|
||||||
|
const templateKey = String(body.templateKey || body.template_key || "").trim().toLowerCase();
|
||||||
|
if (!name || !templateKey) throw httpError(400, "workflow_template_fields_required", "流程模板名称和唯一键不能为空");
|
||||||
|
if (!/^[a-z0-9][a-z0-9._-]{1,80}$/.test(templateKey)) throw httpError(400, "workflow_template_key_invalid", "流程模板唯一键只能使用小写字母、数字、点、短横线和下划线");
|
||||||
|
const steps = (Array.isArray(body.steps) ? body.steps : []).slice(0, 32).map(normalizeStep);
|
||||||
|
if (!steps.length) throw httpError(400, "workflow_template_steps_required", "流程模板至少需要一个步骤");
|
||||||
|
const status = String(body.status || "draft");
|
||||||
|
if (!["draft", "active", "archived"].includes(status)) throw httpError(400, "workflow_template_status_invalid", "流程模板状态无效");
|
||||||
|
return { name, templateKey, category: String(body.category || templateKey), status, description: String(body.description || "").trim(), steps, gates: Array.isArray(body.gates) ? body.gates.slice(0, 32) : [], defaultAdapterId: String(body.defaultAdapterId || body.default_adapter_id || "owned-model-platform"), requestedVersion: Number(body.version || 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkflowTemplate(context, body = {}) {
|
||||||
|
const values = validateTemplateBody(body);
|
||||||
|
const version = values.requestedVersion > 0 ? Math.floor(values.requestedVersion) : Number(dbGet("SELECT MAX(version_number) AS value FROM workflow_templates WHERE organization_id = ? AND workspace_id = ? AND template_key = ?", [context.organization.id, context.workspace.id, values.templateKey])?.value || 0) + 1;
|
||||||
|
const id = makeId("workflow-template");
|
||||||
|
const timestamp = now();
|
||||||
|
dbRun(
|
||||||
|
`INSERT INTO workflow_templates(id, organization_id, workspace_id, template_key, version_number, name, category, status, description, steps_json, gates_json, default_adapter_id, created_by, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[id, context.organization.id, context.workspace.id, values.templateKey, version, values.name, values.category, values.status, values.description, JSON.stringify(values.steps), JSON.stringify(values.gates), values.defaultAdapterId, context.user.id, timestamp, timestamp]
|
||||||
|
);
|
||||||
|
addAudit({ context, action: "workflow.template.created", targetType: "workflow_template", targetId: id, metadata: { templateKey: values.templateKey, version, status: values.status } });
|
||||||
|
return templatePayload(dbGet("SELECT * FROM workflow_templates WHERE id = ?", [id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWorkflowTemplate(context, templateId, body = {}) {
|
||||||
|
const current = templateForContext(context, templateId);
|
||||||
|
if (!current.organization_id || current.workspace_id !== context.workspace.id) throw httpError(403, "workflow_template_global_locked", "内置全局模板不能直接修改,请创建当前工作区的新版本");
|
||||||
|
const values = validateTemplateBody({ ...templatePayload(current), ...body, steps: body.steps || templatePayload(current).steps });
|
||||||
|
const timestamp = now();
|
||||||
|
dbRun("UPDATE workflow_templates SET name = ?, category = ?, status = ?, description = ?, steps_json = ?, gates_json = ?, default_adapter_id = ?, updated_at = ? WHERE id = ?", [values.name, values.category, values.status, values.description, JSON.stringify(values.steps), JSON.stringify(values.gates), values.defaultAdapterId, timestamp, templateId]);
|
||||||
|
addAudit({ context, action: "workflow.template.updated", targetType: "workflow_template", targetId: templateId, metadata: { status: values.status, version: current.version_number } });
|
||||||
|
return templatePayload(dbGet("SELECT * FROM workflow_templates WHERE id = ?", [templateId]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPayload(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
templateId: row.template_id,
|
||||||
|
episodeId: row.episode_id,
|
||||||
|
shotId: row.shot_id,
|
||||||
|
currentStepIndex: Number(row.current_step_index || 0),
|
||||||
|
steps: parseJson(row.steps_json, []),
|
||||||
|
jobIds: parseJson(row.job_ids_json, [])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWorkflowRuns(context, options = {}) {
|
||||||
|
const params = [context.organization.id, context.workspace.id, context.project?.id || ""];
|
||||||
|
let where = "organization_id = ? AND workspace_id = ? AND project_id = ?";
|
||||||
|
if (options.status) { where += " AND status = ?"; params.push(String(options.status)); }
|
||||||
|
return dbAll(`SELECT * FROM workflow_runs WHERE ${where} ORDER BY created_at DESC LIMIT ?`, [...params, Math.max(1, Math.min(100, Number(options.limit || 50)))]).map(runPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function instantiateWorkflow(context, templateId, body = {}) {
|
||||||
|
if (!context.project) throw httpError(400, "project_required", "执行流程模板必须绑定项目");
|
||||||
|
const template = templatePayload(templateForContext(context, templateId));
|
||||||
|
if (template.status !== "active") throw httpError(409, "workflow_template_not_active", "只有启用状态的流程模板可以进入生产");
|
||||||
|
const shotId = String(body.shotId || body.shot_id || "").trim();
|
||||||
|
const mode = body.mode === "queue" ? "queue" : "plan";
|
||||||
|
const steps = template.steps.map((step) => ({ ...step, status: step.jobKind ? "planned" : "skipped", jobId: "", errorMessage: "" }));
|
||||||
|
if (steps.some((step) => step.requiresShot && !shotId)) throw httpError(400, "workflow_shot_required", "当前流程包含镜头级步骤,请指定一个镜头后再执行");
|
||||||
|
const runId = makeId("workflow-run");
|
||||||
|
const timestamp = now();
|
||||||
|
const jobIds = [];
|
||||||
|
let previousJobId = "";
|
||||||
|
if (mode === "queue") {
|
||||||
|
for (const step of steps) {
|
||||||
|
if (!step.jobKind) continue;
|
||||||
|
try {
|
||||||
|
const result = await createGenerationJob(context, {
|
||||||
|
kind: step.jobKind,
|
||||||
|
adapter: body.adapter || template.defaultAdapterId,
|
||||||
|
shotId: shotId || undefined,
|
||||||
|
dependsOnJobIds: step.dependsOnPrevious && previousJobId ? [previousJobId] : [],
|
||||||
|
approveExternal: Boolean(body.approveExternal),
|
||||||
|
priority: body.priority || 50
|
||||||
|
});
|
||||||
|
step.status = result.job.status === "blocked" ? "blocked" : "queued";
|
||||||
|
step.jobId = result.job.id;
|
||||||
|
previousJobId = result.job.id;
|
||||||
|
jobIds.push(result.job.id);
|
||||||
|
} catch (error) {
|
||||||
|
step.status = "blocked";
|
||||||
|
step.errorMessage = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const status = mode === "plan" ? "planned" : steps.some((step) => step.status === "blocked") ? "blocked" : "queued";
|
||||||
|
withTransaction(() => {
|
||||||
|
dbRun("INSERT INTO workflow_runs(id, organization_id, workspace_id, project_id, episode_id, shot_id, template_id, status, current_step_index, steps_json, job_ids_json, error_message, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)", [runId, context.organization.id, context.workspace.id, context.project.id, body.episodeId || body.episode_id || null, shotId || null, template.id, status, JSON.stringify(steps), JSON.stringify(jobIds), steps.find((step) => step.errorMessage)?.errorMessage || "", context.user.id, timestamp, timestamp]);
|
||||||
|
});
|
||||||
|
addAudit({ context, action: "workflow.run.created", targetType: "workflow_run", targetId: runId, result: status === "blocked" ? "blocked" : "ok", metadata: { templateId: template.id, mode, shotId, jobIds, status } });
|
||||||
|
return { run: runPayload(dbGet("SELECT * FROM workflow_runs WHERE id = ?", [runId])), template, jobs: jobIds };
|
||||||
|
}
|
||||||
@@ -44,7 +44,8 @@ import {
|
|||||||
Webhook,
|
Webhook,
|
||||||
Workflow,
|
Workflow,
|
||||||
XCircle,
|
XCircle,
|
||||||
Zap
|
Zap,
|
||||||
|
Trash2
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
createApiClient,
|
createApiClient,
|
||||||
@@ -90,6 +91,8 @@ import {
|
|||||||
testNotification,
|
testNotification,
|
||||||
fetchNotificationDeliveries,
|
fetchNotificationDeliveries,
|
||||||
fetchStorageUsage,
|
fetchStorageUsage,
|
||||||
|
fetchStorageCleanupPreview,
|
||||||
|
reclaimStorage,
|
||||||
fetchSsoProviders,
|
fetchSsoProviders,
|
||||||
fetchIdentityCenter,
|
fetchIdentityCenter,
|
||||||
fetchOrganizationCommercial,
|
fetchOrganizationCommercial,
|
||||||
@@ -2460,6 +2463,7 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) {
|
|||||||
const [health, setHealth] = useState({ services: [], summary: {} });
|
const [health, setHealth] = useState({ services: [], summary: {} });
|
||||||
const [readiness, setReadiness] = useState({ checks: [], summary: {}, backups: { backups: [], count: 0 } });
|
const [readiness, setReadiness] = useState({ checks: [], summary: {}, backups: { backups: [], count: 0 } });
|
||||||
const [storage, setStorage] = useState(null);
|
const [storage, setStorage] = useState(null);
|
||||||
|
const [cleanup, setCleanup] = useState(null);
|
||||||
const [deliveries, setDeliveries] = useState([]);
|
const [deliveries, setDeliveries] = useState([]);
|
||||||
const [originalSettings, setOriginalSettings] = useState({});
|
const [originalSettings, setOriginalSettings] = useState({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -2468,17 +2472,19 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) {
|
|||||||
const [clientName, setClientName] = useState("");
|
const [clientName, setClientName] = useState("");
|
||||||
const [apiKeyNotice, setApiKeyNotice] = useState("");
|
const [apiKeyNotice, setApiKeyNotice] = useState("");
|
||||||
const [backupBusy, setBackupBusy] = useState(false);
|
const [backupBusy, setBackupBusy] = useState(false);
|
||||||
|
const [cleanupBusy, setCleanupBusy] = useState(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [nextConfig, nextHealth, nextReadiness, nextStorage, nextDeliveries] = await Promise.all([fetchSystemConfig(contextOverrides), fetchSystemHealth(contextOverrides), fetchSystemReadiness(contextOverrides), fetchStorageUsage(contextOverrides), fetchNotificationDeliveries(contextOverrides)]);
|
const [nextConfig, nextHealth, nextReadiness, nextStorage, nextDeliveries, nextCleanup] = await Promise.all([fetchSystemConfig(contextOverrides), fetchSystemHealth(contextOverrides), fetchSystemReadiness(contextOverrides), fetchStorageUsage(contextOverrides), fetchNotificationDeliveries(contextOverrides), fetchStorageCleanupPreview("", contextOverrides)]);
|
||||||
setConfig(nextConfig);
|
setConfig(nextConfig);
|
||||||
const values = Object.fromEntries(nextConfig.settings.map((setting) => [setting.key, setting.value]));
|
const values = Object.fromEntries(nextConfig.settings.map((setting) => [setting.key, setting.value]));
|
||||||
setOriginalSettings(values);
|
setOriginalSettings(values);
|
||||||
setHealth(nextHealth);
|
setHealth(nextHealth);
|
||||||
setReadiness(nextReadiness);
|
setReadiness(nextReadiness);
|
||||||
setStorage(nextStorage.storage || null);
|
setStorage(nextStorage.storage || null);
|
||||||
|
setCleanup(nextCleanup.cleanup || null);
|
||||||
setDeliveries(nextDeliveries.deliveries || []);
|
setDeliveries(nextDeliveries.deliveries || []);
|
||||||
setNotice("");
|
setNotice("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2572,6 +2578,36 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function previewCleanup() {
|
||||||
|
setCleanupBusy(true);
|
||||||
|
try {
|
||||||
|
const retentionDays = config.settings.find((setting) => setting.key === "storage.retention_days")?.value || "";
|
||||||
|
const result = await fetchStorageCleanupPreview(retentionDays, contextOverrides);
|
||||||
|
setCleanup(result.cleanup || null);
|
||||||
|
setNotice(`清理预览完成:${result.cleanup?.candidates?.length || 0} 个无引用临时文件,${((result.cleanup?.totalBytes || 0) / 1024 ** 2).toFixed(2)} MB。`);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setCleanupBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reclaimCleanup() {
|
||||||
|
if (!cleanup?.candidates?.length) return;
|
||||||
|
if (typeof window !== "undefined" && !window.confirm(`确认回收 ${cleanup.candidates.length} 个无引用临时文件吗?角色资产、交付物和 QA 证据不会被处理。`)) return;
|
||||||
|
setCleanupBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await reclaimStorage({ paths: cleanup.candidates.map((item) => item.relativePath) }, contextOverrides);
|
||||||
|
setCleanup(result.cleanup || null);
|
||||||
|
setStorage(result.storage || storage);
|
||||||
|
setNotice(`已回收 ${result.cleanup?.deleted?.length || 0} 个临时文件,释放 ${((result.cleanup?.deletedBytes || 0) / 1024 ** 2).toFixed(2)} MB,并写入审计日志。`);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setCleanupBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="enterprise-page">
|
<div className="enterprise-page">
|
||||||
<AdminHeader eyebrow="SYSTEM SETTINGS / CONTROL PLANE" title={title} description={description} action={<div className="header-actions"><button className="subtle" onClick={load}><RefreshCw size={15} />刷新</button>{section !== "overview" && <button className="primary" onClick={save} disabled={saving || !dirtySettings.length}><Save size={15} />{saving ? "保存中…" : dirtySettings.length ? `保存 ${dirtySettings.length} 项` : "保存配置"}</button>}</div>} />
|
<AdminHeader eyebrow="SYSTEM SETTINGS / CONTROL PLANE" title={title} description={description} action={<div className="header-actions"><button className="subtle" onClick={load}><RefreshCw size={15} />刷新</button>{section !== "overview" && <button className="primary" onClick={save} disabled={saving || !dirtySettings.length}><Save size={15} />{saving ? "保存中…" : dirtySettings.length ? `保存 ${dirtySettings.length} 项` : "保存配置"}</button>}</div>} />
|
||||||
@@ -2583,6 +2619,7 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) {
|
|||||||
{(section !== "notifications" && section !== "overview" && section !== "features") && <section className="studio-card wide-card"><SectionBar title="系统配置" detail={dirtySettings.length ? `${dirtySettings.length} 项未保存` : "已同步"} action={<span className="setting-scope"><Database size={14} />SQLite / system_settings</span>} /><div className="settings-list">{visibleSettings.map((setting) => <div className="system-setting-row" key={setting.key}><div><strong>{settingLabels[setting.category] || setting.category} · {setting.key}</strong><span>{setting.description}</span></div><SettingInput setting={setting} value={setting.value} onChange={(value) => setSetting(setting.key, value)} /></div>)}</div></section>}
|
{(section !== "notifications" && section !== "overview" && section !== "features") && <section className="studio-card wide-card"><SectionBar title="系统配置" detail={dirtySettings.length ? `${dirtySettings.length} 项未保存` : "已同步"} action={<span className="setting-scope"><Database size={14} />SQLite / system_settings</span>} /><div className="settings-list">{visibleSettings.map((setting) => <div className="system-setting-row" key={setting.key}><div><strong>{settingLabels[setting.category] || setting.category} · {setting.key}</strong><span>{setting.description}</span></div><SettingInput setting={setting} value={setting.value} onChange={(value) => setSetting(setting.key, value)} /></div>)}</div></section>}
|
||||||
{(section === "overview" || section === "generation" || section === "deployment" || section === "features") && <section className="studio-card wide-card"><SectionBar title="功能开关" detail="系统范围,变更立即生效" /><div className="feature-flag-grid">{config.featureFlags.map((flag) => <div key={flag.key}><div><strong>{flag.label}</strong><span>{flag.description}</span></div><button type="button" className={`switch-control ${flag.enabled ? "on" : ""}`} onClick={() => toggleFlag(flag)}><span />{flag.enabled ? "启用" : "关闭"}</button></div>)}</div></section>}
|
{(section === "overview" || section === "generation" || section === "deployment" || section === "features") && <section className="studio-card wide-card"><SectionBar title="功能开关" detail="系统范围,变更立即生效" /><div className="feature-flag-grid">{config.featureFlags.map((flag) => <div key={flag.key}><div><strong>{flag.label}</strong><span>{flag.description}</span></div><button type="button" className={`switch-control ${flag.enabled ? "on" : ""}`} onClick={() => toggleFlag(flag)}><span />{flag.enabled ? "启用" : "关闭"}</button></div>)}</div></section>}
|
||||||
{(section === "storage" || section === "overview") && <section className="studio-card wide-card"><SectionBar title="真实存储用量" detail={storage ? `当前项目 ${storage.percent}%` : "读取中"} /><div className="storage-admin-grid"><div className="storage-admin-kpi"><HardDrive size={20} /><strong>{storage ? `${storage.usedGb} GB` : "—"}</strong><span>已使用 / {storage?.limitGb || 0} GB</span></div><div className="storage-admin-track"><div><span>文件系统测量</span><b>{storage ? `${storage.usedBytes.toLocaleString()} / ${storage.limitBytes.toLocaleString()} bytes` : "—"}</b></div><div className="quota-track"><span style={{ width: `${storage?.percent || 0}%` }} /></div><small>资产、任务结果和本地合成输出均计入存储配额。</small></div><div className="storage-admin-files"><span>最大文件</span>{storage?.largestFiles?.slice(0, 4).map((file) => <div key={file.path}><strong>{file.relativePath}</strong><em>{(file.bytes / 1024 ** 2).toFixed(2)} MB</em></div>) || <small>暂无文件</small>}</div></div></section>}
|
{(section === "storage" || section === "overview") && <section className="studio-card wide-card"><SectionBar title="真实存储用量" detail={storage ? `当前项目 ${storage.percent}%` : "读取中"} /><div className="storage-admin-grid"><div className="storage-admin-kpi"><HardDrive size={20} /><strong>{storage ? `${storage.usedGb} GB` : "—"}</strong><span>已使用 / {storage?.limitGb || 0} GB</span></div><div className="storage-admin-track"><div><span>文件系统测量</span><b>{storage ? `${storage.usedBytes.toLocaleString()} / ${storage.limitBytes.toLocaleString()} bytes` : "—"}</b></div><div className="quota-track"><span style={{ width: `${storage?.percent || 0}%` }} /></div><small>资产、任务结果和本地合成输出均计入存储配额。</small></div><div className="storage-admin-files"><span>最大文件</span>{storage?.largestFiles?.slice(0, 4).map((file) => <div key={file.path}><strong>{file.relativePath}</strong><em>{(file.bytes / 1024 ** 2).toFixed(2)} MB</em></div>) || <small>暂无文件</small>}</div></div></section>}
|
||||||
|
{(section === "storage" || section === "overview") && <section className="studio-card wide-card"><SectionBar title="临时媒体治理" detail={cleanup ? `保留 ${cleanup.policy.retentionDays} 天 · ${cleanup.candidates.length} 个候选` : "只处理无引用临时文件"} action={<div className="header-actions"><button className="subtle" onClick={previewCleanup} disabled={cleanupBusy}><RefreshCw size={14} />{cleanupBusy ? "检查中…" : "生成清理预览"}</button>{cleanup?.candidates?.length > 0 && <button className="danger-button" onClick={reclaimCleanup} disabled={cleanupBusy}><Trash2 size={14} />回收候选文件</button>}</div>} /><div className="storage-cleanup-summary"><div><strong>{cleanup?.candidates?.length || 0}</strong><span>无引用临时文件</span></div><div><strong>{((cleanup?.totalBytes || 0) / 1024 ** 2).toFixed(2)} MB</strong><span>预计可释放</span></div><div><strong>{cleanup?.policy?.provider || "local-filesystem"}</strong><span>存储提供方</span></div><div><strong>{cleanup?.cutoff ? cleanup.cutoff.slice(0, 10) : "—"}</strong><span>本次截止日期</span></div></div>{cleanup?.candidates?.length > 0 ? <div className="storage-cleanup-list">{cleanup.candidates.slice(0, 8).map((item) => <div key={item.relativePath}><span className="mono">{item.relativePath}</span><strong>{(item.bytes / 1024 ** 2).toFixed(2)} MB</strong><small>{item.reason} · {item.updatedAt?.replace("T", " ").slice(0, 19)}</small></div>)}{cleanup.candidates.length > 8 && <small className="muted-copy">还有 {cleanup.candidates.length - 8} 个候选文件,执行时会按服务端预览结果处理。</small>}</div> : <div className="empty-table">没有满足保留周期且未被生产证据引用的临时文件。先调整“存储 · storage.retention_days”后重新生成预览。</div>}</section>}
|
||||||
{(section === "overview" || section === "deployment" || section === "storage") && <section className="studio-card wide-card"><SectionBar title="数据库快照备份" detail={`${readiness.backups?.count || 0} 个本地快照`} action={<button className="subtle" onClick={createBackup} disabled={backupBusy}><Database size={14} />{backupBusy ? "创建中…" : "立即创建快照"}</button>} /><div className="backup-summary-row"><div><Database size={18} /><strong>当前业务真源:SQLite</strong><span>快照写入 `data/backups/`,创建动作进入审计日志。</span></div><StatusBadge status={readiness.backups?.count ? "ready" : "needs-evidence"} label={readiness.backups?.count ? "已有备份" : "待创建"} /></div><div className="backup-list">{(readiness.backups?.backups || []).slice(0, 6).map((backup) => <div className="backup-row" key={backup.relativePath}><div><strong>{backup.relativePath}</strong><span>{formatAdminDate(backup.modifiedAt)} · {(Number(backup.bytes || 0) / 1024 ** 2).toFixed(2)} MB</span></div><StatusBadge status="active" label="可用" /></div>)}{!(readiness.backups?.backups || []).length && <div className="empty-table">还没有数据库快照。</div>}</div></section>}
|
{(section === "overview" || section === "deployment" || section === "storage") && <section className="studio-card wide-card"><SectionBar title="数据库快照备份" detail={`${readiness.backups?.count || 0} 个本地快照`} action={<button className="subtle" onClick={createBackup} disabled={backupBusy}><Database size={14} />{backupBusy ? "创建中…" : "立即创建快照"}</button>} /><div className="backup-summary-row"><div><Database size={18} /><strong>当前业务真源:SQLite</strong><span>快照写入 `data/backups/`,创建动作进入审计日志。</span></div><StatusBadge status={readiness.backups?.count ? "ready" : "needs-evidence"} label={readiness.backups?.count ? "已有备份" : "待创建"} /></div><div className="backup-list">{(readiness.backups?.backups || []).slice(0, 6).map((backup) => <div className="backup-row" key={backup.relativePath}><div><strong>{backup.relativePath}</strong><span>{formatAdminDate(backup.modifiedAt)} · {(Number(backup.bytes || 0) / 1024 ** 2).toFixed(2)} MB</span></div><StatusBadge status="active" label="可用" /></div>)}{!(readiness.backups?.backups || []).length && <div className="empty-table">还没有数据库快照。</div>}</div></section>}
|
||||||
{(section === "overview" || section === "notifications") && <div className="enterprise-grid enterprise-grid-main"><section className="studio-card"><SectionBar title="通知渠道" detail="事件通知与 Webhook" action={<button className="subtle" onClick={sendNotificationTest}><Bell size={14} />发送测试</button>} /><div className="notification-list">{config.notifications.map((channel) => <div key={channel.id}><div className="notification-icon"><Bell size={16} /></div><div><strong>{channel.name}</strong><span>{channel.kind} · {channel.endpoint || "本地日志"}</span></div><button type="button" className={`switch-control small ${channel.enabled ? "on" : ""}`} onClick={() => toggleNotification(channel)}><span />{channel.enabled ? "启用" : "关闭"}</button></div>)}</div><div className="notification-delivery-list"><div className="subsection-heading"><strong>最近投递</strong><span>{deliveries.length} 条</span></div>{deliveries.slice(0, 5).map((delivery) => <div key={delivery.id}><span>{delivery.event_key}</span><StatusBadge status={delivery.status} label={delivery.status === "delivered" ? "已送达" : delivery.status === "blocked" ? "已阻断" : "失败"} /><small>{delivery.channel_name} · {delivery.created_at?.replace("T", " ").slice(0, 19)}</small></div>)}{!deliveries.length && <small className="muted-copy">还没有通知投递记录。</small>}</div></section><section className="studio-card"><SectionBar title="API 客户端" detail="Runner 与企业集成" /><div className="api-client-list">{config.apiClients.map((client) => <div key={client.id}><FileKey2 size={16} /><div><strong>{client.name}</strong><span>{client.client_key_preview || client.client_key}</span><div className="api-client-scope-list">{(config.apiClientScopes?.length ? config.apiClientScopes : fallbackApiClientScopes).map((scope) => <button key={scope.key} type="button" className={`scope-toggle ${client.scopes.includes(scope.key) ? "active" : ""}`} aria-pressed={client.scopes.includes(scope.key)} title={scope.description} onClick={() => toggleApiClientScope(client, scope.key)}>{scope.label}</button>)}</div></div><StatusBadge status={client.status} label={client.status === "active" ? "有效" : client.status === "revoked" ? "已撤销" : client.status} /><button className="icon-text-button" onClick={() => rotateClientKey(client)}><RefreshCw size={13} />轮换密钥</button><button className="icon-text-button" onClick={() => toggleApiClient(client)}><XCircle size={13} />{client.status === "active" ? "撤销" : "恢复"}</button></div>)}</div><form className="client-create-form" onSubmit={createClient}><input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="新 API 客户端名称" /><button className="subtle" type="submit"><Plus size={15} />创建</button></form>{apiKeyNotice && <p className="form-notice"><CheckCircle2 size={14} />{apiKeyNotice}</p>}</section></div>}
|
{(section === "overview" || section === "notifications") && <div className="enterprise-grid enterprise-grid-main"><section className="studio-card"><SectionBar title="通知渠道" detail="事件通知与 Webhook" action={<button className="subtle" onClick={sendNotificationTest}><Bell size={14} />发送测试</button>} /><div className="notification-list">{config.notifications.map((channel) => <div key={channel.id}><div className="notification-icon"><Bell size={16} /></div><div><strong>{channel.name}</strong><span>{channel.kind} · {channel.endpoint || "本地日志"}</span></div><button type="button" className={`switch-control small ${channel.enabled ? "on" : ""}`} onClick={() => toggleNotification(channel)}><span />{channel.enabled ? "启用" : "关闭"}</button></div>)}</div><div className="notification-delivery-list"><div className="subsection-heading"><strong>最近投递</strong><span>{deliveries.length} 条</span></div>{deliveries.slice(0, 5).map((delivery) => <div key={delivery.id}><span>{delivery.event_key}</span><StatusBadge status={delivery.status} label={delivery.status === "delivered" ? "已送达" : delivery.status === "blocked" ? "已阻断" : "失败"} /><small>{delivery.channel_name} · {delivery.created_at?.replace("T", " ").slice(0, 19)}</small></div>)}{!deliveries.length && <small className="muted-copy">还没有通知投递记录。</small>}</div></section><section className="studio-card"><SectionBar title="API 客户端" detail="Runner 与企业集成" /><div className="api-client-list">{config.apiClients.map((client) => <div key={client.id}><FileKey2 size={16} /><div><strong>{client.name}</strong><span>{client.client_key_preview || client.client_key}</span><div className="api-client-scope-list">{(config.apiClientScopes?.length ? config.apiClientScopes : fallbackApiClientScopes).map((scope) => <button key={scope.key} type="button" className={`scope-toggle ${client.scopes.includes(scope.key) ? "active" : ""}`} aria-pressed={client.scopes.includes(scope.key)} title={scope.description} onClick={() => toggleApiClientScope(client, scope.key)}>{scope.label}</button>)}</div></div><StatusBadge status={client.status} label={client.status === "active" ? "有效" : client.status === "revoked" ? "已撤销" : client.status} /><button className="icon-text-button" onClick={() => rotateClientKey(client)}><RefreshCw size={13} />轮换密钥</button><button className="icon-text-button" onClick={() => toggleApiClient(client)}><XCircle size={13} />{client.status === "active" ? "撤销" : "恢复"}</button></div>)}</div><form className="client-create-form" onSubmit={createClient}><input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="新 API 客户端名称" /><button className="subtle" type="submit"><Plus size={15} />创建</button></form>{apiKeyNotice && <p className="form-notice"><CheckCircle2 size={14} />{apiKeyNotice}</p>}</section></div>}
|
||||||
</>}
|
</>}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -730,6 +730,11 @@ export async function fetchMediaArtifacts(params = {}, overrides = {}) {
|
|||||||
return apiFetch(`/api/production/media-artifacts${query.toString() ? `?${query.toString()}` : ""}`, {}, overrides);
|
return apiFetch(`/api/production/media-artifacts${query.toString() ? `?${query.toString()}` : ""}`, {}, overrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchMediaArtifactContent(artifactId, frame = "", overrides = {}) {
|
||||||
|
const query = frame ? `?frame=${encodeURIComponent(frame)}` : "";
|
||||||
|
return apiBlob(`/api/production/media-artifacts/${encodeURIComponent(artifactId)}/content${query}`, {}, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
export async function decideProductionReview(reviewId, body, overrides = {}) {
|
export async function decideProductionReview(reviewId, body, overrides = {}) {
|
||||||
return apiFetch(`/api/production/reviews/${encodeURIComponent(reviewId)}/decision`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
return apiFetch(`/api/production/reviews/${encodeURIComponent(reviewId)}/decision`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||||
}
|
}
|
||||||
@@ -905,6 +910,39 @@ export async function fetchStorageUsage(overrides = {}) {
|
|||||||
return apiFetch("/api/usage/storage", {}, overrides);
|
return apiFetch("/api/usage/storage", {}, overrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchStorageCleanupPreview(olderThanDays = "", overrides = {}) {
|
||||||
|
const query = olderThanDays ? `?olderThanDays=${encodeURIComponent(olderThanDays)}` : "";
|
||||||
|
return apiFetch(`/api/system/storage/cleanup-preview${query}`, {}, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reclaimStorage(body = {}, overrides = {}) {
|
||||||
|
return apiFetch("/api/system/storage/reclaim", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWorkflowTemplates(overrides = {}, options = {}) {
|
||||||
|
const query = options.includeArchived ? "?includeArchived=1" : "";
|
||||||
|
return apiFetch(`/api/workflows/templates${query}`, {}, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkflowTemplate(body, overrides = {}) {
|
||||||
|
return apiFetch("/api/workflows/templates", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWorkflowTemplate(templateId, body, overrides = {}) {
|
||||||
|
return apiFetch(`/api/workflows/templates/${encodeURIComponent(templateId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function instantiateWorkflow(templateId, body = {}, overrides = {}) {
|
||||||
|
return apiFetch(`/api/workflows/templates/${encodeURIComponent(templateId)}/instantiate`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWorkflowRuns(overrides = {}, options = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (options.status) query.set("status", options.status);
|
||||||
|
if (options.limit) query.set("limit", String(options.limit));
|
||||||
|
return apiFetch(`/api/workflows/runs${query.toString() ? `?${query.toString()}` : ""}`, {}, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchCompositions(overrides = {}) {
|
export async function fetchCompositions(overrides = {}) {
|
||||||
return apiFetch("/api/production/compositions", {}, overrides);
|
return apiFetch("/api/production/compositions", {}, overrides);
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-2
@@ -5645,6 +5645,20 @@ body {
|
|||||||
.qa-review-summary span { color: var(--faint); font-size: 10px; }
|
.qa-review-summary span { color: var(--faint); font-size: 10px; }
|
||||||
.qa-review-summary .ok strong { color: var(--accent); }
|
.qa-review-summary .ok strong { color: var(--accent); }
|
||||||
.qa-review-summary .warn strong { color: #b16629; }
|
.qa-review-summary .warn strong { color: #b16629; }
|
||||||
|
.workflow-runner-panel { display: grid; gap: 12px; }
|
||||||
|
.workflow-runner-form { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(180px, .7fr) auto; gap: 10px; align-items: end; }
|
||||||
|
.workflow-runner-form label { display: grid; gap: 5px; color: var(--muted); font-size: 10px; font-weight: 800; }
|
||||||
|
.workflow-runner-form select { min-height: 36px; padding: 0 9px; color: var(--ink); background: #fbfcfb; border: 1px solid var(--line); border-radius: 7px; font: inherit; font-size: 12px; }
|
||||||
|
.workflow-step-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 7px; }
|
||||||
|
.workflow-step-strip > div { display: grid; gap: 4px; min-width: 0; padding: 9px; background: #f7f9f7; border: 1px solid #e2e9e3; border-radius: 7px; }
|
||||||
|
.workflow-step-strip span { color: var(--accent); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; font-weight: 800; }
|
||||||
|
.workflow-step-strip strong { overflow: hidden; color: var(--ink); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.workflow-step-strip small { overflow: hidden; color: var(--faint); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.workflow-run-list { display: grid; gap: 7px; }
|
||||||
|
.workflow-run-list > div { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 10px; align-items: center; padding: 8px 10px; border-top: 1px solid #e7ece8; }
|
||||||
|
.workflow-run-list > div > div { display: grid; gap: 3px; min-width: 0; }
|
||||||
|
.workflow-run-list strong { overflow: hidden; color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.workflow-run-list span, .workflow-run-list small { color: var(--faint); font-size: 9px; }
|
||||||
.qa-review-layout { display: grid; grid-template-columns: minmax(280px, .75fr) minmax(0, 1.25fr); gap: 14px; }
|
.qa-review-layout { display: grid; grid-template-columns: minmax(280px, .75fr) minmax(0, 1.25fr); gap: 14px; }
|
||||||
.review-list { display: grid; gap: 6px; }
|
.review-list { display: grid; gap: 6px; }
|
||||||
.review-list > button { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto 15px; gap: 8px; align-items: center; width: 100%; min-height: 58px; padding: 8px; color: var(--muted); text-align: left; background: #f7f9f7; border: 1px solid #e2e9e3; border-radius: 7px; }
|
.review-list > button { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto 15px; gap: 8px; align-items: center; width: 100%; min-height: 58px; padding: 8px; color: var(--muted); text-align: left; background: #f7f9f7; border: 1px solid #e2e9e3; border-radius: 7px; }
|
||||||
@@ -5660,9 +5674,18 @@ body {
|
|||||||
.review-evidence strong { color: var(--ink); font-size: 11px; }
|
.review-evidence strong { color: var(--ink); font-size: 11px; }
|
||||||
.review-evidence pre { max-height: 160px; margin: 0; overflow: auto; color: var(--blue); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; line-height: 1.5; white-space: pre-wrap; }
|
.review-evidence pre { max-height: 160px; margin: 0; overflow: auto; color: var(--blue); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; line-height: 1.5; white-space: pre-wrap; }
|
||||||
.review-artifact-list { display: grid; gap: 7px; margin-top: 10px; }
|
.review-artifact-list { display: grid; gap: 7px; margin-top: 10px; }
|
||||||
.review-artifact-list > div:not(.subsection-heading) { display: grid; gap: 3px; padding: 9px 10px; background: #f7f9f7; border: 1px solid #e2e9e3; border-radius: 7px; }
|
.review-artifact-list > div:not(.subsection-heading), .review-artifact-row { display: grid; gap: 3px; padding: 9px 10px; background: #f7f9f7; border: 1px solid #e2e9e3; border-radius: 7px; }
|
||||||
|
.review-artifact-row { width: 100%; color: inherit; text-align: left; cursor: pointer; }
|
||||||
|
.review-artifact-row:hover, .review-artifact-row.selected { border-color: var(--accent); background: #eef7f0; }
|
||||||
.review-artifact-list strong { overflow: hidden; color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
.review-artifact-list strong { overflow: hidden; color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.review-artifact-list small { color: var(--faint); font-size: 9px; line-height: 1.4; }
|
.review-artifact-list small { color: var(--faint); font-size: 9px; line-height: 1.4; }
|
||||||
|
.review-media-proof { display: grid; gap: 10px; margin-top: 12px; padding: 12px; background: #f7f9f7; border: 1px solid #e2e9e3; border-radius: 7px; }
|
||||||
|
.review-media-proof > img, .review-media-proof > video { display: block; width: 100%; max-height: 440px; object-fit: contain; background: #18221d; border-radius: 6px; }
|
||||||
|
.review-media-proof > audio { width: 100%; }
|
||||||
|
.frame-proof-strip { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||||
|
.frame-proof-strip figure { display: grid; gap: 5px; margin: 0; }
|
||||||
|
.frame-proof-strip img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: contain; background: #18221d; border-radius: 5px; }
|
||||||
|
.frame-proof-strip figcaption { color: var(--faint); font-size: 9px; }
|
||||||
.review-decision-actions { display: flex; gap: 7px; margin: 12px 0; flex-wrap: wrap; }
|
.review-decision-actions { display: flex; gap: 7px; margin: 12px 0; flex-wrap: wrap; }
|
||||||
.danger-button { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 0 11px; color: #9b5b22; background: #fff3e5; border: 1px solid #efcfaa; border-radius: 7px; font-size: 11px; font-weight: 800; }
|
.danger-button { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 0 11px; color: #9b5b22; background: #fff3e5; border: 1px solid #efcfaa; border-radius: 7px; font-size: 11px; font-weight: 800; }
|
||||||
.review-comments { display: grid; gap: 8px; padding-top: 12px; border-top: 1px solid var(--line); }
|
.review-comments { display: grid; gap: 8px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||||
@@ -5980,6 +6003,15 @@ body {
|
|||||||
.storage-admin-files > div { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 10px; }
|
.storage-admin-files > div { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 10px; }
|
||||||
.storage-admin-files strong { overflow: hidden; color: var(--ink); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
.storage-admin-files strong { overflow: hidden; color: var(--ink); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.storage-admin-files em { flex: 0 0 auto; color: var(--faint); font-style: normal; }
|
.storage-admin-files em { flex: 0 0 auto; color: var(--faint); font-style: normal; }
|
||||||
|
.storage-cleanup-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; margin-top: 12px; }
|
||||||
|
.storage-cleanup-summary > div { display: grid; gap: 4px; min-width: 0; padding: 11px; background: #f7f9f7; border: 1px solid #e2e9e3; border-radius: 7px; }
|
||||||
|
.storage-cleanup-summary strong { overflow: hidden; color: var(--ink); font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.storage-cleanup-summary span { color: var(--faint); font-size: 10px; }
|
||||||
|
.storage-cleanup-list { display: grid; gap: 7px; margin-top: 12px; }
|
||||||
|
.storage-cleanup-list > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 10px; padding: 9px 10px; background: #f7f9f7; border: 1px solid #e2e9e3; border-radius: 7px; }
|
||||||
|
.storage-cleanup-list .mono { overflow: hidden; color: var(--ink); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.storage-cleanup-list strong { color: var(--ink); font-size: 10px; }
|
||||||
|
.storage-cleanup-list small { grid-column: 1 / -1; color: var(--faint); font-size: 9px; }
|
||||||
.notification-delivery-list { display: grid; gap: 7px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #e7ece8; }
|
.notification-delivery-list { display: grid; gap: 7px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #e7ece8; }
|
||||||
.notification-delivery-list > div:not(.subsection-heading) { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 8px; align-items: center; padding: 6px 0; border-bottom: 1px solid #edf0ed; }
|
.notification-delivery-list > div:not(.subsection-heading) { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 8px; align-items: center; padding: 6px 0; border-bottom: 1px solid #edf0ed; }
|
||||||
.notification-delivery-list > div:last-child { border-bottom: 0; }
|
.notification-delivery-list > div:last-child { border-bottom: 0; }
|
||||||
@@ -6084,7 +6116,7 @@ body {
|
|||||||
.attempt-log-list > div { grid-template-columns: 1fr auto; }
|
.attempt-log-list > div { grid-template-columns: 1fr auto; }
|
||||||
.attempt-log-list span { grid-column: 1 / -1; }
|
.attempt-log-list span { grid-column: 1 / -1; }
|
||||||
.attempt-log-list small { grid-column: 1 / -1; }
|
.attempt-log-list small { grid-column: 1 / -1; }
|
||||||
.dependency-selector, .storage-admin-grid { grid-template-columns: 1fr; }
|
.dependency-selector, .storage-admin-grid, .storage-cleanup-summary, .workflow-runner-form { grid-template-columns: 1fr; }
|
||||||
.compose-form { grid-template-columns: 1fr; }
|
.compose-form { grid-template-columns: 1fr; }
|
||||||
.composition-list > div { grid-template-columns: minmax(0, 1fr) auto; }
|
.composition-list > div { grid-template-columns: minmax(0, 1fr) auto; }
|
||||||
.composition-list small { grid-column: 1 / -1; }
|
.composition-list small { grid-column: 1 / -1; }
|
||||||
|
|||||||
Reference in New Issue
Block a user