feat: 任务模型物理合并,发布+回收并为单任务双阶段
- tasks 新增 recovery_enabled/recovery_due_at/recovery_instructions/recovery_example_images,回收挂自己名下(先发布后交截图),不再置 complete - 存量 screenshot_collect 迁移并入父任务后删除(mysql/0020 含 DELETE,生产执行前务必备份) - 新 action task_enable_recovery:ghost 面板一键开通回收,截止默认=发布截止 - 企微催办双阶段(待发布/截图待提交);超期自动释放仅发布阶段 - admin 删 groupTaskMasters 聚合改单卡双面板;portal 详情页回收截图区块+批次回收进度 - 测试 309 全绿 + Playwright E2E 20/20
This commit is contained in:
@@ -17,7 +17,6 @@ export type ProjectRow = {
|
||||
main_group_member_ids: string;
|
||||
owner_userid: string;
|
||||
main_chat_id: string;
|
||||
parent_id: string;
|
||||
status: string;
|
||||
note: string;
|
||||
created_at: string;
|
||||
@@ -33,7 +32,6 @@ export type ProjectSummary = {
|
||||
mainGroupMemberIds: string[];
|
||||
ownerUserid: string;
|
||||
mainChatId: string;
|
||||
parentProjectId: string;
|
||||
taskCount: number;
|
||||
};
|
||||
|
||||
@@ -51,16 +49,9 @@ export type ProjectTaskRow = {
|
||||
share_token: string | null;
|
||||
owner_userid: string;
|
||||
task_chat_id: string;
|
||||
project_name?: string;
|
||||
};
|
||||
|
||||
export type ProjectSubproject = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
ownerUserid: string;
|
||||
mainChatId: string;
|
||||
taskCount: number;
|
||||
recovery_enabled: number;
|
||||
recovery_due_at: string;
|
||||
recovery_submitted: number;
|
||||
};
|
||||
|
||||
export type ProjectMainChat = {
|
||||
@@ -72,8 +63,6 @@ export type ProjectDetail = {
|
||||
project: ProjectSummary;
|
||||
tasks: ProjectTaskRow[];
|
||||
mainChat: ProjectMainChat;
|
||||
subprojects: ProjectSubproject[];
|
||||
pushTasks: ProjectTaskRow[];
|
||||
};
|
||||
|
||||
export type MainChatEnsureReason =
|
||||
@@ -238,59 +227,6 @@ export async function ensureProjectMainChat(
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectParents(
|
||||
db: DatabaseClient,
|
||||
): Promise<Map<string, string>> {
|
||||
const result = await db
|
||||
.prepare("SELECT id, parent_id FROM projects")
|
||||
.all<{ id: string; parent_id: string }>();
|
||||
return new Map(result.results.map((row) => [row.id, text(row.parent_id)]));
|
||||
}
|
||||
|
||||
// candidate 是否位于 ancestor 的子孙链上(沿 candidate 的祖先链向上找 ancestor)
|
||||
function isDescendantProject(
|
||||
parents: Map<string, string>,
|
||||
ancestorId: string,
|
||||
candidateId: string,
|
||||
): boolean {
|
||||
let current = candidateId;
|
||||
const seen = new Set<string>();
|
||||
while (current) {
|
||||
if (current === ancestorId) return true;
|
||||
if (seen.has(current)) return false;
|
||||
seen.add(current);
|
||||
current = parents.get(current) ?? "";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function collectDescendantIds(
|
||||
db: DatabaseClient,
|
||||
projectId: string,
|
||||
): Promise<string[]> {
|
||||
const parents = await loadProjectParents(db);
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
for (const [id, parent] of parents) {
|
||||
if (!parent) continue;
|
||||
const list = childrenOf.get(parent) ?? [];
|
||||
list.push(id);
|
||||
childrenOf.set(parent, list);
|
||||
}
|
||||
const descendants: string[] = [];
|
||||
const queue = [text(projectId)];
|
||||
const visited = new Set(queue);
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() as string;
|
||||
for (const child of childrenOf.get(current) ?? []) {
|
||||
if (visited.has(child)) continue;
|
||||
visited.add(child);
|
||||
descendants.push(child);
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
return descendants;
|
||||
}
|
||||
|
||||
export async function listProjects(
|
||||
db: DatabaseClient,
|
||||
ownerUserid?: string,
|
||||
@@ -303,7 +239,7 @@ export async function listProjects(
|
||||
const result = await (ownerFilter
|
||||
? db
|
||||
.prepare(
|
||||
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.parent_id, p.status, p.note, p.created_at,
|
||||
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.status, p.note, p.created_at,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
|
||||
FROM projects p
|
||||
WHERE p.owner_userid = ?
|
||||
@@ -312,7 +248,7 @@ export async function listProjects(
|
||||
.bind(ownerFilter)
|
||||
: db
|
||||
.prepare(
|
||||
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.parent_id, p.status, p.note, p.created_at,
|
||||
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.status, p.note, p.created_at,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
|
||||
FROM projects p
|
||||
ORDER BY p.created_at DESC`,
|
||||
@@ -328,7 +264,6 @@ export async function listProjects(
|
||||
mainGroupMemberIds: parseJsonArray(row.main_group_member_ids),
|
||||
ownerUserid: row.owner_userid ?? "",
|
||||
mainChatId: row.main_chat_id ?? "",
|
||||
parentProjectId: row.parent_id ?? "",
|
||||
taskCount: Number(row.task_count),
|
||||
}));
|
||||
}
|
||||
@@ -340,7 +275,7 @@ export async function getProjectDetail(
|
||||
const id = text(projectId);
|
||||
const project = await db
|
||||
.prepare(
|
||||
`SELECT id, name, brand, main_group_member_ids, owner_userid, main_chat_id, parent_id, status, note, created_at
|
||||
`SELECT id, name, brand, main_group_member_ids, owner_userid, main_chat_id, status, note, created_at
|
||||
FROM projects WHERE id = ?`,
|
||||
)
|
||||
.bind(id)
|
||||
@@ -350,38 +285,13 @@ export async function getProjectDetail(
|
||||
const tasks = await db
|
||||
.prepare(
|
||||
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||
task_type, platform, content_format, share_token, owner_userid, task_chat_id
|
||||
FROM tasks WHERE project_id = ?
|
||||
ORDER BY created_at DESC`,
|
||||
)
|
||||
.bind(id)
|
||||
.all<ProjectTaskRow>();
|
||||
|
||||
const subprojects = await db
|
||||
.prepare(
|
||||
`SELECT p.id, p.name, p.brand, p.owner_userid, p.main_chat_id,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
|
||||
FROM projects p
|
||||
WHERE p.parent_id = ?
|
||||
ORDER BY p.created_at DESC`,
|
||||
)
|
||||
.bind(id)
|
||||
.all<ProjectRow & { task_count: number }>();
|
||||
|
||||
const descendantIds = await collectDescendantIds(db, id);
|
||||
const pushIds = [id, ...descendantIds];
|
||||
const placeholders = pushIds.map(() => "?").join(", ");
|
||||
const pushTasks = await db
|
||||
.prepare(
|
||||
`SELECT t.id, t.name, t.brand, t.quantity, t.claimed_quantity, t.due_at, t.status,
|
||||
t.task_type, t.platform, t.content_format, t.share_token, t.owner_userid, t.task_chat_id,
|
||||
pj.name AS project_name
|
||||
FROM tasks t
|
||||
LEFT JOIN projects pj ON pj.id = t.project_id
|
||||
WHERE t.project_id IN (${placeholders})
|
||||
task_type, platform, content_format, share_token, owner_userid, task_chat_id,
|
||||
recovery_enabled, recovery_due_at,
|
||||
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.result_submitted_at IS NOT NULL) AS recovery_submitted
|
||||
FROM tasks t WHERE t.project_id = ?
|
||||
ORDER BY t.created_at DESC`,
|
||||
)
|
||||
.bind(...pushIds)
|
||||
.bind(id)
|
||||
.all<ProjectTaskRow>();
|
||||
|
||||
return {
|
||||
@@ -395,7 +305,6 @@ export async function getProjectDetail(
|
||||
mainGroupMemberIds: parseJsonArray(project.main_group_member_ids),
|
||||
ownerUserid: project.owner_userid ?? "",
|
||||
mainChatId: project.main_chat_id ?? "",
|
||||
parentProjectId: project.parent_id ?? "",
|
||||
taskCount: tasks.results.length,
|
||||
},
|
||||
tasks: tasks.results,
|
||||
@@ -406,15 +315,6 @@ export async function getProjectDetail(
|
||||
project.owner_userid,
|
||||
),
|
||||
},
|
||||
subprojects: subprojects.results.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
brand: row.brand,
|
||||
ownerUserid: row.owner_userid ?? "",
|
||||
mainChatId: row.main_chat_id ?? "",
|
||||
taskCount: Number(row.task_count),
|
||||
})),
|
||||
pushTasks: pushTasks.results,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -425,7 +325,6 @@ export type SaveProjectInput = {
|
||||
mainGroupMemberIds?: unknown;
|
||||
ownerUserid?: unknown;
|
||||
note?: unknown;
|
||||
parentProjectId?: unknown;
|
||||
};
|
||||
|
||||
export async function saveProject(
|
||||
@@ -439,33 +338,19 @@ export async function saveProject(
|
||||
const brand = text(input.brand);
|
||||
const note = text(input.note ?? "");
|
||||
const ownerUserid = text(input.ownerUserid).slice(0, 64);
|
||||
const parentProjectId = text(input.parentProjectId);
|
||||
const memberIds = effectiveProjectMembers(input.mainGroupMemberIds, ownerUserid);
|
||||
if (!name) throw new ProjectError("请填写项目名称");
|
||||
|
||||
if (parentProjectId) {
|
||||
if (parentProjectId === id) {
|
||||
throw new ProjectError("上级项目不能是项目自身");
|
||||
}
|
||||
const parents = await loadProjectParents(db);
|
||||
if (!parents.has(parentProjectId)) {
|
||||
throw new ProjectError("上级项目不存在", 404);
|
||||
}
|
||||
if (id && isDescendantProject(parents, id, parentProjectId)) {
|
||||
throw new ProjectError("上级项目不能是当前项目的子项目");
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let projectId = id;
|
||||
if (id) {
|
||||
const result = await db
|
||||
.prepare(
|
||||
`UPDATE projects
|
||||
SET name = ?, brand = ?, main_group_member_ids = ?, owner_userid = ?, note = ?, parent_id = ?
|
||||
SET name = ?, brand = ?, main_group_member_ids = ?, owner_userid = ?, note = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, parentProjectId, id)
|
||||
.bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, id)
|
||||
.run();
|
||||
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
|
||||
} else {
|
||||
@@ -473,8 +358,8 @@ export async function saveProject(
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO projects
|
||||
(id, name, brand, main_group_member_ids, owner_userid, parent_id, status, note, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)`,
|
||||
(id, name, brand, main_group_member_ids, owner_userid, status, note, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
projectId,
|
||||
@@ -482,7 +367,6 @@ export async function saveProject(
|
||||
brand,
|
||||
JSON.stringify(memberIds),
|
||||
ownerUserid,
|
||||
parentProjectId,
|
||||
note,
|
||||
now,
|
||||
)
|
||||
@@ -505,13 +389,6 @@ export async function deleteProject(
|
||||
): Promise<void> {
|
||||
const id = text(projectId);
|
||||
if (!id) throw new ProjectError("缺少项目 ID");
|
||||
const childCount = await db
|
||||
.prepare("SELECT COUNT(*) AS count FROM projects WHERE parent_id = ?")
|
||||
.bind(id)
|
||||
.first<{ count: number }>();
|
||||
if (Number(childCount?.count ?? 0) > 0) {
|
||||
throw new ProjectError("该项目下还有子项目,请先删除子项目或把它们移到其他项目下");
|
||||
}
|
||||
const result = await db
|
||||
.prepare("DELETE FROM projects WHERE id = ?")
|
||||
.bind(id)
|
||||
|
||||
Reference in New Issue
Block a user