Files
ai-drama-platform/server/tasks.mjs
T

532 lines
30 KiB
JavaScript

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