import { dbAll, dbGet, dbRun } from "./db.mjs"; import { dispatchNotificationEvent } from "./notifications.mjs"; import { addAudit, hasPermission, requirePermission, requireProjectWritable } from "./tenant.mjs"; const TASK_STATUSES = new Set(["open", "in_progress", "blocked", "done", "cancelled"]); const TASK_PRIORITIES = new Set(["high", "medium", "low"]); const TARGET_TABS = new Set(["creator-home", "tasks", "script", "casting", "director", "jobs", "qa", "export", "assistant"]); const TASK_LINK_TYPES = new Set(["shot", "asset", "job", "review", "artifact", "delivery", "file"]); const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; function parseJson(value, fallback) { try { return JSON.parse(value); } catch { return fallback; } } function taskError(status, code, message, details = {}) { return Object.assign(new Error(message), { status, code, details }); } function taskScope(context) { if (!context?.organization?.id || !context?.workspace?.id || !context?.project?.id || !context?.user?.id) { throw Object.assign(new Error("协作任务必须绑定当前组织、工作区和项目"), { status: 400, code: "task_scope_required" }); } return { organizationId: context.organization.id, workspaceId: context.workspace.id, projectId: context.project.id }; } function parseDueAt(value) { if (value === undefined || value === null || value === "") return null; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { throw Object.assign(new Error("截止时间不是有效日期"), { status: 400, code: "task_due_at_invalid" }); } return parsed.toISOString(); } function validateTaskFields(body = {}) { const title = String(body.title || "").trim(); if (!title) throw Object.assign(new Error("任务标题不能为空"), { status: 400, code: "task_title_required" }); if (title.length > 180) throw Object.assign(new Error("任务标题不能超过 180 个字符"), { status: 400, code: "task_title_too_long" }); const description = String(body.description || "").trim(); if (description.length > 4000) throw Object.assign(new Error("任务说明不能超过 4000 个字符"), { status: 400, code: "task_description_too_long" }); const kind = String(body.kind || "production").trim().slice(0, 60) || "production"; const priority = String(body.priority || "medium").trim(); if (!TASK_PRIORITIES.has(priority)) throw Object.assign(new Error("任务优先级无效"), { status: 400, code: "task_priority_invalid" }); const targetTab = String(body.targetTab || "creator-home").trim(); if (!TARGET_TABS.has(targetTab)) throw Object.assign(new Error("任务关联页面无效"), { status: 400, code: "task_target_invalid" }); return { title, description, kind, priority, targetTab, targetId: String(body.targetId || "").trim().slice(0, 160), dueAt: parseDueAt(body.dueAt) }; } function projectMember(context, userId) { if (!userId) return null; return dbGet( `SELECT u.id, u.display_name, u.email FROM users u JOIN organization_members om ON om.user_id = u.id AND om.organization_id = ? AND om.status = 'active' JOIN workspace_members wm ON wm.user_id = u.id AND wm.workspace_id = ? AND wm.status = 'active' LEFT JOIN project_members pm ON pm.user_id = u.id AND pm.project_id = ? AND pm.status = 'active' WHERE u.id = ? AND u.status = 'active' AND (wm.id IS NOT NULL OR pm.id IS NOT NULL OR om.role_key IN ('org_owner', 'org_admin'))`, [context.organization.id, context.workspace.id, context.project.id, userId] ); } function ensureAssignee(context, userId) { if (!userId) return null; const member = projectMember(context, userId); if (!member) throw Object.assign(new Error("负责人不是当前项目的有效成员"), { status: 400, code: "task_assignee_invalid" }); return member; } function taskPayload(row) { const now = Date.now(); const dueAt = row.due_at || null; return { id: row.id, title: row.title, description: row.description, kind: row.kind, status: row.status, priority: row.priority, dueAt, overdue: Boolean(dueAt && !["done", "cancelled"].includes(row.status) && new Date(dueAt).getTime() < now), completedAt: row.completed_at, targetTab: row.target_tab, targetId: row.target_id || "", assignee: row.assignee_user_id ? { id: row.assignee_user_id, displayName: row.assignee_name || row.assignee_user_id, email: row.assignee_email || "" } : null, createdBy: { id: row.created_by, displayName: row.creator_name || row.created_by }, commentCount: Number(row.comment_count || 0), linkCount: Number(row.link_count || 0), createdAt: row.created_at, updatedAt: row.updated_at }; } function getTask(context, taskId) { const scope = taskScope(context); return dbGet( `SELECT t.*, au.display_name AS assignee_name, au.email AS assignee_email, cu.display_name AS creator_name, (SELECT COUNT(*) FROM task_comments tc WHERE tc.task_id = t.id) AS comment_count, (SELECT COUNT(*) FROM task_links tl WHERE tl.task_id = t.id) AS link_count FROM project_tasks t LEFT JOIN users au ON au.id = t.assignee_user_id LEFT JOIN users cu ON cu.id = t.created_by WHERE t.id = ? AND t.organization_id = ? AND t.workspace_id = ? AND t.project_id = ?`, [taskId, scope.organizationId, scope.workspaceId, scope.projectId] ); } export function listProjectTasks(context, { status = "", assignedTo = "", limit = 100 } = {}) { requirePermission(context, "task:view"); const scope = taskScope(context); const clauses = ["t.organization_id = ?", "t.workspace_id = ?", "t.project_id = ?"]; const params = [scope.organizationId, scope.workspaceId, scope.projectId]; if (status && status !== "all") { if (!TASK_STATUSES.has(status)) throw Object.assign(new Error("任务状态无效"), { status: 400, code: "task_status_invalid" }); clauses.push("t.status = ?"); params.push(status); } if (assignedTo === "me") { clauses.push("t.assignee_user_id = ?"); params.push(context.user.id); } else if (assignedTo) { clauses.push("t.assignee_user_id = ?"); params.push(String(assignedTo)); } const normalizedLimit = Math.max(1, Math.min(200, Number(limit || 100))); const rows = dbAll( `SELECT t.*, au.display_name AS assignee_name, au.email AS assignee_email, cu.display_name AS creator_name, (SELECT COUNT(*) FROM task_comments tc WHERE tc.task_id = t.id) AS comment_count, (SELECT COUNT(*) FROM task_links tl WHERE tl.task_id = t.id) AS link_count FROM project_tasks t LEFT JOIN users au ON au.id = t.assignee_user_id LEFT JOIN users cu ON cu.id = t.created_by WHERE ${clauses.join(" AND ")} ORDER BY CASE t.status WHEN 'blocked' THEN 0 WHEN 'open' THEN 1 WHEN 'in_progress' THEN 2 WHEN 'done' THEN 3 ELSE 4 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 ?`, [...params, normalizedLimit] ); const tasks = rows.map(taskPayload); const byStatus = {}; for (const task of tasks) byStatus[task.status] = (byStatus[task.status] || 0) + 1; return { tasks, summary: { total: tasks.length, open: tasks.filter((task) => ["open", "in_progress"].includes(task.status)).length, blocked: tasks.filter((task) => task.status === "blocked").length, done: tasks.filter((task) => task.status === "done").length, overdue: tasks.filter((task) => task.overdue).length, byStatus }, scope, generatedAt: new Date().toISOString() }; } export function createProjectTask(context, body = {}) { requirePermission(context, "task:manage"); const project = requireProjectWritable(context); const fields = validateTaskFields(body); const assigneeUserId = body.assigneeUserId === undefined ? context.user.id : String(body.assigneeUserId || ""); const assignee = ensureAssignee(context, assigneeUserId); const status = String(body.status || "open").trim(); if (!["open", "in_progress", "blocked"].includes(status)) throw Object.assign(new Error("新任务只能创建为打开、进行中或阻塞"), { status: 400, code: "task_create_status_invalid" }); const timestamp = new Date().toISOString(); const id = makeId("task"); dbRun( `INSERT INTO project_tasks(id, organization_id, workspace_id, project_id, title, description, kind, status, priority, assignee_user_id, target_tab, target_id, due_at, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, context.organization.id, context.workspace.id, project.id, fields.title, fields.description, fields.kind, status, fields.priority, assignee?.id || null, fields.targetTab, fields.targetId, fields.dueAt, context.user.id, timestamp, timestamp] ); addAudit({ context, action: "project.task.created", targetType: "project_task", targetId: id, metadata: { title: fields.title, assigneeUserId: assignee?.id || null, priority: fields.priority, dueAt: fields.dueAt } }); if (assignee?.id) { void dispatchNotificationEvent({ context, eventKey: "task.assigned", payload: { recipientUserId: assignee.id, taskId: id, taskTitle: fields.title, targetId: id, targetTab: "tasks" } }); } return { task: taskPayload(getTask(context, id)) }; } export function updateProjectTask(context, taskId, body = {}) { const current = getTask(context, taskId); if (!current) throw Object.assign(new Error("任务不存在或不属于当前项目"), { status: 404, code: "task_not_found" }); const canManage = hasPermission(context, "task:manage"); if (canManage) requireProjectWritable(context); else { requirePermission(context, "task:complete"); requireProjectWritable(context); if (current.assignee_user_id !== context.user.id) throw Object.assign(new Error("只能更新自己负责的任务"), { status: 403, code: "task_assignee_only" }); const disallowed = ["title", "description", "kind", "priority", "assigneeUserId", "targetTab", "targetId", "dueAt"].some((key) => Object.prototype.hasOwnProperty.call(body, key)); if (disallowed) throw Object.assign(new Error("当前角色只能更新本人任务状态"), { status: 403, code: "task_update_scope_denied" }); } const nextStatus = body.status === undefined ? current.status : String(body.status).trim(); if (!TASK_STATUSES.has(nextStatus)) throw Object.assign(new Error("任务状态无效"), { status: 400, code: "task_status_invalid" }); const next = canManage ? validateTaskFields({ title: body.title === undefined ? current.title : body.title, description: body.description === undefined ? current.description : body.description, kind: body.kind === undefined ? current.kind : body.kind, priority: body.priority === undefined ? current.priority : body.priority, targetTab: body.targetTab === undefined ? current.target_tab : body.targetTab, targetId: body.targetId === undefined ? current.target_id : body.targetId, dueAt: body.dueAt === undefined ? current.due_at : body.dueAt }) : { title: current.title, description: current.description, kind: current.kind, priority: current.priority, targetTab: current.target_tab, targetId: current.target_id, dueAt: current.due_at }; let assigneeUserId = current.assignee_user_id; if (canManage && Object.prototype.hasOwnProperty.call(body, "assigneeUserId")) assigneeUserId = body.assigneeUserId ? String(body.assigneeUserId) : null; const assignee = ensureAssignee(context, assigneeUserId); const timestamp = new Date().toISOString(); const completedAt = nextStatus === "done" ? (current.completed_at || timestamp) : null; dbRun( `UPDATE project_tasks SET title = ?, description = ?, kind = ?, status = ?, priority = ?, assignee_user_id = ?, target_tab = ?, target_id = ?, due_at = ?, completed_at = ?, updated_at = ? WHERE id = ?`, [next.title, next.description, next.kind, nextStatus, next.priority, assignee?.id || null, next.targetTab, next.targetId, next.dueAt, completedAt, timestamp, taskId] ); addAudit({ context, action: "project.task.updated", targetType: "project_task", targetId: taskId, metadata: { previousStatus: current.status, status: nextStatus, assigneeUserId: assignee?.id || null, changedByAssignee: !canManage } }); if (assignee?.id && (assignee.id !== context.user.id || current.assignee_user_id !== assignee.id)) { void dispatchNotificationEvent({ context, eventKey: current.assignee_user_id === assignee.id ? "task.updated" : "task.assigned", payload: { recipientUserId: assignee.id, taskId, taskTitle: next.title, status: nextStatus, targetId: taskId, targetTab: "tasks" } }); } return { task: taskPayload(getTask(context, taskId)) }; } function ensureTask(context, taskId) { const task = getTask(context, taskId); if (!task) throw taskError(404, "task_not_found", "任务不存在或不属于当前项目"); return task; } function commentPayload(row) { return { id: row.id, taskId: row.task_id, parentCommentId: row.parent_comment_id || null, body: row.body, mentions: parseJson(row.mentions_json, []), author: { id: row.author_user_id, displayName: row.author_name || row.author_user_id, email: row.author_email || "" }, createdAt: row.created_at, updatedAt: row.updated_at }; } function linkPayload(row) { return { id: row.id, taskId: row.task_id, type: row.link_type, targetId: row.target_id, label: row.label || row.target_id, metadata: parseJson(row.metadata_json, {}), createdBy: { id: row.created_by, displayName: row.created_by_name || row.created_by }, createdAt: row.created_at }; } function taskComments(context, taskId) { ensureTask(context, taskId); return dbAll( `SELECT c.*, u.display_name AS author_name, u.email AS author_email FROM task_comments c LEFT JOIN users u ON u.id = c.author_user_id WHERE c.task_id = ? AND c.organization_id = ? AND c.workspace_id = ? AND c.project_id = ? ORDER BY c.created_at ASC`, [taskId, context.organization.id, context.workspace.id, context.project.id] ).map(commentPayload); } function taskLinks(context, taskId) { ensureTask(context, taskId); return dbAll( `SELECT l.*, u.display_name AS created_by_name FROM task_links l LEFT JOIN users u ON u.id = l.created_by WHERE l.task_id = ? AND l.organization_id = ? AND l.workspace_id = ? AND l.project_id = ? ORDER BY l.created_at DESC`, [taskId, context.organization.id, context.workspace.id, context.project.id] ).map(linkPayload); } function taskDetail(context, taskId) { const task = ensureTask(context, taskId); return { task: taskPayload(task), comments: taskComments(context, taskId), links: taskLinks(context, taskId) }; } function activeProjectMember(context, userId) { return projectMember(context, String(userId || "").trim()); } function mentionUserIds(context, bodyText, requestedIds = []) { const candidateIds = Array.isArray(requestedIds) ? requestedIds.map((id) => String(id || "").trim()).filter(Boolean) : []; const valid = new Map(); for (const id of candidateIds) { const member = activeProjectMember(context, id); if (member) valid.set(member.id, member); } const text = String(bodyText || ""); const tokens = text.match(/@[\u4e00-\u9fa5A-Za-z0-9_.-]+/g) || []; const members = dbAll( `SELECT DISTINCT u.id, u.display_name, u.email FROM users u JOIN organization_members om ON om.user_id = u.id AND om.organization_id = ? AND om.status = 'active' LEFT JOIN workspace_members wm ON wm.user_id = u.id AND wm.workspace_id = ? AND wm.status = 'active' LEFT JOIN project_members pm ON pm.user_id = u.id AND pm.project_id = ? AND pm.status = 'active' WHERE u.status = 'active' AND (wm.user_id IS NOT NULL OR pm.user_id IS NOT NULL OR om.role_key IN ('org_owner', 'org_admin'))`, [context.organization.id, context.workspace.id, context.project.id] ); for (const token of tokens) { const needle = token.slice(1).toLowerCase(); const member = members.find((item) => [item.id, item.display_name, item.email].some((value) => String(value || "").toLowerCase() === needle)); if (member) valid.set(member.id, member); } return [...valid.values()]; } export function getProjectTask(context, taskId) { requirePermission(context, "task:view"); return taskDetail(context, taskId); } export function listTaskComments(context, taskId) { requirePermission(context, "task:view"); return { taskId, comments: taskComments(context, taskId), generatedAt: new Date().toISOString() }; } export function createTaskComment(context, taskId, body = {}) { requirePermission(context, "task:view"); requireProjectWritable(context); const task = ensureTask(context, taskId); const bodyText = String(body.body || "").trim(); if (!bodyText) throw taskError(400, "task_comment_required", "评论内容不能为空"); if (bodyText.length > 4000) throw taskError(400, "task_comment_too_long", "评论不能超过 4000 个字符"); const parentCommentId = body.parentCommentId ? String(body.parentCommentId).trim() : null; if (parentCommentId) { const parent = dbGet("SELECT id FROM task_comments WHERE id = ? AND task_id = ? AND project_id = ?", [parentCommentId, taskId, context.project.id]); if (!parent) throw taskError(400, "task_comment_parent_invalid", "回复目标不存在或不属于当前任务"); } const mentions = mentionUserIds(context, bodyText, body.mentionUserIds); const timestamp = new Date().toISOString(); const id = makeId("task-comment"); dbRun( `INSERT INTO task_comments(id, organization_id, workspace_id, project_id, task_id, parent_comment_id, author_user_id, body, mentions_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, context.organization.id, context.workspace.id, context.project.id, taskId, parentCommentId, context.user.id, bodyText, JSON.stringify(mentions.map((member) => ({ id: member.id, displayName: member.display_name, email: member.email }))), timestamp, timestamp] ); addAudit({ context, action: "project.task.comment.created", targetType: "task_comment", targetId: id, metadata: { taskId, parentCommentId, mentionUserIds: mentions.map((member) => member.id) } }); const recipientIds = [...new Set([task.assignee_user_id, task.created_by, ...mentions.map((member) => member.id)].filter(Boolean))].filter((idValue) => idValue !== context.user.id); if (recipientIds.length) { void dispatchNotificationEvent({ context, eventKey: "task.commented", payload: { recipientUserIds: recipientIds, taskId, taskTitle: task.title, commentId: id, targetId: taskId, targetTab: "tasks", mentionUserIds: mentions.map((member) => member.id) } }); } return { ...taskDetail(context, taskId), comment: commentPayload(dbGet("SELECT c.*, u.display_name AS author_name, u.email AS author_email FROM task_comments c LEFT JOIN users u ON u.id = c.author_user_id WHERE c.id = ?", [id])) }; } function resolveTaskLink(context, linkType, targetId, label, metadata = {}) { const id = String(targetId || "").trim(); if (!TASK_LINK_TYPES.has(linkType)) throw taskError(400, "task_link_type_invalid", "任务关联类型无效", { linkType }); if (!id) throw taskError(400, "task_link_target_required", "关联对象不能为空"); let resolvedLabel = String(label || "").trim().slice(0, 180); let resolvedMetadata = metadata && typeof metadata === "object" ? metadata : {}; if (linkType === "shot") { const row = dbGet(`SELECT s.id, s.title, s.shot_number, e.episode_number FROM shots s JOIN episodes e ON e.id = s.episode_id JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE s.id = ? AND sr.project_id = ?`, [id, context.project.id]); if (!row) throw taskError(400, "task_link_shot_invalid", "镜头不存在或不属于当前项目"); resolvedLabel ||= `E${String(row.episode_number).padStart(2, "0")}-S${String(row.shot_number).padStart(2, "0")} ${row.title}`; resolvedMetadata = { ...resolvedMetadata, shotId: row.id, episodeNumber: row.episode_number, shotNumber: row.shot_number }; } else if (linkType === "asset") { const row = dbGet("SELECT id, kind, name, lock_status FROM assets WHERE id = ? AND project_id = ?", [id, context.project.id]); if (!row) throw taskError(400, "task_link_asset_invalid", "资产不存在或不属于当前项目"); resolvedLabel ||= `${row.name} · ${row.kind}`; resolvedMetadata = { ...resolvedMetadata, assetId: row.id, kind: row.kind, lockStatus: row.lock_status }; } else if (linkType === "job") { const row = dbGet("SELECT id, kind, status, shot_id FROM generation_jobs WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [id, context.organization.id, context.workspace.id, context.project.id]); if (!row) throw taskError(400, "task_link_job_invalid", "生成任务不存在或不属于当前项目"); resolvedLabel ||= `${row.kind} · ${row.status}`; resolvedMetadata = { ...resolvedMetadata, jobId: row.id, status: row.status, shotId: row.shot_id || null }; } else if (linkType === "review") { const row = dbGet("SELECT id, lane, status, shot_id FROM reviews WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [id, context.organization.id, context.workspace.id, context.project.id]); if (!row) throw taskError(400, "task_link_review_invalid", "审片记录不存在或不属于当前项目"); resolvedLabel ||= `${row.lane} · ${row.status}`; resolvedMetadata = { ...resolvedMetadata, reviewId: row.id, lane: row.lane, status: row.status, shotId: row.shot_id || null }; } else if (linkType === "artifact") { const row = dbGet("SELECT id, kind, path, shot_id FROM media_artifacts WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [id, context.organization.id, context.workspace.id, context.project.id]); if (!row) throw taskError(400, "task_link_artifact_invalid", "媒体证据不存在或不属于当前项目"); resolvedLabel ||= `${row.kind} · ${row.path}`; resolvedMetadata = { ...resolvedMetadata, artifactId: row.id, kind: row.kind, path: row.path, shotId: row.shot_id || null }; } else if (linkType === "delivery") { const row = dbGet("SELECT id, version_label, status FROM deliveries WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [id, context.organization.id, context.workspace.id, context.project.id]); if (!row) throw taskError(400, "task_link_delivery_invalid", "交付版本不存在或不属于当前项目"); resolvedLabel ||= `${row.version_label || row.id} · ${row.status}`; resolvedMetadata = { ...resolvedMetadata, deliveryId: row.id, status: row.status }; } else if (linkType === "file") { if (/^https?:\/\//i.test(id)) throw taskError(400, "task_link_external_blocked", "本地平台不允许把外部云端 URL 当作任务附件"); resolvedLabel ||= id.split("/").pop() || id; resolvedMetadata = { ...resolvedMetadata, path: id, localOnly: true }; } return { targetId: id, label: resolvedLabel || id, metadata: resolvedMetadata }; } export function addTaskLink(context, taskId, body = {}) { requirePermission(context, "task:manage"); requireProjectWritable(context); const task = ensureTask(context, taskId); const linkType = String(body.linkType || body.type || "").trim(); const resolved = resolveTaskLink(context, linkType, body.targetId, body.label, body.metadata); const timestamp = new Date().toISOString(); const id = makeId("task-link"); dbRun( `INSERT INTO task_links(id, organization_id, workspace_id, project_id, task_id, link_type, target_id, label, metadata_json, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(task_id, link_type, target_id) DO UPDATE SET label = excluded.label, metadata_json = excluded.metadata_json`, [id, context.organization.id, context.workspace.id, context.project.id, taskId, linkType, resolved.targetId, resolved.label, JSON.stringify(resolved.metadata), context.user.id, timestamp] ); const saved = dbGet("SELECT l.*, u.display_name AS created_by_name FROM task_links l LEFT JOIN users u ON u.id = l.created_by WHERE l.task_id = ? AND l.link_type = ? AND l.target_id = ?", [taskId, linkType, resolved.targetId]); addAudit({ context, action: "project.task.link.created", targetType: "task_link", targetId: saved.id, metadata: { taskId, linkType, targetId: resolved.targetId } }); return { ...taskDetail(context, taskId), link: linkPayload(saved) }; } export function removeTaskLink(context, taskId, linkId) { requirePermission(context, "task:manage"); requireProjectWritable(context); ensureTask(context, taskId); const link = dbGet("SELECT * FROM task_links WHERE id = ? AND task_id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [linkId, taskId, context.organization.id, context.workspace.id, context.project.id]); if (!link) throw taskError(404, "task_link_not_found", "任务关联不存在或不属于当前项目"); dbRun("DELETE FROM task_links WHERE id = ?", [linkId]); addAudit({ context, action: "project.task.link.removed", targetType: "task_link", targetId: linkId, metadata: { taskId, linkType: link.link_type, targetId: link.target_id } }); return taskDetail(context, taskId); } const ACTIVITY_LABELS = { "project.task.created": "创建协作任务", "project.task.updated": "更新协作任务", "project.task.comment.created": "添加任务评论", "project.task.link.created": "关联生产对象", "project.task.link.removed": "移除任务关联", "shot.created": "创建镜头", "shot.updated": "更新镜头", "shot.prompt.version.created": "保存镜头提示版本", "shot.version.restored": "恢复镜头版本", "asset.created": "创建资产", "asset.version.created": "创建资产版本", "asset.version.restored": "恢复资产版本", "asset.lock.updated": "更新资产锁", "asset.bound": "绑定资产到镜头", "asset.content.verified": "验证资产内容", "generation_job.created": "创建生成任务", "generation_job.completed": "生成任务完成", "generation_job.failed": "生成任务失败", "generation_job.retry": "重试生成任务", "media.composition.planned": "规划合成版本", "media.composition.completed": "合成版本完成", "media.composition.failed": "合成版本失败", "qa.media_inspection.run": "执行媒体深检", "review.approved": "审片通过", "review.changes_requested": "审片要求修改", "review.rejected": "审片驳回", "qa.comment.created": "添加审片评论", "series_bible.updated": "更新系列 Bible", "assistant.query": "使用项目助手", "organization.invitation.created": "创建组织邀请", "project.created": "创建项目", "delivery.created": "创建交付版本", "delivery.approved": "批准交付版本", "delivery.batch.created": "创建交付批次", "delivery.batch.activated": "激活交付批次", "delivery.batch.rolled_back": "回滚交付批次" }; function activityTarget(context, row, metadata) { const type = row.target_type; if (type === "project_task") return dbGet("SELECT title FROM project_tasks WHERE id = ? AND project_id = ?", [row.target_id, context.project.id])?.title || row.target_id; if (type === "task_comment") return dbGet("SELECT t.title FROM task_comments c JOIN project_tasks t ON t.id = c.task_id WHERE c.id = ? AND c.project_id = ?", [row.target_id, context.project.id])?.title || row.target_id; if (type === "task_link") return `${metadata.linkType || "关联"} · ${metadata.targetId || row.target_id}`; if (type === "shot" || type === "shot_version") return dbGet("SELECT s.title FROM shots s JOIN episodes e ON e.id = s.episode_id JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE s.id = ? AND sr.project_id = ?", [metadata.shotId || row.target_id, context.project.id])?.title || metadata.shotId || row.target_id; if (type === "asset" || type === "asset_version") return dbGet("SELECT name FROM assets WHERE id = ? AND project_id = ?", [metadata.assetId || row.target_id, context.project.id])?.name || metadata.assetId || row.target_id; if (type === "generation_job") return dbGet("SELECT kind FROM generation_jobs WHERE id = ? AND project_id = ?", [row.target_id, context.project.id])?.kind || row.target_id; if (type === "review") return `${metadata.lane || "审片"} · ${metadata.shotId || row.target_id}`; if (type === "delivery" || type === "delivery_batch") return metadata.version || metadata.deliveryId || row.target_id; return row.target_id; } function activityTab(targetType) { if (["project_task", "task_comment", "task_link"].includes(targetType)) return "tasks"; if (["shot", "shot_version", "script_document"].includes(targetType)) return "director"; if (["asset", "asset_version", "asset_binding"].includes(targetType)) return "casting"; if (["generation_job", "media_composition"].includes(targetType)) return "jobs"; if (targetType === "review" || targetType === "review_comment") return "qa"; if (["delivery", "delivery_batch"].includes(targetType)) return "export"; if (targetType === "invitation") return "admin-members"; if (["series", "episode"].includes(targetType)) return "bible"; return "creator-home"; } export function listProjectActivity(context, { limit = 80 } = {}) { requirePermission(context, "task:view"); const scope = taskScope(context); const normalizedLimit = Math.max(1, Math.min(200, Number(limit || 80))); const rows = dbAll( `SELECT a.*, u.display_name AS actor_name FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id WHERE a.organization_id = ? AND a.workspace_id = ? AND a.project_id = ? AND a.action NOT LIKE 'auth.%' AND a.action NOT LIKE 'system.%' AND a.action NOT LIKE 'user.%' ORDER BY a.created_at DESC LIMIT ?`, [scope.organizationId, scope.workspaceId, scope.projectId, normalizedLimit] ); return { activities: rows.map((row) => { const metadata = parseJson(row.metadata_json, {}); return { id: row.id, action: row.action, label: ACTIVITY_LABELS[row.action] || row.action, result: row.result, targetType: row.target_type, targetId: row.target_id, targetLabel: activityTarget(context, row, metadata), targetTab: activityTab(row.target_type), actor: { id: row.actor_user_id, displayName: row.actor_name || row.actor_user_id || "系统" }, metadata, createdAt: row.created_at }; }), scope, generatedAt: new Date().toISOString() }; }