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:
ABAPPLO
2026-09-24 15:52:45 +08:00
parent ab78b187c0
commit e2a93e22cf
29 changed files with 2219 additions and 2195 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,7 @@ import {
} from "../../../lib/feishu-client"; } from "../../../lib/feishu-client";
import { import {
createDistributionTask, createDistributionTask,
createScreenshotTask, enableTaskRecovery,
updateTask, updateTask,
} from "../../../lib/task-service"; } from "../../../lib/task-service";
import { import {
@@ -204,23 +204,34 @@ export async function POST(request: Request) {
contentFormat, contentFormat,
projectId: String(body.projectId ?? "").trim(), projectId: String(body.projectId ?? "").trim(),
ownerUserid: String(body.ownerUserid ?? "").trim(), ownerUserid: String(body.ownerUserid ?? "").trim(),
recoveryEnabled: body.recoveryEnabled !== false,
recoveryDueAt: String(body.recoveryDueAt ?? "").trim(),
recoveryInstructions: String(body.recoveryInstructions ?? "").trim(),
recoveryExampleImageKey: String(body.recoveryExampleImageKey ?? "").trim(),
}, },
env as unknown as FeishuBindings, env as unknown as FeishuBindings,
); );
ownerWarning = await assignOwnerQuietly(creation.taskId, body.ownerUserid); ownerWarning = await assignOwnerQuietly(creation.taskId, body.ownerUserid);
} else if (body.action === "create_screenshot_task") { } else if (body.action === "task_enable_recovery") {
const forbidden = await projectOwnerGate(body.projectId); const taskId = String(body.taskId ?? "").trim();
if (!taskId) {
return Response.json({ error: "缺少 taskId" }, { status: 400 });
}
const taskRow = await db
.prepare("SELECT project_id FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ project_id: string }>();
if (!taskRow) {
return Response.json({ error: "任务不存在" }, { status: 404 });
}
const forbidden = await projectOwnerGate(taskRow.project_id);
if (forbidden) return forbidden; if (forbidden) return forbidden;
const creation = await createScreenshotTask({ await enableTaskRecovery({
name: String(body.name ?? "").trim(), taskId,
relatedTaskId: String(body.relatedTaskId ?? "").trim(), recoveryDueAt: String(body.recoveryDueAt ?? "").trim(),
dueAt: String(body.dueAt ?? "").trim(), recoveryInstructions: String(body.recoveryInstructions ?? "").trim(),
instructions: String(body.instructions ?? "").trim(), recoveryExampleImageKey: String(body.recoveryExampleImageKey ?? "").trim(),
exampleImageKey: String(body.exampleImageKey ?? "").trim(),
projectId: String(body.projectId ?? "").trim(),
ownerUserid: String(body.ownerUserid ?? "").trim(),
}); });
ownerWarning = await assignOwnerQuietly(creation.taskId, body.ownerUserid);
} else if (body.action === "claim") { } else if (body.action === "claim") {
const forbidden = managerGate(); const forbidden = managerGate();
if (forbidden) return forbidden; if (forbidden) return forbidden;
@@ -939,7 +950,6 @@ export async function POST(request: Request) {
mainGroupMemberIds: body.mainGroupMemberIds, mainGroupMemberIds: body.mainGroupMemberIds,
ownerUserid: nextOwnerUserid, ownerUserid: nextOwnerUserid,
note: body.note, note: body.note,
parentProjectId: body.parentProjectId,
}, },
resolveWecomConfig(env as unknown as WecomBindings), resolveWecomConfig(env as unknown as WecomBindings),
); );
@@ -1011,10 +1021,13 @@ export async function POST(request: Request) {
projectId: String(body.projectId ?? ""), projectId: String(body.projectId ?? ""),
platform, platform,
contentFormat, contentFormat,
quantity: numberValue(body.quantity), recoveryEnabled:
keyword: String(body.keyword ?? ""), body.recoveryEnabled === undefined
instructions: String(body.instructions ?? ""), ? undefined
exampleImageKey: String(body.exampleImageKey ?? ""), : Boolean(body.recoveryEnabled),
recoveryDueAt: String(body.recoveryDueAt ?? ""),
recoveryInstructions: String(body.recoveryInstructions ?? ""),
recoveryExampleImageKey: String(body.recoveryExampleImageKey ?? ""),
}); });
ownerWarning = await assignOwnerQuietly(update.taskId, body.ownerUserid); ownerWarning = await assignOwnerQuietly(update.taskId, body.ownerUserid);
} else if (body.action === "partner_save") { } else if (body.action === "partner_save") {

View File

@@ -75,9 +75,11 @@ async function handleGet(request: Request) {
c.video_assets, c.video_assets,
d.result_screenshot_key, d.result_screenshot_key,
d.publish_screenshot_key, d.publish_screenshot_key,
d.screenshot_key d.screenshot_key,
t.recovery_example_images
FROM distributions d FROM distributions d
JOIN contents c ON c.id = d.content_id JOIN contents c ON c.id = d.content_id
JOIN tasks t ON t.id = d.task_id
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
WHERE d.id = ? WHERE d.id = ?
AND b.share_token = ? AND b.share_token = ?
@@ -90,6 +92,7 @@ async function handleGet(request: Request) {
result_screenshot_key: string | null; result_screenshot_key: string | null;
publish_screenshot_key: string | null; publish_screenshot_key: string | null;
screenshot_key: string | null; screenshot_key: string | null;
recovery_example_images: string;
}>() }>()
: await getRawDb() : await getRawDb()
.prepare( .prepare(
@@ -97,7 +100,8 @@ async function handleGet(request: Request) {
c.video_assets, c.video_assets,
d.result_screenshot_key, d.result_screenshot_key,
d.publish_screenshot_key, d.publish_screenshot_key,
d.screenshot_key d.screenshot_key,
t.recovery_example_images
FROM distributions d FROM distributions d
JOIN contents c ON c.id = d.content_id JOIN contents c ON c.id = d.content_id
JOIN claims cl ON cl.id = d.claim_id JOIN claims cl ON cl.id = d.claim_id
@@ -114,6 +118,7 @@ async function handleGet(request: Request) {
result_screenshot_key: string | null; result_screenshot_key: string | null;
publish_screenshot_key: string | null; publish_screenshot_key: string | null;
screenshot_key: string | null; screenshot_key: string | null;
recovery_example_images: string;
}>(); }>();
const asset = const asset =
imageKind === "result" imageKind === "result"
@@ -125,6 +130,10 @@ async function handleGet(request: Request) {
], ],
} }
: undefined : undefined
: imageKind === "task-example"
? row
? findAsset(row.recovery_example_images, imageIndex, ["task-assets/"])
: undefined
: imageKind === "publish" : imageKind === "publish"
? row?.publish_screenshot_key?.startsWith("publish-evidence/") ? row?.publish_screenshot_key?.startsWith("publish-evidence/")
? { index: 1, key: row.publish_screenshot_key } ? { index: 1, key: row.publish_screenshot_key }

View File

@@ -93,7 +93,7 @@ async function handlePost(request: Request) {
const assignment = upload.delegationToken const assignment = upload.delegationToken
? await getRawDb() ? await getRawDb()
.prepare( .prepare(
`SELECT d.id, d.publish_url, d.result_screenshot_key, t.task_type `SELECT d.id, d.publish_url, d.result_screenshot_key, t.recovery_enabled
FROM distributions d FROM distributions d
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
JOIN tasks t ON t.id = d.task_id JOIN tasks t ON t.id = d.task_id
@@ -102,10 +102,10 @@ async function handlePost(request: Request) {
AND b.status = 'active'`, AND b.status = 'active'`,
) )
.bind(upload.distributionId, upload.delegationToken) .bind(upload.distributionId, upload.delegationToken)
.first<{ id: string; publish_url: string | null; result_screenshot_key: string | null; task_type: string }>() .first<{ id: string; publish_url: string | null; result_screenshot_key: string | null; recovery_enabled: number }>()
: await getRawDb() : await getRawDb()
.prepare( .prepare(
`SELECT d.id, d.publish_url, d.result_screenshot_key, t.task_type `SELECT d.id, d.publish_url, d.result_screenshot_key, t.recovery_enabled
FROM distributions d FROM distributions d
JOIN claims c ON c.id = d.claim_id JOIN claims c ON c.id = d.claim_id
JOIN tasks t ON t.id = d.task_id JOIN tasks t ON t.id = d.task_id
@@ -115,7 +115,7 @@ async function handlePost(request: Request) {
AND c.task_id = t.id`, AND c.task_id = t.id`,
) )
.bind(upload.distributionId, upload.claimToken, upload.taskToken) .bind(upload.distributionId, upload.claimToken, upload.taskToken)
.first<{ id: string; publish_url: string | null; result_screenshot_key: string | null; task_type: string }>(); .first<{ id: string; publish_url: string | null; result_screenshot_key: string | null; recovery_enabled: number }>();
if (!assignment) { if (!assignment) {
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 }); return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
} }
@@ -125,11 +125,8 @@ async function handlePost(request: Request) {
{ status: 409 }, { status: 409 },
); );
} }
if (isTaskResult && assignment.task_type !== "screenshot_collect") { if (isTaskResult && Number(assignment.recovery_enabled) !== 1) {
return Response.json({ error: "当前任务不支持截图结果回填" }, { status: 400 }); return Response.json({ error: "该任务未开通截图回收" }, { status: 400 });
}
if (!isTaskResult && assignment.task_type === "screenshot_collect") {
return Response.json({ error: "截图回收任务请上传任务结果截图" }, { status: 400 });
} }
const existingResultKeys = isTaskResult const existingResultKeys = isTaskResult
? parseResultScreenshotKeys(assignment.result_screenshot_key) ? parseResultScreenshotKeys(assignment.result_screenshot_key)

View File

@@ -102,6 +102,15 @@ function idList(value: unknown) {
]; ];
} }
function recoveryPayload(task: PartnerTask) {
return {
recoveryEnabled: Number(task?.recovery_enabled) === 1,
recoveryDueAt: String(task?.recovery_due_at ?? ""),
recoveryInstructions: String(task?.recovery_instructions ?? ""),
recoveryExampleImages: publicImageAssets(task?.recovery_example_images),
};
}
function publicImageAssets(value: unknown): ImageAsset[] { function publicImageAssets(value: unknown): ImageAsset[] {
try { try {
const assets = JSON.parse(String(value ?? "[]")) as Array< const assets = JSON.parse(String(value ?? "[]")) as Array<
@@ -137,13 +146,18 @@ type PartnerTask = {
task_type: string; task_type: string;
platform: string; platform: string;
content_format: string; content_format: string;
recovery_enabled: number;
recovery_due_at: string;
recovery_instructions: string | null;
recovery_example_images: string;
}; };
async function findTask(taskToken: string) { async function findTask(taskToken: string) {
return getRawDb() return getRawDb()
.prepare( .prepare(
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status, `SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
task_type, platform, content_format task_type, platform, content_format,
recovery_enabled, recovery_due_at, recovery_instructions, recovery_example_images
FROM tasks WHERE share_token = ?`, FROM tasks WHERE share_token = ?`,
) )
.bind(taskToken) .bind(taskToken)
@@ -164,6 +178,10 @@ async function findDelegationAccess(delegationToken: string) {
t.task_type, t.task_type,
t.platform, t.platform,
t.content_format, t.content_format,
t.recovery_enabled,
t.recovery_due_at,
t.recovery_instructions,
t.recovery_example_images,
b.id AS bundle_id, b.id AS bundle_id,
b.label AS bundle_label, b.label AS bundle_label,
b.quantity AS bundle_quantity, b.quantity AS bundle_quantity,
@@ -398,7 +416,7 @@ async function handleGet(request: Request) {
.all(); .all();
delegation = { delegation = {
id: delegationAccess.bundle_id, id: delegationAccess.bundle_id,
label: task.task_type === "screenshot_collect" ? "转派截图任务包" : "转派发布包", label: "转派发布包",
quantity: delegationAccess.bundle_quantity, quantity: delegationAccess.bundle_quantity,
createdAt: delegationAccess.bundle_created_at, createdAt: delegationAccess.bundle_created_at,
assignments: assignments.results.map((assignment) => ({ assignments: assignments.results.map((assignment) => ({
@@ -421,6 +439,8 @@ async function handleGet(request: Request) {
type: task.task_type, type: task.task_type,
platform: task.platform, platform: task.platform,
contentFormat: task.content_format, contentFormat: task.content_format,
recoveryEnabled: recoveryPayload(task).recoveryEnabled,
recoveryDueAt: recoveryPayload(task).recoveryDueAt,
} }
: { : {
name: task.name, name: task.name,
@@ -433,6 +453,7 @@ async function handleGet(request: Request) {
platform: task.platform, platform: task.platform,
contentFormat: task.content_format, contentFormat: task.content_format,
availableQuantity: available?.count ?? 0, availableQuantity: available?.count ?? 0,
...recoveryPayload(task),
}, },
claim, claim,
delegation, delegation,
@@ -534,8 +555,7 @@ async function handlePost(request: Request) {
c.created_at, c.created_at,
COUNT(d.id) AS note_count, COUNT(d.id) AS note_count,
SUM(CASE SUM(CASE
WHEN ? = 'screenshot_collect' AND d.result_submitted_at IS NOT NULL THEN 1 WHEN d.publish_url IS NOT NULL AND d.publish_url != '' THEN 1
WHEN ? != 'screenshot_collect' AND d.publish_url IS NOT NULL AND d.publish_url != '' THEN 1
ELSE 0 END) AS completed_count, ELSE 0 END) AS completed_count,
MIN(co.title) AS first_title MIN(co.title) AS first_title
FROM claims c FROM claims c
@@ -548,7 +568,7 @@ async function handlePost(request: Request) {
ORDER BY c.created_at DESC, c.id DESC ORDER BY c.created_at DESC, c.id DESC
LIMIT 20`, LIMIT 20`,
) )
.bind(task.task_type, task.task_type, task.id, ...partnerCandidates) .bind(task.id, ...partnerCandidates)
.all<{ .all<{
claim_token: string; claim_token: string;
quantity: number; quantity: number;
@@ -569,7 +589,7 @@ async function handlePost(request: Request) {
quantity: claim.note_count || claim.quantity, quantity: claim.note_count || claim.quantity,
completedCount: claim.completed_count || 0, completedCount: claim.completed_count || 0,
createdAt: claim.created_at, createdAt: claim.created_at,
firstTitle: claim.first_title || (task.task_type === "screenshot_collect" ? "领取的截图任务" : "领取的笔记"), firstTitle: claim.first_title || "领取的笔记",
})), })),
}); });
} }
@@ -788,7 +808,7 @@ async function handlePost(request: Request) {
} }
if (distributionIds.length === 0) { if (distributionIds.length === 0) {
return Response.json( return Response.json(
{ error: task.task_type === "screenshot_collect" ? "请至少选择一份待提交任务" : "请至少选择一篇待发布笔记" }, { error: "请至少选择一篇待发布笔记" },
{ status: 400 }, { status: 400 },
); );
} }
@@ -823,13 +843,9 @@ async function handlePost(request: Request) {
{ status: 403 }, { status: 403 },
); );
} }
if (selected.results.some((item) => if (selected.results.some((item) => item.publish_url)) {
task.task_type === "screenshot_collect"
? item.result_submitted_at
: item.publish_url,
)) {
return Response.json( return Response.json(
{ error: task.task_type === "screenshot_collect" ? "已提交的截图任务不能再次转派" : "已发布的笔记不能再次转派" }, { error: "已发布的笔记不能再次转派" },
{ status: 409 }, { status: 409 },
); );
} }
@@ -942,7 +958,7 @@ async function handlePost(request: Request) {
.first<{ count: number }>(); .first<{ count: number }>();
if ((completed?.count ?? 0) > 0) { if ((completed?.count ?? 0) > 0) {
return Response.json( return Response.json(
{ error: task.task_type === "screenshot_collect" ? "该分享包已有截图提交,不能撤销" : "该分享包已有笔记发布,需保留链接继续完成第7天数据回收" }, { error: "该分享包已有笔记发布,需保留链接继续完成第7天数据回收" },
{ status: 409 }, { status: 409 },
); );
} }
@@ -968,9 +984,6 @@ async function handlePost(request: Request) {
} }
if (body.action === "submit") { if (body.action === "submit") {
if (task.task_type === "screenshot_collect") {
return Response.json({ error: "截图回收任务无需填写发布链接" }, { status: 400 });
}
const claimToken = textValue(body.claimToken, 80); const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80); const distributionId = textValue(body.distributionId, 80);
const publishInput = textValue(body.publishUrl, 5000); const publishInput = textValue(body.publishUrl, 5000);
@@ -1193,9 +1206,6 @@ async function handlePost(request: Request) {
} }
if (body.action === "submit_creator_metrics") { if (body.action === "submit_creator_metrics") {
if (task.task_type === "screenshot_collect") {
return Response.json({ error: "截图回收任务不需要创作者数据" }, { status: 400 });
}
const claimToken = textValue(body.claimToken, 80); const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80); const distributionId = textValue(body.distributionId, 80);
const exposure = creatorMetricValue(body.exposure); const exposure = creatorMetricValue(body.exposure);
@@ -1244,8 +1254,8 @@ async function handlePost(request: Request) {
} }
if (body.action === "submit_screenshot_result") { if (body.action === "submit_screenshot_result") {
if (task.task_type !== "screenshot_collect") { if (Number(task.recovery_enabled) !== 1) {
return Response.json({ error: "当前任务不支持这种回填方式" }, { status: 400 }); return Response.json({ error: "该任务未开通截图回收" }, { status: 400 });
} }
const claimToken = textValue(body.claimToken, 80); const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80); const distributionId = textValue(body.distributionId, 80);
@@ -1256,17 +1266,22 @@ async function handlePost(request: Request) {
delegationToken, delegationToken,
); );
if (!assignment) { if (!assignment) {
return Response.json({ error: "任务与领取凭证不匹配" }, { status: 403 }); return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
}
if (!assignment.publish_url) {
return Response.json(
{ error: "请先回填这篇笔记的发布链接,再提交回收截图" },
{ status: 409 },
);
} }
if (!assignment.result_screenshot_key) { if (!assignment.result_screenshot_key) {
return Response.json({ error: "请先上传任务截图" }, { status: 400 }); return Response.json({ error: "请先上传回收截图" }, { status: 400 });
} }
const statements: DatabaseStatement[] = [ const statements: DatabaseStatement[] = [
db db
.prepare( .prepare(
`UPDATE distributions SET `UPDATE distributions SET
result_submitted_at = CURRENT_TIMESTAMP, result_submitted_at = CURRENT_TIMESTAMP,
status = 'complete',
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ?`, WHERE id = ?`,
) )

View File

@@ -40,6 +40,7 @@ type ExportRow = {
publish_url: string | null; publish_url: string | null;
publish_time: string | null; publish_time: string | null;
publish_screenshot_key: string | null; publish_screenshot_key: string | null;
result_submitted_at: string | null;
screenshot_key: string | null; screenshot_key: string | null;
exposure: number | null; exposure: number | null;
views: number | null; views: number | null;
@@ -252,9 +253,17 @@ export async function GET(request: Request) {
} }
} }
const task = await db const task = await db
.prepare("SELECT id, name, brand, platform FROM tasks WHERE id = ?") .prepare(
"SELECT id, name, brand, platform, recovery_enabled FROM tasks WHERE id = ?",
)
.bind(taskId) .bind(taskId)
.first<{ id: string; name: string; brand: string; platform: string }>(); .first<{
id: string;
name: string;
brand: string;
platform: string;
recovery_enabled: number;
}>();
if (!task) { if (!task) {
return Response.json({ error: "没有找到这个任务" }, { status: 404 }); return Response.json({ error: "没有找到这个任务" }, { status: 404 });
} }
@@ -270,6 +279,7 @@ export async function GET(request: Request) {
d.publish_url, d.publish_url,
d.publish_time, d.publish_time,
d.publish_screenshot_key, d.publish_screenshot_key,
d.result_submitted_at,
d.screenshot_key, d.screenshot_key,
d.exposure, d.exposure,
d.views, d.views,
@@ -304,34 +314,7 @@ export async function GET(request: Request) {
.bind(taskId) .bind(taskId)
.all<ExportRow>(); .all<ExportRow>();
const rows = result.results; const rows = result.results;
const companionTaskRow = await db const recoveryEnabled = Number(task.recovery_enabled) === 1;
.prepare(
"SELECT id FROM tasks WHERE related_task_id = ? AND task_type = 'screenshot_collect' LIMIT 1",
)
.bind(taskId)
.first<{ id: string }>();
let companionSubmittedByContent: Map<string, boolean> | null = null;
if (companionTaskRow) {
const companionRows = await db
.prepare(
`SELECT c.related_content_id, d.result_submitted_at
FROM distributions d
JOIN contents c ON c.id = d.content_id
WHERE d.task_id = ? AND c.related_content_id != ''`,
)
.bind(companionTaskRow.id)
.all<{
related_content_id: string;
result_submitted_at: string | null;
}>();
companionSubmittedByContent = new Map<string, boolean>();
for (const row of companionRows.results) {
companionSubmittedByContent.set(
row.related_content_id,
Boolean(row.result_submitted_at),
);
}
}
const assetsByContent = new Map( const assetsByContent = new Map(
rows.map((row) => [row.content_id, parseAssets(row.image_assets)]), rows.map((row) => [row.content_id, parseAssets(row.image_assets)]),
); );
@@ -384,7 +367,7 @@ export async function GET(request: Request) {
"采集状态", "采集状态",
"合作方", "合作方",
"领取人微信号/手机号", "领取人微信号/手机号",
...(companionSubmittedByContent ? ["收录截图"] : []), ...(recoveryEnabled ? ["收录截图"] : []),
]; ];
const originalImageStart = 3; const originalImageStart = 3;
const accountColumn = originalImageStart + maxContentImages; const accountColumn = originalImageStart + maxContentImages;
@@ -427,10 +410,10 @@ export async function GET(request: Request) {
row.distribution_id ? collectionLabel(row) : "未领取", row.distribution_id ? collectionLabel(row) : "未领取",
row.partner_name || "", row.partner_name || "",
row.claimant_name || "", row.claimant_name || "",
...(companionSubmittedByContent ...(recoveryEnabled
? [ ? [
companionSubmittedByContent.has(row.content_id) row.distribution_id
? companionSubmittedByContent.get(row.content_id) ? row.result_submitted_at
? "已提交" ? "已提交"
: "未提交" : "未提交"
: "—", : "—",
@@ -471,7 +454,7 @@ export async function GET(request: Request) {
24, 24,
18, 18,
22, 22,
...(companionSubmittedByContent ? [12] : []), ...(recoveryEnabled ? [12] : []),
]; ];
const workbook = buildRecoveryWorkbook({ const workbook = buildRecoveryWorkbook({
sheetName: `${task.name}-数据回收`, sheetName: `${task.name}-数据回收`,

View File

@@ -29,11 +29,11 @@ export async function GET(request: Request) {
return ownerForbidden(); return ownerForbidden();
} }
const task = await getRawDb() const task = await getRawDb()
.prepare("SELECT name, task_type FROM tasks WHERE id = ?") .prepare("SELECT name FROM tasks WHERE id = ?")
.bind(taskId) .bind(taskId)
.first<{ name: string; task_type: string }>(); .first<{ name: string }>();
if (!task || task.task_type !== "screenshot_collect") { if (!task) {
return Response.json({ error: "没有找到截图回收任务" }, { status: 404 }); return Response.json({ error: "没有找到任务" }, { status: 404 });
} }
const rows = await getRawDb() const rows = await getRawDb()
.prepare( .prepare(
@@ -60,7 +60,7 @@ export async function GET(request: Request) {
}>(); }>();
const files: Record<string, Uint8Array> = {}; const files: Record<string, Uint8Array> = {};
const manifest = [ const manifest = [
["序号", "搜索关键词", "领取人", "领取时间", "提交时间", "文件名"], ["序号", "笔记标题", "领取人", "领取时间", "提交时间", "文件名"],
]; ];
let exported = 0; let exported = 0;
for (const [rowIndex, row] of rows.results.entries()) { for (const [rowIndex, row] of rows.results.entries()) {

View File

@@ -1761,7 +1761,7 @@ a {
border-color: rgb(20 40 34 / 0.18); border-color: rgb(20 40 34 / 0.18);
} }
.group-push-modal { .modal-card.group-push-modal {
width: min(720px, 100%); width: min(720px, 100%);
} }
@@ -1917,64 +1917,472 @@ a {
.task-list { .task-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
}
.task-row {
display: grid;
grid-template-columns: auto minmax(220px, 1fr) 110px 82px 70px auto;
gap: 16px;
align-items: center;
min-height: 82px;
border-bottom: 1px solid #edf0ee;
}
.task-row:last-child {
border-bottom: 0;
}
.task-row-main > div:first-child {
display: flex;
align-items: baseline;
gap: 10px; gap: 10px;
} }
.task-row-main h3 { .master-task-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
border: 1px solid #e6ede9;
border-radius: 12px;
background: #fff;
}
.master-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.master-index {
display: inline-grid;
place-items: center;
min-width: 24px;
height: 22px;
padding: 0 5px;
border-radius: 6px;
background: #f0f4f2;
color: #7d8f87;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.master-head h3 {
margin: 0; margin: 0;
font-size: 14px;
}
.master-chips {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.master-chip {
padding: 2px 9px;
border-radius: 999px;
background: #eef3f0;
color: #4d6357;
font-size: 11px;
white-space: nowrap;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
}
.master-chip.project {
background: rgb(31 143 105 / 0.12);
color: var(--green-deep);
font-weight: 620;
}
.master-chips .platform-badge.compact {
height: auto;
padding: 3px 9px 3px 4px;
border: 0;
border-radius: 999px;
background: #eef3f0;
color: #4d6357 !important;
font-size: 11px !important;
font-weight: 600;
}
.master-chips .platform-badge.compact .platform-logo {
width: 15px !important;
height: 15px !important;
border-radius: 4px;
}
.master-chips .platform-badge.compact .platform-logo.xiaohongshu b {
font-size: 5px;
}
.due-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
background: #eef3f0;
color: #5d6f66;
font-size: 11px;
font-weight: 620;
white-space: nowrap;
}
.due-badge svg {
width: 11px;
height: 11px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
}
.due-badge.soon {
background: #fdf4e3;
color: #b97a12;
}
.due-badge.overdue {
background: #fdeceb;
color: #c0392b;
}
.master-meta {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
color: #66756d;
font-size: 12px;
white-space: nowrap;
}
.master-menu-wrap {
position: relative;
}
.master-menu-button {
width: 30px;
height: 30px;
border: 1px solid #dfe7e2;
border-radius: 8px;
background: #fff;
color: #66756d;
font-size: 15px;
line-height: 1;
cursor: pointer;
}
.master-menu-button:hover,
.master-task-card.menu-open .master-menu-button {
border-color: #a9cebf;
background: #eaf5f0;
color: var(--green-deep);
}
.master-menu-backdrop {
position: fixed;
inset: 0;
z-index: 60;
}
.master-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 61;
min-width: 190px;
padding: 6px;
border: 1px solid #dfe7e2;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 32px rgb(23 43 35 / 0.14);
}
.master-menu-group + .master-menu-group {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid #eef2ef;
}
.master-menu-group b {
display: block;
padding: 5px 10px 3px;
color: #8aa396;
font-size: 11px;
}
.master-menu-group button {
display: block;
width: 100%;
padding: 7px 10px;
border: 0;
border-radius: 6px;
background: none;
text-align: left;
font-size: 13px;
color: #2c3e36;
cursor: pointer;
}
.master-menu-group button:hover:not(:disabled) {
background: #f2f7f4;
}
.master-menu-group button:disabled {
color: #b7c6be;
cursor: not-allowed;
}
.master-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.master-panels.single {
grid-template-columns: 1fr;
}
.master-panel {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px 8px;
border: 1px solid transparent;
border-radius: 10px;
}
.master-panel.publish {
background: #f4faf7;
border-color: #dcefe6;
}
.master-panel.recovery {
background: #fdf9ef;
border-color: #f3e9d2;
}
.master-panel.ghost {
background: transparent;
border: 1px dashed #cfddd6;
}
.panel-head {
display: flex;
align-items: center;
gap: 8px;
}
.panel-head b {
font-size: 12px;
font-weight: 750;
}
.master-panel.publish .panel-head b {
color: var(--green-deep);
}
.master-panel.recovery .panel-head b,
.master-panel.ghost .panel-head b {
color: #9a6b13;
}
.master-panel.ghost .panel-head b {
color: #9aa8a1;
}
.panel-count {
margin-left: auto;
color: #7b8b84;
font-size: 11px;
white-space: nowrap;
}
.panel-body {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.panel-metric {
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
min-width: 0;
max-width: 220px;
}
.panel-progress {
width: 100%;
height: 4px;
border-radius: 999px;
background: rgb(0 0 0 / 0.06);
overflow: hidden;
}
.panel-progress .publish {
display: block;
height: 100%;
border-radius: inherit;
background: var(--green);
}
.panel-progress .recovery {
display: block;
height: 100%;
border-radius: inherit;
background: #d99a26;
}
.panel-metric b {
color: #22352d;
font-size: 16px;
font-weight: 750;
line-height: 1.15;
font-variant-numeric: tabular-nums;
}
.panel-metric b i {
color: #8aa396;
font-style: normal;
font-size: 11px;
font-weight: 600;
}
.panel-metric em {
color: #7b8b84;
font-style: normal;
font-size: 11px;
}
.panel-metric.muted b {
color: #9aa8a1;
font-weight: 600;
}
.panel-chat {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.panel-chat::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
}
.panel-chat.on {
background: #e8f1ec;
color: var(--green-deep);
}
.panel-chat.on::before {
background: var(--green);
}
.panel-chat.off {
background: #f1f4f2;
color: #84948c;
}
.panel-chat.off::before {
background: #b7c6be;
}
.panel-foot {
display: flex;
justify-content: flex-end;
}
.panel-detail {
padding: 4px 8px;
border: 0;
background: none;
color: var(--green);
font-size: 12px;
font-weight: 620;
cursor: pointer;
}
.panel-detail:hover {
text-decoration: underline;
}
@media (max-width: 720px) {
.master-panels {
grid-template-columns: 1fr;
}
}
.main-chat-summary {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 10px 12px;
border: 1px solid #e6ede9;
border-radius: 9px;
background: #f8fbf9;
color: #4d6357;
font-size: 12px; font-size: 12px;
} }
.task-row-main > div:first-child span, .main-chat-manage {
.task-date span {
color: #929b98;
font-size: 9px;
}
.task-row-main > small {
display: block;
margin-top: 4px;
color: #9aa39f;
font-size: 8px;
}
.task-row-main .progress {
margin-top: 9px;
}
.task-number,
.task-date {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px;
margin-top: 10px;
} }
.task-number strong { .main-chat-manage-actions {
font-size: 16px; display: flex;
gap: 8px;
flex-wrap: wrap;
} }
.task-number span, .task-pair-sub {
.task-date strong { display: block;
margin-top: 3px; margin-top: 2px;
color: #7e8a86; color: #66756d;
font-size: 9px; font-size: 11px;
}
.pair-tag {
margin-left: 6px;
}
.recovery-inline-fields {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid #e2e8e4;
border-radius: 9px;
background: #fbfcfb;
}
.recovery-toggle {
display: flex;
align-items: flex-start;
gap: 10px;
cursor: pointer;
}
.recovery-toggle input[type="checkbox"] {
width: 16px;
height: 16px;
margin-top: 2px;
flex-shrink: 0;
accent-color: var(--green);
}
.recovery-toggle b {
font-size: 13px;
}
.recovery-toggle small {
display: block;
margin-top: 2px;
color: #66756d;
font-size: 11px;
} }
.round-arrow { .round-arrow {
@@ -1988,12 +2396,6 @@ a {
background: white; background: white;
} }
.task-share-actions {
display: flex;
align-items: center;
gap: 7px;
}
.share-link-button { .share-link-button {
height: 31px; height: 31px;
padding: 0 10px; padding: 0 10px;
@@ -5456,12 +5858,24 @@ label small {
border-radius: 6px; border-radius: 6px;
background: #f7faf8; background: #f7faf8;
color: #5b6b62; color: #5b6b62;
font-size: 11px;
line-height: 1;
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition:
border-color 0.15s ease,
color 0.15s ease,
background 0.15s ease;
}
.project-tree-toggle svg {
width: 12px;
height: 12px;
transition: transform 0.18s ease;
}
.project-tree-toggle.open svg {
transform: rotate(90deg);
} }
.project-tree-toggle:hover:not(:disabled) { .project-tree-toggle:hover:not(:disabled) {
@@ -5497,6 +5911,14 @@ label small {
min-width: 0; min-width: 0;
} }
.project-card-title.clickable {
cursor: pointer;
}
.project-card-title.clickable:hover h3 {
color: var(--green-deep);
}
.project-card-title h3 { .project-card-title h3 {
font-size: 15px; font-size: 15px;
} }
@@ -5687,26 +6109,54 @@ label small {
.project-push-target { .project-push-target {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 8px; gap: 10px;
padding: 10px 12px; padding: 11px 14px;
border: 1px solid #e2e8e4; border: 1px solid #e2e8e4;
border-radius: 9px; border-radius: 9px;
background: #fbfcfb; background: #fbfcfb;
} }
.project-push-target input[type="checkbox"] {
width: 16px;
height: 16px;
margin-top: 2px;
flex-shrink: 0;
accent-color: var(--green);
}
.project-push-target b { .project-push-target b {
display: block; display: block;
font-size: 12px; font-size: 13px;
} }
.project-push-target small { .project-push-target small {
display: block; display: block;
margin-top: 2px; margin-top: 3px;
color: #8a968f; color: #66756d;
font-size: 10px; font-size: 12px;
line-height: 1.6; line-height: 1.6;
} }
.chat-status {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
white-space: nowrap;
}
.chat-status.on {
color: #1f7a52;
background: rgb(31 143 105 / 0.08);
border: 1px solid rgb(31 143 105 / 0.3);
}
.chat-status.off {
color: rgb(20 40 34 / 0.5);
background: rgb(20 40 34 / 0.04);
border: 1px solid rgb(20 40 34 / 0.14);
}
.koc-picker { .koc-picker {
margin: 10px 0; margin: 10px 0;
} }

View File

@@ -73,6 +73,14 @@ export const tasks = mysqlTable(
relatedTaskId: varchar("related_task_id", { length: 64 }) relatedTaskId: varchar("related_task_id", { length: 64 })
.notNull() .notNull()
.default(""), .default(""),
recoveryEnabled: int("recovery_enabled").notNull().default(1),
recoveryDueAt: varchar("recovery_due_at", { length: 32 })
.notNull()
.default(""),
recoveryInstructions: text("recovery_instructions").notNull().default(""),
recoveryExampleImages: text("recovery_example_images")
.notNull()
.default("[]"),
createdAt: timestamp("created_at"), createdAt: timestamp("created_at"),
}, },
(table) => [uniqueIndex("tasks_share_token_idx").on(table.shareToken)], (table) => [uniqueIndex("tasks_share_token_idx").on(table.shareToken)],

View File

@@ -702,6 +702,19 @@ footer {
linear-gradient(130deg, #142721, #235b47); linear-gradient(130deg, #142721, #235b47);
} }
.hero-progress {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.recovery-progress {
color: #96b8ab;
font-size: 9px;
font-weight: 600;
}
.claimed-hero h1 { .claimed-hero h1 {
margin: 0; margin: 0;
font-size: clamp(25px, 4vw, 38px); font-size: clamp(25px, 4vw, 38px);

View File

@@ -71,9 +71,17 @@ type TaskPayload = {
availableQuantity?: number; availableQuantity?: number;
dueAt: string; dueAt: string;
status: string; status: string;
type: "content_publish" | "screenshot_collect"; type: "content_publish";
platform: "小红书" | "抖音"; platform: "小红书" | "抖音";
contentFormat: "image_text" | "video"; contentFormat: "image_text" | "video";
recoveryEnabled?: boolean;
recoveryDueAt?: string;
recoveryInstructions?: string;
recoveryExampleImages?: Array<{
index: number;
width: number | null;
height: number | null;
}>;
}; };
claim: null | { claim: null | {
id: string; id: string;
@@ -250,10 +258,7 @@ function isMobilePortalViewport() {
); );
} }
function statusLabel(item: Assignment, taskType = "content_publish") { function statusLabel(item: Assignment) {
if (taskType === "screenshot_collect") {
return item.result_submitted_at ? "已提交" : "待提交";
}
return item.publish_url ? "已回填" : "待发布"; return item.publish_url ? "已回填" : "待发布";
} }
@@ -714,11 +719,7 @@ export default function Home() {
setDelegationToken(""); setDelegationToken("");
updateUrl(result.claimToken); updateUrl(result.claimToken);
await loadTask(taskToken, result.claimToken); await loadTask(taskToken, result.claimToken);
setToast( setToast(`已领取 ${result.claimedCount} 篇,逐篇发布即可`);
payload?.task.type === "screenshot_collect"
? `已领取 ${result.claimedCount} 份,完成后逐份上传截图`
: `已领取 ${result.claimedCount} 篇,逐篇发布即可`,
);
} catch (reason) { } catch (reason) {
setToast(reason instanceof Error ? reason.message : "领取失败"); setToast(reason instanceof Error ? reason.message : "领取失败");
} finally { } finally {
@@ -795,6 +796,21 @@ export default function Home() {
return `${adminOrigin}/api/partner-image?${params}`; return `${adminOrigin}/api/partner-image?${params}`;
}; };
const recoveryExampleImageUrl = (imageIndex: number) => {
if (!selected) return "";
const params = new URLSearchParams({
distribution: selected.id,
index: String(imageIndex),
kind: "task-example",
});
if (delegationToken) params.set("share", delegationToken);
else {
params.set("task", taskToken);
params.set("claim", claimToken);
}
return `${adminOrigin}/api/partner-image?${params}`;
};
const noteVideoUrl = ( const noteVideoUrl = (
item: Assignment, item: Assignment,
videoIndex: number, videoIndex: number,
@@ -1275,9 +1291,13 @@ export default function Home() {
} }
}; };
const submitScreenshotTask = async (event: FormEvent) => { const submitRecoveryScreenshots = async (event?: FormEvent) => {
event.preventDefault(); event?.preventDefault();
if (!selected) return; if (!selected) return;
if (!selected.publish_url) {
setToast("请先回填这篇笔记的发布链接,再提交回收截图");
return;
}
try { try {
setWorking(true); setWorking(true);
const existingScreenshots = resultScreenshotKeys( const existingScreenshots = resultScreenshotKeys(
@@ -1291,7 +1311,7 @@ export default function Home() {
); );
} }
} else if (existingScreenshots.length === 0) { } else if (existingScreenshots.length === 0) {
throw new Error("请上传任务要求的截图"); throw new Error("请上传发布后的笔记截图");
} }
const response = await fetch(partnerApi("/api/partner"), { const response = await fetch(partnerApi("/api/partner"), {
method: "POST", method: "POST",
@@ -1310,8 +1330,8 @@ export default function Home() {
setTaskResultScreenshots([]); setTaskResultScreenshots([]);
setToast( setToast(
taskResultScreenshots.length > 1 taskResultScreenshots.length > 1
? `${taskResultScreenshots.length}张任务截图已提交` ? `${taskResultScreenshots.length}张回收截图已提交`
: "任务截图已提交", : "回收截图已提交",
); );
} catch (reason) { } catch (reason) {
setToast(reason instanceof Error ? reason.message : "截图提交失败"); setToast(reason instanceof Error ? reason.message : "截图提交失败");
@@ -1407,149 +1427,6 @@ export default function Home() {
} }
if (selected && activeBatch) { if (selected && activeBatch) {
if (payload.task.type === "screenshot_collect") {
return (
<main className="portal-shell detail-shell">
<header className="portal-topbar">
<div className="portal-brand"><span>K</span><strong>KOC LOOP</strong></div>
<div className="claimant-chip">
{payload.claim ? payload.claim.claimantName : "合作社转派 · 无需登录"}
</div>
</header>
<button className="back-link" onClick={closeNote}>← 返回我的任务</button>
<div className="detail-grid screenshot-task-detail">
<article className="note-document">
<div className="note-document-meta">
<span>截图任务 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
<span>只需上传结果截图</span>
</div>
<div className="document-label-row">
<p className="document-label">小红书搜索关键词</p>
<PlatformBadge platform={payload.task.platform} compact />
</div>
<div className="note-title-row">
<h1>{selected.title}</h1>
<button
type="button"
className="inline-copy"
onClick={() => void copyText(selected.title, "搜索关键词已复制")}
>
复制关键词
</button>
</div>
<div className="document-rule" />
<p className="document-label">任务说明</p>
<div className="note-body">{selected.body}</div>
{selected.images.length > 0 && (
<section className="note-images">
<div className="note-images-heading">
<div>
<p className="document-label">示例截图</p>
<span>请参考示例,确保小红书 AI 总结完整可见</span>
</div>
</div>
<div className="note-image-grid">
{selected.images.map((image, index) => (
<div className="note-image-card" key={image.index}>
<a className="note-image-preview" href={noteImageUrl(selected, image.index)} target="_blank" rel="noreferrer">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={noteImageUrl(selected, image.index)} alt={`示例截图 ${index + 1}`} />
</a>
</div>
))}
</div>
</section>
)}
</article>
<form className="submit-card" onSubmit={submitScreenshotTask}>
<div className="submit-heading">
<div>
<p className="micro">截图回收</p>
<h2>上传小红书 AI 总结截图</h2>
</div>
<span className={selected.result_submitted_at ? "done" : "pending"}>
{statusLabel(selected, payload.task.type)}
</span>
</div>
<div className="mapping-note">
<span>1 : N</span>
<p>这些截图只会绑定到当前任务,不需要填写发布链接或其他数据。</p>
</div>
<label>
<span className="task-result-label-row">
<span>任务结果截图</span>
<small>
已选择 {resultScreenshotKeys(selected.result_screenshot_key).length + taskResultScreenshots.length}/{MAX_TASK_RESULT_SCREENSHOTS} 张
</small>
</span>
<div className="task-result-gallery">
{resultScreenshotKeys(selected.result_screenshot_key).map((_, index) => {
const imageUrl = taskResultImageUrl(selected, index + 1);
return (
<button
type="button"
className="task-result-thumb uploaded"
onClick={() =>
setPreviewImage({
src: imageUrl,
alt: `已上传任务截图 ${index + 1}`,
})
}
key={`uploaded-${index}`}
>
<img src={imageUrl} alt={`已上传任务截图 ${index + 1}`} />
<span>已上传</span>
</button>
)})}
{taskResultPreviews.map(({ file, url }, index) => (
<div className="task-result-thumb pending-upload" key={`${file.name}-${file.lastModified}`}>
<img src={url} alt={`待上传任务截图 ${index + 1}`} />
<button
type="button"
aria-label={`移除 ${file.name}`}
onClick={() => removeTaskResultScreenshot(file)}
>
×
</button>
<span>待提交</span>
</div>
))}
{resultScreenshotKeys(selected.result_screenshot_key).length + taskResultScreenshots.length < MAX_TASK_RESULT_SCREENSHOTS && (
<div className="task-result-add">
<input
type="file"
accept="image/*"
multiple
onChange={chooseTaskResultScreenshots}
/>
<b>+</b>
<strong>
{taskResultScreenshots.length || selected.result_screenshot_key
? "继续添加"
: "选择截图"}
</strong>
</div>
)}
</div>
<small className="task-result-hint">支持一次选择或继续追加,最多9张,每张不超过8MB;点击图片可在当前页面查看大图。</small>
</label>
<button className="submit-primary" disabled={working}>
{working
? "正在提交…"
: selected.result_submitted_at
? "更新任务截图"
: "确认提交任务截图"}
</button>
{selected.result_submitted_at && (
<p className="submitted-at">上次提交:{formatDate(selected.result_submitted_at, true)}</p>
)}
</form>
</div>
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
{toast && <div className={toastClassName(toast)}>{toast}</div>}
</main>
);
}
const creatorDueAt = creatorScreenshotDueAt(selected.publish_time); const creatorDueAt = creatorScreenshotDueAt(selected.publish_time);
const creatorDataReady = const creatorDataReady =
Boolean(selected.creator_screenshot_key) && Boolean(selected.creator_screenshot_key) &&
@@ -1908,6 +1785,153 @@ export default function Home() {
</button> </button>
</section> </section>
)} )}
{payload.task.recoveryEnabled && (
<section className="creator-recovery task-recovery">
<div className="creator-recovery-heading">
<div>
<p className="micro">截图回收</p>
<h3>上传发布后的笔记截图</h3>
</div>
<span className={selected.result_submitted_at ? "done" : ""}>
{selected.result_submitted_at ? "已提交" : "待提交"}
</span>
</div>
<p className="creator-recovery-tip">
{payload.task.recoveryInstructions || "请提交发布后的笔记截图"}
{payload.task.recoveryDueAt
? ` 截止:${formatDate(payload.task.recoveryDueAt)}。`
: ""}
</p>
{(payload.task.recoveryExampleImages?.length ?? 0) > 0 && (
<div className="task-result-gallery recovery-example">
{payload.task.recoveryExampleImages?.map((image) => (
<button
type="button"
className="task-result-thumb uploaded"
key={`recovery-example-${image.index}`}
onClick={() =>
setPreviewImage({
src: recoveryExampleImageUrl(image.index),
alt: "回收示例截图",
})
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={recoveryExampleImageUrl(image.index)}
alt="回收示例截图"
/>
<span>示例</span>
</button>
))}
</div>
)}
{selected.publish_url ? (
<>
<div className="evidence-field compact-field">
<span className="task-result-label-row">
<span>回收截图</span>
<small>
已选择{" "}
{resultScreenshotKeys(selected.result_screenshot_key)
.length +
taskResultScreenshots.length}
/{MAX_TASK_RESULT_SCREENSHOTS} 张
</small>
</span>
<div className="task-result-gallery">
{resultScreenshotKeys(selected.result_screenshot_key).map(
(_, index) => {
const imageUrl = taskResultImageUrl(selected, index + 1);
return (
<button
type="button"
className="task-result-thumb uploaded"
onClick={() =>
setPreviewImage({
src: imageUrl,
alt: `已上传回收截图 ${index + 1}`,
})
}
key={`uploaded-${index}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt={`已上传回收截图 ${index + 1}`}
/>
<span>已上传</span>
</button>
);
},
)}
{taskResultPreviews.map(({ file, url }, index) => (
<div
className="task-result-thumb pending-upload"
key={`${file.name}-${file.lastModified}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt={`待上传回收截图 ${index + 1}`} />
<button
type="button"
aria-label={`移除 ${file.name}`}
onClick={() => removeTaskResultScreenshot(file)}
>
×
</button>
<span>待提交</span>
</div>
))}
{resultScreenshotKeys(selected.result_screenshot_key)
.length +
taskResultScreenshots.length <
MAX_TASK_RESULT_SCREENSHOTS && (
<div className="task-result-add">
<input
type="file"
accept="image/*"
multiple
onChange={chooseTaskResultScreenshots}
/>
<b>+</b>
<strong>
{taskResultScreenshots.length ||
selected.result_screenshot_key
? "继续添加"
: "选择截图"}
</strong>
</div>
)}
</div>
<small className="task-result-hint">
支持一次选择或继续追加,最多9张,每张不超过8MB;点击图片可在当前页面查看大图。
</small>
</div>
<button
type="button"
className="creator-submit"
disabled={working}
onClick={() => void submitRecoveryScreenshots()}
>
{working
? "正在提交…"
: selected.result_submitted_at
? "更新回收截图"
: "确认提交回收截图"}
</button>
{selected.result_submitted_at && (
<p className="submitted-at">
上次提交:{formatDate(selected.result_submitted_at, true)}
</p>
)}
</>
) : (
<p className="creator-recovery-tip">
回填发布链接后,这里会开放回收截图上传。
</p>
)}
</section>
)}
</form> </form>
</div> </div>
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} /> <ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
@@ -1917,15 +1941,17 @@ export default function Home() {
} }
if (activeBatch) { if (activeBatch) {
const isScreenshotTask = payload.task.type === "screenshot_collect"; const recoveryEnabled = payload.task.recoveryEnabled === true;
const completed = activeBatch.assignments.filter((item) => const completed = activeBatch.assignments.filter(
isScreenshotTask ? item.result_submitted_at : item.publish_url, (item) => item.publish_url,
).length; ).length;
const recoverySubmitted = recoveryEnabled
? activeBatch.assignments.filter((item) => item.result_submitted_at).length
: 0;
const isClaimOwner = Boolean(payload.claim); const isClaimOwner = Boolean(payload.claim);
const shareableCount = payload.claim?.assignments.filter( const shareableCount =
(item) => payload.claim?.assignments.filter(
!(isScreenshotTask ? item.result_submitted_at : item.publish_url) && (item) => !item.publish_url && !item.delegation_bundle_id,
!item.delegation_bundle_id,
).length ?? 0; ).length ?? 0;
return ( return (
<main className="portal-shell"> <main className="portal-shell">
@@ -1943,24 +1969,30 @@ export default function Home() {
<section className="claimed-hero"> <section className="claimed-hero">
<div> <div>
<p className="micro"> <p className="micro">
{isClaimOwner {isClaimOwner ? "我的领取任务" : "合作社转派发布包"}
? "我的领取任务"
: isScreenshotTask
? "合作社转派截图任务包"
: "合作社转派发布包"}
</p> </p>
<h1>{payload.task.name}</h1> <h1>{payload.task.name}</h1>
<p className="platform-meta-line"> <p className="platform-meta-line">
<span>{payload.task.brand}</span> <span>{payload.task.brand}</span>
<PlatformBadge platform={payload.task.platform} compact /> <PlatformBadge platform={payload.task.platform} compact />
<span>{formatDate(payload.task.dueAt)}前{isScreenshotTask ? "提交" : "发布"}</span> <span>{formatDate(payload.task.dueAt)}前发布</span>
{recoveryEnabled && payload.task.recoveryDueAt
? ` · 截图回收 ${formatDate(payload.task.recoveryDueAt)}截止`
: ""}
{payload.delegation ? ` · ${payload.delegation.label}` : ""} {payload.delegation ? ` · ${payload.delegation.label}` : ""}
</p> </p>
</div> </div>
<div className="hero-progress">
<div className="completion-ring"> <div className="completion-ring">
<strong>{completed}/{activeBatch.assignments.length}</strong> <strong>{completed}/{activeBatch.assignments.length}</strong>
<span>已回填</span> <span>已回填</span>
</div> </div>
{recoveryEnabled && (
<small className="recovery-progress">
截图已收 {recoverySubmitted}/{activeBatch.assignments.length}
</small>
)}
</div>
</section> </section>
{isClaimOwner && payload.claim?.joinQrCode ? ( {isClaimOwner && payload.claim?.joinQrCode ? (
<section className="join-qr-card"> <section className="join-qr-card">
@@ -1977,23 +2009,16 @@ export default function Home() {
<div className="section-heading"> <div className="section-heading">
<div> <div>
<h2> <h2>
{isClaimOwner {isClaimOwner ? "我领取的笔记" : "需要发布的笔记"}
? isScreenshotTask ? "我领取的截图任务" : "我领取的笔记"
: isScreenshotTask ? "需要完成的截图任务" : "需要发布的笔记"}
</h2> </h2>
<p> <p>
{isClaimOwner {isClaimOwner
? isScreenshotTask ? "可逐篇回填,也可导出Excel填写后批量上传;还可以选择笔记转派给底层KOC"
? "打开一份查看关键词和要求,完成后单独上传截图;也可以转派给底层KOC"
: "可逐篇回填,也可导出Excel填写后批量上传;还可以选择笔记转派给底层KOC"
: isScreenshotTask
? "打开任务查看搜索关键词和要求,完成后逐份上传截图"
: "打开一篇查看完整内容,发布后逐篇回填"} : "打开一篇查看完整内容,发布后逐篇回填"}
</p> </p>
</div> </div>
<div className="section-actions"> <div className="section-actions">
<span>{activeBatch.assignments.length} {isScreenshotTask ? "份" : "篇"}</span> <span>{activeBatch.assignments.length} 篇</span>
{!isScreenshotTask && (
<> <>
<button <button
type="button" type="button"
@@ -2017,7 +2042,6 @@ export default function Home() {
onChange={(event) => void importBatchWorkbook(event)} onChange={(event) => void importBatchWorkbook(event)}
/> />
</> </>
)}
{isClaimOwner && ( {isClaimOwner && (
<button <button
type="button" type="button"
@@ -2029,7 +2053,7 @@ export default function Home() {
setShareLabel(""); setShareLabel("");
}} }}
> >
{shareComposerOpen ? "取消选择" : isScreenshotTask ? "选择任务并分享" : "选择笔记并分享"} {shareComposerOpen ? "取消选择" : "选择笔记并分享"}
</button> </button>
)} )}
</div> </div>
@@ -2040,9 +2064,7 @@ export default function Home() {
<p className="micro">合作社转派</p> <p className="micro">合作社转派</p>
<h3>生成免登录分享链接</h3> <h3>生成免登录分享链接</h3>
<p> <p>
{isScreenshotTask 勾选待发布笔记,底层KOC打开链接即可查看内容并逐篇回填。
? "勾选待提交任务,底层KOC打开链接即可查看要求并上传截图。"
: "勾选待发布笔记,底层KOC打开链接即可查看内容并逐篇回填。"}
</p> </p>
</div> </div>
<label> <label>
@@ -2056,7 +2078,7 @@ export default function Home() {
/> />
</label> </label>
<div className="share-composer-action"> <div className="share-composer-action">
<strong>已选 {selectedForShare.length} {isScreenshotTask ? "份" : "篇"}</strong> <strong>已选 {selectedForShare.length} 篇</strong>
<button <button
className="share-primary" className="share-primary"
disabled={ disabled={
@@ -2076,8 +2098,7 @@ export default function Home() {
<div className="note-list"> <div className="note-list">
{activeBatch.assignments.map((item, index) => { {activeBatch.assignments.map((item, index) => {
const shareDisabled = Boolean( const shareDisabled = Boolean(
(isScreenshotTask ? item.result_submitted_at : item.publish_url) || item.publish_url || item.delegation_bundle_id,
item.delegation_bundle_id,
); );
return ( return (
<article <article
@@ -2088,8 +2109,8 @@ export default function Home() {
<label <label
className={`share-checkbox ${shareDisabled ? "disabled" : ""}`} className={`share-checkbox ${shareDisabled ? "disabled" : ""}`}
title={ title={
(isScreenshotTask ? item.result_submitted_at : item.publish_url) item.publish_url
? isScreenshotTask ? "已提交任务不能转派" : "已发布笔记不能转派" ? "已发布笔记不能转派"
: item.delegation_bundle_id : item.delegation_bundle_id
? `已转派给 ${item.delegation_label}` ? `已转派给 ${item.delegation_label}`
: "选择这篇笔记" : "选择这篇笔记"
@@ -2105,7 +2126,7 @@ export default function Home() {
<span>✓</span> <span>✓</span>
</label> </label>
)} )}
<span className={`note-index ${(isScreenshotTask ? item.result_submitted_at : item.publish_url) ? "done" : ""}`}> <span className={`note-index ${item.publish_url ? "done" : ""}`}>
{String(index + 1).padStart(2, "0")} {String(index + 1).padStart(2, "0")}
</span> </span>
{item.images[0] ? ( {item.images[0] ? (
@@ -2129,36 +2150,35 @@ export default function Home() {
<div> <div>
<h3>{item.title}</h3> <h3>{item.title}</h3>
<p> <p>
{isScreenshotTask
? `搜索关键词 · ${statusLabel(item, payload.task.type)}`
: <>
<span>{item.videos.length > 0 ? `${item.videos.length} 个视频` : `${item.images.length} 张配图`}</span> <span>{item.videos.length > 0 ? `${item.videos.length} 个视频` : `${item.images.length} 张配图`}</span>
<PlatformBadge platform={payload.task.platform} compact /> <PlatformBadge platform={payload.task.platform} compact />
<span>飞书源行 {item.source_row ?? "—"} · {statusLabel(item, payload.task.type)}</span> <span>飞书源行 {item.source_row ?? "—"} · {statusLabel(item)}</span>
</>} {recoveryEnabled && item.publish_url
{!isScreenshotTask && item.creator_screenshot_key ? " · D7截图已交" : ""} ? item.result_submitted_at
? " · 截图已交"
: " · 截图待交"
: ""}
{item.creator_screenshot_key ? " · D7截图已交" : ""}
{item.delegation_label ? ` · 已转派给 ${item.delegation_label}` : ""} {item.delegation_label ? ` · 已转派给 ${item.delegation_label}` : ""}
</p> </p>
</div> </div>
<span <span
className={`note-status ${ className={`note-status ${
(isScreenshotTask ? item.result_submitted_at : item.publish_url) item.publish_url
? "done" ? "done"
: item.delegation_bundle_id : item.delegation_bundle_id
? "delegated" ? "delegated"
: "" : ""
}`} }`}
> >
{(isScreenshotTask ? item.result_submitted_at : item.publish_url) {item.publish_url
? isScreenshotTask ? "已提交" : "已回填" ? "已回填"
: item.delegation_bundle_id : item.delegation_bundle_id
? "已转派" ? "已转派"
: isScreenshotTask ? "待提交" : "待发布"} : "待发布"}
</span> </span>
<button onClick={() => openNote(item)}> <button onClick={() => openNote(item)}>
{(isScreenshotTask ? item.result_submitted_at : item.publish_url) {item.publish_url ? "查看 / 修改" : "查看并回填"} →
? "查看 / 修改"
: isScreenshotTask ? "查看并上传" : "查看并回填"} →
</button> </button>
</article> </article>
); );
@@ -2169,7 +2189,7 @@ export default function Home() {
<div className="delegation-heading"> <div className="delegation-heading">
<div> <div>
<p className="micro">分享记录</p> <p className="micro">分享记录</p>
<h3>{isScreenshotTask ? "转派截图任务包" : "转派发布包"}</h3> <h3>转派发布包</h3>
</div> </div>
<span>{payload.claim.delegations.length} 个</span> <span>{payload.claim.delegations.length} 个</span>
</div> </div>
@@ -2182,9 +2202,9 @@ export default function Home() {
<div> <div>
<strong>{bundle.label}</strong> <strong>{bundle.label}</strong>
<span> <span>
{bundle.quantity} {isScreenshotTask ? "份" : "篇"} · {isScreenshotTask ? "已提交" : "已发布"}{" "} {bundle.quantity} 篇 · 已发布{" "}
{Number(isScreenshotTask ? bundle.result_completed_count ?? bundle.completed_count : bundle.completed_count ?? 0)}/{bundle.quantity} {Number(bundle.completed_count ?? 0)}/{bundle.quantity}
{!isScreenshotTask && ` · D7完成 ${Number(bundle.creator_completed_count ?? 0)}`} {` · D7完成 ${Number(bundle.creator_completed_count ?? 0)}`}
</span> </span>
</div> </div>
<small>{formatDate(bundle.created_at, true)}</small> <small>{formatDate(bundle.created_at, true)}</small>
@@ -2204,7 +2224,7 @@ export default function Home() {
> >
复制链接 复制链接
</button> </button>
{Number(isScreenshotTask ? bundle.result_completed_count ?? bundle.completed_count : bundle.completed_count ?? 0) === 0 && ( {Number(bundle.completed_count ?? 0) === 0 && (
<button <button
type="button" type="button"
className="danger" className="danger"
@@ -2225,8 +2245,6 @@ export default function Home() {
<div className="safe-tip"> <div className="safe-tip">
{isClaimOwner {isClaimOwner
? "以后再次打开原任务链接,填写相同微信号或手机号即可找回全部领取批次和分享记录。" ? "以后再次打开原任务链接,填写相同微信号或手机号即可找回全部领取批次和分享记录。"
: isScreenshotTask
? "请保存当前分享链接;完成任务后通过此链接上传截图即可。"
: "请保存当前分享链接;发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"} : "请保存当前分享链接;发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"}
</div> </div>
{toast && <div className={toastClassName(toast)}>{toast}</div>} {toast && <div className={toastClassName(toast)}>{toast}</div>}
@@ -2248,13 +2266,15 @@ export default function Home() {
</div> </div>
<h1>{payload.task.name}</h1> <h1>{payload.task.name}</h1>
<p> <p>
{payload.task.type === "screenshot_collect" 领取后可查看完整笔记正文,每篇笔记独立回填发布信息。
? "领取后查看搜索关键词和任务要求,完成后逐份上传截图。" {payload.task.recoveryEnabled ? "发布后还需提交回收截图。" : ""}
: "领取后可查看完整笔记正文,每篇笔记独立回填发布信息。"}
</p> </p>
<div className="hero-meta"> <div className="hero-meta">
<span>截止 {formatDate(payload.task.dueAt)}</span> <span>截止 {formatDate(payload.task.dueAt)}</span>
<span>剩余 {payload.task.availableQuantity} {payload.task.type === "screenshot_collect" ? "份" : "篇"}</span> {payload.task.recoveryEnabled && payload.task.recoveryDueAt ? (
<span>截图回收截止 {formatDate(payload.task.recoveryDueAt)}</span>
) : null}
<span>剩余 {payload.task.availableQuantity} 篇</span>
</div> </div>
</div> </div>
<aside className="claim-card"> <aside className="claim-card">
@@ -2267,7 +2287,7 @@ export default function Home() {
setRecoveredClaims([]); setRecoveredClaims([]);
}} }}
> >
{payload.task.type === "screenshot_collect" ? "领取新任务" : "领取新笔记"} 领取新笔记
</button> </button>
<button <button
type="button" type="button"
@@ -2293,7 +2313,7 @@ export default function Home() {
<form onSubmit={claimNotes}> <form onSubmit={claimNotes}>
<div> <div>
<p className="micro">01 · 确认领取</p> <p className="micro">01 · 确认领取</p>
<h2>你要领取多少{payload.task.type === "screenshot_collect" ? "份" : "篇"}?</h2> <h2>你要领取多少篇?</h2>
</div> </div>
{(!wecomIdentity || manualEntry) && ( {(!wecomIdentity || manualEntry) && (
<label> <label>
@@ -2312,7 +2332,7 @@ export default function Home() {
)} )}
<div className="quantity-control"> <div className="quantity-control">
<button type="button" onClick={() => setQuantity(Math.max(1, quantity - 1))}>−</button> <button type="button" onClick={() => setQuantity(Math.max(1, quantity - 1))}>−</button>
<div><strong>{quantity}</strong><span>{payload.task.type === "screenshot_collect" ? "份" : "篇"}</span></div> <div><strong>{quantity}</strong><span>篇</span></div>
<button <button
type="button" type="button"
onClick={() => onClick={() =>
@@ -2330,15 +2350,15 @@ export default function Home() {
? "当前任务已领完" ? "当前任务已领完"
: working : working
? "领取中…" ? "领取中…"
: `确认领取 ${quantity} ${payload.task.type === "screenshot_collect" ? "份" : "篇"}`} : `确认领取 ${quantity} 篇`}
</button> </button>
<small>领取后系统会生成你的专属{payload.task.type === "screenshot_collect" ? "任务清单" : "笔记清单"}</small> <small>领取后系统会生成你的专属笔记清单</small>
</form> </form>
) : ( ) : (
<form onSubmit={recoverClaims}> <form onSubmit={recoverClaims}>
<div> <div>
<p className="micro">找回历史记录</p> <p className="micro">找回历史记录</p>
<h2>继续之前领取的{payload.task.type === "screenshot_collect" ? "任务" : "笔记"}</h2> <h2>继续之前领取的笔记</h2>
<p className="recover-intro"> <p className="recover-intro">
{wecomIdentity && !manualEntry {wecomIdentity && !manualEntry
? "已用企微身份查找,同一任务多次领取会分批展示。" ? "已用企微身份查找,同一任务多次领取会分批展示。"
@@ -2386,9 +2406,9 @@ export default function Home() {
<section className="steps"> <section className="steps">
<div><span>1</span><strong>选择数量</strong><p>一次可领多篇</p></div> <div><span>1</span><strong>选择数量</strong><p>一次可领多篇</p></div>
<i /> <i />
<div><span>2</span><strong>{payload.task.type === "screenshot_collect" ? "逐份查看" : "逐篇查看"}</strong><p>{payload.task.type === "screenshot_collect" ? "关键词与要求完整展示" : "标题正文完整展示"}</p></div> <div><span>2</span><strong>逐篇查看</strong><p>标题正文完整展示</p></div>
<i /> <i />
<div><span>3</span><strong>{payload.task.type === "screenshot_collect" ? "上传截图" : "单篇回填"}</strong><p>{payload.task.type === "screenshot_collect" ? "每份任务与截图一一对应" : "昵称、链接、截图一一对应"}</p></div> <div><span>3</span><strong>单篇回填</strong><p>昵称、链接、截图一一对应</p></div>
</section> </section>
<footer>由 KOC LOOP 提供任务与数据安全保障</footer> <footer>由 KOC LOOP 提供任务与数据安全保障</footer>
{toast && <div className={toastClassName(toast)}>{toast}</div>} {toast && <div className={toastClassName(toast)}>{toast}</div>}

View File

@@ -4,7 +4,6 @@ import { refreshTaskStatus } from "./task-service";
type ReleasableDistribution = { type ReleasableDistribution = {
id: string; id: string;
task_id: string; task_id: string;
task_type: string;
content_id: string; content_id: string;
content_title: string; content_title: string;
partner_id: string; partner_id: string;
@@ -28,7 +27,6 @@ export class DistributionReleaseError extends Error {
export function distributionReleaseBlockReason(input: { export function distributionReleaseBlockReason(input: {
publishUrl?: string | null; publishUrl?: string | null;
resultSubmittedAt?: string | null; resultSubmittedAt?: string | null;
taskType?: string | null;
}) { }) {
if (input.publishUrl) return "已回填发布链接的笔记不能释放"; if (input.publishUrl) return "已回填发布链接的笔记不能释放";
if (input.resultSubmittedAt) return "已提交结果截图的任务不能释放"; if (input.resultSubmittedAt) return "已提交结果截图的任务不能释放";
@@ -49,7 +47,6 @@ export async function releaseUnfinishedDistribution(
`SELECT `SELECT
d.id, d.id,
d.task_id, d.task_id,
t.task_type,
d.content_id, d.content_id,
c.title AS content_title, c.title AS content_title,
d.partner_id, d.partner_id,
@@ -74,7 +71,6 @@ export async function releaseUnfinishedDistribution(
const blocked = distributionReleaseBlockReason({ const blocked = distributionReleaseBlockReason({
publishUrl: distribution.publish_url, publishUrl: distribution.publish_url,
resultSubmittedAt: distribution.result_submitted_at, resultSubmittedAt: distribution.result_submitted_at,
taskType: distribution.task_type,
}); });
if (blocked) throw new DistributionReleaseError(blocked, 409); if (blocked) throw new DistributionReleaseError(blocked, 409);

View File

@@ -871,21 +871,12 @@ export async function getDashboardData(ownerUserid?: string) {
(SELECT c.body FROM contents c WHERE c.task_id = t.id ORDER BY c.source_row LIMIT 1) AS instructions, (SELECT c.body FROM contents c WHERE c.task_id = t.id ORDER BY c.source_row LIMIT 1) AS instructions,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id) AS dist_total, (SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id) AS dist_total,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.status = 'complete') AS dist_complete, (SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.status = 'complete') AS dist_complete,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.result_submitted_at IS NOT NULL) AS dist_submitted, (SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.result_submitted_at IS NOT NULL) AS dist_submitted
(SELECT rt.name FROM tasks rt WHERE rt.id = t.related_task_id) AS related_task_name,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.related_task_id) AS related_dist_total,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.related_task_id AND d.status = 'complete') AS related_dist_complete,
(SELECT rt2.id FROM tasks rt2 WHERE rt2.related_task_id = t.id LIMIT 1) AS reverse_related_task_id,
(SELECT rt2.name FROM tasks rt2 WHERE rt2.related_task_id = t.id LIMIT 1) AS reverse_related_task_name,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = (SELECT rt2.id FROM tasks rt2 WHERE rt2.related_task_id = t.id LIMIT 1)) AS reverse_dist_total,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = (SELECT rt2.id FROM tasks rt2 WHERE rt2.related_task_id = t.id LIMIT 1) AND d.result_submitted_at IS NOT NULL) AS reverse_dist_submitted
FROM tasks t FROM tasks t
LEFT JOIN projects pj ON pj.id = t.project_id`; LEFT JOIN projects pj ON pj.id = t.project_id`;
const distributionsBase = `SELECT const distributionsBase = `SELECT
d.*, d.*,
c.title AS content_title, c.title AS content_title,
c.related_content_id AS related_content_id,
rc.title AS related_content_title,
p.name AS partner_name, p.name AS partner_name,
cl.claimant_name AS claimant_name, cl.claimant_name AS claimant_name,
a.nickname AS account_nickname, a.nickname AS account_nickname,
@@ -898,7 +889,6 @@ export async function getDashboardData(ownerUserid?: string) {
t.due_at AS due_at t.due_at AS due_at
FROM distributions d FROM distributions d
JOIN contents c ON c.id = d.content_id JOIN contents c ON c.id = d.content_id
LEFT JOIN contents rc ON rc.id = c.related_content_id
JOIN partners p ON p.id = d.partner_id JOIN partners p ON p.id = d.partner_id
LEFT JOIN claims cl ON cl.id = d.claim_id LEFT JOIN claims cl ON cl.id = d.claim_id
JOIN tasks t ON t.id = d.task_id JOIN tasks t ON t.id = d.task_id

View File

@@ -58,12 +58,7 @@ export function buildProjectAppMessage(
const portal = text(portalUrl).replace(/\/$/, ""); const portal = text(portalUrl).replace(/\/$/, "");
const claimUrl = const claimUrl =
portal && task.share_token ? `${portal}/?task=${task.share_token}` : ""; portal && task.share_token ? `${portal}/?task=${task.share_token}` : "";
const formatLabel = const formatLabel = task.content_format === "video" ? "视频" : "图文";
task.task_type === "screenshot_collect"
? "截图回收"
: task.content_format === "video"
? "视频"
: "图文";
return [ return [
`【项目任务】${task.name}`, `【项目任务】${task.name}`,
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`, `品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,

View File

@@ -17,7 +17,6 @@ export type ProjectRow = {
main_group_member_ids: string; main_group_member_ids: string;
owner_userid: string; owner_userid: string;
main_chat_id: string; main_chat_id: string;
parent_id: string;
status: string; status: string;
note: string; note: string;
created_at: string; created_at: string;
@@ -33,7 +32,6 @@ export type ProjectSummary = {
mainGroupMemberIds: string[]; mainGroupMemberIds: string[];
ownerUserid: string; ownerUserid: string;
mainChatId: string; mainChatId: string;
parentProjectId: string;
taskCount: number; taskCount: number;
}; };
@@ -51,16 +49,9 @@ export type ProjectTaskRow = {
share_token: string | null; share_token: string | null;
owner_userid: string; owner_userid: string;
task_chat_id: string; task_chat_id: string;
project_name?: string; recovery_enabled: number;
}; recovery_due_at: string;
recovery_submitted: number;
export type ProjectSubproject = {
id: string;
name: string;
brand: string;
ownerUserid: string;
mainChatId: string;
taskCount: number;
}; };
export type ProjectMainChat = { export type ProjectMainChat = {
@@ -72,8 +63,6 @@ export type ProjectDetail = {
project: ProjectSummary; project: ProjectSummary;
tasks: ProjectTaskRow[]; tasks: ProjectTaskRow[];
mainChat: ProjectMainChat; mainChat: ProjectMainChat;
subprojects: ProjectSubproject[];
pushTasks: ProjectTaskRow[];
}; };
export type MainChatEnsureReason = 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( export async function listProjects(
db: DatabaseClient, db: DatabaseClient,
ownerUserid?: string, ownerUserid?: string,
@@ -303,7 +239,7 @@ export async function listProjects(
const result = await (ownerFilter const result = await (ownerFilter
? db ? db
.prepare( .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 (SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
FROM projects p FROM projects p
WHERE p.owner_userid = ? WHERE p.owner_userid = ?
@@ -312,7 +248,7 @@ export async function listProjects(
.bind(ownerFilter) .bind(ownerFilter)
: db : db
.prepare( .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 (SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
FROM projects p FROM projects p
ORDER BY p.created_at DESC`, ORDER BY p.created_at DESC`,
@@ -328,7 +264,6 @@ export async function listProjects(
mainGroupMemberIds: parseJsonArray(row.main_group_member_ids), mainGroupMemberIds: parseJsonArray(row.main_group_member_ids),
ownerUserid: row.owner_userid ?? "", ownerUserid: row.owner_userid ?? "",
mainChatId: row.main_chat_id ?? "", mainChatId: row.main_chat_id ?? "",
parentProjectId: row.parent_id ?? "",
taskCount: Number(row.task_count), taskCount: Number(row.task_count),
})); }));
} }
@@ -340,7 +275,7 @@ export async function getProjectDetail(
const id = text(projectId); const id = text(projectId);
const project = await db const project = await db
.prepare( .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 = ?`, FROM projects WHERE id = ?`,
) )
.bind(id) .bind(id)
@@ -350,38 +285,13 @@ export async function getProjectDetail(
const tasks = await db const tasks = await db
.prepare( .prepare(
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status, `SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
task_type, platform, content_format, share_token, owner_userid, task_chat_id task_type, platform, content_format, share_token, owner_userid, task_chat_id,
FROM tasks WHERE project_id = ? recovery_enabled, recovery_due_at,
ORDER BY created_at DESC`, (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 = ?
.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})
ORDER BY t.created_at DESC`, ORDER BY t.created_at DESC`,
) )
.bind(...pushIds) .bind(id)
.all<ProjectTaskRow>(); .all<ProjectTaskRow>();
return { return {
@@ -395,7 +305,6 @@ export async function getProjectDetail(
mainGroupMemberIds: parseJsonArray(project.main_group_member_ids), mainGroupMemberIds: parseJsonArray(project.main_group_member_ids),
ownerUserid: project.owner_userid ?? "", ownerUserid: project.owner_userid ?? "",
mainChatId: project.main_chat_id ?? "", mainChatId: project.main_chat_id ?? "",
parentProjectId: project.parent_id ?? "",
taskCount: tasks.results.length, taskCount: tasks.results.length,
}, },
tasks: tasks.results, tasks: tasks.results,
@@ -406,15 +315,6 @@ export async function getProjectDetail(
project.owner_userid, 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; mainGroupMemberIds?: unknown;
ownerUserid?: unknown; ownerUserid?: unknown;
note?: unknown; note?: unknown;
parentProjectId?: unknown;
}; };
export async function saveProject( export async function saveProject(
@@ -439,33 +338,19 @@ export async function saveProject(
const brand = text(input.brand); const brand = text(input.brand);
const note = text(input.note ?? ""); const note = text(input.note ?? "");
const ownerUserid = text(input.ownerUserid).slice(0, 64); const ownerUserid = text(input.ownerUserid).slice(0, 64);
const parentProjectId = text(input.parentProjectId);
const memberIds = effectiveProjectMembers(input.mainGroupMemberIds, ownerUserid); const memberIds = effectiveProjectMembers(input.mainGroupMemberIds, ownerUserid);
if (!name) throw new ProjectError("请填写项目名称"); 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(); const now = new Date().toISOString();
let projectId = id; let projectId = id;
if (id) { if (id) {
const result = await db const result = await db
.prepare( .prepare(
`UPDATE projects `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 = ?`, WHERE id = ?`,
) )
.bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, parentProjectId, id) .bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, id)
.run(); .run();
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404); if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
} else { } else {
@@ -473,8 +358,8 @@ export async function saveProject(
await db await db
.prepare( .prepare(
`INSERT INTO projects `INSERT INTO projects
(id, name, brand, main_group_member_ids, owner_userid, parent_id, status, note, created_at) (id, name, brand, main_group_member_ids, owner_userid, status, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)`, VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`,
) )
.bind( .bind(
projectId, projectId,
@@ -482,7 +367,6 @@ export async function saveProject(
brand, brand,
JSON.stringify(memberIds), JSON.stringify(memberIds),
ownerUserid, ownerUserid,
parentProjectId,
note, note,
now, now,
) )
@@ -505,13 +389,6 @@ export async function deleteProject(
): Promise<void> { ): Promise<void> {
const id = text(projectId); const id = text(projectId);
if (!id) throw new ProjectError("缺少项目 ID"); 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 const result = await db
.prepare("DELETE FROM projects WHERE id = ?") .prepare("DELETE FROM projects WHERE id = ?")
.bind(id) .bind(id)

View File

@@ -15,17 +15,13 @@ export type CreateDistributionTaskInput = {
contentFormat?: "image_text" | "video"; contentFormat?: "image_text" | "video";
projectId?: string; projectId?: string;
ownerUserid?: string; ownerUserid?: string;
recoveryEnabled?: boolean;
recoveryDueAt?: string;
recoveryInstructions?: string;
recoveryExampleImageKey?: string;
}; };
export type CreateScreenshotTaskInput = { ;
name: string;
relatedTaskId: string;
dueAt: string;
instructions: string;
exampleImageKey?: string;
projectId?: string;
ownerUserid?: string;
};
export type DistributionTaskCreation = { export type DistributionTaskCreation = {
created: boolean; created: boolean;
@@ -42,16 +38,7 @@ export type DistributionTaskCreation = {
contentFormat: "image_text" | "video"; contentFormat: "image_text" | "video";
}; };
export type ScreenshotTaskCreation = {
created: true;
taskId: string;
shareToken: string;
name: string;
brand: string;
dueAt: string;
quantity: number;
relatedTaskId: string;
};
type TaskRow = { type TaskRow = {
id: string; id: string;
@@ -133,9 +120,25 @@ async function findExistingTask(
.first<TaskRow>(); .first<TaskRow>();
} }
type RecoveryConfig = {
enabled: boolean;
dueAt: string;
instructions: string;
exampleImages: string;
};
function recoveryExampleImagesJson(exampleImageKey: string) {
return exampleImageKey.startsWith("task-assets/")
? JSON.stringify([
{ index: 1, key: exampleImageKey, width: null, height: null },
])
: "[]";
}
async function insertTaskFromSource( async function insertTaskFromSource(
source: FeishuSource, source: FeishuSource,
input: CreateDistributionTaskInput, input: CreateDistributionTaskInput,
recovery: RecoveryConfig,
) { ) {
const db = getRawDb(); const db = getRawDb();
const taskId = uid("task"); const taskId = uid("task");
@@ -146,8 +149,10 @@ async function insertTaskFromSource(
(id, name, brand, quantity, claimed_quantity, due_at, status, (id, name, brand, quantity, claimed_quantity, due_at, status,
platform, content_format, project_id, owner_userid, platform, content_format, project_id, owner_userid,
source_url, source_sheet_id, source_sheet_name, source_synced_at, source_url, source_sheet_id, source_sheet_name, source_synced_at,
share_token, join_qr_code) share_token, join_qr_code,
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?, ?, ?, '')`, recovery_enabled, recovery_due_at, recovery_instructions, recovery_example_images)
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?, ?, ?, '',
?, ?, ?, ?)`,
) )
.bind( .bind(
taskId, taskId,
@@ -164,6 +169,10 @@ async function insertTaskFromSource(
source.sheetName, source.sheetName,
source.syncedAt, source.syncedAt,
shareToken, shareToken,
recovery.enabled ? 1 : 0,
recovery.dueAt,
recovery.instructions,
recovery.exampleImages,
) )
.run(); .run();
@@ -228,6 +237,10 @@ export async function createDistributionTask(
contentFormat: rawInput.contentFormat === "video" ? "video" : "image_text", contentFormat: rawInput.contentFormat === "video" ? "video" : "image_text",
projectId: normalizedValue(rawInput.projectId ?? ""), projectId: normalizedValue(rawInput.projectId ?? ""),
ownerUserid: normalizedValue(rawInput.ownerUserid ?? "").slice(0, 64), ownerUserid: normalizedValue(rawInput.ownerUserid ?? "").slice(0, 64),
recoveryEnabled: rawInput.recoveryEnabled !== false,
recoveryDueAt: normalizedValue(rawInput.recoveryDueAt ?? ""),
recoveryInstructions: normalizedValue(rawInput.recoveryInstructions ?? ""),
recoveryExampleImageKey: normalizedValue(rawInput.recoveryExampleImageKey ?? ""),
}; };
if (!input.feishuUrl || !input.name || !input.brand) { if (!input.feishuUrl || !input.name || !input.brand) {
throw new Error("请补全飞书链接、任务名称和品牌/项目"); throw new Error("请补全飞书链接、任务名称和品牌/项目");
@@ -260,7 +273,17 @@ export async function createDistributionTask(
) { ) {
throw new Error("视频任务中存在未识别到视频的内容行,请检查飞书“视频”列"); throw new Error("视频任务中存在未识别到视频的内容行,请检查飞书“视频”列");
} }
const inserted = await insertTaskFromSource(source, input); const recoveryDueAt = normalizedValue(rawInput.recoveryDueAt ?? "");
const inserted = await insertTaskFromSource(source, input, {
enabled: rawInput.recoveryEnabled !== false,
dueAt: recoveryDueAt ? normalizedDueDate(recoveryDueAt) : input.dueAt,
instructions:
normalizedValue(rawInput.recoveryInstructions ?? "") ||
"请提交发布后的笔记截图",
exampleImages: recoveryExampleImagesJson(
normalizedValue(rawInput.recoveryExampleImageKey ?? ""),
),
});
return { return {
created: true, created: true,
taskId: inserted.taskId, taskId: inserted.taskId,
@@ -277,141 +300,6 @@ export async function createDistributionTask(
}; };
} }
type ScreenshotSourceTaskRow = {
id: string;
name: string;
brand: string;
quantity: number;
project_id: string;
owner_userid: string;
task_type: "content_publish" | "screenshot_collect";
};
type ScreenshotSourceContentRow = {
id: string;
title: string;
source_row: number | null;
};
export async function createScreenshotTask(
rawInput: CreateScreenshotTaskInput,
): Promise<ScreenshotTaskCreation> {
await ensureSchema();
const input = {
name: normalizedValue(rawInput.name),
relatedTaskId: normalizedValue(rawInput.relatedTaskId),
dueAt: normalizedDueDate(rawInput.dueAt),
instructions: normalizedValue(rawInput.instructions),
exampleImageKey: normalizedValue(rawInput.exampleImageKey ?? ""),
projectId: normalizedValue(rawInput.projectId ?? ""),
ownerUserid: normalizedValue(rawInput.ownerUserid ?? "").slice(0, 64),
};
if (!input.name || !input.relatedTaskId || !input.instructions) {
throw new Error("请补全任务名称、关联分发任务和任务说明");
}
if (input.exampleImageKey && !input.exampleImageKey.startsWith("task-assets/")) {
throw new Error("示例截图无效,请重新上传");
}
const db = getRawDb();
const source = await db
.prepare(
`SELECT id, name, brand, quantity, project_id, owner_userid, task_type
FROM tasks WHERE id = ?`,
)
.bind(input.relatedTaskId)
.first<ScreenshotSourceTaskRow>();
if (!source) throw new Error("关联的分发任务不存在");
if (source.task_type !== "content_publish") {
throw new Error("请选择内容发布类型的分发任务");
}
const existing = await db
.prepare("SELECT id FROM tasks WHERE related_task_id = ?")
.bind(input.relatedTaskId)
.first<{ id: string }>();
if (existing) throw new Error("该分发任务已有关联回收任务");
const sourceContents = await db
.prepare(
"SELECT id, title, source_row FROM contents WHERE task_id = ? ORDER BY source_row, id",
)
.bind(input.relatedTaskId)
.all<ScreenshotSourceContentRow>();
if (sourceContents.results.length === 0) {
throw new Error("分发任务还没有可回收的笔记");
}
const taskId = uid("task");
const shareToken = crypto.randomUUID().replaceAll("-", "");
const imageAssets = input.exampleImageKey
? JSON.stringify([
{ index: 1, key: input.exampleImageKey, width: null, height: null },
])
: "[]";
const projectId = input.projectId || source.project_id;
const ownerUserid = input.ownerUserid || source.owner_userid;
await db
.prepare(
`INSERT INTO tasks
(id, name, brand, quantity, claimed_quantity, due_at, status,
task_type, project_id, owner_userid, source_url, source_sheet_id, source_sheet_name,
share_token, collection_days, join_qr_code, related_task_id)
VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', ?, ?, '', '', '', ?, '[]', '', ?)`,
)
.bind(
taskId,
input.name,
source.brand,
sourceContents.results.length,
input.dueAt,
projectId,
ownerUserid,
shareToken,
input.relatedTaskId,
)
.run();
try {
const statements = sourceContents.results.map((row) =>
db
.prepare(
`INSERT INTO contents
(id, task_id, title, body, image_assets, video_assets, status, source, source_row, related_content_id)
VALUES (?, ?, ?, ?, ?, '[]', 'available', '截图回收任务', ?, ?)`,
)
.bind(
uid("content"),
taskId,
row.title,
input.instructions,
imageAssets,
row.source_row,
row.id,
),
);
for (let index = 0; index < statements.length; index += 100) {
await db.batch(statements.slice(index, index + 100));
}
} catch (error) {
await db.batch([
db.prepare("DELETE FROM contents WHERE task_id = ?").bind(taskId),
db.prepare("DELETE FROM tasks WHERE id = ?").bind(taskId),
]);
throw error;
}
return {
created: true,
taskId,
shareToken,
name: input.name,
brand: source.brand,
dueAt: input.dueAt,
quantity: sourceContents.results.length,
relatedTaskId: input.relatedTaskId,
};
}
export type UpdateTaskInput = { export type UpdateTaskInput = {
taskId: string; taskId: string;
name: string; name: string;
@@ -420,10 +308,10 @@ export type UpdateTaskInput = {
projectId?: string; projectId?: string;
platform?: "小红书" | "抖音"; platform?: "小红书" | "抖音";
contentFormat?: "image_text" | "video"; contentFormat?: "image_text" | "video";
quantity?: number; recoveryEnabled?: boolean;
keyword?: string; recoveryDueAt?: string;
instructions?: string; recoveryInstructions?: string;
exampleImageKey?: string; recoveryExampleImageKey?: string;
}; };
export type TaskUpdateResult = { export type TaskUpdateResult = {
@@ -434,79 +322,16 @@ export type TaskUpdateResult = {
type UpdatableTaskRow = { type UpdatableTaskRow = {
id: string; id: string;
task_type: "content_publish" | "screenshot_collect";
quantity: number; quantity: number;
claimed_quantity: number; claimed_quantity: number;
platform: "小红书" | "抖音"; platform: "小红书" | "抖音";
content_format: "image_text" | "video"; content_format: "image_text" | "video";
related_task_id: string; recovery_enabled: number;
recovery_due_at: string;
recovery_instructions: string | null;
recovery_example_images: string;
}; };
// 截图任务的 keyword/instructions 存在 contents 每行(title/body),数量与行数一一对应
async function syncScreenshotContents(
db: DatabaseClient,
taskId: string,
params: {
oldQuantity: number;
quantity: number;
keyword: string;
instructions: string;
exampleImageKey: string;
},
) {
const delta = params.quantity - params.oldQuantity;
if (delta > 0) {
const sample = await db
.prepare(
"SELECT image_assets FROM contents WHERE task_id = ? ORDER BY source_row LIMIT 1",
)
.bind(taskId)
.first<{ image_assets: string }>();
const imageAssets = sample?.image_assets ?? "[]";
const statements = Array.from({ length: delta }, (_, index) =>
db
.prepare(
`INSERT INTO contents
(id, task_id, title, body, image_assets, video_assets, status, source, source_row)
VALUES (?, ?, ?, ?, ?, '[]', 'available', '截图回收任务', ?)`,
)
.bind(
uid("content"),
taskId,
params.keyword,
params.instructions,
imageAssets,
params.oldQuantity + index + 1,
),
);
for (let index = 0; index < statements.length; index += 100) {
await db.batch(statements.slice(index, index + 100));
}
} else if (delta < 0) {
// 只删未领取的尾部行,已领取(claimed)的保留
await db
.prepare(
`DELETE FROM contents WHERE task_id = ? AND status = 'available'
ORDER BY source_row DESC LIMIT ?`,
)
.bind(taskId, -delta)
.run();
}
await db
.prepare("UPDATE contents SET title = ?, body = ? WHERE task_id = ?")
.bind(params.keyword, params.instructions, taskId)
.run();
if (params.exampleImageKey.startsWith("task-assets/")) {
const imageAssets = JSON.stringify([
{ index: 1, key: params.exampleImageKey, width: null, height: null },
]);
await db
.prepare("UPDATE contents SET image_assets = ? WHERE task_id = ?")
.bind(imageAssets, taskId)
.run();
}
}
export async function updateTask( export async function updateTask(
rawInput: UpdateTaskInput, rawInput: UpdateTaskInput,
db: DatabaseClient = getRawDb(), db: DatabaseClient = getRawDb(),
@@ -524,10 +349,7 @@ export async function updateTask(
contentFormat: rawInput.contentFormat === "video" || rawInput.contentFormat === "image_text" contentFormat: rawInput.contentFormat === "video" || rawInput.contentFormat === "image_text"
? rawInput.contentFormat ? rawInput.contentFormat
: undefined, : undefined,
quantity: Math.floor(Number(rawInput.quantity ?? 0)), recoveryExampleImageKey: normalizedValue(rawInput.recoveryExampleImageKey ?? ""),
keyword: normalizedValue(rawInput.keyword ?? ""),
instructions: normalizedValue(rawInput.instructions ?? ""),
exampleImageKey: normalizedValue(rawInput.exampleImageKey ?? ""),
}; };
if (!input.name || !input.brand) { if (!input.name || !input.brand) {
throw new Error("请补全任务名称和品牌"); throw new Error("请补全任务名称和品牌");
@@ -535,81 +357,41 @@ export async function updateTask(
const row = await db const row = await db
.prepare( .prepare(
"SELECT id, task_type, quantity, claimed_quantity, platform, content_format, related_task_id FROM tasks WHERE id = ?", `SELECT id, quantity, claimed_quantity, platform, content_format,
recovery_enabled, recovery_due_at, recovery_instructions, recovery_example_images
FROM tasks WHERE id = ?`,
) )
.bind(taskId) .bind(taskId)
.first<UpdatableTaskRow>(); .first<UpdatableTaskRow>();
if (!row) throw new Error("任务不存在"); if (!row) throw new Error("任务不存在");
const quantity = Number(row.quantity);
const claimed = Number(row.claimed_quantity); const claimed = Number(row.claimed_quantity);
let quantity = Number(row.quantity); const recoveryEnabled =
if (row.task_type === "screenshot_collect") { rawInput.recoveryEnabled === undefined
const related = Boolean(row.related_task_id); ? row.recovery_enabled
if (!input.instructions) { : rawInput.recoveryEnabled
throw new Error(related ? "请补全任务说明" : "请补全搜索关键词和任务说明"); ? 1
} : 0;
if (related) { const recoveryDueAtInput = normalizedValue(rawInput.recoveryDueAt ?? "");
if ( const recoveryDueAt = recoveryDueAtInput
rawInput.quantity !== undefined && ? normalizedDueDate(recoveryDueAtInput)
rawInput.quantity !== null && : row.recovery_due_at || input.dueAt;
input.quantity !== quantity const recoveryInstructions =
) { normalizedValue(rawInput.recoveryInstructions ?? "") ||
throw new Error("关联任务的份数由分发任务决定,不可修改"); row.recovery_instructions ||
} "";
if (input.keyword) { const recoveryExampleImages = input.recoveryExampleImageKey
const firstTitle = await db ? recoveryExampleImagesJson(input.recoveryExampleImageKey)
.prepare( : row.recovery_example_images;
"SELECT title FROM contents WHERE task_id = ? ORDER BY source_row, id LIMIT 1",
)
.bind(taskId)
.first<{ title: string }>();
if (firstTitle && input.keyword !== firstTitle.title) {
throw new Error("关联任务的搜索关键词由分发任务笔记决定,不可修改");
}
}
await db
.prepare("UPDATE contents SET body = ? WHERE task_id = ?")
.bind(input.instructions, taskId)
.run();
if (input.exampleImageKey.startsWith("task-assets/")) {
const imageAssets = JSON.stringify([
{ index: 1, key: input.exampleImageKey, width: null, height: null },
]);
await db
.prepare("UPDATE contents SET image_assets = ? WHERE task_id = ?")
.bind(imageAssets, taskId)
.run();
}
} else {
if (!input.keyword) {
throw new Error("请补全搜索关键词和任务说明");
}
if (
!Number.isInteger(input.quantity) ||
input.quantity < 1 ||
input.quantity > 500
) {
throw new Error("任务数量需为 1—500 份");
}
if (input.quantity < claimed) {
throw new Error(`任务数量不能低于已领取的 ${claimed} 份`);
}
quantity = input.quantity;
await syncScreenshotContents(db, taskId, {
oldQuantity: Number(row.quantity),
quantity,
keyword: input.keyword,
instructions: input.instructions,
exampleImageKey: input.exampleImageKey,
});
}
}
await db await db
.prepare( .prepare(
`UPDATE tasks `UPDATE tasks
SET name = ?, brand = ?, due_at = ?, project_id = ?, SET name = ?, brand = ?, due_at = ?, project_id = ?,
platform = ?, content_format = ?, quantity = ? platform = ?, content_format = ?, quantity = ?,
recovery_enabled = ?, recovery_due_at = ?, recovery_instructions = ?,
recovery_example_images = ?
WHERE id = ?`, WHERE id = ?`,
) )
.bind( .bind(
@@ -620,6 +402,10 @@ export async function updateTask(
input.platform ?? row.platform, input.platform ?? row.platform,
input.contentFormat ?? row.content_format, input.contentFormat ?? row.content_format,
quantity, quantity,
recoveryEnabled,
recoveryDueAt,
recoveryInstructions,
recoveryExampleImages,
taskId, taskId,
) )
.run(); .run();
@@ -628,6 +414,52 @@ export async function updateTask(
return { taskId, quantity, claimedQuantity: claimed }; return { taskId, quantity, claimedQuantity: claimed };
} }
export type EnableRecoveryInput = {
taskId: string;
recoveryDueAt?: string;
recoveryInstructions?: string;
recoveryExampleImageKey?: string;
};
export async function enableTaskRecovery(
rawInput: EnableRecoveryInput,
db: DatabaseClient = getRawDb(),
): Promise<{ taskId: string; recoveryDueAt: string }> {
const taskId = normalizedValue(rawInput.taskId);
if (!taskId) throw new Error("缺少 taskId");
const row = await db
.prepare(
"SELECT id, due_at, recovery_example_images FROM tasks WHERE id = ?",
)
.bind(taskId)
.first<{
id: string;
due_at: string;
recovery_example_images: string;
}>();
if (!row) throw new Error("任务不存在");
const dueAtInput = normalizedValue(rawInput.recoveryDueAt ?? "");
const dueAt = dueAtInput ? normalizedDueDate(dueAtInput) : row.due_at;
const exampleImageKey = normalizedValue(rawInput.recoveryExampleImageKey ?? "");
await db
.prepare(
`UPDATE tasks SET recovery_enabled = 1, recovery_due_at = ?,
recovery_instructions = ?, recovery_example_images = ?
WHERE id = ?`,
)
.bind(
dueAt,
normalizedValue(rawInput.recoveryInstructions ?? "") ||
"请提交发布后的笔记截图",
exampleImageKey
? recoveryExampleImagesJson(exampleImageKey)
: row.recovery_example_images,
taskId,
)
.run();
return { taskId, recoveryDueAt: dueAt };
}
export function buildClaimUrl(portalOrigin: string, shareToken: string) { export function buildClaimUrl(portalOrigin: string, shareToken: string) {
const origin = normalizedValue(portalOrigin).replace(/\/$/, ""); const origin = normalizedValue(portalOrigin).replace(/\/$/, "");
if (!origin) throw new Error("KOC 领取站点地址尚未配置"); if (!origin) throw new Error("KOC 领取站点地址尚未配置");
@@ -638,32 +470,29 @@ export function buildClaimUrl(portalOrigin: string, shareToken: string) {
type TaskStatusRow = { type TaskStatusRow = {
id: string; id: string;
task_type: "content_publish" | "screenshot_collect";
status: string; status: string;
}; };
type DistributionProgressRow = { type DistributionProgressRow = {
total: number; total: number;
complete_count: number; complete_count: number;
submitted_count: number;
}; };
// 纯重算任务状态:条件满足置 completed,不满足回 active(释放重开等场景); // 纯重算任务状态:条件满足置 completed,不满足回 active(释放重开等场景);
// importing 是导入中间态,不参与推导 // importing 是导入中间态,不参与推导。完成判定取发布阶段口径(complete_count)
export async function refreshTaskStatus( export async function refreshTaskStatus(
taskId: string, taskId: string,
db: DatabaseClient = getRawDb(), db: DatabaseClient = getRawDb(),
): Promise<string> { ): Promise<string> {
const row = await db const row = await db
.prepare("SELECT id, task_type, status FROM tasks WHERE id = ?") .prepare("SELECT id, status FROM tasks WHERE id = ?")
.bind(taskId) .bind(taskId)
.first<TaskStatusRow>(); .first<TaskStatusRow>();
if (!row || row.status === "importing") return row?.status ?? ""; if (!row || row.status === "importing") return row?.status ?? "";
const progress = await db const progress = await db
.prepare( .prepare(
`SELECT COUNT(*) AS total, `SELECT COUNT(*) AS total,
COALESCE(SUM(CASE WHEN status = 'complete' THEN 1 ELSE 0 END), 0) AS complete_count, COALESCE(SUM(CASE WHEN status = 'complete' THEN 1 ELSE 0 END), 0) AS complete_count
COALESCE(SUM(CASE WHEN result_submitted_at IS NOT NULL THEN 1 ELSE 0 END), 0) AS submitted_count
FROM distributions WHERE task_id = ?`, FROM distributions WHERE task_id = ?`,
) )
.bind(taskId) .bind(taskId)
@@ -676,13 +505,10 @@ export async function refreshTaskStatus(
.first<{ available_count: number }>(); .first<{ available_count: number }>();
const total = Number(progress?.total ?? 0); const total = Number(progress?.total ?? 0);
const availableCount = Number(available?.available_count ?? 0); const availableCount = Number(available?.available_count ?? 0);
let completed = false; const completed =
if (total > 0 && availableCount === 0) { total > 0 &&
completed = availableCount === 0 &&
row.task_type === "screenshot_collect" Number(progress?.complete_count ?? 0) === total;
? Number(progress?.submitted_count ?? 0) === total
: Number(progress?.complete_count ?? 0) === total;
}
const next = completed ? "completed" : "active"; const next = completed ? "completed" : "active";
if (next !== row.status) { if (next !== row.status) {
await db await db

View File

@@ -80,12 +80,7 @@ export function buildTaskGroupPushMessage(
const portal = portalUrl.trim().replace(/\/$/, ""); const portal = portalUrl.trim().replace(/\/$/, "");
const shareToken = bindingValue(task.share_token); const shareToken = bindingValue(task.share_token);
const claimUrl = portal && shareToken ? `${portal}/?task=${shareToken}` : ""; const claimUrl = portal && shareToken ? `${portal}/?task=${shareToken}` : "";
const formatLabel = const formatLabel = task.content_format === "video" ? "视频" : "图文";
task.task_type === "screenshot_collect"
? "截图回收"
: task.content_format === "video"
? "视频"
: "图文";
const lines = [ const lines = [
`【新任务】${task.name}`, `【新任务】${task.name}`,
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`, `品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,

View File

@@ -27,17 +27,17 @@ type DueSoonRow = {
wecom_external_user_id: string | null; wecom_external_user_id: string | null;
task_id: string; task_id: string;
task_name: string; task_name: string;
task_type: string; pending_phase: "publish" | "recovery";
due_at: string; pending_due_at: string;
content_title: string; content_title: string;
}; };
function pendingActionLabel(taskType: string) { function pendingActionLabel(pendingPhase: string) {
return taskType === "screenshot_collect" ? "截图待提交" : "待发布"; return pendingPhase === "recovery" ? "截图待提交" : "待发布";
} }
function pendingSummary(rows: DueSoonRow[]) { function pendingSummary(rows: DueSoonRow[]) {
const shot = rows.filter((row) => row.task_type === "screenshot_collect").length; const shot = rows.filter((row) => row.pending_phase === "recovery").length;
const publish = rows.length - shot; const publish = rows.length - shot;
if (shot === 0) return `${rows.length} 条笔记待发布`; if (shot === 0) return `${rows.length} 条笔记待发布`;
if (publish === 0) return `${rows.length} 份截图待提交`; if (publish === 0) return `${rows.length} 份截图待提交`;
@@ -66,6 +66,7 @@ export async function runDueSoonWecomNotifications(
const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL); const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL);
const taskFilter = String(taskId ?? "").trim(); const taskFilter = String(taskId ?? "").trim();
// 双阶段催办:发布阶段(未回填链接)看 due_at;回收阶段(已发布未收截图)看 recovery_due_at
const dueSoonBase = `SELECT const dueSoonBase = `SELECT
d.id AS distribution_id, d.id AS distribution_id,
d.partner_id, d.partner_id,
@@ -73,27 +74,29 @@ export async function runDueSoonWecomNotifications(
p.wecom_external_user_id, p.wecom_external_user_id,
t.id AS task_id, t.id AS task_id,
t.name AS task_name, t.name AS task_name,
t.task_type, CASE WHEN d.publish_url IS NULL OR d.publish_url = '' THEN 'publish' ELSE 'recovery' END AS pending_phase,
t.due_at, CASE WHEN d.publish_url IS NULL OR d.publish_url = '' THEN t.due_at ELSE t.recovery_due_at END AS pending_due_at,
c.title AS content_title c.title AS content_title
FROM distributions d FROM distributions d
JOIN tasks t ON t.id = d.task_id JOIN tasks t ON t.id = d.task_id
JOIN partners p ON p.id = d.partner_id JOIN partners p ON p.id = d.partner_id
JOIN contents c ON c.id = d.content_id JOIN contents c ON c.id = d.content_id
WHERE ( WHERE (
(t.task_type = 'screenshot_collect' AND d.result_submitted_at IS NULL) ((d.publish_url IS NULL OR d.publish_url = '')
OR (t.task_type != 'screenshot_collect' AND t.due_at IS NOT NULL AND t.due_at != '' AND t.due_at <= ?)
AND (d.publish_url IS NULL OR d.publish_url = '')) OR (t.recovery_enabled = 1
) AND d.publish_url IS NOT NULL AND d.publish_url != ''
AND t.due_at IS NOT NULL AND t.due_at != '' AND d.result_submitted_at IS NULL
AND t.due_at <= ?`; AND t.recovery_due_at IS NOT NULL AND t.recovery_due_at != ''
AND t.recovery_due_at <= ?)
)`;
const statement = db const statement = db
.prepare( .prepare(
taskFilter taskFilter
? `${dueSoonBase} AND t.id = ? ORDER BY t.due_at ASC, p.name ASC` ? `${dueSoonBase} AND t.id = ? ORDER BY pending_due_at ASC, p.name ASC`
: `${dueSoonBase} ORDER BY t.due_at ASC, p.name ASC`, : `${dueSoonBase} ORDER BY pending_due_at ASC, p.name ASC`,
) )
.bind(...(taskFilter ? [cutoff, taskFilter] : [cutoff])); .bind(...(taskFilter ? [cutoff, cutoff, taskFilter] : [cutoff, cutoff]));
const result = await statement.all<DueSoonRow>(); const result = await statement.all<DueSoonRow>();
const grouped = new Map< const grouped = new Map<
@@ -129,7 +132,7 @@ export async function runDueSoonWecomNotifications(
? [entry.externalUserId] ? [entry.externalUserId]
: []; : [];
const lines = entry.rows.slice(0, 5).map((row) => { const lines = entry.rows.slice(0, 5).map((row) => {
return `· 《${truncate(row.task_name, 24)}》— ${truncate(row.content_title, 24)}(${pendingActionLabel(row.task_type)},截止 ${row.due_at})`; return `· 《${truncate(row.task_name, 24)}》— ${truncate(row.content_title, 24)}(${pendingActionLabel(row.pending_phase)},截止 ${row.pending_due_at})`;
}); });
const overflow = const overflow =
entry.rows.length > 5 ? `\n…还有 ${entry.rows.length - 5} 条` : ""; entry.rows.length > 5 ? `\n…还有 ${entry.rows.length - 5} 条` : "";
@@ -155,7 +158,7 @@ export async function runDueSoonWecomNotifications(
} else { } else {
dueSoonSent += 1; dueSoonSent += 1;
} }
const earliestDue = entry.rows[0]?.due_at ?? ""; const earliestDue = entry.rows[0]?.pending_due_at ?? "";
digestTasks.push( digestTasks.push(
`· ${entry.partnerName}(${entry.rows.length} 条,最近截止 ${earliestDue})`, `· ${entry.partnerName}(${entry.rows.length} 条,最近截止 ${earliestDue})`,
); );
@@ -168,7 +171,7 @@ export async function runDueSoonWecomNotifications(
for (const row of result.results) { for (const row of result.results) {
const entry = byTask.get(row.task_id) ?? { const entry = byTask.get(row.task_id) ?? {
taskName: row.task_name, taskName: row.task_name,
dueAt: row.due_at, dueAt: row.pending_due_at,
rows: [], rows: [],
}; };
entry.rows.push(row); entry.rows.push(row);
@@ -224,7 +227,8 @@ type OverdueRow = {
id: string; id: string;
task_id: string; task_id: string;
task_name: string; task_name: string;
due_at: string; pending_phase: "publish" | "recovery";
pending_due_at: string;
project_id: string; project_id: string;
partner_id: string; partner_id: string;
partner_name: string; partner_name: string;
@@ -241,24 +245,33 @@ export type OverdueEscalationSummary = {
async function queryOverdueUnpublished( async function queryOverdueUnpublished(
db: DatabaseClient, db: DatabaseClient,
cutoff: string, cutoff: string,
options: { phases: "publish" | "all" } = { phases: "all" },
): Promise<OverdueRow[]> { ): Promise<OverdueRow[]> {
const publishBranch = `(d.publish_url IS NULL OR d.publish_url = '')
AND t.due_at IS NOT NULL AND t.due_at != ''
AND t.due_at < ?`;
const recoveryBranch = `t.recovery_enabled = 1
AND d.publish_url IS NOT NULL AND d.publish_url != ''
AND d.result_submitted_at IS NULL
AND t.recovery_due_at IS NOT NULL AND t.recovery_due_at != ''
AND t.recovery_due_at < ?`;
const where =
options.phases === "publish"
? publishBranch
: `(${publishBranch}) OR (${recoveryBranch})`;
const result = await db const result = await db
.prepare( .prepare(
`SELECT d.id, t.id AS task_id, t.name AS task_name, t.due_at, `SELECT d.id, t.id AS task_id, t.name AS task_name,
CASE WHEN d.publish_url IS NULL OR d.publish_url = '' THEN 'publish' ELSE 'recovery' END AS pending_phase,
CASE WHEN d.publish_url IS NULL OR d.publish_url = '' THEN t.due_at ELSE t.recovery_due_at END AS pending_due_at,
t.project_id, p.id AS partner_id, p.name AS partner_name t.project_id, p.id AS partner_id, p.name AS partner_name
FROM distributions d FROM distributions d
JOIN tasks t ON t.id = d.task_id JOIN tasks t ON t.id = d.task_id
JOIN partners p ON p.id = d.partner_id JOIN partners p ON p.id = d.partner_id
WHERE ( WHERE ${where}
(t.task_type = 'screenshot_collect' AND d.result_submitted_at IS NULL) ORDER BY pending_due_at ASC, t.name ASC`,
OR (t.task_type != 'screenshot_collect'
AND (d.publish_url IS NULL OR d.publish_url = ''))
) )
AND t.due_at IS NOT NULL AND t.due_at != '' .bind(...(options.phases === "publish" ? [cutoff] : [cutoff, cutoff]))
AND t.due_at < ?
ORDER BY t.due_at ASC, t.name ASC`,
)
.bind(cutoff)
.all<OverdueRow>(); .all<OverdueRow>();
return result.results; return result.results;
} }
@@ -274,19 +287,28 @@ export async function runOverdueEscalation(
const byTask = new Map< const byTask = new Map<
string, string,
{ taskName: string; dueAt: string; projectId: string; count: number; partners: Set<string> } {
taskName: string;
dueAt: string;
phase: "publish" | "recovery";
projectId: string;
count: number;
partners: Set<string>;
}
>(); >();
for (const row of rows) { for (const row of rows) {
const entry = byTask.get(row.task_id) ?? { const key = `${row.task_id}:${row.pending_phase}`;
const entry = byTask.get(key) ?? {
taskName: row.task_name, taskName: row.task_name,
dueAt: row.due_at, dueAt: row.pending_due_at,
phase: row.pending_phase,
projectId: bindingValue(row.project_id), projectId: bindingValue(row.project_id),
count: 0, count: 0,
partners: new Set<string>(), partners: new Set<string>(),
}; };
entry.count += 1; entry.count += 1;
entry.partners.add(row.partner_name || row.partner_id); entry.partners.add(row.partner_name || row.partner_id);
byTask.set(row.task_id, entry); byTask.set(key, entry);
} }
const summary: OverdueEscalationSummary = { const summary: OverdueEscalationSummary = {
@@ -303,7 +325,8 @@ export async function runOverdueEscalation(
const lines = [...byTask.values()].map((entry) => { const lines = [...byTask.values()].map((entry) => {
const partners = [...entry.partners].slice(0, 5).join("、"); const partners = [...entry.partners].slice(0, 5).join("、");
const overflow = entry.partners.size > 5 ? ` 等 ${entry.partners.size} 位` : ""; const overflow = entry.partners.size > 5 ? ` 等 ${entry.partners.size} 位` : "";
return `· ${truncate(entry.taskName, 24)}(截止 ${entry.dueAt}):${entry.count} 条超期未发布 — ${partners}${overflow}`; const label = entry.phase === "recovery" ? "截图超期未提交" : "超期未发布";
return `· ${truncate(entry.taskName, 24)}(截止 ${entry.dueAt}):${entry.count} 条${label} — ${partners}${overflow}`;
}); });
try { try {
await sendWecomRobotMessage( await sendWecomRobotMessage(
@@ -321,11 +344,16 @@ export async function runOverdueEscalation(
const env = getRuntimeEnv(); const env = getRuntimeEnv();
const cardUrl = bindingValue(env.APP_ORIGIN) || bindingValue(env.KOC_PORTAL_URL); const cardUrl = bindingValue(env.APP_ORIGIN) || bindingValue(env.KOC_PORTAL_URL);
for (const [taskId, entry] of byTask) { for (const [key, entry] of byTask) {
const taskId = key.split(":")[0];
const overdueText =
entry.phase === "recovery"
? `${entry.count} 份截图超期未提交`
: `${entry.count} 条内容超期未发布`;
await sendTaskChatMessage( await sendTaskChatMessage(
db, db,
taskId, taskId,
`【超期催办】《${truncate(entry.taskName, 24)}》(截止 ${entry.dueAt})有 ${entry.count} 条内容超期未发布,请跟进催办或释放。`, `【超期催办】《${truncate(entry.taskName, 24)}》(截止 ${entry.dueAt})有 ${overdueText},请跟进催办或释放。`,
config, config,
fetchImpl, fetchImpl,
); );
@@ -343,7 +371,7 @@ export async function runOverdueEscalation(
touser: members, touser: members,
taskId: `task:${taskId}:due`, taskId: `task:${taskId}:due`,
title: `超期催办:${truncate(entry.taskName, 20)}`, title: `超期催办:${truncate(entry.taskName, 20)}`,
description: `${entry.count} 条内容超期未发布(截止 ${entry.dueAt}),请跟进催办或释放`, description: `${entry.phase === "recovery" ? `${entry.count} 份截图超期未提交` : `${entry.count} 条内容超期未发布`}(截止 ${entry.dueAt}),请跟进催办或释放`,
url: cardUrl, url: cardUrl,
buttons: [ buttons: [
{ key: "view_task", name: "查看任务", url: cardUrl }, { key: "view_task", name: "查看任务", url: cardUrl },
@@ -398,7 +426,10 @@ export async function runOverdueAutoRelease(
const today = shanghaiDateFromTimestamp(now); const today = shanghaiDateFromTimestamp(now);
summary.cutoff = computeDueCutoff(today, -days); summary.cutoff = computeDueCutoff(today, -days);
const rows = await queryOverdueUnpublished(db, summary.cutoff); // 自动释放只处理发布阶段超期:回收超期的笔记已发布,释放重派无意义
const rows = await queryOverdueUnpublished(db, summary.cutoff, {
phases: "publish",
});
for (const row of rows) { for (const row of rows) {
try { try {

View File

@@ -0,0 +1 @@
ALTER TABLE `projects` DROP COLUMN `parent_id`;

View File

@@ -0,0 +1,125 @@
-- 任务模型物理合并:回收成为分发任务的第二阶段(单任务双阶段)
-- 上线前请先备份生产库;本迁移有 DELETE,回滚需依赖备份
ALTER TABLE tasks ADD COLUMN recovery_enabled INTEGER NOT NULL DEFAULT 1 AFTER related_task_id;
-- statement-breakpoint
ALTER TABLE tasks ADD COLUMN recovery_due_at TEXT NOT NULL DEFAULT ('') AFTER recovery_enabled;
-- statement-breakpoint
ALTER TABLE tasks ADD COLUMN recovery_instructions TEXT NOT NULL DEFAULT ('') AFTER recovery_due_at;
-- statement-breakpoint
ALTER TABLE tasks ADD COLUMN recovery_example_images TEXT NOT NULL DEFAULT ('[]') AFTER recovery_instructions;
-- statement-breakpoint
-- 老分发任务默认关回收;下方为有伴生回收的父任务重新置 1
UPDATE tasks SET recovery_enabled = 0 WHERE task_type = 'content_publish';
-- statement-breakpoint
-- 配对回收:回收字段并入父任务(说明/示例图取回收任务首条内容,截止取回收任务截止)
UPDATE tasks parent
JOIN tasks child ON child.related_task_id = parent.id
JOIN (
SELECT task_id, body, image_assets
FROM contents
WHERE id IN (SELECT MIN(id) FROM contents GROUP BY task_id)
) first_content ON first_content.task_id = child.id
SET parent.recovery_due_at = child.due_at,
parent.recovery_instructions = first_content.body,
parent.recovery_example_images = first_content.image_assets,
parent.recovery_enabled = 1
WHERE child.task_type = 'screenshot_collect';
-- statement-breakpoint
-- 配对回收:父任务无内容兜底(回收任务没有 contents 行时仍开通回收)
UPDATE tasks parent
JOIN tasks child ON child.related_task_id = parent.id
SET parent.recovery_due_at = child.due_at,
parent.recovery_instructions = '请提交发布后的笔记截图',
parent.recovery_example_images = '[]',
parent.recovery_enabled = 1
WHERE child.task_type = 'screenshot_collect'
AND parent.recovery_enabled = 0;
-- statement-breakpoint
-- 配对回收:截图提交记录搬到父任务同 content+partner 的分发行(父行已有提交时保留父行)
UPDATE distributions target
JOIN contents legacy_content ON legacy_content.id = target.content_id
JOIN distributions legacy ON legacy.content_id = legacy_content.related_content_id
AND legacy.partner_id = target.partner_id
JOIN tasks legacy_task ON legacy_task.id = legacy.task_id
SET target.result_screenshot_key = legacy.result_screenshot_key,
target.result_submitted_at = legacy.result_submitted_at
WHERE legacy_task.task_type = 'screenshot_collect'
AND legacy_task.related_task_id = target.task_id
AND target.result_submitted_at IS NULL;
-- statement-breakpoint
-- 配对回收:父任务里没有对应分发行时,保留为一条只挂回收字段的分发记录
INSERT INTO distributions (id, task_id, content_id, partner_id, status, result_screenshot_key, result_submitted_at, claimed_at)
SELECT
CONCAT('dist-m', LEFT(REPLACE(UUID(), '-', ''), 20)),
legacy_task.related_task_id,
legacy_content.related_content_id,
legacy.partner_id,
'claimed',
legacy.result_screenshot_key,
legacy.result_submitted_at,
legacy.claimed_at
FROM distributions legacy
JOIN contents legacy_content ON legacy_content.id = legacy.content_id
JOIN tasks legacy_task ON legacy_task.id = legacy.task_id
WHERE legacy_task.task_type = 'screenshot_collect'
AND legacy_task.related_task_id != ''
AND legacy_content.related_content_id != ''
AND NOT EXISTS (
SELECT 1 FROM distributions parent_dist
WHERE parent_dist.task_id = legacy_task.related_task_id
AND parent_dist.content_id = legacy_content.related_content_id
AND parent_dist.partner_id = legacy.partner_id
);
-- statement-breakpoint
-- 配对回收:清理伴生回收任务的分发/领取/内容/任务行
DELETE FROM distributions
WHERE task_id IN (SELECT id FROM (SELECT id FROM tasks WHERE task_type = 'screenshot_collect' AND related_task_id != '') AS doomed);
-- statement-breakpoint
DELETE FROM claims
WHERE task_id IN (SELECT id FROM (SELECT id FROM tasks WHERE task_type = 'screenshot_collect' AND related_task_id != '') AS doomed);
-- statement-breakpoint
DELETE FROM contents
WHERE task_id IN (SELECT id FROM (SELECT id FROM tasks WHERE task_type = 'screenshot_collect' AND related_task_id != '') AS doomed);
-- statement-breakpoint
DELETE FROM tasks WHERE task_type = 'screenshot_collect' AND related_task_id != '';
-- statement-breakpoint
-- 独立回收(无关联分发):无领取记录的直接删除
DELETE FROM contents
WHERE task_id IN (
SELECT id FROM (
SELECT t.id FROM tasks t
WHERE t.task_type = 'screenshot_collect'
AND (t.related_task_id IS NULL OR t.related_task_id = '')
AND NOT EXISTS (SELECT 1 FROM distributions d WHERE d.task_id = t.id)
) AS doomed
);
-- statement-breakpoint
DELETE FROM tasks
WHERE task_type = 'screenshot_collect'
AND (related_task_id IS NULL OR related_task_id = '')
AND id NOT IN (SELECT DISTINCT task_id FROM distributions);
-- statement-breakpoint
-- 独立回收(有领取记录):原地转正为单模型任务(发布阶段字段留空,仅存历史数据)
UPDATE tasks standalone
JOIN (
SELECT task_id, body, image_assets
FROM contents
WHERE id IN (SELECT MIN(id) FROM contents GROUP BY task_id)
) first_content ON first_content.task_id = standalone.id
SET standalone.task_type = 'content_publish',
standalone.recovery_due_at = standalone.due_at,
standalone.recovery_instructions = first_content.body,
standalone.recovery_example_images = first_content.image_assets,
standalone.recovery_enabled = 1
WHERE standalone.task_type = 'screenshot_collect';
-- statement-breakpoint
UPDATE tasks standalone
SET standalone.task_type = 'content_publish',
standalone.recovery_due_at = standalone.due_at,
standalone.recovery_instructions = '请提交发布后的笔记截图',
standalone.recovery_example_images = '[]',
standalone.recovery_enabled = 1
WHERE standalone.task_type = 'screenshot_collect';
-- statement-breakpoint
UPDATE contents SET related_content_id = '' WHERE related_content_id IS NOT NULL AND related_content_id != '';

View File

@@ -78,7 +78,7 @@ test("project-owner management actions gate on project ownership", () => {
"project_delete", "project_delete",
"project_ensure_main_chat", "project_ensure_main_chat",
"create_task", "create_task",
"create_screenshot_task", "task_enable_recovery",
]) { ]) {
const body = section(actionRoute, action); const body = section(actionRoute, action);
assert.match( assert.match(
@@ -319,7 +319,7 @@ test("project detail exposes inline main-chat member management", () => {
assert.match(globalsCss, /\.project-member-remove/); assert.match(globalsCss, /\.project-member-remove/);
}); });
test("every project_save payload carries parentProjectId(改总群成员不得清空层级)", () => { test("no project_save payload carries parentProjectId(项目层级已移除)", () => {
const adminApp = readFileSync( const adminApp = readFileSync(
new URL("../app/admin-app.tsx", import.meta.url), new URL("../app/admin-app.tsx", import.meta.url),
"utf8", "utf8",
@@ -330,10 +330,9 @@ test("every project_save payload carries parentProjectId(改总群成员不得
assert.ok(starts.length >= 2, "至少应有两处 project_save 调用(编辑器/总群成员)"); assert.ok(starts.length >= 2, "至少应有两处 project_save 调用(编辑器/总群成员)");
for (const start of starts) { for (const start of starts) {
const payload = adminApp.slice(start, start + 600); const payload = adminApp.slice(start, start + 600);
assert.match( assert.ok(
payload, !/parentProjectId/.test(payload),
/parentProjectId/, "project_save 载荷不应再带 parentProjectId(项目层级已移除)",
"project_save 载荷缺 parentProjectId,会把 parent_id 重置为空",
); );
} }
}); });

View File

@@ -109,5 +109,5 @@ test("recover builds dynamic partner candidates including wecom identity", () =>
routeSource, routeSource,
/wecomPartnerId \? \[wecomPartnerId\] : \[partnerId, legacyPartnerId\]/, /wecomPartnerId \? \[wecomPartnerId\] : \[partnerId, legacyPartnerId\]/,
); );
assert.match(routeSource, /\.bind\(task\.task_type, task\.task_type, task\.id, \.\.\.partnerCandidates\)/); assert.match(routeSource, /\.bind\(task\.id, \.\.\.partnerCandidates\)/);
}); });

View File

@@ -105,11 +105,10 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
brand: params[2], brand: params[2],
main_group_member_ids: params[3], main_group_member_ids: params[3],
owner_userid: params[4], owner_userid: params[4],
parent_id: params[5],
main_chat_id: "", main_chat_id: "",
status: "active", status: "active",
note: params[6], note: params[5],
created_at: params[7], created_at: params[6],
}); });
return 1; return 1;
} }
@@ -119,7 +118,7 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
return row ? 1 : 0; return row ? 1 : 0;
} }
if (/^UPDATE projects/.test(normalized)) { if (/^UPDATE projects/.test(normalized)) {
const row = projects.find((item) => item.id === params[6]); const row = projects.find((item) => item.id === params[5]);
if (!row) return 0; if (!row) return 0;
Object.assign(row, { Object.assign(row, {
name: params[0], name: params[0],
@@ -127,7 +126,6 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
main_group_member_ids: params[2], main_group_member_ids: params[2],
owner_userid: params[3], owner_userid: params[3],
note: params[4], note: params[4],
parent_id: params[5],
}); });
return 1; return 1;
} }
@@ -172,11 +170,6 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
}, },
async first() { async first() {
executed.push({ sql, params }); executed.push({ sql, params });
if (/COUNT\(\*\) AS count FROM projects WHERE parent_id = \?/.test(sql)) {
return {
count: projects.filter((item) => item.parent_id === params[0]).length,
};
}
if (/FROM projects WHERE id = \?/.test(sql)) { if (/FROM projects WHERE id = \?/.test(sql)) {
return projects.find((item) => item.id === params[0]) ?? null; return projects.find((item) => item.id === params[0]) ?? null;
} }
@@ -196,22 +189,11 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
}, },
async all() { async all() {
executed.push({ sql, params }); executed.push({ sql, params });
if (/^SELECT id, parent_id FROM projects/.test(sql.trim())) {
return {
results: projects.map((item) => ({
id: item.id,
parent_id: item.parent_id ?? "",
})),
};
}
if (/FROM projects p/.test(sql)) { if (/FROM projects p/.test(sql)) {
const ownerFiltered = /WHERE p\.owner_userid = \?/.test(sql); const ownerFiltered = /WHERE p\.owner_userid = \?/.test(sql);
const parentFiltered = /WHERE p\.parent_id = \?/.test(sql);
let rows = projects; let rows = projects;
if (ownerFiltered) { if (ownerFiltered) {
rows = rows.filter((item) => item.owner_userid === params[0]); rows = rows.filter((item) => item.owner_userid === params[0]);
} else if (parentFiltered) {
rows = rows.filter((item) => item.parent_id === params[0]);
} }
return { return {
results: rows.map((item) => ({ results: rows.map((item) => ({
@@ -220,22 +202,15 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
})), })),
}; };
} }
if (/FROM tasks WHERE project_id/.test(sql)) { if (/FROM tasks( t)? WHERE (t\.)?project_id/.test(sql)) {
return { return {
results: tasks results: tasks
.filter((task) => task.project_id === params[0]) .filter((task) => task.project_id === params[0])
.map((task) => ({ ...task })),
};
}
if (/FROM tasks t\s+LEFT JOIN projects pj/.test(sql)) {
const wanted = new Set(params);
return {
results: tasks
.filter((task) => wanted.has(task.project_id))
.map((task) => ({ .map((task) => ({
recovery_enabled: 1,
recovery_due_at: "",
recovery_submitted: 0,
...task, ...task,
project_name:
projects.find((item) => item.id === task.project_id)?.name ?? "",
})), })),
}; };
} }
@@ -267,7 +242,6 @@ const projectRow = (overrides = {}) => ({
brand: "", brand: "",
main_group_member_ids: "[]", main_group_member_ids: "[]",
owner_userid: "", owner_userid: "",
parent_id: "",
main_chat_id: "", main_chat_id: "",
status: "active", status: "active",
note: "", note: "",
@@ -436,6 +410,9 @@ test("getProjectDetail returns tasks with owner and mainChat members", async ()
project_id: "proj-1", project_id: "proj-1",
owner_userid: "owner-a", owner_userid: "owner-a",
task_chat_id: "tgc_t1", task_chat_id: "tgc_t1",
recovery_enabled: 0,
recovery_due_at: "2026-08-10",
recovery_submitted: 2,
}, },
], ],
}); });
@@ -444,6 +421,9 @@ test("getProjectDetail returns tasks with owner and mainChat members", async ()
assert.equal(detail.project.mainChatId, "pgcproj1"); assert.equal(detail.project.mainChatId, "pgcproj1");
assert.equal(detail.tasks[0].owner_userid, "owner-a"); assert.equal(detail.tasks[0].owner_userid, "owner-a");
assert.equal(detail.tasks[0].task_chat_id, "tgc_t1"); assert.equal(detail.tasks[0].task_chat_id, "tgc_t1");
assert.equal(detail.tasks[0].recovery_enabled, 0);
assert.equal(detail.tasks[0].recovery_due_at, "2026-08-10");
assert.equal(detail.tasks[0].recovery_submitted, 2);
assert.equal(detail.mainChat.chatId, "pgcproj1"); assert.equal(detail.mainChat.chatId, "pgcproj1");
assert.deepEqual(detail.mainChat.members, ["u1", "owner-a"]); assert.deepEqual(detail.mainChat.members, ["u1", "owner-a"]);
}); });
@@ -728,100 +708,3 @@ test("findOrCreateGroupOwnerPartner creates partner with userid fallback name",
assert.equal(db.partners.length, 1); assert.equal(db.partners.length, 1);
}); });
test("saveProject persists parentProjectId on create and update", async () => {
const db = fakeProjectDb({
projects: [projectRow({ id: "proj-root", name: "总项目" })],
});
await saveProject(db, {
name: "子项目",
brand: "b",
mainGroupMemberIds: [],
ownerUserid: "",
parentProjectId: "proj-root",
});
assert.equal(db.projects.length, 2);
const child = db.projects.find((item) => item.name === "子项目");
assert.equal(child.parent_id, "proj-root");
await saveProject(db, {
id: "proj-root",
name: "总项目",
mainGroupMemberIds: [],
parentProjectId: "",
});
assert.equal(db.projects.find((item) => item.id === "proj-root").parent_id, "");
});
test("saveProject rejects invalid parentProjectId (missing / self / descendant)", async () => {
const db = fakeProjectDb({
projects: [
projectRow({ id: "proj-1", name: "一级" }),
projectRow({ id: "proj-2", name: "二级", parent_id: "proj-1" }),
projectRow({ id: "proj-3", name: "三级", parent_id: "proj-2" }),
],
});
await assert.rejects(
() => saveProject(db, { id: "proj-1", name: "一级", parentProjectId: "proj-404" }),
(error) => error instanceof ProjectError && error.status === 404,
);
await assert.rejects(
() => saveProject(db, { id: "proj-1", name: "一级", parentProjectId: "proj-1" }),
(error) => error instanceof ProjectError && /自身/.test(error.message),
);
await assert.rejects(
() => saveProject(db, { id: "proj-1", name: "一级", parentProjectId: "proj-3" }),
(error) =>
error instanceof ProjectError && /上级项目不能是当前项目的子项目/.test(error.message),
);
// 兄弟项目/父项目合法
await saveProject(db, { id: "proj-3", name: "三级", parentProjectId: "proj-1" });
assert.equal(db.projects.find((item) => item.id === "proj-3").parent_id, "proj-1");
});
test("deleteProject refuses while child projects exist", async () => {
const db = fakeProjectDb({
projects: [
projectRow({ id: "proj-1", name: "一级" }),
projectRow({ id: "proj-2", name: "二级", parent_id: "proj-1" }),
],
});
await assert.rejects(
() => deleteProject(db, "proj-1"),
(error) =>
error instanceof ProjectError && /还有子项目/.test(error.message),
);
assert.equal(db.projects.length, 2);
await deleteProject(db, "proj-2");
await deleteProject(db, "proj-1");
assert.equal(db.projects.length, 0);
});
test("getProjectDetail returns subprojects and aggregated push tasks", async () => {
const db = fakeProjectDb({
projects: [
projectRow({ id: "proj-1", name: "一级" }),
projectRow({ id: "proj-2", name: "二级", parent_id: "proj-1", main_chat_id: "pgc2" }),
projectRow({ id: "proj-3", name: "三级", parent_id: "proj-2" }),
projectRow({ id: "proj-other", name: "无关项目" }),
],
tasks: [
{ id: "t1", name: "直属任务", project_id: "proj-1" },
{ id: "t2", name: "子项目任务", project_id: "proj-2" },
{ id: "t3", name: "孙项目任务", project_id: "proj-3" },
{ id: "t4", name: "无关任务", project_id: "proj-other" },
],
});
const detail = await getProjectDetail(db, "proj-1");
assert.equal(detail.project.parentProjectId, "");
assert.deepEqual(detail.tasks.map((task) => task.id), ["t1"]);
assert.deepEqual(
detail.subprojects.map((sub) => sub.id),
["proj-2"],
);
assert.equal(detail.subprojects[0].mainChatId, "pgc2");
assert.equal(detail.subprojects[0].taskCount, 1);
assert.deepEqual(
detail.pushTasks.map((task) => `${task.project_name}:${task.name}`).sort(),
["一级:直属任务", "三级:孙项目任务", "二级:子项目任务"],
);
});

View File

@@ -29,7 +29,7 @@ test("merges distribution detail into the task center", async () => {
readFile(new URL("../app/globals.css", import.meta.url), "utf8"), readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
]); ]);
assert.match(adminApp, /分发明细/); assert.match(adminApp, /查看明细/);
assert.match(adminApp, /function TaskDistributionDetail/); assert.match(adminApp, /function TaskDistributionDetail/);
assert.match(adminApp, /选择一个数据回收任务/); assert.match(adminApp, /选择一个数据回收任务/);
assert.doesNotMatch(adminApp, /"distributions"/); assert.doesNotMatch(adminApp, /"distributions"/);
@@ -212,7 +212,7 @@ test("issues external task links and supports one-to-one note submissions", asyn
assert.match(adminApp, /platform-logo/); assert.match(adminApp, /platform-logo/);
assert.match(adminApp, /task-scope-subline/); assert.match(adminApp, /task-scope-subline/);
assert.match(adminApp, /识别失败,请手动填写/); assert.match(adminApp, /识别失败,请手动填写/);
assert.match(adminApp, /分发明细/); assert.match(adminApp, /查看明细/);
assert.match(adminApp, /AdminImageLightbox/); assert.match(adminApp, /AdminImageLightbox/);
assert.match(adminApp, /CreatorScreenshotPreview/); assert.match(adminApp, /CreatorScreenshotPreview/);
assert.match(adminApp, /admin-image-lightbox/); assert.match(adminApp, /admin-image-lightbox/);
@@ -362,7 +362,7 @@ test("supports task collection schedules and latest public metrics", async () =>
assert.doesNotMatch(mcpClient, /"collect_xhs_wen_note_detail"/); assert.doesNotMatch(mcpClient, /"collect_xhs_wen_note_detail"/);
}); });
test("supports fixed screenshot collection tasks without publish metrics", async () => { test("supports merged task recovery screenshots without publish metrics", async () => {
const [adminApp, actionRoute, partnerRoute, uploadRoute, migration, multiMigration, exportRoute] = const [adminApp, actionRoute, partnerRoute, uploadRoute, migration, multiMigration, exportRoute] =
await Promise.all([ await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"), readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
@@ -374,13 +374,13 @@ test("supports fixed screenshot collection tasks without publish metrics", async
readFile(new URL("../app/api/screenshot-task-export/route.ts", import.meta.url), "utf8"), readFile(new URL("../app/api/screenshot-task-export/route.ts", import.meta.url), "utf8"),
]); ]);
assert.match(adminApp, /内容发布/); assert.match(adminApp, /开启截图回收(第二阶段)/);
assert.match(adminApp, /截图回收/);
assert.match(adminApp, /小红书搜索关键词/);
assert.match(adminApp, /批量下载截图/); assert.match(adminApp, /批量下载截图/);
assert.match(actionRoute, /create_screenshot_task/); assert.match(actionRoute, /task_enable_recovery/);
assert.doesNotMatch(actionRoute, /create_screenshot_task/);
assert.match(partnerRoute, /submit_screenshot_result/); assert.match(partnerRoute, /submit_screenshot_result/);
assert.match(partnerRoute, /截图回收任务无需填写发布链接/); assert.match(partnerRoute, /请先回填这篇笔记的发布链接,再提交回收截图/);
assert.match(partnerRoute, /recovery_enabled/);
assert.match(uploadRoute, /task-results\//); assert.match(uploadRoute, /task-results\//);
assert.match(uploadRoute, /task-result/); assert.match(uploadRoute, /task-result/);
assert.match(uploadRoute, /MAX_RESULT_SCREENSHOTS/); assert.match(uploadRoute, /MAX_RESULT_SCREENSHOTS/);

View File

@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const adminApp = readFileSync(
new URL("../app/admin-app.tsx", import.meta.url),
"utf8",
);
const adminCss = readFileSync(
new URL("../app/globals.css", import.meta.url),
"utf8",
);
const actionRoute = readFileSync(
new URL("../app/api/action/route.ts", import.meta.url),
"utf8",
);
const taskService = readFileSync(
new URL("../lib/task-service.ts", import.meta.url),
"utf8",
);
const projectService = readFileSync(
new URL("../lib/project-service.ts", import.meta.url),
"utf8",
);
const migration = readFileSync(
new URL("../mysql/0020_task_recovery_merge.sql", import.meta.url),
"utf8",
);
test("单任务模型:配对聚合与截图任务分支已删除", () => {
assert.doesNotMatch(adminApp, /groupTaskMasters/);
assert.doesNotMatch(adminApp, /PairableTask/);
assert.doesNotMatch(adminApp, /companion/);
assert.doesNotMatch(adminApp, /screenshot_collect/);
assert.doesNotMatch(adminApp, /withScreenshotTask/);
assert.doesNotMatch(adminApp, /仅回收/);
assert.doesNotMatch(adminApp, /回收群/);
assert.doesNotMatch(taskService, /createScreenshotTask/);
assert.doesNotMatch(actionRoute, /create_screenshot_task/);
assert.doesNotMatch(adminCss, /\.task-row-companion/);
});
test("TaskRowCard 双面板:发布面板 + 回收实面板/ghost 开通面板", () => {
assert.match(adminApp, /master-panel publish/);
assert.match(adminApp, /master-panel recovery/);
assert.match(adminApp, /master-panel ghost/);
assert.match(adminApp, /未开通回收/);
assert.match(adminApp, /开通回收 ›/);
assert.match(adminApp, /已收取/);
assert.match(adminApp, /recovery_submitted/);
assert.match(adminApp, /回收已开通/);
assert.match(adminCss, /\.master-panel\.ghost/);
});
test("创建/编辑表单携带回收设置", () => {
assert.match(adminApp, /开启截图回收(第二阶段)/);
assert.match(adminApp, /recoveryEnabled/);
assert.match(adminApp, /recoveryDueAt/);
assert.match(adminApp, /recoveryInstructions/);
assert.match(adminApp, /recoveryExampleImageKey/);
assert.match(adminApp, /发布截止日期/);
});
test("create_task / task_update / task_enable_recovery 三个 action 都落 recovery 字段", () => {
const createBlock = actionRoute.slice(
actionRoute.indexOf('body.action === "create_task"'),
actionRoute.indexOf('body.action === "task_update"'),
);
assert.match(createBlock, /recoveryEnabled/);
assert.match(createBlock, /recoveryExampleImageKey/);
const enableBlock = actionRoute.slice(
actionRoute.indexOf('body.action === "task_enable_recovery"'),
);
assert.match(enableBlock, /enableTaskRecovery/);
assert.match(enableBlock, /recoveryDueAt/);
assert.match(taskService, /export async function enableTaskRecovery/);
assert.match(taskService, /recovery_enabled, recovery_due_at, recovery_instructions, recovery_example_images/);
});
test("项目详情查询带回 recovery 字段(不再依赖 related_task_id)", () => {
assert.match(projectService, /recovery_enabled, recovery_due_at/);
assert.match(projectService, /recovery_submitted/);
assert.doesNotMatch(projectService, /related_task_id/);
});
test("存量迁移 SQL:配对并入、独立转正、删除老截图任务", () => {
assert.match(migration, /ALTER TABLE tasks ADD COLUMN recovery_enabled/);
assert.match(migration, /ADD COLUMN recovery_due_at/);
assert.match(migration, /ADD COLUMN recovery_instructions/);
assert.match(migration, /ADD COLUMN recovery_example_images/);
assert.match(migration, /child\.related_task_id = parent\.id/);
assert.match(migration, /target\.result_screenshot_key = legacy\.result_screenshot_key/);
assert.match(migration, /DELETE FROM tasks WHERE task_type = 'screenshot_collect' AND related_task_id != ''/);
assert.match(migration, /standalone\.task_type = 'content_publish'/);
});

View File

@@ -1,6 +1,6 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { updateTask } from "../lib/task-service.ts"; import { updateTask, enableTaskRecovery } from "../lib/task-service.ts";
function fakeDb({ task, contents = [] }) { function fakeDb({ task, contents = [] }) {
const tasks = task ? [task] : []; const tasks = task ? [task] : [];
@@ -14,21 +14,40 @@ function fakeDb({ task, contents = [] }) {
if (/FROM tasks WHERE id = \?/.test(sql)) { if (/FROM tasks WHERE id = \?/.test(sql)) {
return tasks.find((row) => row.id === values[0]) ?? null; return tasks.find((row) => row.id === values[0]) ?? null;
} }
if (/SELECT image_assets FROM contents/.test(sql)) {
const sorted = rows
.filter((row) => row.task_id === values[0])
.sort((a, b) => a.source_row - b.source_row);
return sorted[0] ? { image_assets: sorted[0].image_assets } : null;
}
return null; return null;
}, },
async run() { async run() {
const normalized = sql.trim(); const normalized = sql.trim();
if (/^UPDATE tasks/.test(normalized)) { if (/^UPDATE tasks\s+SET recovery_enabled = 1/.test(normalized)) {
const [name, brand, due_at, project_id, platform, content_format, quantity, id] = const [recovery_due_at, recovery_instructions, recovery_example_images, id] =
values; values;
const row = tasks.find((item) => item.id === id); const row = tasks.find((item) => item.id === id);
if (!row) return { meta: { changes: 0 } }; if (!row) return { meta: { changes: 0 } };
Object.assign(row, {
recovery_enabled: 1,
recovery_due_at,
recovery_instructions,
recovery_example_images,
});
return { meta: { changes: 1 } };
}
if (/^UPDATE tasks/.test(normalized)) {
const [
name,
brand,
due_at,
project_id,
platform,
content_format,
quantity,
recovery_enabled,
recovery_due_at,
recovery_instructions,
recovery_example_images,
id,
] = values;
const row = tasks.find((item) => item.id === id);
if (!row) return { meta: { changes: 0 } };
Object.assign(row, { Object.assign(row, {
name, name,
brand, brand,
@@ -37,50 +56,13 @@ function fakeDb({ task, contents = [] }) {
platform, platform,
content_format, content_format,
quantity, quantity,
recovery_enabled,
recovery_due_at,
recovery_instructions,
recovery_example_images,
}); });
return { meta: { changes: 1 } }; return { meta: { changes: 1 } };
} }
if (/^INSERT INTO contents/.test(normalized)) {
const [id, task_id, title, body, image_assets, source_row] = values;
rows.push({
id,
task_id,
title,
body,
image_assets,
status: "available",
source_row,
});
return { meta: { changes: 1 } };
}
if (/^DELETE FROM contents/.test(normalized)) {
const [taskId, limit] = values;
const available = rows
.filter((row) => row.task_id === taskId && row.status === "available")
.sort((a, b) => b.source_row - a.source_row);
const doomed = new Set(available.slice(0, limit).map((row) => row.id));
for (let index = rows.length - 1; index >= 0; index -= 1) {
if (doomed.has(rows[index].id)) rows.splice(index, 1);
}
return { meta: { changes: doomed.size } };
}
if (/^UPDATE contents SET title/.test(normalized)) {
const [title, body, taskId] = values;
for (const row of rows) {
if (row.task_id === taskId) {
row.title = title;
row.body = body;
}
}
return { meta: { changes: rows.length } };
}
if (/^UPDATE contents SET image_assets/.test(normalized)) {
const [image_assets, taskId] = values;
for (const row of rows) {
if (row.task_id === taskId) row.image_assets = image_assets;
}
return { meta: { changes: rows.length } };
}
return { meta: { changes: 0 } }; return { meta: { changes: 0 } };
}, },
async all() { async all() {
@@ -101,234 +83,78 @@ function fakeDb({ task, contents = [] }) {
return { db, tasks, contents: rows }; return { db, tasks, contents: rows };
} }
function screenshotTask(overrides = {}) { function publishTask(overrides = {}) {
return { return {
id: "task-shot-1", id: "task-pub-1",
task_type: "screenshot_collect", task_type: "content_publish",
quantity: 3, quantity: 18,
claimed_quantity: 0, claimed_quantity: 2,
platform: "小红书", platform: "抖音",
content_format: "image_text", content_format: "video",
name: "旧名称", name: "旧名称",
brand: "旧品牌", brand: "旧品牌",
due_at: "2026-08-01", due_at: "2026-08-01",
project_id: "", project_id: "",
status: "active",
recovery_enabled: 1,
recovery_due_at: "2026-08-10",
recovery_instructions: "请提交发布后的笔记截图",
recovery_example_images: "[]",
...overrides, ...overrides,
}; };
} }
function screenshotContents(quantity, overrides = {}) { function publishContents(quantity) {
return Array.from({ length: quantity }, (_, index) => ({ return Array.from({ length: quantity }, (_, index) => ({
id: `content-${index + 1}`, id: `content-${index + 1}`,
task_id: "task-shot-1",
title: overrides.title ?? "旧关键词",
body: overrides.body ?? "旧说明",
image_assets: overrides.imageAssets ?? "[]",
status: index < (overrides.claimed ?? 0) ? "claimed" : "available",
source_row: index + 1,
}));
}
test("updateTask rewrites basic fields and syncs keyword/instructions on all rows", async () => {
const { db, tasks, contents } = fakeDb({
task: screenshotTask(),
contents: screenshotContents(3),
});
const result = await updateTask(
{
taskId: "task-shot-1",
name: "新名称",
brand: "新品牌",
dueAt: "2026-09-30",
projectId: "proj-1",
keyword: "新关键词",
instructions: "新说明",
quantity: 3,
},
db,
);
assert.equal(result.quantity, 3);
assert.equal(tasks[0].name, "新名称");
assert.equal(tasks[0].brand, "新品牌");
assert.equal(tasks[0].due_at, "2026-09-30");
assert.equal(tasks[0].project_id, "proj-1");
assert.equal(contents.length, 3);
for (const row of contents) {
assert.equal(row.title, "新关键词");
assert.equal(row.body, "新说明");
}
});
test("updateTask grows placeholder contents when quantity increases", async () => {
const imageAssets = JSON.stringify([{ index: 1, key: "task-assets/a.png", width: null, height: null }]);
const { db, tasks, contents } = fakeDb({
task: screenshotTask(),
contents: screenshotContents(3, { imageAssets }),
});
await updateTask(
{
taskId: "task-shot-1",
name: "n",
brand: "b",
dueAt: "2026-09-30",
keyword: "k",
instructions: "i",
quantity: 5,
},
db,
);
assert.equal(tasks[0].quantity, 5);
assert.equal(contents.length, 5);
const added = contents.filter((row) => row.source_row > 3);
assert.equal(added.length, 2);
for (const row of added) {
assert.equal(row.status, "available");
assert.equal(row.image_assets, imageAssets, "new rows copy example image");
assert.equal(row.title, "k");
}
});
test("updateTask shrinks only available tail rows when quantity decreases", async () => {
const { db, tasks, contents } = fakeDb({
task: screenshotTask({ quantity: 5, claimed_quantity: 2 }),
contents: screenshotContents(5, { claimed: 2 }),
});
await updateTask(
{
taskId: "task-shot-1",
name: "n",
brand: "b",
dueAt: "2026-09-30",
keyword: "k",
instructions: "i",
quantity: 3,
},
db,
);
assert.equal(tasks[0].quantity, 3);
assert.equal(contents.length, 3);
const claimedRows = contents.filter((row) => row.status === "claimed");
assert.equal(claimedRows.length, 2, "claimed rows preserved");
assert.ok(
contents.every((row) => row.source_row <= 3),
"tail rows removed",
);
});
test("updateTask rejects quantity below claimed_quantity", async () => {
const { db } = fakeDb({
task: screenshotTask({ quantity: 5, claimed_quantity: 2 }),
contents: screenshotContents(5, { claimed: 2 }),
});
await assert.rejects(
() =>
updateTask(
{
taskId: "task-shot-1",
name: "n",
brand: "b",
dueAt: "2026-09-30",
keyword: "k",
instructions: "i",
quantity: 1,
},
db,
),
/不能低于已领取的 2 份/,
);
});
test("updateTask validates screenshot quantity and keyword", async () => {
const { db } = fakeDb({ task: screenshotTask(), contents: screenshotContents(3) });
await assert.rejects(
() =>
updateTask(
{ taskId: "task-shot-1", name: "n", brand: "b", dueAt: "2026-09-30", keyword: "k", instructions: "i", quantity: 0 },
db,
),
/1—500/,
);
await assert.rejects(
() =>
updateTask(
{ taskId: "task-shot-1", name: "n", brand: "b", dueAt: "2026-09-30", keyword: " ", instructions: "i", quantity: 3 },
db,
),
/请补全搜索关键词和任务说明/,
);
});
test("updateTask replaces example image across contents when re-uploaded", async () => {
const { db, contents } = fakeDb({
task: screenshotTask(),
contents: screenshotContents(3),
});
await updateTask(
{
taskId: "task-shot-1",
name: "n",
brand: "b",
dueAt: "2026-09-30",
keyword: "k",
instructions: "i",
quantity: 3,
exampleImageKey: "task-assets/new.png",
},
db,
);
for (const row of contents) {
assert.equal(JSON.parse(row.image_assets)[0].key, "task-assets/new.png");
}
});
test("updateTask ignores quantity and keyword for content_publish tasks", async () => {
const { db, tasks, contents } = fakeDb({
task: screenshotTask({
id: "task-pub-1",
task_type: "content_publish",
quantity: 18,
platform: "抖音",
content_format: "video",
}),
contents: [
{
id: "content-pub-1",
task_id: "task-pub-1", task_id: "task-pub-1",
title: "笔记标题", title: "笔记标题",
body: "笔记正文", body: "笔记正文",
image_assets: "[]", image_assets: "[]",
status: "available", status: index < 2 ? "claimed" : "available",
source_row: 1, source_row: index + 1,
}, }));
], }
test("updateTask rewrites basic fields, quantity and contents stay untouched", async () => {
const { db, tasks, contents } = fakeDb({
task: publishTask(),
contents: publishContents(5),
}); });
await updateTask( const result = await updateTask(
{ {
taskId: "task-pub-1", taskId: "task-pub-1",
name: "新名称", name: "新名称",
brand: "新品牌", brand: "新品牌",
dueAt: "2026-09-30", dueAt: "2026-09-30",
projectId: "proj-2", projectId: "proj-1",
quantity: 99,
keyword: "ignored",
instructions: "ignored",
}, },
db, db,
); );
assert.equal(tasks[0].quantity, 18, "feishu row count untouched"); assert.equal(result.quantity, 18);
assert.equal(tasks[0].platform, "抖音", "platform kept when not provided"); assert.equal(result.claimedQuantity, 2);
assert.equal(tasks[0].content_format, "video"); assert.equal(tasks[0].name, "新名称");
assert.equal(contents[0].title, "笔记标题", "contents untouched"); assert.equal(tasks[0].brand, "新品牌");
assert.equal(tasks[0].due_at, "2026-09-30");
assert.equal(tasks[0].project_id, "proj-1");
assert.equal(tasks[0].quantity, 18, "quantity 由飞书行数决定,不可编辑");
assert.equal(contents.length, 5, "contents 不随编辑变动");
assert.equal(contents[0].title, "笔记标题");
}); });
test("updateTask applies provided platform/contentFormat for content_publish", async () => { test("updateTask keeps platform/contentFormat when not provided", async () => {
const { db, tasks } = fakeDb({ task: publishTask() });
await updateTask(
{ taskId: "task-pub-1", name: "n", brand: "b", dueAt: "2026-09-30" },
db,
);
assert.equal(tasks[0].platform, "抖音");
assert.equal(tasks[0].content_format, "video");
});
test("updateTask applies provided platform/contentFormat", async () => {
const { db, tasks } = fakeDb({ const { db, tasks } = fakeDb({
task: screenshotTask({ task: publishTask({ platform: "小红书", content_format: "image_text" }),
id: "task-pub-1",
task_type: "content_publish",
platform: "小红书",
content_format: "image_text",
}),
}); });
await updateTask( await updateTask(
{ {
@@ -345,22 +171,107 @@ test("updateTask applies provided platform/contentFormat for content_publish", a
assert.equal(tasks[0].content_format, "video"); assert.equal(tasks[0].content_format, "video");
}); });
test("updateTask rejects unknown tasks and invalid dates", async () => { test("updateTask updates recovery fields and can toggle recovery off", async () => {
const { db } = fakeDb({ task: screenshotTask() }); const { db, tasks } = fakeDb({ task: publishTask() });
await updateTask(
{
taskId: "task-pub-1",
name: "n",
brand: "b",
dueAt: "2026-09-30",
recoveryEnabled: false,
recoveryDueAt: "2026-10-15",
recoveryInstructions: "新回收说明",
},
db,
);
assert.equal(tasks[0].recovery_enabled, 0);
assert.equal(tasks[0].recovery_due_at, "2026-10-15");
assert.equal(tasks[0].recovery_instructions, "新回收说明");
});
test("updateTask keeps row recovery values when input omits them", async () => {
const { db, tasks } = fakeDb({
task: publishTask({
recovery_enabled: 0,
recovery_due_at: "",
recovery_instructions: "",
}),
});
await updateTask(
{ taskId: "task-pub-1", name: "n", brand: "b", dueAt: "2026-09-30" },
db,
);
assert.equal(tasks[0].recovery_enabled, 0, "未传开关时保持关闭");
assert.equal(
tasks[0].recovery_due_at,
"2026-09-30",
"回收截止为空时兜底为发布截止",
);
});
test("updateTask replaces example image JSON when key provided", async () => {
const { db, tasks } = fakeDb({ task: publishTask() });
await updateTask(
{
taskId: "task-pub-1",
name: "n",
brand: "b",
dueAt: "2026-09-30",
recoveryExampleImageKey: "task-assets/new.png",
},
db,
);
assert.deepEqual(JSON.parse(tasks[0].recovery_example_images), [
{ index: 1, key: "task-assets/new.png", width: null, height: null },
]);
});
test("enableTaskRecovery 开通老任务回收:默认截止=发布截止、默认说明、保留示例图", async () => {
const { db, tasks } = fakeDb({
task: publishTask({
recovery_enabled: 0,
recovery_due_at: "",
recovery_instructions: "",
recovery_example_images: "[]",
}),
});
const result = await enableTaskRecovery({ taskId: "task-pub-1" }, db);
assert.equal(result.recoveryDueAt, "2026-08-01");
assert.equal(tasks[0].recovery_enabled, 1);
assert.equal(tasks[0].recovery_due_at, "2026-08-01");
assert.equal(tasks[0].recovery_instructions, "请提交发布后的笔记截图");
const keepDb = fakeDb({
task: publishTask({
recovery_example_images:
'[{"index":1,"key":"task-assets/keep.png","width":null,"height":null}]',
}),
});
await enableTaskRecovery(
{ taskId: "task-pub-1", recoveryDueAt: "2026-10-01", recoveryInstructions: "定制说明" },
keepDb.db,
);
assert.equal(keepDb.tasks[0].recovery_due_at, "2026-10-01");
assert.equal(keepDb.tasks[0].recovery_instructions, "定制说明");
assert.deepEqual(JSON.parse(keepDb.tasks[0].recovery_example_images), [
{ index: 1, key: "task-assets/keep.png", width: null, height: null },
]);
});
test("updateTask / enableTaskRecovery reject unknown tasks and invalid dates", async () => {
const { db } = fakeDb({ task: publishTask() });
await assert.rejects( await assert.rejects(
() => () =>
updateTask( updateTask({ taskId: "missing", name: "n", brand: "b", dueAt: "2026-09-30" }, db),
{ taskId: "missing", name: "n", brand: "b", dueAt: "2026-09-30" },
db,
),
/任务不存在/, /任务不存在/,
); );
await assert.rejects( await assert.rejects(
() => () =>
updateTask( updateTask({ taskId: "task-pub-1", name: "n", brand: "b", dueAt: "2026-13-01" }, db),
{ taskId: "task-shot-1", name: "n", brand: "b", dueAt: "2026-13-01" },
db,
),
/截止日期/, /截止日期/,
); );
await assert.rejects(
() => enableTaskRecovery({ taskId: "missing" }, db),
/任务不存在/,
);
}); });

View File

@@ -26,7 +26,8 @@ const overdueRows = [
task_id: "task-A", task_id: "task-A",
task_name: "超期任务A", task_name: "超期任务A",
due_at: "2026-08-20", due_at: "2026-08-20",
task_type: "content_publish", recovery_enabled: 1,
recovery_due_at: "2026-08-27",
publish_url: null, publish_url: null,
result_submitted_at: null, result_submitted_at: null,
project_id: "proj-1", project_id: "proj-1",
@@ -38,7 +39,8 @@ const overdueRows = [
task_id: "task-A", task_id: "task-A",
task_name: "超期任务A", task_name: "超期任务A",
due_at: "2026-08-20", due_at: "2026-08-20",
task_type: "content_publish", recovery_enabled: 1,
recovery_due_at: "2026-08-27",
publish_url: null, publish_url: null,
result_submitted_at: null, result_submitted_at: null,
project_id: "proj-1", project_id: "proj-1",
@@ -50,7 +52,8 @@ const overdueRows = [
task_id: "task-B", task_id: "task-B",
task_name: "无项目任务B", task_name: "无项目任务B",
due_at: "2026-08-25", due_at: "2026-08-25",
task_type: "content_publish", recovery_enabled: 0,
recovery_due_at: "",
publish_url: null, publish_url: null,
result_submitted_at: null, result_submitted_at: null,
project_id: "", project_id: "",
@@ -62,7 +65,6 @@ const overdueRows = [
const releasableDistribution = { const releasableDistribution = {
id: "d-1", id: "d-1",
task_id: "task-A", task_id: "task-A",
task_type: "content_publish",
content_id: "c-1", content_id: "c-1",
content_title: "超期内容", content_title: "超期内容",
partner_id: "p-1", partner_id: "p-1",
@@ -73,13 +75,30 @@ const releasableDistribution = {
result_submitted_at: null, result_submitted_at: null,
}; };
function filterOverdue(rows, cutoff) { // 双阶段超期判定:发布阶段看 due_at,回收阶段(已发布未收截图)看 recovery_due_at
return rows.filter((row) => { function filterOverdue(rows, cutoff, { phases = "all" } = {}) {
if (!(row.due_at && row.due_at < cutoff)) return false; const output = [];
return row.task_type === "screenshot_collect" for (const row of rows) {
? !row.result_submitted_at if (!row.publish_url && row.due_at && row.due_at < cutoff) {
: !row.publish_url; output.push({ ...row, pending_phase: "publish", pending_due_at: row.due_at });
continue;
}
if (
phases === "all" &&
row.recovery_enabled === 1 &&
row.publish_url &&
!row.result_submitted_at &&
row.recovery_due_at &&
row.recovery_due_at < cutoff
) {
output.push({
...row,
pending_phase: "recovery",
pending_due_at: row.recovery_due_at,
}); });
}
}
return output;
} }
function fakeEscalationDb({ rows = overdueRows } = {}) { function fakeEscalationDb({ rows = overdueRows } = {}) {
@@ -95,11 +114,12 @@ function fakeEscalationDb({ rows = overdueRows } = {}) {
return null; return null;
}, },
async all() { async all() {
if ( if (/FROM distributions d/.test(sql)) {
/FROM distributions d/.test(sql) && return {
/result_submitted_at IS NULL/.test(sql) results: filterOverdue(rows, params[0], {
) { phases: sql.includes("t.recovery_enabled") ? "all" : "publish",
return { results: filterOverdue(rows, params[0]) }; }),
};
} }
return { results: [] }; return { results: [] };
}, },
@@ -138,11 +158,12 @@ function fakeReleaseDb({ rows = overdueRows, blockedIds = [] } = {}) {
return null; return null;
}, },
async all() { async all() {
if ( if (/FROM distributions d/.test(sql)) {
/FROM distributions d/.test(sql) && return {
/result_submitted_at IS NULL/.test(sql) results: filterOverdue(rows, params[0], {
) { phases: sql.includes("t.recovery_enabled") ? "all" : "publish",
return { results: filterOverdue(rows, params[0]) }; }),
};
} }
return { results: [] }; return { results: [] };
}, },
@@ -250,38 +271,44 @@ test("runOverdueEscalation no-ops without overdue rows", async () => {
assert.equal(calls.length, 0); assert.equal(calls.length, 0);
}); });
test("runOverdueEscalation distinguishes screenshot tasks from content tasks", async () => { test("runOverdueEscalation 按发布/回收两阶段分别出文案", async () => {
const rows = [ const rows = [
{ {
id: "d-s1", // 回收阶段超期:已发布、未收截图、recovery_due_at 已过
id: "d-r1",
task_id: "task-S", task_id: "task-S",
task_name: "截图任务S", task_name: "回收任务S",
due_at: "2026-08-20", due_at: "2026-08-10",
task_type: "screenshot_collect", recovery_enabled: 1,
publish_url: null, recovery_due_at: "2026-08-20",
publish_url: "https://www.xiaohongshu.com/note/s1",
result_submitted_at: null, result_submitted_at: null,
project_id: "", project_id: "",
partner_id: "p-1", partner_id: "p-1",
partner_name: "合作方甲", partner_name: "合作方甲",
}, },
{ {
id: "d-s2", // 已提交截图:两阶段都不超期
id: "d-r2",
task_id: "task-S", task_id: "task-S",
task_name: "截图任务S", task_name: "回收任务S",
due_at: "2026-08-20", due_at: "2026-08-10",
task_type: "screenshot_collect", recovery_enabled: 1,
publish_url: null, recovery_due_at: "2026-08-20",
publish_url: "https://www.xiaohongshu.com/note/s2",
result_submitted_at: "2026-08-21 09:00:00", result_submitted_at: "2026-08-21 09:00:00",
project_id: "", project_id: "",
partner_id: "p-2", partner_id: "p-2",
partner_name: "合作方乙", partner_name: "合作方乙",
}, },
{ {
// 已发布且回收未到期:不算超期
id: "d-c1", id: "d-c1",
task_id: "task-A", task_id: "task-A",
task_name: "内容任务A", task_name: "发布任务A",
due_at: "2026-08-20", due_at: "2026-08-20",
task_type: "content_publish", recovery_enabled: 1,
recovery_due_at: "2026-09-10",
publish_url: "https://www.xiaohongshu.com/note/1", publish_url: "https://www.xiaohongshu.com/note/1",
result_submitted_at: null, result_submitted_at: null,
project_id: "", project_id: "",
@@ -289,11 +316,13 @@ test("runOverdueEscalation distinguishes screenshot tasks from content tasks", a
partner_name: "合作方丙", partner_name: "合作方丙",
}, },
{ {
// 发布阶段超期:未回填链接
id: "d-c2", id: "d-c2",
task_id: "task-A", task_id: "task-A",
task_name: "内容任务A", task_name: "发布任务A",
due_at: "2026-08-20", due_at: "2026-08-20",
task_type: "content_publish", recovery_enabled: 1,
recovery_due_at: "2026-09-10",
publish_url: null, publish_url: null,
result_submitted_at: null, result_submitted_at: null,
project_id: "", project_id: "",
@@ -308,15 +337,14 @@ test("runOverdueEscalation distinguishes screenshot tasks from content tasks", a
wecomFetch(calls), wecomFetch(calls),
NOW, NOW,
); );
// 已提交截图的 d-s2 与已回填链接的 d-c1 不算超期 assert.equal(summary.overdueTasks, 2, "任务×阶段 各一条");
assert.equal(summary.overdueTasks, 2);
assert.equal(summary.overdueDistributions, 2); assert.equal(summary.overdueDistributions, 2);
const robotBodies = calls const robotBodies = calls
.filter((call) => call.url.host === "qyapi.example.com") .filter((call) => call.url.host === "qyapi.example.com")
.map((call) => JSON.parse(String(call.init.body))); .map((call) => JSON.parse(String(call.init.body)));
assert.equal(robotBodies.length, 1); assert.equal(robotBodies.length, 1);
assert.match(robotBodies[0].text.content, /截图任务S(截止 2026-08-20):1 条/); assert.match(robotBodies[0].text.content, /回收任务S(截止 2026-08-20):1 条截图超期未提交/);
assert.match(robotBodies[0].text.content, /内容任务A(截止 2026-08-20):1 条/); assert.match(robotBodies[0].text.content, /发布任务A(截止 2026-08-20):1 条超期未发布/);
}); });
test("runOverdueAutoRelease is disabled without env switch", async () => { test("runOverdueAutoRelease is disabled without env switch", async () => {

View File

@@ -54,17 +54,18 @@ test("buildTaskGroupPushMessage composes text and link with claim url", () => {
assert.match(message.link.desc, /品牌A · 3 份 · 截止 2026-09-01/); assert.match(message.link.desc, /品牌A · 3 份 · 截止 2026-09-01/);
}); });
test("buildTaskGroupPushMessage marks video and screenshot tasks", () => { test("buildTaskGroupPushMessage marks video tasks (单模型下不再有截图任务文案)", () => {
const video = buildTaskGroupPushMessage( const video = buildTaskGroupPushMessage(
{ ...baseTask, content_format: "video" }, { ...baseTask, content_format: "video" },
"https://portal.example.com", "https://portal.example.com",
); );
assert.match(video.text, /形式:视频/); assert.match(video.text, /形式:视频/);
const screenshot = buildTaskGroupPushMessage( const imageText = buildTaskGroupPushMessage(
{ ...baseTask, task_type: "screenshot_collect" }, baseTask,
"https://portal.example.com", "https://portal.example.com",
); );
assert.match(screenshot.text, /形式:截图回收/); assert.match(imageText.text, /形式:图文/);
assert.doesNotMatch(imageText.text, /截图回收/);
}); });
test("buildTaskGroupPushMessage drops link when share_token missing", () => { test("buildTaskGroupPushMessage drops link when share_token missing", () => {

View File

@@ -62,7 +62,7 @@ test("runDueSoonWecomNotifications filters by taskId when given", async () => {
); );
assert.equal(executed.length, 1); assert.equal(executed.length, 1);
assert.match(executed[0].sql, /AND t\.id = \?/); assert.match(executed[0].sql, /AND t\.id = \?/);
assert.deepEqual(executed[0].values, ["2026-08-21", "task-9"]); assert.deepEqual(executed[0].values, ["2026-08-21", "2026-08-21", "task-9"]);
assert.equal(summary.dueSoonAttempted, 0); assert.equal(summary.dueSoonAttempted, 0);
assert.equal(summary.digestSent, false); assert.equal(summary.digestSent, false);
}); });
@@ -73,5 +73,5 @@ test("runDueSoonWecomNotifications keeps global scan without taskId", async () =
await runDueSoonWecomNotifications(db, NOTIFY_CONFIG, undefined, now); await runDueSoonWecomNotifications(db, NOTIFY_CONFIG, undefined, now);
assert.equal(executed.length, 1); assert.equal(executed.length, 1);
assert.ok(!/AND t\.id = \?/.test(executed[0].sql)); assert.ok(!/AND t\.id = \?/.test(executed[0].sql));
assert.deepEqual(executed[0].values, ["2026-08-21"]); assert.deepEqual(executed[0].values, ["2026-08-21", "2026-08-21"]);
}); });