532 lines
30 KiB
JavaScript
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()
|
|
};
|
|
}
|