feat: add style kit production standards
This commit is contained in:
@@ -32,6 +32,7 @@
|
|||||||
"smoke:knowledge-rag": "node scripts/smoke-knowledge-rag.mjs",
|
"smoke:knowledge-rag": "node scripts/smoke-knowledge-rag.mjs",
|
||||||
"smoke:knowledge-governance": "node scripts/smoke-knowledge-governance.mjs",
|
"smoke:knowledge-governance": "node scripts/smoke-knowledge-governance.mjs",
|
||||||
"smoke:asset-governance": "node scripts/smoke-asset-governance.mjs",
|
"smoke:asset-governance": "node scripts/smoke-asset-governance.mjs",
|
||||||
|
"smoke:style-kits": "node scripts/smoke-style-kits.mjs",
|
||||||
"smoke:model-connectors": "node scripts/smoke-model-connectors.mjs",
|
"smoke:model-connectors": "node scripts/smoke-model-connectors.mjs",
|
||||||
"smoke:model-approvals": "node scripts/smoke-model-approvals.mjs",
|
"smoke:model-approvals": "node scripts/smoke-model-approvals.mjs",
|
||||||
"smoke:model-routing": "node scripts/smoke-model-routing.mjs",
|
"smoke:model-routing": "node scripts/smoke-model-routing.mjs",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const scripts = [
|
|||||||
"smoke:knowledge-rag",
|
"smoke:knowledge-rag",
|
||||||
"smoke:knowledge-governance",
|
"smoke:knowledge-governance",
|
||||||
"smoke:asset-governance",
|
"smoke:asset-governance",
|
||||||
|
"smoke:style-kits",
|
||||||
"smoke:model-connectors",
|
"smoke:model-connectors",
|
||||||
"smoke:model-approvals",
|
"smoke:model-approvals",
|
||||||
"smoke:model-routing",
|
"smoke:model-routing",
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { dbRun, withTransaction } from "../server/db.mjs";
|
||||||
|
|
||||||
|
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
|
||||||
|
const projectRoot = resolve(import.meta.dirname, "..");
|
||||||
|
const organizationId = "org-studio-lab";
|
||||||
|
const workspaceId = "ws-local-aidrama";
|
||||||
|
const projectId = "thunder-mouth";
|
||||||
|
const northstarScope = {
|
||||||
|
"x-organization-id": "org-northstar",
|
||||||
|
"x-workspace-id": "ws-northstar-main",
|
||||||
|
"x-project-id": "northstar-pilot"
|
||||||
|
};
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const response = await fetch(`${api}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: { "content-type": "application/json", ...(options.headers || {}) }
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
return { response, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectOk(result, label) {
|
||||||
|
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
|
||||||
|
return result.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(email) {
|
||||||
|
const result = expectOk(await request("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, password: "Demo@123456" })
|
||||||
|
}), `${email} login`);
|
||||||
|
return { authorization: `Bearer ${result.session.token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerAuth = await login("producer@local.test");
|
||||||
|
const writerAuth = await login("writer@local.test");
|
||||||
|
const northstarAuth = await login("producer2@local.test");
|
||||||
|
const headers = { ...ownerAuth, "x-organization-id": organizationId, "x-workspace-id": workspaceId, "x-project-id": projectId };
|
||||||
|
const writerHeaders = { ...writerAuth, "x-organization-id": organizationId, "x-workspace-id": workspaceId, "x-project-id": projectId };
|
||||||
|
const northstarHeaders = { ...northstarAuth, ...northstarScope };
|
||||||
|
const createdStyleKitIds = [];
|
||||||
|
const createdJobIds = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const initial = expectOk(await request("/api/style-kits", { headers }), "list seeded style kits");
|
||||||
|
assert.ok(initial.kits.some((kit) => kit.id === "stylekit-studio-guoman-vertical"), "seeded studio style kit must be visible");
|
||||||
|
assert.equal(initial.effective.styleKit.id, "stylekit-studio-guoman-vertical", "thunder-mouth must inherit seeded style kit binding");
|
||||||
|
assert.equal(initial.effective.binding.projectId, projectId, "effective binding must belong to current project");
|
||||||
|
assert.equal(initial.effective.styleKit.voicePolicy.forbidRandomNativeVoice, true, "fixed voice policy must forbid random native voice as final");
|
||||||
|
|
||||||
|
const writerRead = await request("/api/style-kits", { headers: writerHeaders });
|
||||||
|
assert.equal(writerRead.response.status, 200, "writer should read effective style kit for generation");
|
||||||
|
const writerCreate = await request("/api/style-kits", {
|
||||||
|
method: "POST",
|
||||||
|
headers: writerHeaders,
|
||||||
|
body: JSON.stringify({ name: "编剧不应创建", scopeMode: "workspace" })
|
||||||
|
});
|
||||||
|
assert.equal(writerCreate.response.status, 403, "writer must not manage style kits");
|
||||||
|
|
||||||
|
const styleKitId = `stylekit-smoke-${Date.now()}`;
|
||||||
|
const created = expectOk(await request("/api/style-kits", {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: styleKitId,
|
||||||
|
name: "Smoke 商用风格标准",
|
||||||
|
scopeMode: "workspace",
|
||||||
|
status: "active",
|
||||||
|
description: "用于 smoke 验收的本地商用风格标准。",
|
||||||
|
visualProfile: {
|
||||||
|
styleFamily: "原创国漫 2D 动画",
|
||||||
|
cameraLanguage: "稳定中景,单一完整画面",
|
||||||
|
continuityLocks: ["character", "costume", "location", "prop", "weather", "camera"]
|
||||||
|
},
|
||||||
|
subtitlePreset: { language: "zh-CN", position: "bottom-safe-area", maxCharsPerLine: 17 },
|
||||||
|
voicePolicy: { finalVoiceMode: "fixed-reference-required", forbidRandomNativeVoice: true, fallbackMouthPlan: ["侧脸", "反应镜头"] },
|
||||||
|
deliverySpec: { aspectRatio: "9:16", resolution: "1080x1920", fps: 24, clipDurationSec: [5, 10], exportFormat: "mp4" },
|
||||||
|
promptRules: {
|
||||||
|
positivePrefix: "ONE SINGLE COMPLETE 9:16 CHINESE ANIMATION FRAME, smoke standard.",
|
||||||
|
negativeTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "watermark"]
|
||||||
|
},
|
||||||
|
singleFramePolicy: { enabled: true, imageOutputCount: 1, batchSize: 1, forbiddenTerms: ["split-screen", "collage", "contact sheet", "多格"] },
|
||||||
|
namingRules: { shotPattern: "{projectId}/smoke/shot-{shotNumber}-{version}", deliveryPattern: "{projectName}-smoke-{releaseVersion}" },
|
||||||
|
qaPolicy: { gates: ["single-frame", "continuity", "voice-rights", "subtitle-asr-alignment"], minimumReviewScore: 91, requireLockedAssets: true },
|
||||||
|
metadata: { source: "smoke-style-kits" }
|
||||||
|
})
|
||||||
|
}), "create style kit");
|
||||||
|
createdStyleKitIds.push(styleKitId);
|
||||||
|
assert.equal(created.styleKit.id, styleKitId, "created style kit id must be stable");
|
||||||
|
assert.ok(created.styleKit.promptRules.negativeTerms.includes("contact sheet"), "server must normalize single-frame negative terms");
|
||||||
|
assert.equal(created.styleKit.singleFramePolicy.batchSize, 1, "style kit must lock batch size to 1");
|
||||||
|
|
||||||
|
const bound = expectOk(await request(`/api/projects/${encodeURIComponent(projectId)}/style-kit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ styleKitId, enforcement: "strict", notes: "smoke binds the workspace kit to the current project" })
|
||||||
|
}), "bind style kit to project");
|
||||||
|
assert.equal(bound.effective.styleKit.id, styleKitId, "effective style kit must switch after binding");
|
||||||
|
assert.equal(bound.effective.binding.enforcement, "strict", "binding enforcement must be persisted");
|
||||||
|
|
||||||
|
const effective = expectOk(await request("/api/style-kits/effective", { headers }), "read effective style kit");
|
||||||
|
assert.equal(effective.styleKit.id, styleKitId, "effective endpoint must return project binding");
|
||||||
|
|
||||||
|
const preview = expectOk(await request("/api/jobs/preview", {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ kind: "单画面关键帧", shotId: "shot-01", adapter: "owned-model-platform" })
|
||||||
|
}), "preview generation job with style kit");
|
||||||
|
assert.equal(preview.preview.styleKit.id, styleKitId, "generation preview contract must include effective style kit");
|
||||||
|
assert.equal(preview.preview.constraints.imageOutputCount, 1, "generation contract must keep image output count at 1");
|
||||||
|
assert.equal(preview.preview.constraints.batchSize, 1, "generation contract must keep batch size at 1");
|
||||||
|
assert.ok(preview.preview.constraints.negativePromptTerms.includes("contact sheet"), "generation contract must inject single-frame negative terms");
|
||||||
|
assert.equal(preview.preview.constraints.voicePolicy.forbidRandomNativeVoice, true, "generation contract must carry final voice policy");
|
||||||
|
|
||||||
|
const blockedPrompt = await request("/api/jobs/preview", {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ kind: "单画面关键帧", shotId: "shot-01", adapter: "owned-model-platform", prompt: "请生成 split-screen 对比图" })
|
||||||
|
});
|
||||||
|
assert.equal(blockedPrompt.response.status, 422, "positive prompt with split-screen must be blocked");
|
||||||
|
assert.equal(blockedPrompt.payload.error, "single_frame_contract_violation", "single-frame violation error code must be explicit");
|
||||||
|
|
||||||
|
const crossOrgBind = await request(`/api/projects/${encodeURIComponent("northstar-pilot")}/style-kit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: northstarHeaders,
|
||||||
|
body: JSON.stringify({ styleKitId, enforcement: "strict" })
|
||||||
|
});
|
||||||
|
assert.equal(crossOrgBind.response.status, 404, "another organization must not bind this style kit");
|
||||||
|
|
||||||
|
const createdJob = expectOk(await request("/api/jobs", {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ kind: "单画面关键帧", shotId: "shot-01", adapter: "owned-model-platform", output: `qa/style-kit/${styleKitId}/frame.json` })
|
||||||
|
}), "create generation job with style kit");
|
||||||
|
createdJobIds.push(createdJob.job.id);
|
||||||
|
assert.equal(createdJob.job.request.styleKit.id, styleKitId, "created job request_json must persist style kit");
|
||||||
|
assert.equal(createdJob.job.request.constraints.styleEnforcement, "project-binding", "created job must record style enforcement source");
|
||||||
|
|
||||||
|
console.log(`style kits smoke passed: ${styleKitId}`);
|
||||||
|
} finally {
|
||||||
|
await request(`/api/projects/${encodeURIComponent(projectId)}/style-kit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ styleKitId: "stylekit-studio-guoman-vertical", enforcement: "strict", notes: "《雷雨口》商用 MVP 默认继承工作区国漫竖屏生产标准。" })
|
||||||
|
}).catch(() => {});
|
||||||
|
withTransaction(() => {
|
||||||
|
for (const jobId of createdJobIds) {
|
||||||
|
dbRun("DELETE FROM job_dependencies WHERE job_id = ? OR depends_on_job_id = ?", [jobId, jobId]);
|
||||||
|
dbRun("DELETE FROM job_attempts WHERE job_id = ?", [jobId]);
|
||||||
|
dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ?", [`%${jobId}%`]);
|
||||||
|
dbRun("DELETE FROM audit_logs WHERE target_type = 'generation_job' AND target_id = ?", [jobId]);
|
||||||
|
dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]);
|
||||||
|
}
|
||||||
|
for (const styleKitId of createdStyleKitIds) {
|
||||||
|
dbRun("DELETE FROM project_style_kit_bindings WHERE style_kit_id = ?", [styleKitId]);
|
||||||
|
dbRun("DELETE FROM audit_logs WHERE target_type = 'style_kit' AND target_id = ?", [styleKitId]);
|
||||||
|
dbRun("DELETE FROM style_kits WHERE id = ?", [styleKitId]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const jobId of createdJobIds) {
|
||||||
|
await rm(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
+186
-9
@@ -247,6 +247,8 @@ function seedRoles() {
|
|||||||
["task:view", "查看项目协作任务"],
|
["task:view", "查看项目协作任务"],
|
||||||
["task:manage", "创建、分派和管理项目协作任务"],
|
["task:manage", "创建、分派和管理项目协作任务"],
|
||||||
["task:complete", "更新本人负责的协作任务状态"],
|
["task:complete", "更新本人负责的协作任务状态"],
|
||||||
|
["style:read", "查看组织、工作区和项目风格生产标准"],
|
||||||
|
["style:manage", "管理风格标准、品牌规范和项目绑定"],
|
||||||
["script:edit", "编辑剧本和对白"],
|
["script:edit", "编辑剧本和对白"],
|
||||||
["script:read", "查看剧本、分集和镜头"],
|
["script:read", "查看剧本、分集和镜头"],
|
||||||
["asset:edit", "编辑角色、场景和道具锁"],
|
["asset:edit", "编辑角色、场景和道具锁"],
|
||||||
@@ -283,17 +285,17 @@ 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",
|
||||||
"workflow:manage", "task:view", "task:manage", "task:complete", "model:manage", "model:approve", "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", "style:read", "style:manage", "model:manage", "model:approve", "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", "workflow: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", "style:read", "style:manage", "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", "style:read", "job:create"],
|
||||||
art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "job:create"],
|
art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "style:read", "style:manage", "job:create"],
|
||||||
voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "job:create"],
|
voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "style:read", "job:create"],
|
||||||
reviewer: ["script:read", "task:view", "task:complete", "qa:review", "voice:approve", "delivery:view"],
|
reviewer: ["script:read", "task:view", "task:complete", "style:read", "qa:review", "voice:approve", "delivery:view"],
|
||||||
project_guest: ["script:read", "task:view", "delivery:view"],
|
project_guest: ["script:read", "task:view", "style:read", "delivery:view"],
|
||||||
project_editor: ["script:read", "script:edit", "asset:edit", "prompt:edit", "voice:edit", "task:view", "task:manage", "task:complete", "job:create", "delivery:view"],
|
project_editor: ["script:read", "script:edit", "asset:edit", "prompt:edit", "voice:edit", "task:view", "task:manage", "task:complete", "style:read", "job:create", "delivery:view"],
|
||||||
project_viewer: ["script:read", "task:view", "delivery:view"]
|
project_viewer: ["script:read", "task:view", "style:read", "delivery:view"]
|
||||||
};
|
};
|
||||||
for (const [roleKey, permissionKeys] of Object.entries(rolePermissions)) {
|
for (const [roleKey, permissionKeys] of Object.entries(rolePermissions)) {
|
||||||
for (const permissionKey of permissionKeys) {
|
for (const permissionKey of permissionKeys) {
|
||||||
@@ -362,6 +364,7 @@ function commercialEntitlementDefaults(billing = {}) {
|
|||||||
["limit.workspaces", "工作区数量", "tenant", 8, "个", 1, "block", { commercialGate: "workspace:create" }],
|
["limit.workspaces", "工作区数量", "tenant", 8, "个", 1, "block", { commercialGate: "workspace:create" }],
|
||||||
["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }],
|
["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }],
|
||||||
["limit.generation_jobs_monthly", "月度生成任务", "production", clipLimit, "job", 1, "block", { commercialGate: "generation_job:create" }],
|
["limit.generation_jobs_monthly", "月度生成任务", "production", clipLimit, "job", 1, "block", { commercialGate: "generation_job:create" }],
|
||||||
|
["limit.style_kits", "风格生产标准", "production", 24, "套", 1, "block", { commercialGate: "style_kit:create" }],
|
||||||
["limit.storage_gb", "存储容量", "storage", storageGb, "GB", 1, "block", { commercialGate: "storage:write" }],
|
["limit.storage_gb", "存储容量", "storage", storageGb, "GB", 1, "block", { commercialGate: "storage:write" }],
|
||||||
["limit.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector:create" }],
|
["limit.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector:create" }],
|
||||||
["limit.api_clients", "API 客户端", "system", 8, "个", 1, "block", { commercialGate: "api_client:create" }],
|
["limit.api_clients", "API 客户端", "system", 8, "个", 1, "block", { commercialGate: "api_client:create" }],
|
||||||
@@ -604,6 +607,179 @@ function seedModelRoutes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seedStyleKits() {
|
||||||
|
const timestamp = now();
|
||||||
|
const kits = [
|
||||||
|
{
|
||||||
|
id: "stylekit-studio-guoman-vertical",
|
||||||
|
organizationId: "org-studio-lab",
|
||||||
|
workspaceId: "ws-local-aidrama",
|
||||||
|
projectId: null,
|
||||||
|
scopeMode: "workspace",
|
||||||
|
name: "星河国漫竖屏生产标准",
|
||||||
|
description: "工作区默认商用风格标准:原创国漫 2D、9:16、固定字幕与声线策略,所有关键帧强制单画面。",
|
||||||
|
status: "active",
|
||||||
|
visualProfile: {
|
||||||
|
styleFamily: "国产漫画 / 国漫 2D 动画",
|
||||||
|
linework: "干净线稿、清晰轮廓、轻赛璐璐阴影",
|
||||||
|
colorScript: "雷雨青绿、暖肤色、低饱和环境光",
|
||||||
|
cameraLanguage: "稳定中景、反应镜头、环境插入镜头优先",
|
||||||
|
continuityLocks: ["character", "costume", "location", "prop", "weather", "camera"]
|
||||||
|
},
|
||||||
|
subtitlePreset: {
|
||||||
|
language: "zh-CN",
|
||||||
|
fontFamily: "PingFang SC / Noto Sans SC",
|
||||||
|
position: "bottom-safe-area",
|
||||||
|
maxCharsPerLine: 18,
|
||||||
|
style: "白字深色描边,避免遮挡人物脸部和关键道具"
|
||||||
|
},
|
||||||
|
voicePolicy: {
|
||||||
|
finalVoiceMode: "fixed-reference-required",
|
||||||
|
forbidRandomNativeVoice: true,
|
||||||
|
ttsPreference: "local-or-owned-first",
|
||||||
|
fallbackMouthPlan: ["侧脸", "背影", "低头", "远景", "反应镜头", "环境插入镜头"]
|
||||||
|
},
|
||||||
|
deliverySpec: {
|
||||||
|
aspectRatio: "9:16",
|
||||||
|
resolution: "1080x1920",
|
||||||
|
fps: 24,
|
||||||
|
clipDurationSec: [5, 10],
|
||||||
|
exportFormat: "mp4",
|
||||||
|
projectFolder: "exports/{projectId}"
|
||||||
|
},
|
||||||
|
promptRules: {
|
||||||
|
positivePrefix: "ONE SINGLE COMPLETE 9:16 CHINESE ANIMATION FRAME, one continuous scene, one complete image.",
|
||||||
|
negativeTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "故事板拼图"],
|
||||||
|
renderNotes: ["角色服装、道具、天气和机位必须继承 continuity ledger", "每次图片生成 batch size 固定为 1"]
|
||||||
|
},
|
||||||
|
singleFramePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
imageOutputCount: 1,
|
||||||
|
batchSize: 1,
|
||||||
|
forbiddenTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "多时间点"],
|
||||||
|
requiredPhrase: "one single complete frame"
|
||||||
|
},
|
||||||
|
namingRules: {
|
||||||
|
shotPattern: "{projectId}/S{season}E{episode}/shot-{shotNumber}-{assetKind}-{version}",
|
||||||
|
subtitlePattern: "{episodeId}-{voiceLineId}.srt",
|
||||||
|
deliveryPattern: "{projectName}-{episodeId}-{releaseVersion}"
|
||||||
|
},
|
||||||
|
qaPolicy: {
|
||||||
|
gates: ["single-frame", "continuity", "voice-rights", "subtitle-asr-alignment", "clip-transition"],
|
||||||
|
minimumReviewScore: 90,
|
||||||
|
requireLockedAssets: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
source: "seed://style-kits/studio-guoman-vertical",
|
||||||
|
commercialReference: "brand-kit-style-guide-production-standard"
|
||||||
|
},
|
||||||
|
bindProjectId: "thunder-mouth",
|
||||||
|
bindingNotes: "《雷雨口》商用 MVP 默认继承工作区国漫竖屏生产标准。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stylekit-northstar-fog-pilot",
|
||||||
|
organizationId: "org-northstar",
|
||||||
|
workspaceId: "ws-northstar-main",
|
||||||
|
projectId: "northstar-pilot",
|
||||||
|
scopeMode: "project",
|
||||||
|
name: "北辰雾境试播标准",
|
||||||
|
description: "北辰内容厂牌专用试播标准,用于验证跨组织隔离和项目级风格绑定。",
|
||||||
|
status: "active",
|
||||||
|
visualProfile: {
|
||||||
|
styleFamily: "原创国漫 2D 奇幻",
|
||||||
|
linework: "偏硬朗线条、雾中高反差边缘光",
|
||||||
|
colorScript: "冷雾灰、青铜色、少年角色暖色点光",
|
||||||
|
cameraLanguage: "稳定中景与环境压迫感,禁止多格构图",
|
||||||
|
continuityLocks: ["character", "location", "prop", "weather", "camera"]
|
||||||
|
},
|
||||||
|
subtitlePreset: {
|
||||||
|
language: "zh-CN",
|
||||||
|
fontFamily: "PingFang SC",
|
||||||
|
position: "bottom-safe-area",
|
||||||
|
maxCharsPerLine: 16,
|
||||||
|
style: "白字深描边"
|
||||||
|
},
|
||||||
|
voicePolicy: {
|
||||||
|
finalVoiceMode: "fixed-reference-required",
|
||||||
|
forbidRandomNativeVoice: true,
|
||||||
|
ttsPreference: "local-runner-only",
|
||||||
|
fallbackMouthPlan: ["远景", "侧脸", "反应镜头"]
|
||||||
|
},
|
||||||
|
deliverySpec: {
|
||||||
|
aspectRatio: "9:16",
|
||||||
|
resolution: "1080x1920",
|
||||||
|
fps: 24,
|
||||||
|
clipDurationSec: [5, 8],
|
||||||
|
exportFormat: "mp4",
|
||||||
|
projectFolder: "exports/{projectId}"
|
||||||
|
},
|
||||||
|
promptRules: {
|
||||||
|
positivePrefix: "ONE SINGLE COMPLETE 9:16 CHINESE ANIMATION FRAME, fantasy fog gate, one scene only.",
|
||||||
|
negativeTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "分屏"],
|
||||||
|
renderNotes: ["不得串用星河实验室角色或道具", "首尾帧必须来自当前项目"]
|
||||||
|
},
|
||||||
|
singleFramePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
imageOutputCount: 1,
|
||||||
|
batchSize: 1,
|
||||||
|
forbiddenTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏"],
|
||||||
|
requiredPhrase: "one single complete frame"
|
||||||
|
},
|
||||||
|
namingRules: {
|
||||||
|
shotPattern: "{projectId}/pilot/shot-{shotNumber}-{version}",
|
||||||
|
subtitlePattern: "{projectId}-{voiceLineId}.srt",
|
||||||
|
deliveryPattern: "{projectName}-pilot-{releaseVersion}"
|
||||||
|
},
|
||||||
|
qaPolicy: {
|
||||||
|
gates: ["single-frame", "cross-org-isolation", "continuity", "clip-transition"],
|
||||||
|
minimumReviewScore: 92,
|
||||||
|
requireLockedAssets: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
source: "seed://style-kits/northstar-fog-pilot",
|
||||||
|
commercialReference: "tenant-isolated-production-standard"
|
||||||
|
},
|
||||||
|
bindProjectId: "northstar-pilot",
|
||||||
|
bindingNotes: "北辰试播项目强制使用项目级雾境风格标准。"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
for (const kit of kits) {
|
||||||
|
insertIgnore(
|
||||||
|
`INSERT OR IGNORE INTO style_kits(
|
||||||
|
id, organization_id, workspace_id, project_id, scope_mode, name, description, status,
|
||||||
|
visual_profile_json, subtitle_preset_json, voice_policy_json, delivery_spec_json,
|
||||||
|
prompt_rules_json, single_frame_policy_json, naming_rules_json, qa_policy_json,
|
||||||
|
metadata_json, created_by, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'u-owner', ?, ?)`,
|
||||||
|
[
|
||||||
|
kit.id,
|
||||||
|
kit.organizationId,
|
||||||
|
kit.workspaceId,
|
||||||
|
kit.projectId,
|
||||||
|
kit.scopeMode,
|
||||||
|
kit.name,
|
||||||
|
kit.description,
|
||||||
|
kit.status,
|
||||||
|
JSON.stringify(kit.visualProfile),
|
||||||
|
JSON.stringify(kit.subtitlePreset),
|
||||||
|
JSON.stringify(kit.voicePolicy),
|
||||||
|
JSON.stringify(kit.deliverySpec),
|
||||||
|
JSON.stringify(kit.promptRules),
|
||||||
|
JSON.stringify(kit.singleFramePolicy),
|
||||||
|
JSON.stringify(kit.namingRules),
|
||||||
|
JSON.stringify(kit.qaPolicy),
|
||||||
|
JSON.stringify(kit.metadata),
|
||||||
|
timestamp,
|
||||||
|
timestamp
|
||||||
|
]
|
||||||
|
);
|
||||||
|
insertIgnore(
|
||||||
|
"INSERT OR IGNORE INTO project_style_kit_bindings(id, organization_id, workspace_id, project_id, style_kit_id, enforcement, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'strict', ?, 'u-owner', ?, ?)",
|
||||||
|
[`binding-${kit.bindProjectId}-style-kit`, kit.organizationId, kit.workspaceId, kit.bindProjectId, kit.id, kit.bindingNotes, timestamp, timestamp]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function seedKnowledgeLibrary() {
|
function seedKnowledgeLibrary() {
|
||||||
const timestamp = now();
|
const timestamp = now();
|
||||||
const content = [
|
const content = [
|
||||||
@@ -1068,6 +1244,7 @@ withTransaction(() => {
|
|||||||
seedModels();
|
seedModels();
|
||||||
seedModelCatalog();
|
seedModelCatalog();
|
||||||
seedModelRoutes();
|
seedModelRoutes();
|
||||||
|
seedStyleKits();
|
||||||
seedSystemGovernance();
|
seedSystemGovernance();
|
||||||
seedWorkflowTemplates();
|
seedWorkflowTemplates();
|
||||||
seedProductionGraph();
|
seedProductionGraph();
|
||||||
|
|||||||
+14
-2
@@ -17,6 +17,7 @@ import { productionGraph } from "./production.mjs";
|
|||||||
import { dispatchNotificationEvent } from "./notifications.mjs";
|
import { dispatchNotificationEvent } from "./notifications.mjs";
|
||||||
import { registerJobArtifacts } from "./media-artifacts.mjs";
|
import { registerJobArtifacts } from "./media-artifacts.mjs";
|
||||||
import { resolveKnowledgeContextForJob } from "./knowledge.mjs";
|
import { resolveKnowledgeContextForJob } from "./knowledge.mjs";
|
||||||
|
import { effectiveStyleKitForProject, styleKitForbiddenTerms, styleKitNegativeTerms } from "./style-kits.mjs";
|
||||||
|
|
||||||
const projectRoot = resolve(import.meta.dirname, "..");
|
const projectRoot = resolve(import.meta.dirname, "..");
|
||||||
const jobStorageRoot = resolve(projectRoot, "storage", "jobs");
|
const jobStorageRoot = resolve(projectRoot, "storage", "jobs");
|
||||||
@@ -373,13 +374,15 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
|
|||||||
const shot = shotForJob(context, body.shotId);
|
const shot = shotForJob(context, body.shotId);
|
||||||
const kind = String(body.kind || "自定义生成任务").trim();
|
const kind = String(body.kind || "自定义生成任务").trim();
|
||||||
const knowledge = resolveKnowledgeContextForJob(context, body);
|
const knowledge = resolveKnowledgeContextForJob(context, body);
|
||||||
|
const styleKit = context.project ? effectiveStyleKitForProject(context, context.project.id).styleKit : null;
|
||||||
const inputs = { ...(body.inputs || {}) };
|
const inputs = { ...(body.inputs || {}) };
|
||||||
if (knowledge) {
|
if (knowledge) {
|
||||||
inputs.knowledgeContext = knowledge.promptContext;
|
inputs.knowledgeContext = knowledge.promptContext;
|
||||||
inputs.knowledgeCitations = knowledge.citations;
|
inputs.knowledgeCitations = knowledge.citations;
|
||||||
inputs.knowledgeChunkIds = knowledge.chunkIds;
|
inputs.knowledgeChunkIds = knowledge.chunkIds;
|
||||||
}
|
}
|
||||||
const blockedTerms = ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "故事板拼图"];
|
const blockedTerms = styleKitForbiddenTerms(styleKit);
|
||||||
|
const negativeTerms = styleKitNegativeTerms(styleKit);
|
||||||
// Negative prompts intentionally name the forbidden layouts; only inspect positive generation text here.
|
// Negative prompts intentionally name the forbidden layouts; only inspect positive generation text here.
|
||||||
const promptText = [shot?.prompt, shot?.videoPrompt, shot?.action, shot?.camera, body.prompt].filter(Boolean).join(" ").toLowerCase();
|
const promptText = [shot?.prompt, shot?.videoPrompt, shot?.action, shot?.camera, body.prompt].filter(Boolean).join(" ").toLowerCase();
|
||||||
const blocked = blockedTerms.filter((term) => promptText.includes(term.toLowerCase()));
|
const blocked = blockedTerms.filter((term) => promptText.includes(term.toLowerCase()));
|
||||||
@@ -408,15 +411,24 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
|
|||||||
} : null,
|
} : null,
|
||||||
preflight,
|
preflight,
|
||||||
knowledge,
|
knowledge,
|
||||||
|
styleKit,
|
||||||
shot,
|
shot,
|
||||||
inputs,
|
inputs,
|
||||||
constraints: {
|
constraints: {
|
||||||
singleFrameOnly: true,
|
singleFrameOnly: true,
|
||||||
imageOutputCount: 1,
|
imageOutputCount: 1,
|
||||||
|
batchSize: 1,
|
||||||
requireActualLastFrame: true,
|
requireActualLastFrame: true,
|
||||||
localOnly: routing ? routing.policyMode === "local-only" || routing.connectorCostMode === "local" : adapter.costMode === "local",
|
localOnly: routing ? routing.policyMode === "local-only" || routing.connectorCostMode === "local" : adapter.costMode === "local",
|
||||||
approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired),
|
approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired),
|
||||||
blockedTerms
|
blockedTerms,
|
||||||
|
negativePromptTerms: negativeTerms,
|
||||||
|
styleEnforcement: styleKit?.source || "default-contract",
|
||||||
|
aspectRatio: styleKit?.deliverySpec?.aspectRatio || "9:16",
|
||||||
|
resolution: styleKit?.deliverySpec?.resolution || "1080x1920",
|
||||||
|
fps: styleKit?.deliverySpec?.fps || 24,
|
||||||
|
voicePolicy: styleKit?.voicePolicy || null,
|
||||||
|
subtitlePreset: styleKit?.subtitlePreset || null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ import {
|
|||||||
import { reclaimStorage, requireStorageQuota, storageCleanupPreview, 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 { bindProjectStyleKit, createStyleKit, getProjectStyleKit, getStyleKit, listStyleKits, updateStyleKit } from "./style-kits.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, readArtifactContent } from "./media-artifacts.mjs";
|
import { listProjectArtifacts, readArtifactContent } from "./media-artifacts.mjs";
|
||||||
@@ -1974,6 +1975,18 @@ createServer(async (req, res) => {
|
|||||||
return send(res, 200, { project: updateProject(context, projectId, await readBody(req)) });
|
return send(res, 200, { project: updateProject(context, projectId, await readBody(req)) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectStyleKitMatch = pathname.match(/^\/api\/projects\/([^/]+)\/style-kit$/);
|
||||||
|
if (req.method === "GET" && projectStyleKitMatch) {
|
||||||
|
const projectId = decodeURIComponent(projectStyleKitMatch[1]);
|
||||||
|
const context = contextWith({ ...Object.fromEntries(url.searchParams), projectId }, req);
|
||||||
|
return send(res, 200, getProjectStyleKit(context, projectId));
|
||||||
|
}
|
||||||
|
if (req.method === "POST" && projectStyleKitMatch) {
|
||||||
|
const projectId = decodeURIComponent(projectStyleKitMatch[1]);
|
||||||
|
const context = contextWith({ ...Object.fromEntries(url.searchParams), projectId }, req);
|
||||||
|
return send(res, 200, bindProjectStyleKit(context, projectId, await readBody(req)));
|
||||||
|
}
|
||||||
|
|
||||||
const projectMemberMatch = pathname.match(/^\/api\/projects\/([^/]+)\/members$/);
|
const projectMemberMatch = pathname.match(/^\/api\/projects\/([^/]+)\/members$/);
|
||||||
if (req.method === "GET" && projectMemberMatch) {
|
if (req.method === "GET" && projectMemberMatch) {
|
||||||
const context = resolveContext(req.headers, url.searchParams);
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
@@ -2063,6 +2076,31 @@ createServer(async (req, res) => {
|
|||||||
return send(res, 200, { roles: payload.roles, permissions: payload.permissions, effective: context.permissions });
|
return send(res, 200, { roles: payload.roles, permissions: payload.permissions, effective: context.permissions });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && pathname === "/api/style-kits") {
|
||||||
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
|
return send(res, 200, listStyleKits(context, { includeArchived: url.searchParams.get("includeArchived") === "1" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && pathname === "/api/style-kits") {
|
||||||
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
|
return send(res, 201, createStyleKit(context, await readBody(req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && pathname === "/api/style-kits/effective") {
|
||||||
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
|
return send(res, 200, getProjectStyleKit(context, url.searchParams.get("projectId") || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
const styleKitMatch = pathname.match(/^\/api\/style-kits\/([^/]+)$/);
|
||||||
|
if (req.method === "GET" && styleKitMatch) {
|
||||||
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
|
return send(res, 200, getStyleKit(context, decodeURIComponent(styleKitMatch[1])));
|
||||||
|
}
|
||||||
|
if (req.method === "PATCH" && styleKitMatch) {
|
||||||
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
|
return send(res, 200, updateStyleKit(context, decodeURIComponent(styleKitMatch[1]), await readBody(req)));
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && pathname === "/api/project") {
|
if (req.method === "GET" && pathname === "/api/project") {
|
||||||
const context = resolveContext(req.headers, url.searchParams);
|
const context = resolveContext(req.headers, url.searchParams);
|
||||||
const { project, jobs } = scopedProjectPayload(context);
|
const { project, jobs } = scopedProjectPayload(context);
|
||||||
|
|||||||
@@ -709,6 +709,45 @@ CREATE TABLE IF NOT EXISTS model_route_approval_requests (
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS style_kits (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
scope_mode TEXT NOT NULL DEFAULT 'workspace' CHECK (scope_mode IN ('organization', 'workspace', 'project')),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'paused', 'archived')),
|
||||||
|
visual_profile_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
subtitle_preset_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
voice_policy_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
delivery_spec_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
prompt_rules_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
single_frame_policy_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
naming_rules_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
qa_policy_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
metadata_json 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 project_style_kit_bindings (
|
||||||
|
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 UNIQUE REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
style_kit_id TEXT NOT NULL REFERENCES style_kits(id) ON DELETE CASCADE,
|
||||||
|
enforcement TEXT NOT NULL DEFAULT 'locked' CHECK (enforcement IN ('advisory', 'locked', 'strict')),
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_style_kits_scope ON style_kits(organization_id, workspace_id, project_id, scope_mode, status, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_style_kit_bindings_scope ON project_style_kit_bindings(organization_id, workspace_id, project_id, style_kit_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS series (
|
CREATE TABLE IF NOT EXISTS series (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
project_id TEXT NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE,
|
project_id TEXT NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
|
||||||
|
import { addAudit, hasPermission, httpError, requireEntitlement, requirePermission, requireProjectWritable } from "./tenant.mjs";
|
||||||
|
|
||||||
|
const now = () => new Date().toISOString();
|
||||||
|
const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||||
|
|
||||||
|
const DEFAULT_FORBIDDEN_TERMS = [
|
||||||
|
"split-screen",
|
||||||
|
"comic panel",
|
||||||
|
"collage",
|
||||||
|
"contact sheet",
|
||||||
|
"storyboard",
|
||||||
|
"多格",
|
||||||
|
"拼图",
|
||||||
|
"分屏",
|
||||||
|
"故事板拼图"
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_STYLE_KIT = {
|
||||||
|
id: "",
|
||||||
|
name: "平台默认单画面生产标准",
|
||||||
|
scopeMode: "default",
|
||||||
|
status: "active",
|
||||||
|
visualProfile: {
|
||||||
|
styleFamily: "国产漫画 / 国漫 2D 动画",
|
||||||
|
cameraLanguage: "one continuous scene, one complete frame",
|
||||||
|
continuityLocks: ["character", "costume", "location", "prop", "weather", "camera"]
|
||||||
|
},
|
||||||
|
subtitlePreset: {
|
||||||
|
language: "zh-CN",
|
||||||
|
position: "bottom-safe-area",
|
||||||
|
maxCharsPerLine: 18
|
||||||
|
},
|
||||||
|
voicePolicy: {
|
||||||
|
finalVoiceMode: "fixed-reference-required",
|
||||||
|
forbidRandomNativeVoice: true,
|
||||||
|
fallbackMouthPlan: ["侧脸", "背影", "低头", "远景", "反应镜头", "环境插入镜头"]
|
||||||
|
},
|
||||||
|
deliverySpec: {
|
||||||
|
aspectRatio: "9:16",
|
||||||
|
resolution: "1080x1920",
|
||||||
|
fps: 24,
|
||||||
|
clipDurationSec: [5, 10],
|
||||||
|
exportFormat: "mp4"
|
||||||
|
},
|
||||||
|
promptRules: {
|
||||||
|
positivePrefix: "ONE SINGLE COMPLETE 9:16 CHINESE ANIMATION FRAME, one continuous scene, one complete image.",
|
||||||
|
negativeTerms: DEFAULT_FORBIDDEN_TERMS,
|
||||||
|
renderNotes: ["batch size must be 1", "do not generate storyboards or panels"]
|
||||||
|
},
|
||||||
|
singleFramePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
imageOutputCount: 1,
|
||||||
|
batchSize: 1,
|
||||||
|
forbiddenTerms: DEFAULT_FORBIDDEN_TERMS,
|
||||||
|
requiredPhrase: "one single complete frame"
|
||||||
|
},
|
||||||
|
namingRules: {
|
||||||
|
shotPattern: "{projectId}/S{season}E{episode}/shot-{shotNumber}-{assetKind}-{version}",
|
||||||
|
deliveryPattern: "{projectName}-{episodeId}-{releaseVersion}"
|
||||||
|
},
|
||||||
|
qaPolicy: {
|
||||||
|
gates: ["single-frame", "continuity", "voice-rights", "subtitle-asr-alignment", "clip-transition"],
|
||||||
|
requireLockedAssets: true
|
||||||
|
},
|
||||||
|
metadata: { source: "platform-default" }
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseJson(value, fallback) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return parsed === undefined || parsed === null ? fallback : parsed;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonObject(value, fallback = {}) {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = parseJson(value, fallback);
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueList(values = []) {
|
||||||
|
return [...new Set(values.map((item) => String(item || "").trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function styleKitForbiddenTerms(styleKit) {
|
||||||
|
return uniqueList([
|
||||||
|
...DEFAULT_FORBIDDEN_TERMS,
|
||||||
|
...(styleKit?.singleFramePolicy?.forbiddenTerms || []),
|
||||||
|
...(styleKit?.promptRules?.forbiddenTerms || [])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function styleKitNegativeTerms(styleKit) {
|
||||||
|
return uniqueList([
|
||||||
|
...DEFAULT_FORBIDDEN_TERMS,
|
||||||
|
...(styleKit?.promptRules?.negativeTerms || []),
|
||||||
|
...(styleKit?.singleFramePolicy?.forbiddenTerms || [])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canReadStyleKits(context) {
|
||||||
|
return [
|
||||||
|
"style:read",
|
||||||
|
"style:manage",
|
||||||
|
"script:read",
|
||||||
|
"job:create",
|
||||||
|
"project:manage",
|
||||||
|
"model:manage",
|
||||||
|
"compliance:manage"
|
||||||
|
].some((permission) => hasPermission(context, permission));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireStyleRead(context) {
|
||||||
|
if (!canReadStyleKits(context)) throw httpError(403, "permission_denied", "当前角色没有风格标准访问权限");
|
||||||
|
}
|
||||||
|
|
||||||
|
function accessibleProjectIds(context) {
|
||||||
|
return new Set((context.projects || []).map((project) => project.id).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureProjectInContext(context, projectId = "") {
|
||||||
|
const id = String(projectId || context.project?.id || "").trim();
|
||||||
|
if (!id) throw httpError(400, "project_required", "项目级风格标准必须绑定项目");
|
||||||
|
const project = dbGet(
|
||||||
|
`SELECT p.*, w.organization_id
|
||||||
|
FROM projects p
|
||||||
|
JOIN workspaces w ON w.id = p.workspace_id
|
||||||
|
WHERE p.id = ? AND p.workspace_id = ? AND w.organization_id = ?`,
|
||||||
|
[id, context.workspace.id, context.organization.id]
|
||||||
|
);
|
||||||
|
if (!project) throw httpError(404, "project_not_found", "项目不存在或不属于当前工作区", { projectId: id });
|
||||||
|
if (!accessibleProjectIds(context).has(project.id) && context.project?.id !== project.id) {
|
||||||
|
throw httpError(403, "project_forbidden", "当前用户没有该项目访问权", { projectId: id });
|
||||||
|
}
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleKitRowVisible(context, id) {
|
||||||
|
const projectIds = [...accessibleProjectIds(context)];
|
||||||
|
const projectClause = projectIds.length ? `OR sk.project_id IN (${projectIds.map(() => "?").join(",")})` : "";
|
||||||
|
const row = dbGet(
|
||||||
|
`SELECT sk.*, w.name AS workspace_name, p.name AS project_name, u.display_name AS creator_name
|
||||||
|
FROM style_kits sk
|
||||||
|
LEFT JOIN workspaces w ON w.id = sk.workspace_id
|
||||||
|
LEFT JOIN projects p ON p.id = sk.project_id
|
||||||
|
LEFT JOIN users u ON u.id = sk.created_by
|
||||||
|
WHERE sk.id = ? AND sk.organization_id = ?
|
||||||
|
AND (
|
||||||
|
(sk.workspace_id IS NULL AND sk.project_id IS NULL)
|
||||||
|
OR sk.workspace_id = ?
|
||||||
|
${projectClause}
|
||||||
|
)`,
|
||||||
|
[id, context.organization.id, context.workspace.id, ...projectIds]
|
||||||
|
);
|
||||||
|
if (!row) throw httpError(404, "style_kit_not_found", "风格标准不存在或不属于当前组织/工作区", { styleKitId: id });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleKitPayload(row, source = "") {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organization_id,
|
||||||
|
workspaceId: row.workspace_id || "",
|
||||||
|
projectId: row.project_id || "",
|
||||||
|
scopeMode: row.scope_mode,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description || "",
|
||||||
|
status: row.status,
|
||||||
|
visualProfile: parseJson(row.visual_profile_json, {}),
|
||||||
|
subtitlePreset: parseJson(row.subtitle_preset_json, {}),
|
||||||
|
voicePolicy: parseJson(row.voice_policy_json, {}),
|
||||||
|
deliverySpec: parseJson(row.delivery_spec_json, {}),
|
||||||
|
promptRules: parseJson(row.prompt_rules_json, {}),
|
||||||
|
singleFramePolicy: parseJson(row.single_frame_policy_json, {}),
|
||||||
|
namingRules: parseJson(row.naming_rules_json, {}),
|
||||||
|
qaPolicy: parseJson(row.qa_policy_json, {}),
|
||||||
|
metadata: parseJson(row.metadata_json, {}),
|
||||||
|
workspaceName: row.workspace_name || "",
|
||||||
|
projectName: row.project_name || "",
|
||||||
|
creatorName: row.creator_name || row.created_by || "",
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
...(source ? { source } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindingPayload(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organization_id,
|
||||||
|
workspaceId: row.workspace_id,
|
||||||
|
projectId: row.project_id,
|
||||||
|
styleKitId: row.style_kit_id,
|
||||||
|
enforcement: row.enforcement,
|
||||||
|
notes: row.notes || "",
|
||||||
|
createdBy: row.created_by,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStyleKitBody(context, body = {}, current = null) {
|
||||||
|
const scopeMode = String(body.scopeMode ?? body.scope_mode ?? current?.scope_mode ?? "workspace").trim();
|
||||||
|
if (!["organization", "workspace", "project"].includes(scopeMode)) throw httpError(400, "style_kit_scope_invalid", "风格标准作用域只能是 organization、workspace 或 project");
|
||||||
|
const project = scopeMode === "project" ? ensureProjectInContext(context, body.projectId ?? body.project_id ?? current?.project_id) : null;
|
||||||
|
const name = String(body.name ?? current?.name ?? "").trim().slice(0, 100);
|
||||||
|
if (name.length < 2) throw httpError(400, "style_kit_name_required", "风格标准名称至少需要 2 个字符");
|
||||||
|
const status = String(body.status ?? current?.status ?? "draft").trim();
|
||||||
|
if (!["draft", "active", "paused", "archived"].includes(status)) throw httpError(400, "style_kit_status_invalid", "风格标准状态无效");
|
||||||
|
const currentJson = (column, fallback) => current ? parseJson(current[column], fallback) : fallback;
|
||||||
|
const styleKit = {
|
||||||
|
scopeMode,
|
||||||
|
name,
|
||||||
|
description: String(body.description ?? current?.description ?? "").trim().slice(0, 800),
|
||||||
|
status,
|
||||||
|
workspaceId: scopeMode === "organization" ? null : context.workspace.id,
|
||||||
|
projectId: scopeMode === "project" ? project.id : null,
|
||||||
|
visualProfile: jsonObject(body.visualProfile ?? body.visual_profile, currentJson("visual_profile_json", DEFAULT_STYLE_KIT.visualProfile)),
|
||||||
|
subtitlePreset: jsonObject(body.subtitlePreset ?? body.subtitle_preset, currentJson("subtitle_preset_json", DEFAULT_STYLE_KIT.subtitlePreset)),
|
||||||
|
voicePolicy: jsonObject(body.voicePolicy ?? body.voice_policy, currentJson("voice_policy_json", DEFAULT_STYLE_KIT.voicePolicy)),
|
||||||
|
deliverySpec: jsonObject(body.deliverySpec ?? body.delivery_spec, currentJson("delivery_spec_json", DEFAULT_STYLE_KIT.deliverySpec)),
|
||||||
|
promptRules: jsonObject(body.promptRules ?? body.prompt_rules, currentJson("prompt_rules_json", DEFAULT_STYLE_KIT.promptRules)),
|
||||||
|
singleFramePolicy: jsonObject(body.singleFramePolicy ?? body.single_frame_policy, currentJson("single_frame_policy_json", DEFAULT_STYLE_KIT.singleFramePolicy)),
|
||||||
|
namingRules: jsonObject(body.namingRules ?? body.naming_rules, currentJson("naming_rules_json", DEFAULT_STYLE_KIT.namingRules)),
|
||||||
|
qaPolicy: jsonObject(body.qaPolicy ?? body.qa_policy, currentJson("qa_policy_json", DEFAULT_STYLE_KIT.qaPolicy)),
|
||||||
|
metadata: jsonObject(body.metadata ?? body.metadata_json, currentJson("metadata_json", {}))
|
||||||
|
};
|
||||||
|
styleKit.promptRules.negativeTerms = styleKitNegativeTerms(styleKit);
|
||||||
|
styleKit.singleFramePolicy = {
|
||||||
|
enabled: true,
|
||||||
|
imageOutputCount: 1,
|
||||||
|
batchSize: 1,
|
||||||
|
...styleKit.singleFramePolicy,
|
||||||
|
forbiddenTerms: styleKitForbiddenTerms(styleKit)
|
||||||
|
};
|
||||||
|
styleKit.voicePolicy = {
|
||||||
|
...styleKit.voicePolicy,
|
||||||
|
forbidRandomNativeVoice: styleKit.voicePolicy.forbidRandomNativeVoice !== false
|
||||||
|
};
|
||||||
|
return styleKit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopedStyleKitRows(context, { includeArchived = false } = {}) {
|
||||||
|
const projectIds = [...accessibleProjectIds(context)];
|
||||||
|
const projectClause = projectIds.length ? `OR sk.project_id IN (${projectIds.map(() => "?").join(",")})` : "";
|
||||||
|
return dbAll(
|
||||||
|
`SELECT sk.*, w.name AS workspace_name, p.name AS project_name, u.display_name AS creator_name
|
||||||
|
FROM style_kits sk
|
||||||
|
LEFT JOIN workspaces w ON w.id = sk.workspace_id
|
||||||
|
LEFT JOIN projects p ON p.id = sk.project_id
|
||||||
|
LEFT JOIN users u ON u.id = sk.created_by
|
||||||
|
WHERE sk.organization_id = ?
|
||||||
|
AND (? = 1 OR sk.status != 'archived')
|
||||||
|
AND (
|
||||||
|
(sk.workspace_id IS NULL AND sk.project_id IS NULL)
|
||||||
|
OR sk.workspace_id = ?
|
||||||
|
${projectClause}
|
||||||
|
)
|
||||||
|
ORDER BY CASE sk.scope_mode WHEN 'project' THEN 0 WHEN 'workspace' THEN 1 ELSE 2 END, sk.updated_at DESC, sk.name`,
|
||||||
|
[context.organization.id, includeArchived ? 1 : 0, context.workspace.id, ...projectIds]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentBinding(context, projectId = "") {
|
||||||
|
const project = ensureProjectInContext(context, projectId || context.project?.id);
|
||||||
|
const row = dbGet(
|
||||||
|
`SELECT b.*
|
||||||
|
FROM project_style_kit_bindings b
|
||||||
|
WHERE b.organization_id = ? AND b.workspace_id = ? AND b.project_id = ?`,
|
||||||
|
[context.organization.id, context.workspace.id, project.id]
|
||||||
|
);
|
||||||
|
return bindingPayload(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeStyleKitCandidate(context, projectId = "") {
|
||||||
|
const project = ensureProjectInContext(context, projectId || context.project?.id);
|
||||||
|
const binding = dbGet(
|
||||||
|
`SELECT sk.*, w.name AS workspace_name, p.name AS project_name, u.display_name AS creator_name,
|
||||||
|
b.id AS binding_id, b.organization_id AS binding_organization_id,
|
||||||
|
b.workspace_id AS binding_workspace_id, b.project_id AS binding_project_id,
|
||||||
|
b.style_kit_id AS binding_style_kit_id, b.enforcement AS binding_enforcement,
|
||||||
|
b.notes AS binding_notes, b.created_by AS binding_created_by,
|
||||||
|
b.created_at AS binding_created_at, b.updated_at AS binding_updated_at
|
||||||
|
FROM project_style_kit_bindings b
|
||||||
|
JOIN style_kits sk ON sk.id = b.style_kit_id
|
||||||
|
LEFT JOIN workspaces w ON w.id = sk.workspace_id
|
||||||
|
LEFT JOIN projects p ON p.id = sk.project_id
|
||||||
|
LEFT JOIN users u ON u.id = sk.created_by
|
||||||
|
WHERE b.organization_id = ? AND b.workspace_id = ? AND b.project_id = ?
|
||||||
|
AND sk.organization_id = b.organization_id AND sk.status = 'active'
|
||||||
|
AND (
|
||||||
|
(sk.scope_mode = 'organization' AND sk.workspace_id IS NULL AND sk.project_id IS NULL)
|
||||||
|
OR (sk.scope_mode = 'workspace' AND sk.workspace_id = b.workspace_id AND sk.project_id IS NULL)
|
||||||
|
OR (sk.scope_mode = 'project' AND sk.project_id = b.project_id)
|
||||||
|
)
|
||||||
|
LIMIT 1`,
|
||||||
|
[context.organization.id, context.workspace.id, project.id]
|
||||||
|
);
|
||||||
|
if (binding) {
|
||||||
|
return {
|
||||||
|
styleKit: styleKitPayload(binding, "project-binding"),
|
||||||
|
binding: {
|
||||||
|
id: binding.binding_id,
|
||||||
|
organizationId: binding.binding_organization_id,
|
||||||
|
workspaceId: binding.binding_workspace_id,
|
||||||
|
projectId: binding.binding_project_id,
|
||||||
|
styleKitId: binding.binding_style_kit_id,
|
||||||
|
enforcement: binding.binding_enforcement,
|
||||||
|
notes: binding.binding_notes || "",
|
||||||
|
createdBy: binding.binding_created_by,
|
||||||
|
createdAt: binding.binding_created_at,
|
||||||
|
updatedAt: binding.binding_updated_at
|
||||||
|
},
|
||||||
|
project
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const fallback = dbGet(
|
||||||
|
`SELECT sk.*, w.name AS workspace_name, p.name AS project_name, u.display_name AS creator_name
|
||||||
|
FROM style_kits sk
|
||||||
|
LEFT JOIN workspaces w ON w.id = sk.workspace_id
|
||||||
|
LEFT JOIN projects p ON p.id = sk.project_id
|
||||||
|
LEFT JOIN users u ON u.id = sk.created_by
|
||||||
|
WHERE sk.organization_id = ? AND sk.status = 'active'
|
||||||
|
AND (
|
||||||
|
sk.project_id = ?
|
||||||
|
OR (sk.project_id IS NULL AND sk.workspace_id = ?)
|
||||||
|
OR (sk.project_id IS NULL AND sk.workspace_id IS NULL)
|
||||||
|
)
|
||||||
|
ORDER BY CASE
|
||||||
|
WHEN sk.project_id = ? THEN 0
|
||||||
|
WHEN sk.workspace_id = ? THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END, sk.updated_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[context.organization.id, project.id, context.workspace.id, project.id, context.workspace.id]
|
||||||
|
);
|
||||||
|
return { styleKit: fallback ? styleKitPayload(fallback, fallback.project_id ? "project-active" : fallback.workspace_id ? "workspace-default" : "organization-default") : null, binding: null, project };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveStyleKitForProject(context, projectId = "") {
|
||||||
|
if (!context?.organization?.id || !context?.workspace?.id) {
|
||||||
|
return { styleKit: { ...DEFAULT_STYLE_KIT, source: "default-contract" }, binding: null, project: null };
|
||||||
|
}
|
||||||
|
const effective = activeStyleKitCandidate(context, projectId);
|
||||||
|
return {
|
||||||
|
...effective,
|
||||||
|
styleKit: effective.styleKit || { ...DEFAULT_STYLE_KIT, source: "default-contract" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listStyleKits(context, options = {}) {
|
||||||
|
requireStyleRead(context);
|
||||||
|
const effective = context.project ? effectiveStyleKitForProject(context) : { styleKit: { ...DEFAULT_STYLE_KIT, source: "default-contract" }, binding: null, project: null };
|
||||||
|
return {
|
||||||
|
kits: scopedStyleKitRows(context, { includeArchived: options.includeArchived }).map(styleKitPayload),
|
||||||
|
effective,
|
||||||
|
projects: (context.projects || []).map((project) => ({ id: project.id, name: project.name, status: project.status })),
|
||||||
|
summary: {
|
||||||
|
total: Number(dbGet("SELECT COUNT(*) AS count FROM style_kits WHERE organization_id = ? AND status != 'archived'", [context.organization.id])?.count || 0),
|
||||||
|
active: Number(dbGet("SELECT COUNT(*) AS count FROM style_kits WHERE organization_id = ? AND status = 'active'", [context.organization.id])?.count || 0),
|
||||||
|
currentWorkspace: Number(dbGet("SELECT COUNT(*) AS count FROM style_kits WHERE organization_id = ? AND workspace_id = ? AND status != 'archived'", [context.organization.id, context.workspace.id])?.count || 0),
|
||||||
|
boundProjects: Number(dbGet("SELECT COUNT(*) AS count FROM project_style_kit_bindings WHERE organization_id = ? AND workspace_id = ?", [context.organization.id, context.workspace.id])?.count || 0)
|
||||||
|
},
|
||||||
|
defaults: DEFAULT_STYLE_KIT
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStyleKit(context, styleKitId) {
|
||||||
|
requireStyleRead(context);
|
||||||
|
return { styleKit: styleKitPayload(styleKitRowVisible(context, String(styleKitId || "").trim())) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStyleKit(context, body = {}) {
|
||||||
|
requirePermission(context, "style:manage");
|
||||||
|
requireEntitlement(context, "limit.style_kits", 1);
|
||||||
|
const kit = normalizeStyleKitBody(context, body);
|
||||||
|
const id = String(body.id || makeId("stylekit")).trim();
|
||||||
|
const timestamp = now();
|
||||||
|
dbRun(
|
||||||
|
`INSERT INTO style_kits(
|
||||||
|
id, organization_id, workspace_id, project_id, scope_mode, name, description, status,
|
||||||
|
visual_profile_json, subtitle_preset_json, voice_policy_json, delivery_spec_json,
|
||||||
|
prompt_rules_json, single_frame_policy_json, naming_rules_json, qa_policy_json,
|
||||||
|
metadata_json, created_by, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
context.organization.id,
|
||||||
|
kit.workspaceId,
|
||||||
|
kit.projectId,
|
||||||
|
kit.scopeMode,
|
||||||
|
kit.name,
|
||||||
|
kit.description,
|
||||||
|
kit.status,
|
||||||
|
JSON.stringify(kit.visualProfile),
|
||||||
|
JSON.stringify(kit.subtitlePreset),
|
||||||
|
JSON.stringify(kit.voicePolicy),
|
||||||
|
JSON.stringify(kit.deliverySpec),
|
||||||
|
JSON.stringify(kit.promptRules),
|
||||||
|
JSON.stringify(kit.singleFramePolicy),
|
||||||
|
JSON.stringify(kit.namingRules),
|
||||||
|
JSON.stringify(kit.qaPolicy),
|
||||||
|
JSON.stringify(kit.metadata),
|
||||||
|
context.user.id,
|
||||||
|
timestamp,
|
||||||
|
timestamp
|
||||||
|
]
|
||||||
|
);
|
||||||
|
addAudit({ context, action: "style_kit.created", targetType: "style_kit", targetId: id, metadata: { name: kit.name, scopeMode: kit.scopeMode, status: kit.status } });
|
||||||
|
return { styleKit: styleKitPayload(styleKitRowVisible(context, id)), ...listStyleKits(context, { includeArchived: true }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateStyleKit(context, styleKitId, body = {}) {
|
||||||
|
requirePermission(context, "style:manage");
|
||||||
|
const id = String(styleKitId || "").trim();
|
||||||
|
const current = styleKitRowVisible(context, id);
|
||||||
|
const kit = normalizeStyleKitBody(context, body, current);
|
||||||
|
const timestamp = now();
|
||||||
|
dbRun(
|
||||||
|
`UPDATE style_kits
|
||||||
|
SET workspace_id = ?, project_id = ?, scope_mode = ?, name = ?, description = ?, status = ?,
|
||||||
|
visual_profile_json = ?, subtitle_preset_json = ?, voice_policy_json = ?, delivery_spec_json = ?,
|
||||||
|
prompt_rules_json = ?, single_frame_policy_json = ?, naming_rules_json = ?, qa_policy_json = ?,
|
||||||
|
metadata_json = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[
|
||||||
|
kit.workspaceId,
|
||||||
|
kit.projectId,
|
||||||
|
kit.scopeMode,
|
||||||
|
kit.name,
|
||||||
|
kit.description,
|
||||||
|
kit.status,
|
||||||
|
JSON.stringify(kit.visualProfile),
|
||||||
|
JSON.stringify(kit.subtitlePreset),
|
||||||
|
JSON.stringify(kit.voicePolicy),
|
||||||
|
JSON.stringify(kit.deliverySpec),
|
||||||
|
JSON.stringify(kit.promptRules),
|
||||||
|
JSON.stringify(kit.singleFramePolicy),
|
||||||
|
JSON.stringify(kit.namingRules),
|
||||||
|
JSON.stringify(kit.qaPolicy),
|
||||||
|
JSON.stringify(kit.metadata),
|
||||||
|
timestamp,
|
||||||
|
id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
addAudit({ context, action: "style_kit.updated", targetType: "style_kit", targetId: id, metadata: { name: kit.name, scopeMode: kit.scopeMode, status: kit.status } });
|
||||||
|
return { styleKit: styleKitPayload(styleKitRowVisible(context, id)), ...listStyleKits(context, { includeArchived: true }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindProjectStyleKit(context, projectId, body = {}) {
|
||||||
|
requirePermission(context, "style:manage");
|
||||||
|
const project = ensureProjectInContext(context, projectId);
|
||||||
|
requireProjectWritable({ ...context, project });
|
||||||
|
const styleKitId = String(body.styleKitId || body.style_kit_id || "").trim();
|
||||||
|
const timestamp = now();
|
||||||
|
if (!styleKitId) {
|
||||||
|
dbRun("DELETE FROM project_style_kit_bindings WHERE organization_id = ? AND workspace_id = ? AND project_id = ?", [context.organization.id, context.workspace.id, project.id]);
|
||||||
|
addAudit({ context: { ...context, project }, action: "style_kit.unbound", targetType: "project", targetId: project.id, metadata: { projectId: project.id } });
|
||||||
|
return listStyleKits({ ...context, project }, { includeArchived: true });
|
||||||
|
}
|
||||||
|
const kit = styleKitPayload(styleKitRowVisible(context, styleKitId));
|
||||||
|
if (kit.status !== "active") throw httpError(409, "style_kit_not_active", "只有 active 状态的风格标准可以绑定到项目", { styleKitId, status: kit.status });
|
||||||
|
if (kit.scopeMode === "project" && kit.projectId !== project.id) throw httpError(403, "style_kit_project_mismatch", "项目级风格标准不能绑定到其他项目", { styleKitId, kitProjectId: kit.projectId, projectId: project.id });
|
||||||
|
if (kit.scopeMode === "workspace" && kit.workspaceId !== context.workspace.id) throw httpError(403, "style_kit_workspace_mismatch", "工作区级风格标准不能跨工作区绑定", { styleKitId, workspaceId: kit.workspaceId });
|
||||||
|
const enforcement = String(body.enforcement || "locked").trim();
|
||||||
|
if (!["advisory", "locked", "strict"].includes(enforcement)) throw httpError(400, "style_kit_enforcement_invalid", "项目风格绑定强度无效");
|
||||||
|
const notes = String(body.notes || "").trim().slice(0, 500);
|
||||||
|
withTransaction(() => {
|
||||||
|
dbRun(
|
||||||
|
`INSERT INTO project_style_kit_bindings(id, organization_id, workspace_id, project_id, style_kit_id, enforcement, notes, created_by, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(project_id) DO UPDATE SET style_kit_id = excluded.style_kit_id, enforcement = excluded.enforcement, notes = excluded.notes, created_by = excluded.created_by, updated_at = excluded.updated_at`,
|
||||||
|
[`binding-${project.id}-style-kit`, context.organization.id, context.workspace.id, project.id, styleKitId, enforcement, notes, context.user.id, timestamp, timestamp]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
addAudit({ context: { ...context, project }, action: "style_kit.bound", targetType: "project", targetId: project.id, metadata: { projectId: project.id, styleKitId, enforcement } });
|
||||||
|
return listStyleKits({ ...context, project }, { includeArchived: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectStyleKit(context, projectId = "") {
|
||||||
|
requireStyleRead(context);
|
||||||
|
const project = ensureProjectInContext(context, projectId || context.project?.id);
|
||||||
|
return effectiveStyleKitForProject({ ...context, project }, project.id);
|
||||||
|
}
|
||||||
@@ -823,6 +823,7 @@ function entitlementUsageValue(organizationId, key) {
|
|||||||
if (key === "limit.seats") return organizationSeatSummary(organizationId).reserved;
|
if (key === "limit.seats") return organizationSeatSummary(organizationId).reserved;
|
||||||
if (key === "limit.workspaces") return Number(dbGet("SELECT COUNT(*) AS count FROM workspaces WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0);
|
if (key === "limit.workspaces") return Number(dbGet("SELECT COUNT(*) AS count FROM workspaces WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0);
|
||||||
if (key === "limit.projects") return Number(dbGet("SELECT COUNT(*) AS count FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE w.organization_id = ? AND p.status != 'archived'", [organizationId])?.count || 0);
|
if (key === "limit.projects") return Number(dbGet("SELECT COUNT(*) AS count FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE w.organization_id = ? AND p.status != 'archived'", [organizationId])?.count || 0);
|
||||||
|
if (key === "limit.style_kits") return Number(dbGet("SELECT COUNT(*) AS count FROM style_kits WHERE organization_id = ? AND status != 'archived'", [organizationId])?.count || 0);
|
||||||
if (key === "limit.model_connectors") return Number(dbGet("SELECT COUNT(*) AS count FROM model_connectors WHERE organization_id = ?", [organizationId])?.count || 0);
|
if (key === "limit.model_connectors") return Number(dbGet("SELECT COUNT(*) AS count FROM model_connectors WHERE organization_id = ?", [organizationId])?.count || 0);
|
||||||
if (key === "limit.api_clients") return Number(dbGet("SELECT COUNT(*) AS count FROM api_clients WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0);
|
if (key === "limit.api_clients") return Number(dbGet("SELECT COUNT(*) AS count FROM api_clients WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0);
|
||||||
if (key === "limit.knowledge_documents") return Number(dbGet("SELECT COUNT(*) AS count FROM knowledge_documents WHERE organization_id = ? AND status != 'archived'", [organizationId])?.count || 0);
|
if (key === "limit.knowledge_documents") return Number(dbGet("SELECT COUNT(*) AS count FROM knowledge_documents WHERE organization_id = ? AND status != 'archived'", [organizationId])?.count || 0);
|
||||||
@@ -874,6 +875,7 @@ function defaultEntitlementRows(organizationId) {
|
|||||||
["limit.workspaces", "工作区数量", "tenant", 8, "个", 1, "block", { commercialGate: "workspace:create" }],
|
["limit.workspaces", "工作区数量", "tenant", 8, "个", 1, "block", { commercialGate: "workspace:create" }],
|
||||||
["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }],
|
["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }],
|
||||||
["limit.generation_jobs_monthly", "月度生成任务", "production", Number(billing.monthly_clip_quota || 2400), "job", 1, "block", { commercialGate: "generation_job:create" }],
|
["limit.generation_jobs_monthly", "月度生成任务", "production", Number(billing.monthly_clip_quota || 2400), "job", 1, "block", { commercialGate: "generation_job:create" }],
|
||||||
|
["limit.style_kits", "风格生产标准", "production", 24, "套", 1, "block", { commercialGate: "style_kit:create" }],
|
||||||
["limit.storage_gb", "存储容量", "storage", Number(billing.storage_gb || 1024), "GB", 1, "block", { commercialGate: "storage:write" }],
|
["limit.storage_gb", "存储容量", "storage", Number(billing.storage_gb || 1024), "GB", 1, "block", { commercialGate: "storage:write" }],
|
||||||
["limit.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector:create" }],
|
["limit.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector:create" }],
|
||||||
["limit.api_clients", "API 客户端", "system", 8, "个", 1, "block", { commercialGate: "api_client:create" }],
|
["limit.api_clients", "API 客户端", "system", 8, "个", 1, "block", { commercialGate: "api_client:create" }],
|
||||||
|
|||||||
+5
-1
@@ -29,6 +29,7 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
Mic2,
|
Mic2,
|
||||||
Network,
|
Network,
|
||||||
|
Palette,
|
||||||
Plus,
|
Plus,
|
||||||
Play,
|
Play,
|
||||||
RadioTower,
|
RadioTower,
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
AdminAuditPage,
|
AdminAuditPage,
|
||||||
AdminModelsPage,
|
AdminModelsPage,
|
||||||
AdminOverviewPage,
|
AdminOverviewPage,
|
||||||
|
AdminStyleKitsPage,
|
||||||
AdminQueuePage,
|
AdminQueuePage,
|
||||||
AdminUsagePage,
|
AdminUsagePage,
|
||||||
AccountSecurityPage,
|
AccountSecurityPage,
|
||||||
@@ -149,6 +151,7 @@ const navigationGroups = [
|
|||||||
{ id: "admin-overview", label: "管理概览", icon: Gauge, requiredPermission: "usage:view" },
|
{ id: "admin-overview", label: "管理概览", icon: Gauge, requiredPermission: "usage:view" },
|
||||||
{ id: "admin-organizations", label: "组织与工作区", icon: Building2, requiredPermission: "organization:manage" },
|
{ id: "admin-organizations", label: "组织与工作区", icon: Building2, requiredPermission: "organization:manage" },
|
||||||
{ id: "admin-members", label: "用户与权限", icon: Users, requiredPermission: "organization:members:invite" },
|
{ id: "admin-members", label: "用户与权限", icon: Users, requiredPermission: "organization:members:invite" },
|
||||||
|
{ id: "admin-style-kits", label: "风格标准", icon: Palette, requiredAnyPermissions: ["style:read", "style:manage"] },
|
||||||
{ id: "admin-models", label: "模型与 Runner", icon: Network, requiredAnyPermissions: ["model:manage", "model:approve"] },
|
{ id: "admin-models", label: "模型与 Runner", icon: Network, requiredAnyPermissions: ["model:manage", "model:approve"] },
|
||||||
{ id: "admin-queue", label: "队列与任务", icon: Wand2, requiredPermission: "queue:manage" },
|
{ id: "admin-queue", label: "队列与任务", icon: Wand2, requiredPermission: "queue:manage" },
|
||||||
{ id: "admin-usage", label: "用量与成本", icon: Coins, requiredPermission: "usage:view" },
|
{ id: "admin-usage", label: "用量与成本", icon: Coins, requiredPermission: "usage:view" },
|
||||||
@@ -1710,7 +1713,7 @@ function App() {
|
|||||||
};
|
};
|
||||||
const enterpriseArea = activeTab === "creator-home" || activeTab === "tasks" || activeTab === "assistant" || activeTab === "knowledge" || activeTab === "asset-library" || activeTab === "voice-studio" || activeTab === "batch-production" || activeTab === "account" || activeTab === "factory" || activeTab === "script" || activeTab === "casting" || activeTab === "director" || activeTab === "jobs" || activeTab === "bible" || activeTab === "locks" || activeTab === "shots" || activeTab === "modelops" || activeTab === "qa" || activeTab === "export" || activeTab === "delivery-portal" || activeTab.startsWith("admin-") || activeTab.startsWith("system-");
|
const enterpriseArea = activeTab === "creator-home" || activeTab === "tasks" || activeTab === "assistant" || activeTab === "knowledge" || activeTab === "asset-library" || activeTab === "voice-studio" || activeTab === "batch-production" || activeTab === "account" || activeTab === "factory" || activeTab === "script" || activeTab === "casting" || activeTab === "director" || activeTab === "jobs" || activeTab === "bible" || activeTab === "locks" || activeTab === "shots" || activeTab === "modelops" || activeTab === "qa" || activeTab === "export" || activeTab === "delivery-portal" || activeTab.startsWith("admin-") || activeTab.startsWith("system-");
|
||||||
const effectivePermissions = new Set(platformContext?.context?.permissions || []);
|
const effectivePermissions = new Set(platformContext?.context?.permissions || []);
|
||||||
const canViewAdmin = ["workspace:create", "model:manage", "model:approve", "usage:view", "compliance:manage", "audit:view", "queue:manage"].some((permission) => effectivePermissions.has(permission));
|
const canViewAdmin = ["workspace:create", "model:manage", "model:approve", "style:manage", "style:read", "usage:view", "compliance:manage", "audit:view", "queue:manage"].some((permission) => effectivePermissions.has(permission));
|
||||||
const canViewSystem = platformContext?.context?.systemAdmin || effectivePermissions.has("system:settings:view");
|
const canViewSystem = platformContext?.context?.systemAdmin || effectivePermissions.has("system:settings:view");
|
||||||
const canCreateProject = effectivePermissions.has("project:create");
|
const canCreateProject = effectivePermissions.has("project:create");
|
||||||
const canCreateJob = effectivePermissions.has("job:create");
|
const canCreateJob = effectivePermissions.has("job:create");
|
||||||
@@ -1888,6 +1891,7 @@ function App() {
|
|||||||
{activeTab === "admin" && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
|
{activeTab === "admin" && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
|
||||||
{activeTab === "admin-overview" && <AdminOverviewPage platformContext={platformContext} contextOverrides={contextOverrides} setActiveTab={setActiveTab} />}
|
{activeTab === "admin-overview" && <AdminOverviewPage platformContext={platformContext} contextOverrides={contextOverrides} setActiveTab={setActiveTab} />}
|
||||||
{(activeTab === "admin-organizations" || activeTab === "admin-members") && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
|
{(activeTab === "admin-organizations" || activeTab === "admin-members") && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
|
||||||
|
{activeTab === "admin-style-kits" && <AdminStyleKitsPage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
||||||
{activeTab === "admin-models" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />}
|
{activeTab === "admin-models" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />}
|
||||||
{activeTab === "admin-queue" && <AdminQueuePage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
{activeTab === "admin-queue" && <AdminQueuePage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
||||||
{activeTab === "admin-usage" && <AdminUsagePage contextOverrides={contextOverrides} platformContext={platformContext} />}
|
{activeTab === "admin-usage" && <AdminUsagePage contextOverrides={contextOverrides} platformContext={platformContext} />}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
LogIn,
|
LogIn,
|
||||||
ListFilter,
|
ListFilter,
|
||||||
Network,
|
Network,
|
||||||
|
Palette,
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -157,7 +158,11 @@ import {
|
|||||||
fetchNotificationPreferences,
|
fetchNotificationPreferences,
|
||||||
markNotificationRead,
|
markNotificationRead,
|
||||||
markAllNotificationsRead,
|
markAllNotificationsRead,
|
||||||
updateNotificationPreference
|
updateNotificationPreference,
|
||||||
|
bindProjectStyleKit,
|
||||||
|
createStyleKit,
|
||||||
|
fetchStyleKits,
|
||||||
|
updateStyleKit
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { downloadJson } from "../lib/exporters";
|
import { downloadJson } from "../lib/exporters";
|
||||||
|
|
||||||
@@ -2293,6 +2298,280 @@ export function AdminModelsPage({ platformContext, contextOverrides, onModelsCha
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styleKitScopeLabels = { organization: "组织级", workspace: "工作区级", project: "项目级", default: "平台默认" };
|
||||||
|
const styleKitStatusLabels = { draft: "草稿", active: "启用", paused: "暂停", archived: "归档" };
|
||||||
|
const styleKitEnforcementLabels = { advisory: "提示", locked: "锁定", strict: "强制" };
|
||||||
|
|
||||||
|
function stringifyStyleJson(value) {
|
||||||
|
return JSON.stringify(value && typeof value === "object" ? value : {}, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultStyleKitForm(defaults = {}, projectId = "") {
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
name: "",
|
||||||
|
scopeMode: "workspace",
|
||||||
|
projectId,
|
||||||
|
status: "draft",
|
||||||
|
description: "",
|
||||||
|
visualProfileJson: stringifyStyleJson(defaults.visualProfile),
|
||||||
|
subtitlePresetJson: stringifyStyleJson(defaults.subtitlePreset),
|
||||||
|
voicePolicyJson: stringifyStyleJson(defaults.voicePolicy),
|
||||||
|
deliverySpecJson: stringifyStyleJson(defaults.deliverySpec),
|
||||||
|
promptRulesJson: stringifyStyleJson(defaults.promptRules),
|
||||||
|
singleFramePolicyJson: stringifyStyleJson(defaults.singleFramePolicy),
|
||||||
|
namingRulesJson: stringifyStyleJson(defaults.namingRules),
|
||||||
|
qaPolicyJson: stringifyStyleJson(defaults.qaPolicy),
|
||||||
|
metadataJson: stringifyStyleJson({ source: "admin-console" })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleKitFormFromKit(kit, defaults = {}, projectId = "") {
|
||||||
|
if (!kit?.id) return defaultStyleKitForm(defaults, projectId);
|
||||||
|
return {
|
||||||
|
id: kit.id,
|
||||||
|
name: kit.name || "",
|
||||||
|
scopeMode: kit.scopeMode || "workspace",
|
||||||
|
projectId: kit.projectId || projectId,
|
||||||
|
status: kit.status || "draft",
|
||||||
|
description: kit.description || "",
|
||||||
|
visualProfileJson: stringifyStyleJson(kit.visualProfile),
|
||||||
|
subtitlePresetJson: stringifyStyleJson(kit.subtitlePreset),
|
||||||
|
voicePolicyJson: stringifyStyleJson(kit.voicePolicy),
|
||||||
|
deliverySpecJson: stringifyStyleJson(kit.deliverySpec),
|
||||||
|
promptRulesJson: stringifyStyleJson(kit.promptRules),
|
||||||
|
singleFramePolicyJson: stringifyStyleJson(kit.singleFramePolicy),
|
||||||
|
namingRulesJson: stringifyStyleJson(kit.namingRules),
|
||||||
|
qaPolicyJson: stringifyStyleJson(kit.qaPolicy),
|
||||||
|
metadataJson: stringifyStyleJson(kit.metadata)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStyleKitJson(value, label) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value || "{}");
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not-object");
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} 必须是 JSON 对象`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStyleKitForm(form) {
|
||||||
|
return {
|
||||||
|
name: form.name,
|
||||||
|
scopeMode: form.scopeMode,
|
||||||
|
projectId: form.scopeMode === "project" ? form.projectId : "",
|
||||||
|
status: form.status,
|
||||||
|
description: form.description,
|
||||||
|
visualProfile: parseStyleKitJson(form.visualProfileJson, "visualProfile"),
|
||||||
|
subtitlePreset: parseStyleKitJson(form.subtitlePresetJson, "subtitlePreset"),
|
||||||
|
voicePolicy: parseStyleKitJson(form.voicePolicyJson, "voicePolicy"),
|
||||||
|
deliverySpec: parseStyleKitJson(form.deliverySpecJson, "deliverySpec"),
|
||||||
|
promptRules: parseStyleKitJson(form.promptRulesJson, "promptRules"),
|
||||||
|
singleFramePolicy: parseStyleKitJson(form.singleFramePolicyJson, "singleFramePolicy"),
|
||||||
|
namingRules: parseStyleKitJson(form.namingRulesJson, "namingRules"),
|
||||||
|
qaPolicy: parseStyleKitJson(form.qaPolicyJson, "qaPolicy"),
|
||||||
|
metadata: parseStyleKitJson(form.metadataJson, "metadata")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function StyleKitJsonField({ label, value, onChange, rows = 7 }) {
|
||||||
|
return <label className="style-kit-json-field">{label}<textarea rows={rows} value={value} onChange={(event) => onChange(event.target.value)} /></label>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminStyleKitsPage({ platformContext, contextOverrides }) {
|
||||||
|
const permissions = new Set(platformContext?.context?.permissions || []);
|
||||||
|
const canManage = Boolean(platformContext?.context?.systemAdmin || permissions.has("style:manage"));
|
||||||
|
const [data, setData] = useState({ kits: [], projects: [], summary: {}, defaults: {}, effective: { styleKit: null, binding: null } });
|
||||||
|
const [selectedId, setSelectedId] = useState("");
|
||||||
|
const [form, setForm] = useState(defaultStyleKitForm({}, contextOverrides.projectId));
|
||||||
|
const [bindingForm, setBindingForm] = useState({ projectId: contextOverrides.projectId || "", styleKitId: "", enforcement: "strict", notes: "" });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const scopeKey = [contextOverrides.organizationId, contextOverrides.workspaceId, contextOverrides.projectId].join("|");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchStyleKits(contextOverrides, { includeArchived: true });
|
||||||
|
setData(result);
|
||||||
|
const activeKit = result.effective?.styleKit?.id ? result.effective.styleKit : result.kits?.[0];
|
||||||
|
setSelectedId((current) => result.kits?.some((kit) => kit.id === current) ? current : activeKit?.id || "");
|
||||||
|
setForm((current) => current.id && result.kits?.some((kit) => kit.id === current.id) ? current : styleKitFormFromKit(activeKit, result.defaults, contextOverrides.projectId));
|
||||||
|
setBindingForm({
|
||||||
|
projectId: result.effective?.project?.id || contextOverrides.projectId || result.projects?.[0]?.id || "",
|
||||||
|
styleKitId: result.effective?.styleKit?.id || activeKit?.id || "",
|
||||||
|
enforcement: result.effective?.binding?.enforcement || "strict",
|
||||||
|
notes: result.effective?.binding?.notes || ""
|
||||||
|
});
|
||||||
|
setNotice("");
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [scopeKey]);
|
||||||
|
|
||||||
|
const selected = data.kits.find((kit) => kit.id === selectedId) || null;
|
||||||
|
const activeKits = data.kits.filter((kit) => kit.status === "active");
|
||||||
|
const effectiveKit = data.effective?.styleKit || {};
|
||||||
|
const effectiveBinding = data.effective?.binding || null;
|
||||||
|
const projects = data.projects || platformContext?.platform?.projects || [];
|
||||||
|
const negativeTermCount = new Set([...(effectiveKit.promptRules?.negativeTerms || []), ...(effectiveKit.singleFramePolicy?.forbiddenTerms || [])]).size;
|
||||||
|
|
||||||
|
function selectKit(kit) {
|
||||||
|
setSelectedId(kit.id);
|
||||||
|
setForm(styleKitFormFromKit(kit, data.defaults, bindingForm.projectId || contextOverrides.projectId));
|
||||||
|
setNotice("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function newKit() {
|
||||||
|
setSelectedId("");
|
||||||
|
setForm(defaultStyleKitForm(data.defaults, bindingForm.projectId || contextOverrides.projectId));
|
||||||
|
setNotice("已切换到新建风格标准表单");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStyleKit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canManage) return;
|
||||||
|
setBusy("save");
|
||||||
|
try {
|
||||||
|
const body = parseStyleKitForm(form);
|
||||||
|
const result = form.id ? await updateStyleKit(form.id, body, contextOverrides) : await createStyleKit(body, contextOverrides);
|
||||||
|
setData(result);
|
||||||
|
const saved = result.styleKit || result.kits?.find((kit) => kit.name === body.name);
|
||||||
|
setSelectedId(saved?.id || "");
|
||||||
|
setForm(styleKitFormFromKit(saved, result.defaults, bindingForm.projectId || contextOverrides.projectId));
|
||||||
|
setNotice(`风格标准“${body.name}”已保存,并写入审计日志`);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveStyleKit(kit) {
|
||||||
|
if (!canManage) return;
|
||||||
|
setBusy(`archive-${kit.id}`);
|
||||||
|
try {
|
||||||
|
const result = await updateStyleKit(kit.id, { ...kit, status: kit.status === "archived" ? "active" : "archived" }, contextOverrides);
|
||||||
|
setData(result);
|
||||||
|
setSelectedId(result.styleKit?.id || kit.id);
|
||||||
|
setForm(styleKitFormFromKit(result.styleKit || kit, result.defaults, bindingForm.projectId));
|
||||||
|
setNotice(`风格标准“${kit.name}”已${kit.status === "archived" ? "恢复" : "归档"}`);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindStyleKit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canManage || !bindingForm.projectId) return;
|
||||||
|
setBusy("bind");
|
||||||
|
try {
|
||||||
|
const result = await bindProjectStyleKit(bindingForm.projectId, bindingForm, { ...contextOverrides, projectId: bindingForm.projectId });
|
||||||
|
setData(result);
|
||||||
|
setBindingForm((current) => ({
|
||||||
|
...current,
|
||||||
|
styleKitId: result.effective?.styleKit?.id || current.styleKitId,
|
||||||
|
enforcement: result.effective?.binding?.enforcement || current.enforcement,
|
||||||
|
notes: result.effective?.binding?.notes || current.notes
|
||||||
|
}));
|
||||||
|
setNotice("项目风格标准绑定已更新,后续生成合同会自动继承");
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unbindStyleKit() {
|
||||||
|
if (!canManage || !bindingForm.projectId) return;
|
||||||
|
setBusy("unbind");
|
||||||
|
try {
|
||||||
|
const result = await bindProjectStyleKit(bindingForm.projectId, { styleKitId: "" }, { ...contextOverrides, projectId: bindingForm.projectId });
|
||||||
|
setData(result);
|
||||||
|
setBindingForm((current) => ({ ...current, styleKitId: result.effective?.styleKit?.id || "", notes: "" }));
|
||||||
|
setNotice("项目已解除显式绑定,将回落到工作区或组织默认标准");
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="enterprise-page style-kit-page">
|
||||||
|
<AdminHeader eyebrow="ADMIN CONSOLE / STYLE KITS" title="风格标准与品牌生产规范" description="组织级管理视觉风格、字幕、固定声线、交付规格、Prompt 负向约束和单画面 QA。项目绑定后会进入生成合同。" action={<div className="header-actions"><StatusBadge status={effectiveKit.id ? "active" : "needs-evidence"} label={effectiveKit.id ? "已有有效标准" : "使用平台默认"} /><button className="subtle" onClick={load} disabled={loading}><RefreshCw size={15} />刷新</button>{canManage && <button className="primary" onClick={newKit}><Plus size={15} />新建标准</button>}</div>} />
|
||||||
|
{notice && <div className={`inline-notice ${notice.includes("没有") || notice.includes("不能") || notice.includes("必须") ? "warn" : "ok"}`}><CheckCircle2 size={15} />{notice}</div>}
|
||||||
|
<div className="admin-kpi-grid compact-kpis">
|
||||||
|
<AdminKpi icon={Palette} label="标准总数" value={data.summary?.total || data.kits.length} detail={`${activeKits.length} 套启用`} tone="ok" />
|
||||||
|
<AdminKpi icon={Workflow} label="项目绑定" value={data.summary?.boundProjects || 0} detail={effectiveBinding ? styleKitEnforcementLabels[effectiveBinding.enforcement] : "当前项目未显式绑定"} tone={effectiveBinding ? "ok" : "neutral"} />
|
||||||
|
<AdminKpi icon={ShieldCheck} label="单画面策略" value={effectiveKit.singleFramePolicy?.imageOutputCount || 1} detail={`负向/禁止词 ${negativeTermCount} 项`} tone="ok" />
|
||||||
|
<AdminKpi icon={Film} label="交付规格" value={effectiveKit.deliverySpec?.aspectRatio || "9:16"} detail={`${effectiveKit.deliverySpec?.resolution || "1080x1920"} · ${effectiveKit.deliverySpec?.fps || 24}fps`} tone="neutral" />
|
||||||
|
</div>
|
||||||
|
<div className="style-kit-layout">
|
||||||
|
<section className="studio-card style-kit-list-card">
|
||||||
|
<SectionBar title="标准列表" detail={loading ? "读取中…" : `${data.kits.length} 套可见标准`} />
|
||||||
|
<div className="style-kit-list">
|
||||||
|
{data.kits.map((kit) => <button type="button" key={kit.id} className={selectedId === kit.id ? "selected" : ""} onClick={() => selectKit(kit)}><div><strong>{kit.name}</strong><span>{styleKitScopeLabels[kit.scopeMode] || kit.scopeMode} · {kit.projectName || kit.workspaceName || "组织默认"}</span><small>{kit.description || "未填写描述"}</small></div><StatusBadge status={kit.status} label={styleKitStatusLabels[kit.status] || kit.status} /></button>)}
|
||||||
|
{!data.kits.length && <div className="empty-table">当前工作区还没有风格标准。</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="studio-card wide-card style-kit-editor-card">
|
||||||
|
<SectionBar title={form.id ? `编辑:${selected?.name || form.name}` : "新建风格标准"} detail={canManage ? "保存后立即落库" : "只读 · 需要 style:manage"} action={form.id && canManage ? <button className="icon-text-button" type="button" onClick={() => archiveStyleKit(selected || form)} disabled={busy === `archive-${form.id}`}><Ban size={13} />{selected?.status === "archived" ? "恢复" : "归档"}</button> : null} />
|
||||||
|
<form className="style-kit-form" onSubmit={saveStyleKit}>
|
||||||
|
<label>名称<input value={form.name} disabled={!canManage} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="例如:都市悬疑国漫竖屏标准" required /></label>
|
||||||
|
<label>作用域<select value={form.scopeMode} disabled={!canManage} onChange={(event) => setForm({ ...form, scopeMode: event.target.value })}><option value="organization">组织级</option><option value="workspace">工作区级</option><option value="project">项目级</option></select></label>
|
||||||
|
<label>项目<select value={form.projectId} disabled={!canManage || form.scopeMode !== "project"} onChange={(event) => setForm({ ...form, projectId: event.target.value })}>{projects.map((project) => <option key={project.id} value={project.id}>{project.name || project.title}</option>)}</select></label>
|
||||||
|
<label>状态<select value={form.status} disabled={!canManage} onChange={(event) => setForm({ ...form, status: event.target.value })}><option value="draft">草稿</option><option value="active">启用</option><option value="paused">暂停</option><option value="archived">归档</option></select></label>
|
||||||
|
<label className="style-kit-description">描述<textarea rows="3" disabled={!canManage} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></label>
|
||||||
|
<StyleKitJsonField label="visualProfile JSON" value={form.visualProfileJson} onChange={(value) => setForm({ ...form, visualProfileJson: value })} />
|
||||||
|
<StyleKitJsonField label="subtitlePreset JSON" value={form.subtitlePresetJson} onChange={(value) => setForm({ ...form, subtitlePresetJson: value })} />
|
||||||
|
<StyleKitJsonField label="voicePolicy JSON" value={form.voicePolicyJson} onChange={(value) => setForm({ ...form, voicePolicyJson: value })} />
|
||||||
|
<StyleKitJsonField label="deliverySpec JSON" value={form.deliverySpecJson} onChange={(value) => setForm({ ...form, deliverySpecJson: value })} />
|
||||||
|
<StyleKitJsonField label="promptRules JSON" value={form.promptRulesJson} onChange={(value) => setForm({ ...form, promptRulesJson: value })} />
|
||||||
|
<StyleKitJsonField label="singleFramePolicy JSON" value={form.singleFramePolicyJson} onChange={(value) => setForm({ ...form, singleFramePolicyJson: value })} />
|
||||||
|
<StyleKitJsonField label="namingRules JSON" value={form.namingRulesJson} onChange={(value) => setForm({ ...form, namingRulesJson: value })} />
|
||||||
|
<StyleKitJsonField label="qaPolicy JSON" value={form.qaPolicyJson} onChange={(value) => setForm({ ...form, qaPolicyJson: value })} />
|
||||||
|
<StyleKitJsonField label="metadata JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} rows={5} />
|
||||||
|
<div className="approval-policy-note"><ShieldCheck size={15} /><span>保存时服务端会补齐单画面禁止词、batchSize=1、固定声线策略;随机原生声音不会作为最终配音方案。</span></div>
|
||||||
|
<div className="form-actions"><button className="primary" type="submit" disabled={!canManage || busy === "save"}><Save size={15} />{busy === "save" ? "保存中…" : form.id ? "保存标准" : "创建标准"}</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div className="enterprise-grid enterprise-grid-main style-kit-bottom-grid">
|
||||||
|
<section className="studio-card">
|
||||||
|
<SectionBar title="项目绑定" detail={effectiveBinding ? `当前 ${styleKitEnforcementLabels[effectiveBinding.enforcement]}` : "未显式绑定"} />
|
||||||
|
<form className="style-kit-binding-form" onSubmit={bindStyleKit}>
|
||||||
|
<label>项目<select value={bindingForm.projectId} disabled={!canManage} onChange={(event) => setBindingForm({ ...bindingForm, projectId: event.target.value })}>{projects.map((project) => <option key={project.id} value={project.id}>{project.name || project.title}</option>)}</select></label>
|
||||||
|
<label>风格标准<select value={bindingForm.styleKitId} disabled={!canManage} onChange={(event) => setBindingForm({ ...bindingForm, styleKitId: event.target.value })}>{activeKits.map((kit) => <option key={kit.id} value={kit.id}>{kit.name}</option>)}</select></label>
|
||||||
|
<label>强度<select value={bindingForm.enforcement} disabled={!canManage} onChange={(event) => setBindingForm({ ...bindingForm, enforcement: event.target.value })}><option value="advisory">提示</option><option value="locked">锁定</option><option value="strict">强制</option></select></label>
|
||||||
|
<label>备注<textarea rows="3" disabled={!canManage} value={bindingForm.notes} onChange={(event) => setBindingForm({ ...bindingForm, notes: event.target.value })} /></label>
|
||||||
|
<div className="form-actions"><button className="primary" type="submit" disabled={!canManage || busy === "bind" || !bindingForm.styleKitId}><Link2 size={15} />绑定项目</button><button className="subtle" type="button" disabled={!canManage || busy === "unbind"} onClick={unbindStyleKit}>解除绑定</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section className="studio-card wide-card">
|
||||||
|
<SectionBar title="有效生成合同预览" detail={effectiveKit.name || "平台默认"} action={<StatusBadge status={effectiveKit.status || "active"} label={styleKitScopeLabels[effectiveKit.scopeMode] || effectiveKit.source || "默认"} />} />
|
||||||
|
<div className="style-kit-contract-preview">
|
||||||
|
<div><span>视觉</span><strong>{effectiveKit.visualProfile?.styleFamily || "国漫 2D"}</strong><small>{effectiveKit.visualProfile?.cameraLanguage || "单一连续画面"}</small></div>
|
||||||
|
<div><span>字幕</span><strong>{effectiveKit.subtitlePreset?.language || "zh-CN"}</strong><small>{effectiveKit.subtitlePreset?.position || "bottom-safe-area"}</small></div>
|
||||||
|
<div><span>声线</span><strong>{effectiveKit.voicePolicy?.finalVoiceMode || "fixed-reference-required"}</strong><small>{effectiveKit.voicePolicy?.forbidRandomNativeVoice === false ? "允许随机试听" : "禁止随机原生声线作为最终方案"}</small></div>
|
||||||
|
<div><span>QA</span><strong>{effectiveKit.qaPolicy?.minimumReviewScore || 90}</strong><small>{(effectiveKit.qaPolicy?.gates || []).join(" / ")}</small></div>
|
||||||
|
</div>
|
||||||
|
<pre className="style-kit-effective-json">{JSON.stringify({ styleKit: effectiveKit, binding: effectiveBinding }, null, 2)}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const auditResultLabels = {
|
const auditResultLabels = {
|
||||||
ok: "成功",
|
ok: "成功",
|
||||||
pass: "通过",
|
pass: "通过",
|
||||||
|
|||||||
@@ -824,6 +824,28 @@ export async function fetchProductionReviews(overrides = {}) {
|
|||||||
return apiFetch("/api/production/reviews", {}, overrides);
|
return apiFetch("/api/production/reviews", {}, overrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchStyleKits(overrides = {}, options = {}) {
|
||||||
|
const query = options.includeArchived ? "?includeArchived=1" : "";
|
||||||
|
return apiFetch(`/api/style-kits${query}`, {}, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEffectiveStyleKit(projectId = "", overrides = {}) {
|
||||||
|
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||||
|
return apiFetch(`/api/style-kits/effective${query}`, {}, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStyleKit(body, overrides = {}) {
|
||||||
|
return apiFetch("/api/style-kits", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStyleKit(styleKitId, body, overrides = {}) {
|
||||||
|
return apiFetch(`/api/style-kits/${encodeURIComponent(styleKitId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bindProjectStyleKit(projectId, body, overrides = {}) {
|
||||||
|
return apiFetch(`/api/projects/${encodeURIComponent(projectId)}/style-kit`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
export async function runAutomatedProductionQa(overrides = {}) {
|
export async function runAutomatedProductionQa(overrides = {}) {
|
||||||
return apiFetch("/api/production/qa/run", { method: "POST", body: JSON.stringify({}) }, overrides);
|
return apiFetch("/api/production/qa/run", { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||||
}
|
}
|
||||||
|
|||||||
+144
@@ -6884,6 +6884,145 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.style-kit-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 0.85fr) minmax(0, 1.65fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.style-kit-list-card {
|
||||||
|
position: sticky;
|
||||||
|
top: 18px;
|
||||||
|
}
|
||||||
|
.style-kit-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.style-kit-list button {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 86px;
|
||||||
|
padding: 10px 11px;
|
||||||
|
color: inherit;
|
||||||
|
background: #f8fbf8;
|
||||||
|
border: 1px solid #e0e7e2;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.style-kit-list button.selected,
|
||||||
|
.style-kit-list button:hover {
|
||||||
|
background: #eef8f2;
|
||||||
|
border-color: #8fc7aa;
|
||||||
|
}
|
||||||
|
.style-kit-list strong,
|
||||||
|
.style-kit-contract-preview strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.style-kit-list span,
|
||||||
|
.style-kit-list small,
|
||||||
|
.style-kit-contract-preview span,
|
||||||
|
.style-kit-contract-preview small {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.style-kit-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
.style-kit-form label,
|
||||||
|
.style-kit-binding-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.style-kit-form input,
|
||||||
|
.style-kit-form select,
|
||||||
|
.style-kit-form textarea,
|
||||||
|
.style-kit-binding-form select,
|
||||||
|
.style-kit-binding-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
outline: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.style-kit-form textarea,
|
||||||
|
.style-kit-binding-form textarea {
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.style-kit-description,
|
||||||
|
.style-kit-form .approval-policy-note {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.style-kit-json-field {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
.style-kit-json-field textarea,
|
||||||
|
.style-kit-effective-json {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.style-kit-json-field textarea {
|
||||||
|
min-height: 154px;
|
||||||
|
background: #fbfcfb;
|
||||||
|
}
|
||||||
|
.style-kit-contract-preview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.style-kit-contract-preview > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: #f7faf8;
|
||||||
|
border: 1px solid #e0e7e2;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.style-kit-effective-json {
|
||||||
|
max-height: 360px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
color: #f5fbf5;
|
||||||
|
background: #101b17;
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.style-kit-binding-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.style-kit-bottom-grid {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.production-template-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.production-template-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
.production-three-column { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.production-three-column { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
@@ -6908,10 +7047,15 @@ body {
|
|||||||
.model-resolution-form,
|
.model-resolution-form,
|
||||||
.model-resolution-cards,
|
.model-resolution-cards,
|
||||||
.model-approval-kpis,
|
.model-approval-kpis,
|
||||||
|
.style-kit-layout,
|
||||||
|
.style-kit-form,
|
||||||
|
.style-kit-contract-preview,
|
||||||
.governance-form-grid,
|
.governance-form-grid,
|
||||||
.knowledge-search-controls,
|
.knowledge-search-controls,
|
||||||
.studio-queue-grid { grid-template-columns: 1fr; }
|
.studio-queue-grid { grid-template-columns: 1fr; }
|
||||||
.model-resolution-wide { grid-column: auto; }
|
.model-resolution-wide { grid-column: auto; }
|
||||||
|
.style-kit-list-card { position: static; }
|
||||||
|
.style-kit-json-field { grid-column: auto; }
|
||||||
.studio-banner,
|
.studio-banner,
|
||||||
.studio-stage-head { flex-direction: column; }
|
.studio-stage-head { flex-direction: column; }
|
||||||
.studio-banner-side { width: 100%; }
|
.studio-banner-side { width: 100%; }
|
||||||
|
|||||||
Reference in New Issue
Block a user