feat: bootstrap commercial AI drama platform
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
import { dbAll } from "./db.mjs";
|
||||
import { hasPermission } from "./tenant.mjs";
|
||||
|
||||
const REVIEW_LABELS = {
|
||||
"single-frame": "一图一画面",
|
||||
"continuity-lock": "连续性证据",
|
||||
"voice-subtitle-asr": "声音 / 字幕 / ASR 对齐",
|
||||
"clip-bridge": "片段衔接 / 实际末帧"
|
||||
};
|
||||
|
||||
const PRIORITY_ORDER = { high: 0, medium: 1, low: 2 };
|
||||
const ACTIONABLE_JOB_STATUSES = new Set(["queued", "running", "blocked", "failed"]);
|
||||
const ACTIONABLE_TASK_STATUSES = new Set(["open", "in_progress", "blocked"]);
|
||||
const ACTIONABLE_ASSET_RIGHTS = new Set(["needs-evidence", "pending", "review", "rejected"]);
|
||||
const ACTIONABLE_DELIVERY_STATUSES = new Set(["draft", "prepared", "pending", "review"]);
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function scope(context) {
|
||||
return {
|
||||
organizationId: context.organization?.id || "",
|
||||
workspaceId: context.workspace?.id || "",
|
||||
projectId: context.project?.id || ""
|
||||
};
|
||||
}
|
||||
|
||||
function priorityForStatus(status) {
|
||||
if (["blocked", "failed", "changes_requested", "rejected"].includes(status)) return "high";
|
||||
if (["queued", "pending", "draft", "prepared", "review"].includes(status)) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
function reviewTitle(row) {
|
||||
const episode = row.episode_number ? `E${String(row.episode_number).padStart(2, "0")}` : "当前集";
|
||||
const shot = row.shot_number ? `-S${String(row.shot_number).padStart(2, "0")}` : "";
|
||||
const label = REVIEW_LABELS[row.lane] || row.lane;
|
||||
if (row.status === "changes_requested") return `${episode}${shot} ${label}需修改`;
|
||||
if (row.status === "rejected") return `${episode}${shot} ${label}已驳回`;
|
||||
return `${episode}${shot} ${label}待确认`;
|
||||
}
|
||||
|
||||
function jobTitle(row) {
|
||||
const shot = row.shot_number ? ` · E${String(row.episode_number || 1).padStart(2, "0")}-S${String(row.shot_number).padStart(2, "0")}` : "";
|
||||
if (row.status === "blocked") return `${row.kind}${shot} 等待前置任务`;
|
||||
if (row.status === "failed") return `${row.kind}${shot} 生成失败,等待重试`;
|
||||
if (row.status === "running") return `${row.kind}${shot} 正在执行`;
|
||||
return `${row.kind}${shot} 等待本地 Worker`;
|
||||
}
|
||||
|
||||
function assetTitle(row) {
|
||||
if (row.kind === "voice") return `${row.name} · v${row.version_number || 1} 声音授权证据待补`;
|
||||
return `${row.name} · v${row.version_number || 1} 版权证据待补`;
|
||||
}
|
||||
|
||||
function actionableReviews(context) {
|
||||
if (!context.project) return [];
|
||||
const canReview = hasPermission(context, "qa:review");
|
||||
const canFixProduction = ["script:edit", "asset:edit", "prompt:edit", "voice:edit"].some((permission) => hasPermission(context, permission));
|
||||
if (!canReview && !canFixProduction) return [];
|
||||
const rows = dbAll(
|
||||
`SELECT r.id, r.shot_id, r.lane, r.status, r.evidence_json, r.updated_at,
|
||||
s.title AS shot_title, s.shot_number,
|
||||
e.episode_number, e.title AS episode_title
|
||||
FROM reviews r
|
||||
LEFT JOIN shots s ON s.id = r.shot_id
|
||||
LEFT JOIN episodes e ON e.id = s.episode_id
|
||||
WHERE r.organization_id = ? AND r.workspace_id = ? AND r.project_id = ?
|
||||
AND r.status IN ('pending', 'changes_requested', 'rejected')
|
||||
ORDER BY r.updated_at DESC
|
||||
LIMIT 100`,
|
||||
[context.organization.id, context.workspace.id, context.project.id]
|
||||
);
|
||||
return rows
|
||||
.filter((row) => canReview || ["changes_requested", "rejected"].includes(row.status))
|
||||
.map((row) => {
|
||||
const evidence = parseJson(row.evidence_json, {});
|
||||
const blockers = Array.isArray(evidence.blockers) ? evidence.blockers : [];
|
||||
return {
|
||||
id: `review-${row.id}`,
|
||||
kind: "review",
|
||||
title: reviewTitle(row),
|
||||
detail: blockers[0] || REVIEW_LABELS[row.lane] || row.lane,
|
||||
status: row.status,
|
||||
priority: priorityForStatus(row.status),
|
||||
targetTab: "qa",
|
||||
targetId: row.id,
|
||||
updatedAt: row.updated_at,
|
||||
metadata: {
|
||||
reviewId: row.id,
|
||||
shotId: row.shot_id,
|
||||
shotTitle: row.shot_title || "",
|
||||
lane: row.lane,
|
||||
episodeTitle: row.episode_title || ""
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function actionableJobs(context) {
|
||||
if (!context.project || (!hasPermission(context, "job:create") && !hasPermission(context, "queue:manage"))) return [];
|
||||
const rows = dbAll(
|
||||
`SELECT j.id, j.kind, j.status, j.adapter_id, j.error_message, j.updated_at,
|
||||
j.shot_id, s.title AS shot_title, s.shot_number, e.episode_number
|
||||
FROM generation_jobs j
|
||||
LEFT JOIN shots s ON s.id = j.shot_id
|
||||
LEFT JOIN episodes e ON e.id = s.episode_id
|
||||
WHERE j.organization_id = ? AND j.workspace_id = ? AND j.project_id = ?
|
||||
AND j.status IN ('queued', 'running', 'blocked', 'failed')
|
||||
ORDER BY CASE j.status WHEN 'failed' THEN 0 WHEN 'blocked' THEN 1 WHEN 'queued' THEN 2 ELSE 3 END, j.updated_at DESC
|
||||
LIMIT 100`,
|
||||
[context.organization.id, context.workspace.id, context.project.id]
|
||||
);
|
||||
return rows.filter((row) => ACTIONABLE_JOB_STATUSES.has(row.status)).map((row) => ({
|
||||
id: `job-${row.id}`,
|
||||
kind: "job",
|
||||
title: jobTitle(row),
|
||||
detail: row.error_message || row.shot_title || `适配器:${row.adapter_id}`,
|
||||
status: row.status,
|
||||
priority: priorityForStatus(row.status),
|
||||
targetTab: "jobs",
|
||||
targetId: row.id,
|
||||
updatedAt: row.updated_at,
|
||||
metadata: { jobId: row.id, shotId: row.shot_id || null, adapterId: row.adapter_id }
|
||||
}));
|
||||
}
|
||||
|
||||
function actionableAssets(context) {
|
||||
if (!context.project) return [];
|
||||
const canEditAssets = hasPermission(context, "asset:edit") || hasPermission(context, "compliance:manage");
|
||||
const canApproveVoice = hasPermission(context, "voice:approve");
|
||||
if (!canEditAssets && !canApproveVoice) return [];
|
||||
const rows = dbAll(
|
||||
`SELECT a.id, a.kind, a.name, a.lock_status, a.updated_at,
|
||||
av.version_number, av.rights_status, av.metadata_json
|
||||
FROM assets a
|
||||
JOIN projects p ON p.id = a.project_id
|
||||
JOIN workspaces w ON w.id = p.workspace_id
|
||||
LEFT JOIN asset_versions av ON av.id = a.current_version_id
|
||||
WHERE p.id = ? AND p.workspace_id = ? AND w.organization_id = ?
|
||||
ORDER BY a.updated_at DESC
|
||||
LIMIT 100`,
|
||||
[context.project.id, context.workspace.id, context.organization.id]
|
||||
);
|
||||
return rows
|
||||
.filter((row) => ACTIONABLE_ASSET_RIGHTS.has(row.rights_status) && (row.kind === "voice" ? canApproveVoice : canEditAssets))
|
||||
.map((row) => {
|
||||
const metadata = parseJson(row.metadata_json, {});
|
||||
return {
|
||||
id: `asset-${row.id}`,
|
||||
kind: "asset",
|
||||
title: assetTitle(row),
|
||||
detail: metadata.subtitle || (row.kind === "voice" ? "固定声线未完成授权证据确认" : "连续性资产未完成版权证据确认"),
|
||||
status: row.rights_status,
|
||||
priority: row.kind === "voice" ? "high" : "medium",
|
||||
targetTab: "casting",
|
||||
targetId: row.id,
|
||||
updatedAt: row.updated_at,
|
||||
metadata: { assetId: row.id, assetKind: row.kind, lockStatus: row.lock_status, versionNumber: row.version_number || 1 }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function actionableDeliveries(context) {
|
||||
if (!context.project || (!hasPermission(context, "delivery:view") && !hasPermission(context, "delivery:approve"))) return [];
|
||||
const rows = dbAll(
|
||||
`SELECT id, version, channel, status, active_batch_id, updated_at
|
||||
FROM deliveries
|
||||
WHERE organization_id = ? AND workspace_id = ? AND project_id = ?
|
||||
AND status IN ('draft', 'prepared', 'pending', 'review')
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 50`,
|
||||
[context.organization.id, context.workspace.id, context.project.id]
|
||||
);
|
||||
return rows.filter((row) => ACTIONABLE_DELIVERY_STATUSES.has(row.status)).map((row) => ({
|
||||
id: `delivery-${row.id}`,
|
||||
kind: "delivery",
|
||||
title: `${row.version} 内部交付版本待审阅`,
|
||||
detail: row.active_batch_id ? `${row.channel} · 已有活动批次` : `${row.channel} · 尚未激活交付批次`,
|
||||
status: row.status,
|
||||
priority: priorityForStatus(row.status),
|
||||
targetTab: "export",
|
||||
targetId: row.id,
|
||||
updatedAt: row.updated_at,
|
||||
metadata: { deliveryId: row.id, version: row.version, channel: row.channel }
|
||||
}));
|
||||
}
|
||||
|
||||
function actionableInvitations(context) {
|
||||
if (!context.user?.email || !context.organization?.id) return [];
|
||||
const now = new Date().toISOString();
|
||||
const rows = dbAll(
|
||||
`SELECT i.id, i.organization_id, i.workspace_id, i.project_id, i.role_key, i.expires_at, i.created_at,
|
||||
o.name AS organization_name, w.name AS workspace_name, p.name AS project_name,
|
||||
u.display_name AS inviter_name, r.name AS role_name
|
||||
FROM invitations i
|
||||
JOIN organizations o ON o.id = i.organization_id
|
||||
LEFT JOIN workspaces w ON w.id = i.workspace_id
|
||||
LEFT JOIN projects p ON p.id = i.project_id
|
||||
LEFT JOIN users u ON u.id = i.invited_by
|
||||
LEFT JOIN roles r ON r.key = i.role_key
|
||||
WHERE i.organization_id = ?
|
||||
AND lower(i.email) = lower(?)
|
||||
AND i.status = 'pending'
|
||||
AND i.expires_at > ?
|
||||
AND (i.workspace_id IS NULL OR i.workspace_id = ?)
|
||||
AND (i.project_id IS NULL OR i.project_id = ?)
|
||||
ORDER BY i.created_at DESC
|
||||
LIMIT 50`,
|
||||
[context.organization.id, context.user.email, now, context.workspace?.id || "", context.project?.id || ""]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: `invitation-${row.id}`,
|
||||
kind: "invitation",
|
||||
title: `接受加入${row.organization_name}的邀请`,
|
||||
detail: `${row.workspace_name || "组织级"}${row.project_name ? ` · ${row.project_name}` : ""} · ${row.role_name || row.role_key}`,
|
||||
status: "pending",
|
||||
priority: "medium",
|
||||
targetTab: "account",
|
||||
targetId: row.id,
|
||||
updatedAt: row.created_at,
|
||||
metadata: { invitationId: row.id, organizationId: row.organization_id, workspaceId: row.workspace_id, projectId: row.project_id, inviterName: row.inviter_name || "" }
|
||||
}));
|
||||
}
|
||||
|
||||
function actionableTasks(context) {
|
||||
if (!context.project || !hasPermission(context, "task:view")) return [];
|
||||
const canSeeAll = hasPermission(context, "task:manage");
|
||||
const clauses = [
|
||||
"t.organization_id = ?",
|
||||
"t.workspace_id = ?",
|
||||
"t.project_id = ?",
|
||||
"t.status IN ('open', 'in_progress', 'blocked')"
|
||||
];
|
||||
const params = [context.organization.id, context.workspace.id, context.project.id];
|
||||
if (!canSeeAll) {
|
||||
clauses.push("t.assignee_user_id = ?");
|
||||
params.push(context.user.id);
|
||||
}
|
||||
const rows = dbAll(
|
||||
`SELECT t.id, t.title, t.kind, t.status, t.priority, t.due_at, t.updated_at, u.display_name AS assignee_name
|
||||
FROM project_tasks t
|
||||
LEFT JOIN users u ON u.id = t.assignee_user_id
|
||||
WHERE ${clauses.join(" AND ")}
|
||||
ORDER BY CASE t.status WHEN 'blocked' THEN 0 WHEN 'open' THEN 1 ELSE 2 END,
|
||||
CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
|
||||
COALESCE(t.due_at, '9999-12-31T23:59:59.999Z'), t.updated_at DESC
|
||||
LIMIT 100`,
|
||||
params
|
||||
);
|
||||
return rows.filter((row) => ACTIONABLE_TASK_STATUSES.has(row.status)).map((row) => ({
|
||||
id: `task-${row.id}`,
|
||||
kind: "task",
|
||||
title: row.title,
|
||||
detail: `${row.kind} · ${row.assignee_name || "未分派"}${row.due_at ? ` · 截止 ${row.due_at.slice(0, 10)}` : ""}`,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
targetTab: "tasks",
|
||||
targetId: row.id,
|
||||
updatedAt: row.updated_at,
|
||||
metadata: { taskId: row.id, assigneeUserId: row.assignee_user_id || null, dueAt: row.due_at || null }
|
||||
}));
|
||||
}
|
||||
|
||||
export function listWorkItems(context, options = {}) {
|
||||
const items = [
|
||||
...actionableReviews(context),
|
||||
...actionableJobs(context),
|
||||
...actionableAssets(context),
|
||||
...actionableDeliveries(context),
|
||||
...actionableInvitations(context),
|
||||
...actionableTasks(context)
|
||||
].sort((left, right) => {
|
||||
const priority = (PRIORITY_ORDER[left.priority] ?? 9) - (PRIORITY_ORDER[right.priority] ?? 9);
|
||||
if (priority !== 0) return priority;
|
||||
return String(right.updatedAt || "").localeCompare(String(left.updatedAt || ""));
|
||||
});
|
||||
const limit = Math.max(1, Math.min(200, Number(options.limit || 100)));
|
||||
const visibleItems = items.slice(0, limit);
|
||||
const byKind = {};
|
||||
const byStatus = {};
|
||||
for (const item of visibleItems) {
|
||||
byKind[item.kind] = (byKind[item.kind] || 0) + 1;
|
||||
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
|
||||
}
|
||||
return {
|
||||
items: visibleItems,
|
||||
summary: {
|
||||
total: visibleItems.length,
|
||||
high: visibleItems.filter((item) => item.priority === "high").length,
|
||||
byKind,
|
||||
byStatus
|
||||
},
|
||||
scope: scope(context),
|
||||
generatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user