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() }; }