303 lines
12 KiB
JavaScript
303 lines
12 KiB
JavaScript
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()
|
|
};
|
|
}
|