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:
1279
app/admin-app.tsx
1279
app/admin-app.tsx
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,7 @@ import {
|
||||
} from "../../../lib/feishu-client";
|
||||
import {
|
||||
createDistributionTask,
|
||||
createScreenshotTask,
|
||||
enableTaskRecovery,
|
||||
updateTask,
|
||||
} from "../../../lib/task-service";
|
||||
import {
|
||||
@@ -204,23 +204,34 @@ export async function POST(request: Request) {
|
||||
contentFormat,
|
||||
projectId: String(body.projectId ?? "").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,
|
||||
);
|
||||
ownerWarning = await assignOwnerQuietly(creation.taskId, body.ownerUserid);
|
||||
} else if (body.action === "create_screenshot_task") {
|
||||
const forbidden = await projectOwnerGate(body.projectId);
|
||||
} else if (body.action === "task_enable_recovery") {
|
||||
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;
|
||||
const creation = await createScreenshotTask({
|
||||
name: String(body.name ?? "").trim(),
|
||||
relatedTaskId: String(body.relatedTaskId ?? "").trim(),
|
||||
dueAt: String(body.dueAt ?? "").trim(),
|
||||
instructions: String(body.instructions ?? "").trim(),
|
||||
exampleImageKey: String(body.exampleImageKey ?? "").trim(),
|
||||
projectId: String(body.projectId ?? "").trim(),
|
||||
ownerUserid: String(body.ownerUserid ?? "").trim(),
|
||||
await enableTaskRecovery({
|
||||
taskId,
|
||||
recoveryDueAt: String(body.recoveryDueAt ?? "").trim(),
|
||||
recoveryInstructions: String(body.recoveryInstructions ?? "").trim(),
|
||||
recoveryExampleImageKey: String(body.recoveryExampleImageKey ?? "").trim(),
|
||||
});
|
||||
ownerWarning = await assignOwnerQuietly(creation.taskId, body.ownerUserid);
|
||||
} else if (body.action === "claim") {
|
||||
const forbidden = managerGate();
|
||||
if (forbidden) return forbidden;
|
||||
@@ -939,7 +950,6 @@ export async function POST(request: Request) {
|
||||
mainGroupMemberIds: body.mainGroupMemberIds,
|
||||
ownerUserid: nextOwnerUserid,
|
||||
note: body.note,
|
||||
parentProjectId: body.parentProjectId,
|
||||
},
|
||||
resolveWecomConfig(env as unknown as WecomBindings),
|
||||
);
|
||||
@@ -1011,10 +1021,13 @@ export async function POST(request: Request) {
|
||||
projectId: String(body.projectId ?? ""),
|
||||
platform,
|
||||
contentFormat,
|
||||
quantity: numberValue(body.quantity),
|
||||
keyword: String(body.keyword ?? ""),
|
||||
instructions: String(body.instructions ?? ""),
|
||||
exampleImageKey: String(body.exampleImageKey ?? ""),
|
||||
recoveryEnabled:
|
||||
body.recoveryEnabled === undefined
|
||||
? undefined
|
||||
: Boolean(body.recoveryEnabled),
|
||||
recoveryDueAt: String(body.recoveryDueAt ?? ""),
|
||||
recoveryInstructions: String(body.recoveryInstructions ?? ""),
|
||||
recoveryExampleImageKey: String(body.recoveryExampleImageKey ?? ""),
|
||||
});
|
||||
ownerWarning = await assignOwnerQuietly(update.taskId, body.ownerUserid);
|
||||
} else if (body.action === "partner_save") {
|
||||
|
||||
@@ -75,9 +75,11 @@ async function handleGet(request: Request) {
|
||||
c.video_assets,
|
||||
d.result_screenshot_key,
|
||||
d.publish_screenshot_key,
|
||||
d.screenshot_key
|
||||
d.screenshot_key,
|
||||
t.recovery_example_images
|
||||
FROM distributions d
|
||||
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
|
||||
WHERE d.id = ?
|
||||
AND b.share_token = ?
|
||||
@@ -90,6 +92,7 @@ async function handleGet(request: Request) {
|
||||
result_screenshot_key: string | null;
|
||||
publish_screenshot_key: string | null;
|
||||
screenshot_key: string | null;
|
||||
recovery_example_images: string;
|
||||
}>()
|
||||
: await getRawDb()
|
||||
.prepare(
|
||||
@@ -97,7 +100,8 @@ async function handleGet(request: Request) {
|
||||
c.video_assets,
|
||||
d.result_screenshot_key,
|
||||
d.publish_screenshot_key,
|
||||
d.screenshot_key
|
||||
d.screenshot_key,
|
||||
t.recovery_example_images
|
||||
FROM distributions d
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
JOIN claims cl ON cl.id = d.claim_id
|
||||
@@ -114,6 +118,7 @@ async function handleGet(request: Request) {
|
||||
result_screenshot_key: string | null;
|
||||
publish_screenshot_key: string | null;
|
||||
screenshot_key: string | null;
|
||||
recovery_example_images: string;
|
||||
}>();
|
||||
const asset =
|
||||
imageKind === "result"
|
||||
@@ -125,6 +130,10 @@ async function handleGet(request: Request) {
|
||||
],
|
||||
}
|
||||
: undefined
|
||||
: imageKind === "task-example"
|
||||
? row
|
||||
? findAsset(row.recovery_example_images, imageIndex, ["task-assets/"])
|
||||
: undefined
|
||||
: imageKind === "publish"
|
||||
? row?.publish_screenshot_key?.startsWith("publish-evidence/")
|
||||
? { index: 1, key: row.publish_screenshot_key }
|
||||
|
||||
@@ -93,7 +93,7 @@ async function handlePost(request: Request) {
|
||||
const assignment = upload.delegationToken
|
||||
? await getRawDb()
|
||||
.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
|
||||
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
|
||||
JOIN tasks t ON t.id = d.task_id
|
||||
@@ -102,10 +102,10 @@ async function handlePost(request: Request) {
|
||||
AND b.status = 'active'`,
|
||||
)
|
||||
.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()
|
||||
.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
|
||||
JOIN claims c ON c.id = d.claim_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`,
|
||||
)
|
||||
.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) {
|
||||
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
|
||||
}
|
||||
@@ -125,11 +125,8 @@ async function handlePost(request: Request) {
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (isTaskResult && assignment.task_type !== "screenshot_collect") {
|
||||
return Response.json({ error: "当前任务不支持截图结果回填" }, { status: 400 });
|
||||
}
|
||||
if (!isTaskResult && assignment.task_type === "screenshot_collect") {
|
||||
return Response.json({ error: "截图回收任务请上传任务结果截图" }, { status: 400 });
|
||||
if (isTaskResult && Number(assignment.recovery_enabled) !== 1) {
|
||||
return Response.json({ error: "该任务未开通截图回收" }, { status: 400 });
|
||||
}
|
||||
const existingResultKeys = isTaskResult
|
||||
? parseResultScreenshotKeys(assignment.result_screenshot_key)
|
||||
|
||||
@@ -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[] {
|
||||
try {
|
||||
const assets = JSON.parse(String(value ?? "[]")) as Array<
|
||||
@@ -137,13 +146,18 @@ type PartnerTask = {
|
||||
task_type: string;
|
||||
platform: string;
|
||||
content_format: string;
|
||||
recovery_enabled: number;
|
||||
recovery_due_at: string;
|
||||
recovery_instructions: string | null;
|
||||
recovery_example_images: string;
|
||||
};
|
||||
|
||||
async function findTask(taskToken: string) {
|
||||
return getRawDb()
|
||||
.prepare(
|
||||
`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 = ?`,
|
||||
)
|
||||
.bind(taskToken)
|
||||
@@ -164,6 +178,10 @@ async function findDelegationAccess(delegationToken: string) {
|
||||
t.task_type,
|
||||
t.platform,
|
||||
t.content_format,
|
||||
t.recovery_enabled,
|
||||
t.recovery_due_at,
|
||||
t.recovery_instructions,
|
||||
t.recovery_example_images,
|
||||
b.id AS bundle_id,
|
||||
b.label AS bundle_label,
|
||||
b.quantity AS bundle_quantity,
|
||||
@@ -398,7 +416,7 @@ async function handleGet(request: Request) {
|
||||
.all();
|
||||
delegation = {
|
||||
id: delegationAccess.bundle_id,
|
||||
label: task.task_type === "screenshot_collect" ? "转派截图任务包" : "转派发布包",
|
||||
label: "转派发布包",
|
||||
quantity: delegationAccess.bundle_quantity,
|
||||
createdAt: delegationAccess.bundle_created_at,
|
||||
assignments: assignments.results.map((assignment) => ({
|
||||
@@ -421,6 +439,8 @@ async function handleGet(request: Request) {
|
||||
type: task.task_type,
|
||||
platform: task.platform,
|
||||
contentFormat: task.content_format,
|
||||
recoveryEnabled: recoveryPayload(task).recoveryEnabled,
|
||||
recoveryDueAt: recoveryPayload(task).recoveryDueAt,
|
||||
}
|
||||
: {
|
||||
name: task.name,
|
||||
@@ -433,6 +453,7 @@ async function handleGet(request: Request) {
|
||||
platform: task.platform,
|
||||
contentFormat: task.content_format,
|
||||
availableQuantity: available?.count ?? 0,
|
||||
...recoveryPayload(task),
|
||||
},
|
||||
claim,
|
||||
delegation,
|
||||
@@ -534,8 +555,7 @@ async function handlePost(request: Request) {
|
||||
c.created_at,
|
||||
COUNT(d.id) AS note_count,
|
||||
SUM(CASE
|
||||
WHEN ? = 'screenshot_collect' AND d.result_submitted_at IS NOT NULL THEN 1
|
||||
WHEN ? != 'screenshot_collect' AND d.publish_url IS NOT NULL AND d.publish_url != '' THEN 1
|
||||
WHEN d.publish_url IS NOT NULL AND d.publish_url != '' THEN 1
|
||||
ELSE 0 END) AS completed_count,
|
||||
MIN(co.title) AS first_title
|
||||
FROM claims c
|
||||
@@ -548,7 +568,7 @@ async function handlePost(request: Request) {
|
||||
ORDER BY c.created_at DESC, c.id DESC
|
||||
LIMIT 20`,
|
||||
)
|
||||
.bind(task.task_type, task.task_type, task.id, ...partnerCandidates)
|
||||
.bind(task.id, ...partnerCandidates)
|
||||
.all<{
|
||||
claim_token: string;
|
||||
quantity: number;
|
||||
@@ -569,7 +589,7 @@ async function handlePost(request: Request) {
|
||||
quantity: claim.note_count || claim.quantity,
|
||||
completedCount: claim.completed_count || 0,
|
||||
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) {
|
||||
return Response.json(
|
||||
{ error: task.task_type === "screenshot_collect" ? "请至少选择一份待提交任务" : "请至少选择一篇待发布笔记" },
|
||||
{ error: "请至少选择一篇待发布笔记" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
@@ -823,13 +843,9 @@ async function handlePost(request: Request) {
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (selected.results.some((item) =>
|
||||
task.task_type === "screenshot_collect"
|
||||
? item.result_submitted_at
|
||||
: item.publish_url,
|
||||
)) {
|
||||
if (selected.results.some((item) => item.publish_url)) {
|
||||
return Response.json(
|
||||
{ error: task.task_type === "screenshot_collect" ? "已提交的截图任务不能再次转派" : "已发布的笔记不能再次转派" },
|
||||
{ error: "已发布的笔记不能再次转派" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
@@ -942,7 +958,7 @@ async function handlePost(request: Request) {
|
||||
.first<{ count: number }>();
|
||||
if ((completed?.count ?? 0) > 0) {
|
||||
return Response.json(
|
||||
{ error: task.task_type === "screenshot_collect" ? "该分享包已有截图提交,不能撤销" : "该分享包已有笔记发布,需保留链接继续完成第7天数据回收" },
|
||||
{ error: "该分享包已有笔记发布,需保留链接继续完成第7天数据回收" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
@@ -968,9 +984,6 @@ async function handlePost(request: Request) {
|
||||
}
|
||||
|
||||
if (body.action === "submit") {
|
||||
if (task.task_type === "screenshot_collect") {
|
||||
return Response.json({ error: "截图回收任务无需填写发布链接" }, { status: 400 });
|
||||
}
|
||||
const claimToken = textValue(body.claimToken, 80);
|
||||
const distributionId = textValue(body.distributionId, 80);
|
||||
const publishInput = textValue(body.publishUrl, 5000);
|
||||
@@ -1193,9 +1206,6 @@ async function handlePost(request: Request) {
|
||||
}
|
||||
|
||||
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 distributionId = textValue(body.distributionId, 80);
|
||||
const exposure = creatorMetricValue(body.exposure);
|
||||
@@ -1244,8 +1254,8 @@ async function handlePost(request: Request) {
|
||||
}
|
||||
|
||||
if (body.action === "submit_screenshot_result") {
|
||||
if (task.task_type !== "screenshot_collect") {
|
||||
return Response.json({ error: "当前任务不支持这种回填方式" }, { status: 400 });
|
||||
if (Number(task.recovery_enabled) !== 1) {
|
||||
return Response.json({ error: "该任务未开通截图回收" }, { status: 400 });
|
||||
}
|
||||
const claimToken = textValue(body.claimToken, 80);
|
||||
const distributionId = textValue(body.distributionId, 80);
|
||||
@@ -1256,17 +1266,22 @@ async function handlePost(request: Request) {
|
||||
delegationToken,
|
||||
);
|
||||
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) {
|
||||
return Response.json({ error: "请先上传任务截图" }, { status: 400 });
|
||||
return Response.json({ error: "请先上传回收截图" }, { status: 400 });
|
||||
}
|
||||
const statements: DatabaseStatement[] = [
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE distributions SET
|
||||
result_submitted_at = CURRENT_TIMESTAMP,
|
||||
status = 'complete',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ type ExportRow = {
|
||||
publish_url: string | null;
|
||||
publish_time: string | null;
|
||||
publish_screenshot_key: string | null;
|
||||
result_submitted_at: string | null;
|
||||
screenshot_key: string | null;
|
||||
exposure: number | null;
|
||||
views: number | null;
|
||||
@@ -252,9 +253,17 @@ export async function GET(request: Request) {
|
||||
}
|
||||
}
|
||||
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)
|
||||
.first<{ id: string; name: string; brand: string; platform: string }>();
|
||||
.first<{
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
platform: string;
|
||||
recovery_enabled: number;
|
||||
}>();
|
||||
if (!task) {
|
||||
return Response.json({ error: "没有找到这个任务" }, { status: 404 });
|
||||
}
|
||||
@@ -270,6 +279,7 @@ export async function GET(request: Request) {
|
||||
d.publish_url,
|
||||
d.publish_time,
|
||||
d.publish_screenshot_key,
|
||||
d.result_submitted_at,
|
||||
d.screenshot_key,
|
||||
d.exposure,
|
||||
d.views,
|
||||
@@ -304,34 +314,7 @@ export async function GET(request: Request) {
|
||||
.bind(taskId)
|
||||
.all<ExportRow>();
|
||||
const rows = result.results;
|
||||
const companionTaskRow = await db
|
||||
.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 recoveryEnabled = Number(task.recovery_enabled) === 1;
|
||||
const assetsByContent = new Map(
|
||||
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 accountColumn = originalImageStart + maxContentImages;
|
||||
@@ -427,10 +410,10 @@ export async function GET(request: Request) {
|
||||
row.distribution_id ? collectionLabel(row) : "未领取",
|
||||
row.partner_name || "",
|
||||
row.claimant_name || "",
|
||||
...(companionSubmittedByContent
|
||||
...(recoveryEnabled
|
||||
? [
|
||||
companionSubmittedByContent.has(row.content_id)
|
||||
? companionSubmittedByContent.get(row.content_id)
|
||||
row.distribution_id
|
||||
? row.result_submitted_at
|
||||
? "已提交"
|
||||
: "未提交"
|
||||
: "—",
|
||||
@@ -471,7 +454,7 @@ export async function GET(request: Request) {
|
||||
24,
|
||||
18,
|
||||
22,
|
||||
...(companionSubmittedByContent ? [12] : []),
|
||||
...(recoveryEnabled ? [12] : []),
|
||||
];
|
||||
const workbook = buildRecoveryWorkbook({
|
||||
sheetName: `${task.name}-数据回收`,
|
||||
|
||||
@@ -29,11 +29,11 @@ export async function GET(request: Request) {
|
||||
return ownerForbidden();
|
||||
}
|
||||
const task = await getRawDb()
|
||||
.prepare("SELECT name, task_type FROM tasks WHERE id = ?")
|
||||
.prepare("SELECT name FROM tasks WHERE id = ?")
|
||||
.bind(taskId)
|
||||
.first<{ name: string; task_type: string }>();
|
||||
if (!task || task.task_type !== "screenshot_collect") {
|
||||
return Response.json({ error: "没有找到截图回收任务" }, { status: 404 });
|
||||
.first<{ name: string }>();
|
||||
if (!task) {
|
||||
return Response.json({ error: "没有找到任务" }, { status: 404 });
|
||||
}
|
||||
const rows = await getRawDb()
|
||||
.prepare(
|
||||
@@ -60,7 +60,7 @@ export async function GET(request: Request) {
|
||||
}>();
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
const manifest = [
|
||||
["序号", "搜索关键词", "领取人", "领取时间", "提交时间", "文件名"],
|
||||
["序号", "笔记标题", "领取人", "领取时间", "提交时间", "文件名"],
|
||||
];
|
||||
let exported = 0;
|
||||
for (const [rowIndex, row] of rows.results.entries()) {
|
||||
|
||||
570
app/globals.css
570
app/globals.css
@@ -1761,7 +1761,7 @@ a {
|
||||
border-color: rgb(20 40 34 / 0.18);
|
||||
}
|
||||
|
||||
.group-push-modal {
|
||||
.modal-card.group-push-modal {
|
||||
width: min(720px, 100%);
|
||||
}
|
||||
|
||||
@@ -1917,64 +1917,472 @@ a {
|
||||
.task-list {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
|
||||
.task-row-main > div:first-child span,
|
||||
.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 {
|
||||
.main-chat-manage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.task-number strong {
|
||||
font-size: 16px;
|
||||
.main-chat-manage-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-number span,
|
||||
.task-date strong {
|
||||
margin-top: 3px;
|
||||
color: #7e8a86;
|
||||
font-size: 9px;
|
||||
.task-pair-sub {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: #66756d;
|
||||
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 {
|
||||
@@ -1988,12 +2396,6 @@ a {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.task-share-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.share-link-button {
|
||||
height: 31px;
|
||||
padding: 0 10px;
|
||||
@@ -5456,12 +5858,24 @@ label small {
|
||||
border-radius: 6px;
|
||||
background: #f7faf8;
|
||||
color: #5b6b62;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: 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) {
|
||||
@@ -5497,6 +5911,14 @@ label small {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-card-title.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-card-title.clickable:hover h3 {
|
||||
color: var(--green-deep);
|
||||
}
|
||||
|
||||
.project-card-title h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
@@ -5687,26 +6109,54 @@ label small {
|
||||
.project-push-target {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid #e2e8e4;
|
||||
border-radius: 9px;
|
||||
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 {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-push-target small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: #8a968f;
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
color: #66756d;
|
||||
font-size: 12px;
|
||||
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 {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
@@ -73,6 +73,14 @@ export const tasks = mysqlTable(
|
||||
relatedTaskId: varchar("related_task_id", { length: 64 })
|
||||
.notNull()
|
||||
.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"),
|
||||
},
|
||||
(table) => [uniqueIndex("tasks_share_token_idx").on(table.shareToken)],
|
||||
|
||||
@@ -702,6 +702,19 @@ footer {
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: clamp(25px, 4vw, 38px);
|
||||
|
||||
@@ -71,9 +71,17 @@ type TaskPayload = {
|
||||
availableQuantity?: number;
|
||||
dueAt: string;
|
||||
status: string;
|
||||
type: "content_publish" | "screenshot_collect";
|
||||
type: "content_publish";
|
||||
platform: "小红书" | "抖音";
|
||||
contentFormat: "image_text" | "video";
|
||||
recoveryEnabled?: boolean;
|
||||
recoveryDueAt?: string;
|
||||
recoveryInstructions?: string;
|
||||
recoveryExampleImages?: Array<{
|
||||
index: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}>;
|
||||
};
|
||||
claim: null | {
|
||||
id: string;
|
||||
@@ -250,10 +258,7 @@ function isMobilePortalViewport() {
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(item: Assignment, taskType = "content_publish") {
|
||||
if (taskType === "screenshot_collect") {
|
||||
return item.result_submitted_at ? "已提交" : "待提交";
|
||||
}
|
||||
function statusLabel(item: Assignment) {
|
||||
return item.publish_url ? "已回填" : "待发布";
|
||||
}
|
||||
|
||||
@@ -714,11 +719,7 @@ export default function Home() {
|
||||
setDelegationToken("");
|
||||
updateUrl(result.claimToken);
|
||||
await loadTask(taskToken, result.claimToken);
|
||||
setToast(
|
||||
payload?.task.type === "screenshot_collect"
|
||||
? `已领取 ${result.claimedCount} 份,完成后逐份上传截图`
|
||||
: `已领取 ${result.claimedCount} 篇,逐篇发布即可`,
|
||||
);
|
||||
setToast(`已领取 ${result.claimedCount} 篇,逐篇发布即可`);
|
||||
} catch (reason) {
|
||||
setToast(reason instanceof Error ? reason.message : "领取失败");
|
||||
} finally {
|
||||
@@ -795,6 +796,21 @@ export default function Home() {
|
||||
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 = (
|
||||
item: Assignment,
|
||||
videoIndex: number,
|
||||
@@ -1275,9 +1291,13 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const submitScreenshotTask = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const submitRecoveryScreenshots = async (event?: FormEvent) => {
|
||||
event?.preventDefault();
|
||||
if (!selected) return;
|
||||
if (!selected.publish_url) {
|
||||
setToast("请先回填这篇笔记的发布链接,再提交回收截图");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setWorking(true);
|
||||
const existingScreenshots = resultScreenshotKeys(
|
||||
@@ -1291,7 +1311,7 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
} else if (existingScreenshots.length === 0) {
|
||||
throw new Error("请上传任务要求的截图");
|
||||
throw new Error("请上传发布后的笔记截图");
|
||||
}
|
||||
const response = await fetch(partnerApi("/api/partner"), {
|
||||
method: "POST",
|
||||
@@ -1310,8 +1330,8 @@ export default function Home() {
|
||||
setTaskResultScreenshots([]);
|
||||
setToast(
|
||||
taskResultScreenshots.length > 1
|
||||
? `${taskResultScreenshots.length}张任务截图已提交`
|
||||
: "任务截图已提交",
|
||||
? `${taskResultScreenshots.length}张回收截图已提交`
|
||||
: "回收截图已提交",
|
||||
);
|
||||
} catch (reason) {
|
||||
setToast(reason instanceof Error ? reason.message : "截图提交失败");
|
||||
@@ -1407,149 +1427,6 @@ export default function Home() {
|
||||
}
|
||||
|
||||
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 creatorDataReady =
|
||||
Boolean(selected.creator_screenshot_key) &&
|
||||
@@ -1908,6 +1785,153 @@ export default function Home() {
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
|
||||
@@ -1917,15 +1941,17 @@ export default function Home() {
|
||||
}
|
||||
|
||||
if (activeBatch) {
|
||||
const isScreenshotTask = payload.task.type === "screenshot_collect";
|
||||
const completed = activeBatch.assignments.filter((item) =>
|
||||
isScreenshotTask ? item.result_submitted_at : item.publish_url,
|
||||
const recoveryEnabled = payload.task.recoveryEnabled === true;
|
||||
const completed = activeBatch.assignments.filter(
|
||||
(item) => item.publish_url,
|
||||
).length;
|
||||
const recoverySubmitted = recoveryEnabled
|
||||
? activeBatch.assignments.filter((item) => item.result_submitted_at).length
|
||||
: 0;
|
||||
const isClaimOwner = Boolean(payload.claim);
|
||||
const shareableCount = payload.claim?.assignments.filter(
|
||||
(item) =>
|
||||
!(isScreenshotTask ? item.result_submitted_at : item.publish_url) &&
|
||||
!item.delegation_bundle_id,
|
||||
const shareableCount =
|
||||
payload.claim?.assignments.filter(
|
||||
(item) => !item.publish_url && !item.delegation_bundle_id,
|
||||
).length ?? 0;
|
||||
return (
|
||||
<main className="portal-shell">
|
||||
@@ -1943,24 +1969,30 @@ export default function Home() {
|
||||
<section className="claimed-hero">
|
||||
<div>
|
||||
<p className="micro">
|
||||
{isClaimOwner
|
||||
? "我的领取任务"
|
||||
: isScreenshotTask
|
||||
? "合作社转派截图任务包"
|
||||
: "合作社转派发布包"}
|
||||
{isClaimOwner ? "我的领取任务" : "合作社转派发布包"}
|
||||
</p>
|
||||
<h1>{payload.task.name}</h1>
|
||||
<p className="platform-meta-line">
|
||||
<span>{payload.task.brand}</span>
|
||||
<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}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hero-progress">
|
||||
<div className="completion-ring">
|
||||
<strong>{completed}/{activeBatch.assignments.length}</strong>
|
||||
<span>已回填</span>
|
||||
</div>
|
||||
{recoveryEnabled && (
|
||||
<small className="recovery-progress">
|
||||
截图已收 {recoverySubmitted}/{activeBatch.assignments.length}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{isClaimOwner && payload.claim?.joinQrCode ? (
|
||||
<section className="join-qr-card">
|
||||
@@ -1977,23 +2009,16 @@ export default function Home() {
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>
|
||||
{isClaimOwner
|
||||
? isScreenshotTask ? "我领取的截图任务" : "我领取的笔记"
|
||||
: isScreenshotTask ? "需要完成的截图任务" : "需要发布的笔记"}
|
||||
{isClaimOwner ? "我领取的笔记" : "需要发布的笔记"}
|
||||
</h2>
|
||||
<p>
|
||||
{isClaimOwner
|
||||
? isScreenshotTask
|
||||
? "打开一份查看关键词和要求,完成后单独上传截图;也可以转派给底层KOC"
|
||||
: "可逐篇回填,也可导出Excel填写后批量上传;还可以选择笔记转派给底层KOC"
|
||||
: isScreenshotTask
|
||||
? "打开任务查看搜索关键词和要求,完成后逐份上传截图"
|
||||
? "可逐篇回填,也可导出Excel填写后批量上传;还可以选择笔记转派给底层KOC"
|
||||
: "打开一篇查看完整内容,发布后逐篇回填"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="section-actions">
|
||||
<span>{activeBatch.assignments.length} {isScreenshotTask ? "份" : "篇"}</span>
|
||||
{!isScreenshotTask && (
|
||||
<span>{activeBatch.assignments.length} 篇</span>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2017,7 +2042,6 @@ export default function Home() {
|
||||
onChange={(event) => void importBatchWorkbook(event)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isClaimOwner && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -2029,7 +2053,7 @@ export default function Home() {
|
||||
setShareLabel("");
|
||||
}}
|
||||
>
|
||||
{shareComposerOpen ? "取消选择" : isScreenshotTask ? "选择任务并分享" : "选择笔记并分享"}
|
||||
{shareComposerOpen ? "取消选择" : "选择笔记并分享"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -2040,9 +2064,7 @@ export default function Home() {
|
||||
<p className="micro">合作社转派</p>
|
||||
<h3>生成免登录分享链接</h3>
|
||||
<p>
|
||||
{isScreenshotTask
|
||||
? "勾选待提交任务,底层KOC打开链接即可查看要求并上传截图。"
|
||||
: "勾选待发布笔记,底层KOC打开链接即可查看内容并逐篇回填。"}
|
||||
勾选待发布笔记,底层KOC打开链接即可查看内容并逐篇回填。
|
||||
</p>
|
||||
</div>
|
||||
<label>
|
||||
@@ -2056,7 +2078,7 @@ export default function Home() {
|
||||
/>
|
||||
</label>
|
||||
<div className="share-composer-action">
|
||||
<strong>已选 {selectedForShare.length} {isScreenshotTask ? "份" : "篇"}</strong>
|
||||
<strong>已选 {selectedForShare.length} 篇</strong>
|
||||
<button
|
||||
className="share-primary"
|
||||
disabled={
|
||||
@@ -2076,8 +2098,7 @@ export default function Home() {
|
||||
<div className="note-list">
|
||||
{activeBatch.assignments.map((item, index) => {
|
||||
const shareDisabled = Boolean(
|
||||
(isScreenshotTask ? item.result_submitted_at : item.publish_url) ||
|
||||
item.delegation_bundle_id,
|
||||
item.publish_url || item.delegation_bundle_id,
|
||||
);
|
||||
return (
|
||||
<article
|
||||
@@ -2088,8 +2109,8 @@ export default function Home() {
|
||||
<label
|
||||
className={`share-checkbox ${shareDisabled ? "disabled" : ""}`}
|
||||
title={
|
||||
(isScreenshotTask ? item.result_submitted_at : item.publish_url)
|
||||
? isScreenshotTask ? "已提交任务不能转派" : "已发布笔记不能转派"
|
||||
item.publish_url
|
||||
? "已发布笔记不能转派"
|
||||
: item.delegation_bundle_id
|
||||
? `已转派给 ${item.delegation_label}`
|
||||
: "选择这篇笔记"
|
||||
@@ -2105,7 +2126,7 @@ export default function Home() {
|
||||
<span>✓</span>
|
||||
</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")}
|
||||
</span>
|
||||
{item.images[0] ? (
|
||||
@@ -2129,36 +2150,35 @@ export default function Home() {
|
||||
<div>
|
||||
<h3>{item.title}</h3>
|
||||
<p>
|
||||
{isScreenshotTask
|
||||
? `搜索关键词 · ${statusLabel(item, payload.task.type)}`
|
||||
: <>
|
||||
<span>{item.videos.length > 0 ? `${item.videos.length} 个视频` : `${item.images.length} 张配图`}</span>
|
||||
<PlatformBadge platform={payload.task.platform} compact />
|
||||
<span>飞书源行 {item.source_row ?? "—"} · {statusLabel(item, payload.task.type)}</span>
|
||||
</>}
|
||||
{!isScreenshotTask && item.creator_screenshot_key ? " · D7截图已交" : ""}
|
||||
<span>飞书源行 {item.source_row ?? "—"} · {statusLabel(item)}</span>
|
||||
{recoveryEnabled && item.publish_url
|
||||
? item.result_submitted_at
|
||||
? " · 截图已交"
|
||||
: " · 截图待交"
|
||||
: ""}
|
||||
{item.creator_screenshot_key ? " · D7截图已交" : ""}
|
||||
{item.delegation_label ? ` · 已转派给 ${item.delegation_label}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`note-status ${
|
||||
(isScreenshotTask ? item.result_submitted_at : item.publish_url)
|
||||
item.publish_url
|
||||
? "done"
|
||||
: item.delegation_bundle_id
|
||||
? "delegated"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{(isScreenshotTask ? item.result_submitted_at : item.publish_url)
|
||||
? isScreenshotTask ? "已提交" : "已回填"
|
||||
{item.publish_url
|
||||
? "已回填"
|
||||
: item.delegation_bundle_id
|
||||
? "已转派"
|
||||
: isScreenshotTask ? "待提交" : "待发布"}
|
||||
: "待发布"}
|
||||
</span>
|
||||
<button onClick={() => openNote(item)}>
|
||||
{(isScreenshotTask ? item.result_submitted_at : item.publish_url)
|
||||
? "查看 / 修改"
|
||||
: isScreenshotTask ? "查看并上传" : "查看并回填"} →
|
||||
{item.publish_url ? "查看 / 修改" : "查看并回填"} →
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
@@ -2169,7 +2189,7 @@ export default function Home() {
|
||||
<div className="delegation-heading">
|
||||
<div>
|
||||
<p className="micro">分享记录</p>
|
||||
<h3>{isScreenshotTask ? "转派截图任务包" : "转派发布包"}</h3>
|
||||
<h3>转派发布包</h3>
|
||||
</div>
|
||||
<span>{payload.claim.delegations.length} 个</span>
|
||||
</div>
|
||||
@@ -2182,9 +2202,9 @@ export default function Home() {
|
||||
<div>
|
||||
<strong>{bundle.label}</strong>
|
||||
<span>
|
||||
{bundle.quantity} {isScreenshotTask ? "份" : "篇"} · {isScreenshotTask ? "已提交" : "已发布"}{" "}
|
||||
{Number(isScreenshotTask ? bundle.result_completed_count ?? bundle.completed_count : bundle.completed_count ?? 0)}/{bundle.quantity}
|
||||
{!isScreenshotTask && ` · D7完成 ${Number(bundle.creator_completed_count ?? 0)}`}
|
||||
{bundle.quantity} 篇 · 已发布{" "}
|
||||
{Number(bundle.completed_count ?? 0)}/{bundle.quantity}
|
||||
{` · D7完成 ${Number(bundle.creator_completed_count ?? 0)}`}
|
||||
</span>
|
||||
</div>
|
||||
<small>{formatDate(bundle.created_at, true)}</small>
|
||||
@@ -2204,7 +2224,7 @@ export default function Home() {
|
||||
>
|
||||
复制链接
|
||||
</button>
|
||||
{Number(isScreenshotTask ? bundle.result_completed_count ?? bundle.completed_count : bundle.completed_count ?? 0) === 0 && (
|
||||
{Number(bundle.completed_count ?? 0) === 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
@@ -2225,8 +2245,6 @@ export default function Home() {
|
||||
<div className="safe-tip">
|
||||
{isClaimOwner
|
||||
? "以后再次打开原任务链接,填写相同微信号或手机号即可找回全部领取批次和分享记录。"
|
||||
: isScreenshotTask
|
||||
? "请保存当前分享链接;完成任务后通过此链接上传截图即可。"
|
||||
: "请保存当前分享链接;发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"}
|
||||
</div>
|
||||
{toast && <div className={toastClassName(toast)}>{toast}</div>}
|
||||
@@ -2248,13 +2266,15 @@ export default function Home() {
|
||||
</div>
|
||||
<h1>{payload.task.name}</h1>
|
||||
<p>
|
||||
{payload.task.type === "screenshot_collect"
|
||||
? "领取后查看搜索关键词和任务要求,完成后逐份上传截图。"
|
||||
: "领取后可查看完整笔记正文,每篇笔记独立回填发布信息。"}
|
||||
领取后可查看完整笔记正文,每篇笔记独立回填发布信息。
|
||||
{payload.task.recoveryEnabled ? "发布后还需提交回收截图。" : ""}
|
||||
</p>
|
||||
<div className="hero-meta">
|
||||
<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>
|
||||
<aside className="claim-card">
|
||||
@@ -2267,7 +2287,7 @@ export default function Home() {
|
||||
setRecoveredClaims([]);
|
||||
}}
|
||||
>
|
||||
{payload.task.type === "screenshot_collect" ? "领取新任务" : "领取新笔记"}
|
||||
领取新笔记
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2293,7 +2313,7 @@ export default function Home() {
|
||||
<form onSubmit={claimNotes}>
|
||||
<div>
|
||||
<p className="micro">01 · 确认领取</p>
|
||||
<h2>你要领取多少{payload.task.type === "screenshot_collect" ? "份" : "篇"}?</h2>
|
||||
<h2>你要领取多少篇?</h2>
|
||||
</div>
|
||||
{(!wecomIdentity || manualEntry) && (
|
||||
<label>
|
||||
@@ -2312,7 +2332,7 @@ export default function Home() {
|
||||
)}
|
||||
<div className="quantity-control">
|
||||
<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
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -2330,15 +2350,15 @@ export default function Home() {
|
||||
? "当前任务已领完"
|
||||
: working
|
||||
? "领取中…"
|
||||
: `确认领取 ${quantity} ${payload.task.type === "screenshot_collect" ? "份" : "篇"}`}
|
||||
: `确认领取 ${quantity} 篇`}
|
||||
</button>
|
||||
<small>领取后系统会生成你的专属{payload.task.type === "screenshot_collect" ? "任务清单" : "笔记清单"}</small>
|
||||
<small>领取后系统会生成你的专属笔记清单</small>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={recoverClaims}>
|
||||
<div>
|
||||
<p className="micro">找回历史记录</p>
|
||||
<h2>继续之前领取的{payload.task.type === "screenshot_collect" ? "任务" : "笔记"}</h2>
|
||||
<h2>继续之前领取的笔记</h2>
|
||||
<p className="recover-intro">
|
||||
{wecomIdentity && !manualEntry
|
||||
? "已用企微身份查找,同一任务多次领取会分批展示。"
|
||||
@@ -2386,9 +2406,9 @@ export default function Home() {
|
||||
<section className="steps">
|
||||
<div><span>1</span><strong>选择数量</strong><p>一次可领多篇</p></div>
|
||||
<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 />
|
||||
<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>
|
||||
<footer>由 KOC LOOP 提供任务与数据安全保障</footer>
|
||||
{toast && <div className={toastClassName(toast)}>{toast}</div>}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { refreshTaskStatus } from "./task-service";
|
||||
type ReleasableDistribution = {
|
||||
id: string;
|
||||
task_id: string;
|
||||
task_type: string;
|
||||
content_id: string;
|
||||
content_title: string;
|
||||
partner_id: string;
|
||||
@@ -28,7 +27,6 @@ export class DistributionReleaseError extends Error {
|
||||
export function distributionReleaseBlockReason(input: {
|
||||
publishUrl?: string | null;
|
||||
resultSubmittedAt?: string | null;
|
||||
taskType?: string | null;
|
||||
}) {
|
||||
if (input.publishUrl) return "已回填发布链接的笔记不能释放";
|
||||
if (input.resultSubmittedAt) return "已提交结果截图的任务不能释放";
|
||||
@@ -49,7 +47,6 @@ export async function releaseUnfinishedDistribution(
|
||||
`SELECT
|
||||
d.id,
|
||||
d.task_id,
|
||||
t.task_type,
|
||||
d.content_id,
|
||||
c.title AS content_title,
|
||||
d.partner_id,
|
||||
@@ -74,7 +71,6 @@ export async function releaseUnfinishedDistribution(
|
||||
const blocked = distributionReleaseBlockReason({
|
||||
publishUrl: distribution.publish_url,
|
||||
resultSubmittedAt: distribution.result_submitted_at,
|
||||
taskType: distribution.task_type,
|
||||
});
|
||||
if (blocked) throw new DistributionReleaseError(blocked, 409);
|
||||
|
||||
|
||||
@@ -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 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.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
|
||||
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.result_submitted_at IS NOT NULL) AS dist_submitted
|
||||
FROM tasks t
|
||||
LEFT JOIN projects pj ON pj.id = t.project_id`;
|
||||
const distributionsBase = `SELECT
|
||||
d.*,
|
||||
c.title AS content_title,
|
||||
c.related_content_id AS related_content_id,
|
||||
rc.title AS related_content_title,
|
||||
p.name AS partner_name,
|
||||
cl.claimant_name AS claimant_name,
|
||||
a.nickname AS account_nickname,
|
||||
@@ -898,7 +889,6 @@ export async function getDashboardData(ownerUserid?: string) {
|
||||
t.due_at AS due_at
|
||||
FROM distributions d
|
||||
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
|
||||
LEFT JOIN claims cl ON cl.id = d.claim_id
|
||||
JOIN tasks t ON t.id = d.task_id
|
||||
|
||||
@@ -58,12 +58,7 @@ export function buildProjectAppMessage(
|
||||
const portal = text(portalUrl).replace(/\/$/, "");
|
||||
const claimUrl =
|
||||
portal && task.share_token ? `${portal}/?task=${task.share_token}` : "";
|
||||
const formatLabel =
|
||||
task.task_type === "screenshot_collect"
|
||||
? "截图回收"
|
||||
: task.content_format === "video"
|
||||
? "视频"
|
||||
: "图文";
|
||||
const formatLabel = task.content_format === "video" ? "视频" : "图文";
|
||||
return [
|
||||
`【项目任务】${task.name}`,
|
||||
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
|
||||
|
||||
@@ -17,7 +17,6 @@ export type ProjectRow = {
|
||||
main_group_member_ids: string;
|
||||
owner_userid: string;
|
||||
main_chat_id: string;
|
||||
parent_id: string;
|
||||
status: string;
|
||||
note: string;
|
||||
created_at: string;
|
||||
@@ -33,7 +32,6 @@ export type ProjectSummary = {
|
||||
mainGroupMemberIds: string[];
|
||||
ownerUserid: string;
|
||||
mainChatId: string;
|
||||
parentProjectId: string;
|
||||
taskCount: number;
|
||||
};
|
||||
|
||||
@@ -51,16 +49,9 @@ export type ProjectTaskRow = {
|
||||
share_token: string | null;
|
||||
owner_userid: string;
|
||||
task_chat_id: string;
|
||||
project_name?: string;
|
||||
};
|
||||
|
||||
export type ProjectSubproject = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
ownerUserid: string;
|
||||
mainChatId: string;
|
||||
taskCount: number;
|
||||
recovery_enabled: number;
|
||||
recovery_due_at: string;
|
||||
recovery_submitted: number;
|
||||
};
|
||||
|
||||
export type ProjectMainChat = {
|
||||
@@ -72,8 +63,6 @@ export type ProjectDetail = {
|
||||
project: ProjectSummary;
|
||||
tasks: ProjectTaskRow[];
|
||||
mainChat: ProjectMainChat;
|
||||
subprojects: ProjectSubproject[];
|
||||
pushTasks: ProjectTaskRow[];
|
||||
};
|
||||
|
||||
export type MainChatEnsureReason =
|
||||
@@ -238,59 +227,6 @@ export async function ensureProjectMainChat(
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectParents(
|
||||
db: DatabaseClient,
|
||||
): Promise<Map<string, string>> {
|
||||
const result = await db
|
||||
.prepare("SELECT id, parent_id FROM projects")
|
||||
.all<{ id: string; parent_id: string }>();
|
||||
return new Map(result.results.map((row) => [row.id, text(row.parent_id)]));
|
||||
}
|
||||
|
||||
// candidate 是否位于 ancestor 的子孙链上(沿 candidate 的祖先链向上找 ancestor)
|
||||
function isDescendantProject(
|
||||
parents: Map<string, string>,
|
||||
ancestorId: string,
|
||||
candidateId: string,
|
||||
): boolean {
|
||||
let current = candidateId;
|
||||
const seen = new Set<string>();
|
||||
while (current) {
|
||||
if (current === ancestorId) return true;
|
||||
if (seen.has(current)) return false;
|
||||
seen.add(current);
|
||||
current = parents.get(current) ?? "";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function collectDescendantIds(
|
||||
db: DatabaseClient,
|
||||
projectId: string,
|
||||
): Promise<string[]> {
|
||||
const parents = await loadProjectParents(db);
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
for (const [id, parent] of parents) {
|
||||
if (!parent) continue;
|
||||
const list = childrenOf.get(parent) ?? [];
|
||||
list.push(id);
|
||||
childrenOf.set(parent, list);
|
||||
}
|
||||
const descendants: string[] = [];
|
||||
const queue = [text(projectId)];
|
||||
const visited = new Set(queue);
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() as string;
|
||||
for (const child of childrenOf.get(current) ?? []) {
|
||||
if (visited.has(child)) continue;
|
||||
visited.add(child);
|
||||
descendants.push(child);
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
return descendants;
|
||||
}
|
||||
|
||||
export async function listProjects(
|
||||
db: DatabaseClient,
|
||||
ownerUserid?: string,
|
||||
@@ -303,7 +239,7 @@ export async function listProjects(
|
||||
const result = await (ownerFilter
|
||||
? db
|
||||
.prepare(
|
||||
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.parent_id, p.status, p.note, p.created_at,
|
||||
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.status, p.note, p.created_at,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
|
||||
FROM projects p
|
||||
WHERE p.owner_userid = ?
|
||||
@@ -312,7 +248,7 @@ export async function listProjects(
|
||||
.bind(ownerFilter)
|
||||
: db
|
||||
.prepare(
|
||||
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.parent_id, p.status, p.note, p.created_at,
|
||||
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.status, p.note, p.created_at,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
|
||||
FROM projects p
|
||||
ORDER BY p.created_at DESC`,
|
||||
@@ -328,7 +264,6 @@ export async function listProjects(
|
||||
mainGroupMemberIds: parseJsonArray(row.main_group_member_ids),
|
||||
ownerUserid: row.owner_userid ?? "",
|
||||
mainChatId: row.main_chat_id ?? "",
|
||||
parentProjectId: row.parent_id ?? "",
|
||||
taskCount: Number(row.task_count),
|
||||
}));
|
||||
}
|
||||
@@ -340,7 +275,7 @@ export async function getProjectDetail(
|
||||
const id = text(projectId);
|
||||
const project = await db
|
||||
.prepare(
|
||||
`SELECT id, name, brand, main_group_member_ids, owner_userid, main_chat_id, parent_id, status, note, created_at
|
||||
`SELECT id, name, brand, main_group_member_ids, owner_userid, main_chat_id, status, note, created_at
|
||||
FROM projects WHERE id = ?`,
|
||||
)
|
||||
.bind(id)
|
||||
@@ -350,38 +285,13 @@ export async function getProjectDetail(
|
||||
const tasks = await db
|
||||
.prepare(
|
||||
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||
task_type, platform, content_format, share_token, owner_userid, task_chat_id
|
||||
FROM tasks WHERE project_id = ?
|
||||
ORDER BY created_at DESC`,
|
||||
)
|
||||
.bind(id)
|
||||
.all<ProjectTaskRow>();
|
||||
|
||||
const subprojects = await db
|
||||
.prepare(
|
||||
`SELECT p.id, p.name, p.brand, p.owner_userid, p.main_chat_id,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
|
||||
FROM projects p
|
||||
WHERE p.parent_id = ?
|
||||
ORDER BY p.created_at DESC`,
|
||||
)
|
||||
.bind(id)
|
||||
.all<ProjectRow & { task_count: number }>();
|
||||
|
||||
const descendantIds = await collectDescendantIds(db, id);
|
||||
const pushIds = [id, ...descendantIds];
|
||||
const placeholders = pushIds.map(() => "?").join(", ");
|
||||
const pushTasks = await db
|
||||
.prepare(
|
||||
`SELECT t.id, t.name, t.brand, t.quantity, t.claimed_quantity, t.due_at, t.status,
|
||||
t.task_type, t.platform, t.content_format, t.share_token, t.owner_userid, t.task_chat_id,
|
||||
pj.name AS project_name
|
||||
FROM tasks t
|
||||
LEFT JOIN projects pj ON pj.id = t.project_id
|
||||
WHERE t.project_id IN (${placeholders})
|
||||
task_type, platform, content_format, share_token, owner_userid, task_chat_id,
|
||||
recovery_enabled, recovery_due_at,
|
||||
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.result_submitted_at IS NOT NULL) AS recovery_submitted
|
||||
FROM tasks t WHERE t.project_id = ?
|
||||
ORDER BY t.created_at DESC`,
|
||||
)
|
||||
.bind(...pushIds)
|
||||
.bind(id)
|
||||
.all<ProjectTaskRow>();
|
||||
|
||||
return {
|
||||
@@ -395,7 +305,6 @@ export async function getProjectDetail(
|
||||
mainGroupMemberIds: parseJsonArray(project.main_group_member_ids),
|
||||
ownerUserid: project.owner_userid ?? "",
|
||||
mainChatId: project.main_chat_id ?? "",
|
||||
parentProjectId: project.parent_id ?? "",
|
||||
taskCount: tasks.results.length,
|
||||
},
|
||||
tasks: tasks.results,
|
||||
@@ -406,15 +315,6 @@ export async function getProjectDetail(
|
||||
project.owner_userid,
|
||||
),
|
||||
},
|
||||
subprojects: subprojects.results.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
brand: row.brand,
|
||||
ownerUserid: row.owner_userid ?? "",
|
||||
mainChatId: row.main_chat_id ?? "",
|
||||
taskCount: Number(row.task_count),
|
||||
})),
|
||||
pushTasks: pushTasks.results,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -425,7 +325,6 @@ export type SaveProjectInput = {
|
||||
mainGroupMemberIds?: unknown;
|
||||
ownerUserid?: unknown;
|
||||
note?: unknown;
|
||||
parentProjectId?: unknown;
|
||||
};
|
||||
|
||||
export async function saveProject(
|
||||
@@ -439,33 +338,19 @@ export async function saveProject(
|
||||
const brand = text(input.brand);
|
||||
const note = text(input.note ?? "");
|
||||
const ownerUserid = text(input.ownerUserid).slice(0, 64);
|
||||
const parentProjectId = text(input.parentProjectId);
|
||||
const memberIds = effectiveProjectMembers(input.mainGroupMemberIds, ownerUserid);
|
||||
if (!name) throw new ProjectError("请填写项目名称");
|
||||
|
||||
if (parentProjectId) {
|
||||
if (parentProjectId === id) {
|
||||
throw new ProjectError("上级项目不能是项目自身");
|
||||
}
|
||||
const parents = await loadProjectParents(db);
|
||||
if (!parents.has(parentProjectId)) {
|
||||
throw new ProjectError("上级项目不存在", 404);
|
||||
}
|
||||
if (id && isDescendantProject(parents, id, parentProjectId)) {
|
||||
throw new ProjectError("上级项目不能是当前项目的子项目");
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let projectId = id;
|
||||
if (id) {
|
||||
const result = await db
|
||||
.prepare(
|
||||
`UPDATE projects
|
||||
SET name = ?, brand = ?, main_group_member_ids = ?, owner_userid = ?, note = ?, parent_id = ?
|
||||
SET name = ?, brand = ?, main_group_member_ids = ?, owner_userid = ?, note = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, parentProjectId, id)
|
||||
.bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, id)
|
||||
.run();
|
||||
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
|
||||
} else {
|
||||
@@ -473,8 +358,8 @@ export async function saveProject(
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO projects
|
||||
(id, name, brand, main_group_member_ids, owner_userid, parent_id, status, note, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)`,
|
||||
(id, name, brand, main_group_member_ids, owner_userid, status, note, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
projectId,
|
||||
@@ -482,7 +367,6 @@ export async function saveProject(
|
||||
brand,
|
||||
JSON.stringify(memberIds),
|
||||
ownerUserid,
|
||||
parentProjectId,
|
||||
note,
|
||||
now,
|
||||
)
|
||||
@@ -505,13 +389,6 @@ export async function deleteProject(
|
||||
): Promise<void> {
|
||||
const id = text(projectId);
|
||||
if (!id) throw new ProjectError("缺少项目 ID");
|
||||
const childCount = await db
|
||||
.prepare("SELECT COUNT(*) AS count FROM projects WHERE parent_id = ?")
|
||||
.bind(id)
|
||||
.first<{ count: number }>();
|
||||
if (Number(childCount?.count ?? 0) > 0) {
|
||||
throw new ProjectError("该项目下还有子项目,请先删除子项目或把它们移到其他项目下");
|
||||
}
|
||||
const result = await db
|
||||
.prepare("DELETE FROM projects WHERE id = ?")
|
||||
.bind(id)
|
||||
|
||||
@@ -15,17 +15,13 @@ export type CreateDistributionTaskInput = {
|
||||
contentFormat?: "image_text" | "video";
|
||||
projectId?: 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 = {
|
||||
created: boolean;
|
||||
@@ -42,16 +38,7 @@ export type DistributionTaskCreation = {
|
||||
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 = {
|
||||
id: string;
|
||||
@@ -133,9 +120,25 @@ async function findExistingTask(
|
||||
.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(
|
||||
source: FeishuSource,
|
||||
input: CreateDistributionTaskInput,
|
||||
recovery: RecoveryConfig,
|
||||
) {
|
||||
const db = getRawDb();
|
||||
const taskId = uid("task");
|
||||
@@ -146,8 +149,10 @@ async function insertTaskFromSource(
|
||||
(id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||
platform, content_format, project_id, owner_userid,
|
||||
source_url, source_sheet_id, source_sheet_name, source_synced_at,
|
||||
share_token, join_qr_code)
|
||||
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?, ?, ?, '')`,
|
||||
share_token, join_qr_code,
|
||||
recovery_enabled, recovery_due_at, recovery_instructions, recovery_example_images)
|
||||
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?, ?, ?, '',
|
||||
?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
taskId,
|
||||
@@ -164,6 +169,10 @@ async function insertTaskFromSource(
|
||||
source.sheetName,
|
||||
source.syncedAt,
|
||||
shareToken,
|
||||
recovery.enabled ? 1 : 0,
|
||||
recovery.dueAt,
|
||||
recovery.instructions,
|
||||
recovery.exampleImages,
|
||||
)
|
||||
.run();
|
||||
|
||||
@@ -228,6 +237,10 @@ export async function createDistributionTask(
|
||||
contentFormat: rawInput.contentFormat === "video" ? "video" : "image_text",
|
||||
projectId: normalizedValue(rawInput.projectId ?? ""),
|
||||
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) {
|
||||
throw new Error("请补全飞书链接、任务名称和品牌/项目");
|
||||
@@ -260,7 +273,17 @@ export async function createDistributionTask(
|
||||
) {
|
||||
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 {
|
||||
created: true,
|
||||
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 = {
|
||||
taskId: string;
|
||||
name: string;
|
||||
@@ -420,10 +308,10 @@ export type UpdateTaskInput = {
|
||||
projectId?: string;
|
||||
platform?: "小红书" | "抖音";
|
||||
contentFormat?: "image_text" | "video";
|
||||
quantity?: number;
|
||||
keyword?: string;
|
||||
instructions?: string;
|
||||
exampleImageKey?: string;
|
||||
recoveryEnabled?: boolean;
|
||||
recoveryDueAt?: string;
|
||||
recoveryInstructions?: string;
|
||||
recoveryExampleImageKey?: string;
|
||||
};
|
||||
|
||||
export type TaskUpdateResult = {
|
||||
@@ -434,79 +322,16 @@ export type TaskUpdateResult = {
|
||||
|
||||
type UpdatableTaskRow = {
|
||||
id: string;
|
||||
task_type: "content_publish" | "screenshot_collect";
|
||||
quantity: number;
|
||||
claimed_quantity: number;
|
||||
platform: "小红书" | "抖音";
|
||||
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(
|
||||
rawInput: UpdateTaskInput,
|
||||
db: DatabaseClient = getRawDb(),
|
||||
@@ -524,10 +349,7 @@ export async function updateTask(
|
||||
contentFormat: rawInput.contentFormat === "video" || rawInput.contentFormat === "image_text"
|
||||
? rawInput.contentFormat
|
||||
: undefined,
|
||||
quantity: Math.floor(Number(rawInput.quantity ?? 0)),
|
||||
keyword: normalizedValue(rawInput.keyword ?? ""),
|
||||
instructions: normalizedValue(rawInput.instructions ?? ""),
|
||||
exampleImageKey: normalizedValue(rawInput.exampleImageKey ?? ""),
|
||||
recoveryExampleImageKey: normalizedValue(rawInput.recoveryExampleImageKey ?? ""),
|
||||
};
|
||||
if (!input.name || !input.brand) {
|
||||
throw new Error("请补全任务名称和品牌");
|
||||
@@ -535,81 +357,41 @@ export async function updateTask(
|
||||
|
||||
const row = await db
|
||||
.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)
|
||||
.first<UpdatableTaskRow>();
|
||||
if (!row) throw new Error("任务不存在");
|
||||
|
||||
const quantity = Number(row.quantity);
|
||||
const claimed = Number(row.claimed_quantity);
|
||||
let quantity = Number(row.quantity);
|
||||
if (row.task_type === "screenshot_collect") {
|
||||
const related = Boolean(row.related_task_id);
|
||||
if (!input.instructions) {
|
||||
throw new Error(related ? "请补全任务说明" : "请补全搜索关键词和任务说明");
|
||||
}
|
||||
if (related) {
|
||||
if (
|
||||
rawInput.quantity !== undefined &&
|
||||
rawInput.quantity !== null &&
|
||||
input.quantity !== quantity
|
||||
) {
|
||||
throw new Error("关联任务的份数由分发任务决定,不可修改");
|
||||
}
|
||||
if (input.keyword) {
|
||||
const firstTitle = await db
|
||||
.prepare(
|
||||
"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,
|
||||
});
|
||||
}
|
||||
}
|
||||
const recoveryEnabled =
|
||||
rawInput.recoveryEnabled === undefined
|
||||
? row.recovery_enabled
|
||||
: rawInput.recoveryEnabled
|
||||
? 1
|
||||
: 0;
|
||||
const recoveryDueAtInput = normalizedValue(rawInput.recoveryDueAt ?? "");
|
||||
const recoveryDueAt = recoveryDueAtInput
|
||||
? normalizedDueDate(recoveryDueAtInput)
|
||||
: row.recovery_due_at || input.dueAt;
|
||||
const recoveryInstructions =
|
||||
normalizedValue(rawInput.recoveryInstructions ?? "") ||
|
||||
row.recovery_instructions ||
|
||||
"";
|
||||
const recoveryExampleImages = input.recoveryExampleImageKey
|
||||
? recoveryExampleImagesJson(input.recoveryExampleImageKey)
|
||||
: row.recovery_example_images;
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
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 = ?`,
|
||||
)
|
||||
.bind(
|
||||
@@ -620,6 +402,10 @@ export async function updateTask(
|
||||
input.platform ?? row.platform,
|
||||
input.contentFormat ?? row.content_format,
|
||||
quantity,
|
||||
recoveryEnabled,
|
||||
recoveryDueAt,
|
||||
recoveryInstructions,
|
||||
recoveryExampleImages,
|
||||
taskId,
|
||||
)
|
||||
.run();
|
||||
@@ -628,6 +414,52 @@ export async function updateTask(
|
||||
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) {
|
||||
const origin = normalizedValue(portalOrigin).replace(/\/$/, "");
|
||||
if (!origin) throw new Error("KOC 领取站点地址尚未配置");
|
||||
@@ -638,32 +470,29 @@ export function buildClaimUrl(portalOrigin: string, shareToken: string) {
|
||||
|
||||
type TaskStatusRow = {
|
||||
id: string;
|
||||
task_type: "content_publish" | "screenshot_collect";
|
||||
status: string;
|
||||
};
|
||||
|
||||
type DistributionProgressRow = {
|
||||
total: number;
|
||||
complete_count: number;
|
||||
submitted_count: number;
|
||||
};
|
||||
|
||||
// 纯重算任务状态:条件满足置 completed,不满足回 active(释放重开等场景);
|
||||
// importing 是导入中间态,不参与推导
|
||||
// importing 是导入中间态,不参与推导。完成判定取发布阶段口径(complete_count)
|
||||
export async function refreshTaskStatus(
|
||||
taskId: string,
|
||||
db: DatabaseClient = getRawDb(),
|
||||
): Promise<string> {
|
||||
const row = await db
|
||||
.prepare("SELECT id, task_type, status FROM tasks WHERE id = ?")
|
||||
.prepare("SELECT id, status FROM tasks WHERE id = ?")
|
||||
.bind(taskId)
|
||||
.first<TaskStatusRow>();
|
||||
if (!row || row.status === "importing") return row?.status ?? "";
|
||||
const progress = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS total,
|
||||
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
|
||||
COALESCE(SUM(CASE WHEN status = 'complete' THEN 1 ELSE 0 END), 0) AS complete_count
|
||||
FROM distributions WHERE task_id = ?`,
|
||||
)
|
||||
.bind(taskId)
|
||||
@@ -676,13 +505,10 @@ export async function refreshTaskStatus(
|
||||
.first<{ available_count: number }>();
|
||||
const total = Number(progress?.total ?? 0);
|
||||
const availableCount = Number(available?.available_count ?? 0);
|
||||
let completed = false;
|
||||
if (total > 0 && availableCount === 0) {
|
||||
completed =
|
||||
row.task_type === "screenshot_collect"
|
||||
? Number(progress?.submitted_count ?? 0) === total
|
||||
: Number(progress?.complete_count ?? 0) === total;
|
||||
}
|
||||
const completed =
|
||||
total > 0 &&
|
||||
availableCount === 0 &&
|
||||
Number(progress?.complete_count ?? 0) === total;
|
||||
const next = completed ? "completed" : "active";
|
||||
if (next !== row.status) {
|
||||
await db
|
||||
|
||||
@@ -80,12 +80,7 @@ export function buildTaskGroupPushMessage(
|
||||
const portal = portalUrl.trim().replace(/\/$/, "");
|
||||
const shareToken = bindingValue(task.share_token);
|
||||
const claimUrl = portal && shareToken ? `${portal}/?task=${shareToken}` : "";
|
||||
const formatLabel =
|
||||
task.task_type === "screenshot_collect"
|
||||
? "截图回收"
|
||||
: task.content_format === "video"
|
||||
? "视频"
|
||||
: "图文";
|
||||
const formatLabel = task.content_format === "video" ? "视频" : "图文";
|
||||
const lines = [
|
||||
`【新任务】${task.name}`,
|
||||
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
|
||||
|
||||
@@ -27,17 +27,17 @@ type DueSoonRow = {
|
||||
wecom_external_user_id: string | null;
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
task_type: string;
|
||||
due_at: string;
|
||||
pending_phase: "publish" | "recovery";
|
||||
pending_due_at: string;
|
||||
content_title: string;
|
||||
};
|
||||
|
||||
function pendingActionLabel(taskType: string) {
|
||||
return taskType === "screenshot_collect" ? "截图待提交" : "待发布";
|
||||
function pendingActionLabel(pendingPhase: string) {
|
||||
return pendingPhase === "recovery" ? "截图待提交" : "待发布";
|
||||
}
|
||||
|
||||
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;
|
||||
if (shot === 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 taskFilter = String(taskId ?? "").trim();
|
||||
|
||||
// 双阶段催办:发布阶段(未回填链接)看 due_at;回收阶段(已发布未收截图)看 recovery_due_at
|
||||
const dueSoonBase = `SELECT
|
||||
d.id AS distribution_id,
|
||||
d.partner_id,
|
||||
@@ -73,27 +74,29 @@ export async function runDueSoonWecomNotifications(
|
||||
p.wecom_external_user_id,
|
||||
t.id AS task_id,
|
||||
t.name AS task_name,
|
||||
t.task_type,
|
||||
t.due_at,
|
||||
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,
|
||||
c.title AS content_title
|
||||
FROM distributions d
|
||||
JOIN tasks t ON t.id = d.task_id
|
||||
JOIN partners p ON p.id = d.partner_id
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
WHERE (
|
||||
(t.task_type = 'screenshot_collect' AND d.result_submitted_at IS NULL)
|
||||
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 != ''
|
||||
AND t.due_at <= ?`;
|
||||
((d.publish_url IS NULL OR d.publish_url = '')
|
||||
AND t.due_at IS NOT NULL AND t.due_at != '' AND t.due_at <= ?)
|
||||
OR (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 statement = db
|
||||
.prepare(
|
||||
taskFilter
|
||||
? `${dueSoonBase} AND t.id = ? ORDER BY t.due_at ASC, p.name ASC`
|
||||
: `${dueSoonBase} 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 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 grouped = new Map<
|
||||
@@ -129,7 +132,7 @@ export async function runDueSoonWecomNotifications(
|
||||
? [entry.externalUserId]
|
||||
: [];
|
||||
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 =
|
||||
entry.rows.length > 5 ? `\n…还有 ${entry.rows.length - 5} 条` : "";
|
||||
@@ -155,7 +158,7 @@ export async function runDueSoonWecomNotifications(
|
||||
} else {
|
||||
dueSoonSent += 1;
|
||||
}
|
||||
const earliestDue = entry.rows[0]?.due_at ?? "";
|
||||
const earliestDue = entry.rows[0]?.pending_due_at ?? "";
|
||||
digestTasks.push(
|
||||
`· ${entry.partnerName}(${entry.rows.length} 条,最近截止 ${earliestDue})`,
|
||||
);
|
||||
@@ -168,7 +171,7 @@ export async function runDueSoonWecomNotifications(
|
||||
for (const row of result.results) {
|
||||
const entry = byTask.get(row.task_id) ?? {
|
||||
taskName: row.task_name,
|
||||
dueAt: row.due_at,
|
||||
dueAt: row.pending_due_at,
|
||||
rows: [],
|
||||
};
|
||||
entry.rows.push(row);
|
||||
@@ -224,7 +227,8 @@ type OverdueRow = {
|
||||
id: string;
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
due_at: string;
|
||||
pending_phase: "publish" | "recovery";
|
||||
pending_due_at: string;
|
||||
project_id: string;
|
||||
partner_id: string;
|
||||
partner_name: string;
|
||||
@@ -241,24 +245,33 @@ export type OverdueEscalationSummary = {
|
||||
async function queryOverdueUnpublished(
|
||||
db: DatabaseClient,
|
||||
cutoff: string,
|
||||
options: { phases: "publish" | "all" } = { phases: "all" },
|
||||
): 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
|
||||
.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
|
||||
FROM distributions d
|
||||
JOIN tasks t ON t.id = d.task_id
|
||||
JOIN partners p ON p.id = d.partner_id
|
||||
WHERE (
|
||||
(t.task_type = 'screenshot_collect' AND d.result_submitted_at IS NULL)
|
||||
OR (t.task_type != 'screenshot_collect'
|
||||
AND (d.publish_url IS NULL OR d.publish_url = ''))
|
||||
WHERE ${where}
|
||||
ORDER BY pending_due_at ASC, t.name ASC`,
|
||||
)
|
||||
AND t.due_at IS NOT NULL AND t.due_at != ''
|
||||
AND t.due_at < ?
|
||||
ORDER BY t.due_at ASC, t.name ASC`,
|
||||
)
|
||||
.bind(cutoff)
|
||||
.bind(...(options.phases === "publish" ? [cutoff] : [cutoff, cutoff]))
|
||||
.all<OverdueRow>();
|
||||
return result.results;
|
||||
}
|
||||
@@ -274,19 +287,28 @@ export async function runOverdueEscalation(
|
||||
|
||||
const byTask = new Map<
|
||||
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) {
|
||||
const entry = byTask.get(row.task_id) ?? {
|
||||
const key = `${row.task_id}:${row.pending_phase}`;
|
||||
const entry = byTask.get(key) ?? {
|
||||
taskName: row.task_name,
|
||||
dueAt: row.due_at,
|
||||
dueAt: row.pending_due_at,
|
||||
phase: row.pending_phase,
|
||||
projectId: bindingValue(row.project_id),
|
||||
count: 0,
|
||||
partners: new Set<string>(),
|
||||
};
|
||||
entry.count += 1;
|
||||
entry.partners.add(row.partner_name || row.partner_id);
|
||||
byTask.set(row.task_id, entry);
|
||||
byTask.set(key, entry);
|
||||
}
|
||||
|
||||
const summary: OverdueEscalationSummary = {
|
||||
@@ -303,7 +325,8 @@ export async function runOverdueEscalation(
|
||||
const lines = [...byTask.values()].map((entry) => {
|
||||
const partners = [...entry.partners].slice(0, 5).join("、");
|
||||
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 {
|
||||
await sendWecomRobotMessage(
|
||||
@@ -321,11 +344,16 @@ export async function runOverdueEscalation(
|
||||
|
||||
const env = getRuntimeEnv();
|
||||
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(
|
||||
db,
|
||||
taskId,
|
||||
`【超期催办】《${truncate(entry.taskName, 24)}》(截止 ${entry.dueAt})有 ${entry.count} 条内容超期未发布,请跟进催办或释放。`,
|
||||
`【超期催办】《${truncate(entry.taskName, 24)}》(截止 ${entry.dueAt})有 ${overdueText},请跟进催办或释放。`,
|
||||
config,
|
||||
fetchImpl,
|
||||
);
|
||||
@@ -343,7 +371,7 @@ export async function runOverdueEscalation(
|
||||
touser: members,
|
||||
taskId: `task:${taskId}:due`,
|
||||
title: `超期催办:${truncate(entry.taskName, 20)}`,
|
||||
description: `${entry.count} 条内容超期未发布(截止 ${entry.dueAt}),请跟进催办或释放`,
|
||||
description: `${entry.phase === "recovery" ? `${entry.count} 份截图超期未提交` : `${entry.count} 条内容超期未发布`}(截止 ${entry.dueAt}),请跟进催办或释放`,
|
||||
url: cardUrl,
|
||||
buttons: [
|
||||
{ key: "view_task", name: "查看任务", url: cardUrl },
|
||||
@@ -398,7 +426,10 @@ export async function runOverdueAutoRelease(
|
||||
|
||||
const today = shanghaiDateFromTimestamp(now);
|
||||
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) {
|
||||
try {
|
||||
|
||||
1
mysql/0019_project_drop_parent.sql
Normal file
1
mysql/0019_project_drop_parent.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `projects` DROP COLUMN `parent_id`;
|
||||
125
mysql/0020_task_recovery_merge.sql
Normal file
125
mysql/0020_task_recovery_merge.sql
Normal 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 != '';
|
||||
@@ -78,7 +78,7 @@ test("project-owner management actions gate on project ownership", () => {
|
||||
"project_delete",
|
||||
"project_ensure_main_chat",
|
||||
"create_task",
|
||||
"create_screenshot_task",
|
||||
"task_enable_recovery",
|
||||
]) {
|
||||
const body = section(actionRoute, action);
|
||||
assert.match(
|
||||
@@ -319,7 +319,7 @@ test("project detail exposes inline main-chat member management", () => {
|
||||
assert.match(globalsCss, /\.project-member-remove/);
|
||||
});
|
||||
|
||||
test("every project_save payload carries parentProjectId(改总群成员不得清空层级)", () => {
|
||||
test("no project_save payload carries parentProjectId(项目层级已移除)", () => {
|
||||
const adminApp = readFileSync(
|
||||
new URL("../app/admin-app.tsx", import.meta.url),
|
||||
"utf8",
|
||||
@@ -330,10 +330,9 @@ test("every project_save payload carries parentProjectId(改总群成员不得
|
||||
assert.ok(starts.length >= 2, "至少应有两处 project_save 调用(编辑器/总群成员)");
|
||||
for (const start of starts) {
|
||||
const payload = adminApp.slice(start, start + 600);
|
||||
assert.match(
|
||||
payload,
|
||||
/parentProjectId/,
|
||||
"project_save 载荷缺 parentProjectId,会把 parent_id 重置为空",
|
||||
assert.ok(
|
||||
!/parentProjectId/.test(payload),
|
||||
"project_save 载荷不应再带 parentProjectId(项目层级已移除)",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -109,5 +109,5 @@ test("recover builds dynamic partner candidates including wecom identity", () =>
|
||||
routeSource,
|
||||
/wecomPartnerId \? \[wecomPartnerId\] : \[partnerId, legacyPartnerId\]/,
|
||||
);
|
||||
assert.match(routeSource, /\.bind\(task\.task_type, task\.task_type, task\.id, \.\.\.partnerCandidates\)/);
|
||||
assert.match(routeSource, /\.bind\(task\.id, \.\.\.partnerCandidates\)/);
|
||||
});
|
||||
|
||||
@@ -105,11 +105,10 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
|
||||
brand: params[2],
|
||||
main_group_member_ids: params[3],
|
||||
owner_userid: params[4],
|
||||
parent_id: params[5],
|
||||
main_chat_id: "",
|
||||
status: "active",
|
||||
note: params[6],
|
||||
created_at: params[7],
|
||||
note: params[5],
|
||||
created_at: params[6],
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
@@ -119,7 +118,7 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
|
||||
return row ? 1 : 0;
|
||||
}
|
||||
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;
|
||||
Object.assign(row, {
|
||||
name: params[0],
|
||||
@@ -127,7 +126,6 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
|
||||
main_group_member_ids: params[2],
|
||||
owner_userid: params[3],
|
||||
note: params[4],
|
||||
parent_id: params[5],
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
@@ -172,11 +170,6 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
|
||||
},
|
||||
async first() {
|
||||
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)) {
|
||||
return projects.find((item) => item.id === params[0]) ?? null;
|
||||
}
|
||||
@@ -196,22 +189,11 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
|
||||
},
|
||||
async all() {
|
||||
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)) {
|
||||
const ownerFiltered = /WHERE p\.owner_userid = \?/.test(sql);
|
||||
const parentFiltered = /WHERE p\.parent_id = \?/.test(sql);
|
||||
let rows = projects;
|
||||
if (ownerFiltered) {
|
||||
rows = rows.filter((item) => item.owner_userid === params[0]);
|
||||
} else if (parentFiltered) {
|
||||
rows = rows.filter((item) => item.parent_id === params[0]);
|
||||
}
|
||||
return {
|
||||
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 {
|
||||
results: tasks
|
||||
.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) => ({
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "",
|
||||
recovery_submitted: 0,
|
||||
...task,
|
||||
project_name:
|
||||
projects.find((item) => item.id === task.project_id)?.name ?? "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -267,7 +242,6 @@ const projectRow = (overrides = {}) => ({
|
||||
brand: "",
|
||||
main_group_member_ids: "[]",
|
||||
owner_userid: "",
|
||||
parent_id: "",
|
||||
main_chat_id: "",
|
||||
status: "active",
|
||||
note: "",
|
||||
@@ -436,6 +410,9 @@ test("getProjectDetail returns tasks with owner and mainChat members", async ()
|
||||
project_id: "proj-1",
|
||||
owner_userid: "owner-a",
|
||||
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.tasks[0].owner_userid, "owner-a");
|
||||
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.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);
|
||||
});
|
||||
|
||||
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(),
|
||||
["一级:直属任务", "三级:孙项目任务", "二级:子项目任务"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ test("merges distribution detail into the task center", async () => {
|
||||
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(adminApp, /分发明细/);
|
||||
assert.match(adminApp, /查看明细/);
|
||||
assert.match(adminApp, /function TaskDistributionDetail/);
|
||||
assert.match(adminApp, /选择一个数据回收任务/);
|
||||
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, /task-scope-subline/);
|
||||
assert.match(adminApp, /识别失败,请手动填写/);
|
||||
assert.match(adminApp, /分发明细/);
|
||||
assert.match(adminApp, /查看明细/);
|
||||
assert.match(adminApp, /AdminImageLightbox/);
|
||||
assert.match(adminApp, /CreatorScreenshotPreview/);
|
||||
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"/);
|
||||
});
|
||||
|
||||
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] =
|
||||
await Promise.all([
|
||||
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"),
|
||||
]);
|
||||
|
||||
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, /截图回收任务无需填写发布链接/);
|
||||
assert.match(partnerRoute, /请先回填这篇笔记的发布链接,再提交回收截图/);
|
||||
assert.match(partnerRoute, /recovery_enabled/);
|
||||
assert.match(uploadRoute, /task-results\//);
|
||||
assert.match(uploadRoute, /task-result/);
|
||||
assert.match(uploadRoute, /MAX_RESULT_SCREENSHOTS/);
|
||||
|
||||
95
tests/task-pairing.test.mjs
Normal file
95
tests/task-pairing.test.mjs
Normal 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'/);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from "node:test";
|
||||
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 = [] }) {
|
||||
const tasks = task ? [task] : [];
|
||||
@@ -14,21 +14,40 @@ function fakeDb({ task, contents = [] }) {
|
||||
if (/FROM tasks WHERE id = \?/.test(sql)) {
|
||||
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;
|
||||
},
|
||||
async run() {
|
||||
const normalized = sql.trim();
|
||||
if (/^UPDATE tasks/.test(normalized)) {
|
||||
const [name, brand, due_at, project_id, platform, content_format, quantity, id] =
|
||||
if (/^UPDATE tasks\s+SET recovery_enabled = 1/.test(normalized)) {
|
||||
const [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, {
|
||||
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, {
|
||||
name,
|
||||
brand,
|
||||
@@ -37,50 +56,13 @@ function fakeDb({ task, contents = [] }) {
|
||||
platform,
|
||||
content_format,
|
||||
quantity,
|
||||
recovery_enabled,
|
||||
recovery_due_at,
|
||||
recovery_instructions,
|
||||
recovery_example_images,
|
||||
});
|
||||
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 } };
|
||||
},
|
||||
async all() {
|
||||
@@ -101,234 +83,78 @@ function fakeDb({ task, contents = [] }) {
|
||||
return { db, tasks, contents: rows };
|
||||
}
|
||||
|
||||
function screenshotTask(overrides = {}) {
|
||||
function publishTask(overrides = {}) {
|
||||
return {
|
||||
id: "task-shot-1",
|
||||
task_type: "screenshot_collect",
|
||||
quantity: 3,
|
||||
claimed_quantity: 0,
|
||||
platform: "小红书",
|
||||
content_format: "image_text",
|
||||
id: "task-pub-1",
|
||||
task_type: "content_publish",
|
||||
quantity: 18,
|
||||
claimed_quantity: 2,
|
||||
platform: "抖音",
|
||||
content_format: "video",
|
||||
name: "旧名称",
|
||||
brand: "旧品牌",
|
||||
due_at: "2026-08-01",
|
||||
project_id: "",
|
||||
status: "active",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-08-10",
|
||||
recovery_instructions: "请提交发布后的笔记截图",
|
||||
recovery_example_images: "[]",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function screenshotContents(quantity, overrides = {}) {
|
||||
function publishContents(quantity) {
|
||||
return Array.from({ length: quantity }, (_, index) => ({
|
||||
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",
|
||||
title: "笔记标题",
|
||||
body: "笔记正文",
|
||||
image_assets: "[]",
|
||||
status: "available",
|
||||
source_row: 1,
|
||||
},
|
||||
],
|
||||
status: index < 2 ? "claimed" : "available",
|
||||
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",
|
||||
name: "新名称",
|
||||
brand: "新品牌",
|
||||
dueAt: "2026-09-30",
|
||||
projectId: "proj-2",
|
||||
quantity: 99,
|
||||
keyword: "ignored",
|
||||
instructions: "ignored",
|
||||
projectId: "proj-1",
|
||||
},
|
||||
db,
|
||||
);
|
||||
assert.equal(tasks[0].quantity, 18, "feishu row count untouched");
|
||||
assert.equal(tasks[0].platform, "抖音", "platform kept when not provided");
|
||||
assert.equal(tasks[0].content_format, "video");
|
||||
assert.equal(contents[0].title, "笔记标题", "contents untouched");
|
||||
assert.equal(result.quantity, 18);
|
||||
assert.equal(result.claimedQuantity, 2);
|
||||
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(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({
|
||||
task: screenshotTask({
|
||||
id: "task-pub-1",
|
||||
task_type: "content_publish",
|
||||
platform: "小红书",
|
||||
content_format: "image_text",
|
||||
}),
|
||||
task: publishTask({ platform: "小红书", content_format: "image_text" }),
|
||||
});
|
||||
await updateTask(
|
||||
{
|
||||
@@ -345,22 +171,107 @@ test("updateTask applies provided platform/contentFormat for content_publish", a
|
||||
assert.equal(tasks[0].content_format, "video");
|
||||
});
|
||||
|
||||
test("updateTask rejects unknown tasks and invalid dates", async () => {
|
||||
const { db } = fakeDb({ task: screenshotTask() });
|
||||
test("updateTask updates recovery fields and can toggle recovery off", async () => {
|
||||
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(
|
||||
() =>
|
||||
updateTask(
|
||||
{ taskId: "missing", name: "n", brand: "b", dueAt: "2026-09-30" },
|
||||
db,
|
||||
),
|
||||
updateTask({ taskId: "missing", name: "n", brand: "b", dueAt: "2026-09-30" }, db),
|
||||
/任务不存在/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
updateTask(
|
||||
{ taskId: "task-shot-1", name: "n", brand: "b", dueAt: "2026-13-01" },
|
||||
db,
|
||||
),
|
||||
updateTask({ taskId: "task-pub-1", name: "n", brand: "b", dueAt: "2026-13-01" }, db),
|
||||
/截止日期/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => enableTaskRecovery({ taskId: "missing" }, db),
|
||||
/任务不存在/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,7 +26,8 @@ const overdueRows = [
|
||||
task_id: "task-A",
|
||||
task_name: "超期任务A",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "content_publish",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-08-27",
|
||||
publish_url: null,
|
||||
result_submitted_at: null,
|
||||
project_id: "proj-1",
|
||||
@@ -38,7 +39,8 @@ const overdueRows = [
|
||||
task_id: "task-A",
|
||||
task_name: "超期任务A",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "content_publish",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-08-27",
|
||||
publish_url: null,
|
||||
result_submitted_at: null,
|
||||
project_id: "proj-1",
|
||||
@@ -50,7 +52,8 @@ const overdueRows = [
|
||||
task_id: "task-B",
|
||||
task_name: "无项目任务B",
|
||||
due_at: "2026-08-25",
|
||||
task_type: "content_publish",
|
||||
recovery_enabled: 0,
|
||||
recovery_due_at: "",
|
||||
publish_url: null,
|
||||
result_submitted_at: null,
|
||||
project_id: "",
|
||||
@@ -62,7 +65,6 @@ const overdueRows = [
|
||||
const releasableDistribution = {
|
||||
id: "d-1",
|
||||
task_id: "task-A",
|
||||
task_type: "content_publish",
|
||||
content_id: "c-1",
|
||||
content_title: "超期内容",
|
||||
partner_id: "p-1",
|
||||
@@ -73,13 +75,30 @@ const releasableDistribution = {
|
||||
result_submitted_at: null,
|
||||
};
|
||||
|
||||
function filterOverdue(rows, cutoff) {
|
||||
return rows.filter((row) => {
|
||||
if (!(row.due_at && row.due_at < cutoff)) return false;
|
||||
return row.task_type === "screenshot_collect"
|
||||
? !row.result_submitted_at
|
||||
: !row.publish_url;
|
||||
// 双阶段超期判定:发布阶段看 due_at,回收阶段(已发布未收截图)看 recovery_due_at
|
||||
function filterOverdue(rows, cutoff, { phases = "all" } = {}) {
|
||||
const output = [];
|
||||
for (const row of rows) {
|
||||
if (!row.publish_url && row.due_at && row.due_at < cutoff) {
|
||||
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 } = {}) {
|
||||
@@ -95,11 +114,12 @@ function fakeEscalationDb({ rows = overdueRows } = {}) {
|
||||
return null;
|
||||
},
|
||||
async all() {
|
||||
if (
|
||||
/FROM distributions d/.test(sql) &&
|
||||
/result_submitted_at IS NULL/.test(sql)
|
||||
) {
|
||||
return { results: filterOverdue(rows, params[0]) };
|
||||
if (/FROM distributions d/.test(sql)) {
|
||||
return {
|
||||
results: filterOverdue(rows, params[0], {
|
||||
phases: sql.includes("t.recovery_enabled") ? "all" : "publish",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { results: [] };
|
||||
},
|
||||
@@ -138,11 +158,12 @@ function fakeReleaseDb({ rows = overdueRows, blockedIds = [] } = {}) {
|
||||
return null;
|
||||
},
|
||||
async all() {
|
||||
if (
|
||||
/FROM distributions d/.test(sql) &&
|
||||
/result_submitted_at IS NULL/.test(sql)
|
||||
) {
|
||||
return { results: filterOverdue(rows, params[0]) };
|
||||
if (/FROM distributions d/.test(sql)) {
|
||||
return {
|
||||
results: filterOverdue(rows, params[0], {
|
||||
phases: sql.includes("t.recovery_enabled") ? "all" : "publish",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { results: [] };
|
||||
},
|
||||
@@ -250,38 +271,44 @@ test("runOverdueEscalation no-ops without overdue rows", async () => {
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("runOverdueEscalation distinguishes screenshot tasks from content tasks", async () => {
|
||||
test("runOverdueEscalation 按发布/回收两阶段分别出文案", async () => {
|
||||
const rows = [
|
||||
{
|
||||
id: "d-s1",
|
||||
// 回收阶段超期:已发布、未收截图、recovery_due_at 已过
|
||||
id: "d-r1",
|
||||
task_id: "task-S",
|
||||
task_name: "截图任务S",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "screenshot_collect",
|
||||
publish_url: null,
|
||||
task_name: "回收任务S",
|
||||
due_at: "2026-08-10",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-08-20",
|
||||
publish_url: "https://www.xiaohongshu.com/note/s1",
|
||||
result_submitted_at: null,
|
||||
project_id: "",
|
||||
partner_id: "p-1",
|
||||
partner_name: "合作方甲",
|
||||
},
|
||||
{
|
||||
id: "d-s2",
|
||||
// 已提交截图:两阶段都不超期
|
||||
id: "d-r2",
|
||||
task_id: "task-S",
|
||||
task_name: "截图任务S",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "screenshot_collect",
|
||||
publish_url: null,
|
||||
task_name: "回收任务S",
|
||||
due_at: "2026-08-10",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-08-20",
|
||||
publish_url: "https://www.xiaohongshu.com/note/s2",
|
||||
result_submitted_at: "2026-08-21 09:00:00",
|
||||
project_id: "",
|
||||
partner_id: "p-2",
|
||||
partner_name: "合作方乙",
|
||||
},
|
||||
{
|
||||
// 已发布且回收未到期:不算超期
|
||||
id: "d-c1",
|
||||
task_id: "task-A",
|
||||
task_name: "内容任务A",
|
||||
task_name: "发布任务A",
|
||||
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",
|
||||
result_submitted_at: null,
|
||||
project_id: "",
|
||||
@@ -289,11 +316,13 @@ test("runOverdueEscalation distinguishes screenshot tasks from content tasks", a
|
||||
partner_name: "合作方丙",
|
||||
},
|
||||
{
|
||||
// 发布阶段超期:未回填链接
|
||||
id: "d-c2",
|
||||
task_id: "task-A",
|
||||
task_name: "内容任务A",
|
||||
task_name: "发布任务A",
|
||||
due_at: "2026-08-20",
|
||||
task_type: "content_publish",
|
||||
recovery_enabled: 1,
|
||||
recovery_due_at: "2026-09-10",
|
||||
publish_url: null,
|
||||
result_submitted_at: null,
|
||||
project_id: "",
|
||||
@@ -308,15 +337,14 @@ test("runOverdueEscalation distinguishes screenshot tasks from content tasks", a
|
||||
wecomFetch(calls),
|
||||
NOW,
|
||||
);
|
||||
// 已提交截图的 d-s2 与已回填链接的 d-c1 不算超期
|
||||
assert.equal(summary.overdueTasks, 2);
|
||||
assert.equal(summary.overdueTasks, 2, "任务×阶段 各一条");
|
||||
assert.equal(summary.overdueDistributions, 2);
|
||||
const robotBodies = calls
|
||||
.filter((call) => call.url.host === "qyapi.example.com")
|
||||
.map((call) => JSON.parse(String(call.init.body)));
|
||||
assert.equal(robotBodies.length, 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, /回收任务S(截止 2026-08-20):1 条截图超期未提交/);
|
||||
assert.match(robotBodies[0].text.content, /发布任务A(截止 2026-08-20):1 条超期未发布/);
|
||||
});
|
||||
|
||||
test("runOverdueAutoRelease is disabled without env switch", async () => {
|
||||
|
||||
@@ -54,17 +54,18 @@ test("buildTaskGroupPushMessage composes text and link with claim url", () => {
|
||||
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(
|
||||
{ ...baseTask, content_format: "video" },
|
||||
"https://portal.example.com",
|
||||
);
|
||||
assert.match(video.text, /形式:视频/);
|
||||
const screenshot = buildTaskGroupPushMessage(
|
||||
{ ...baseTask, task_type: "screenshot_collect" },
|
||||
const imageText = buildTaskGroupPushMessage(
|
||||
baseTask,
|
||||
"https://portal.example.com",
|
||||
);
|
||||
assert.match(screenshot.text, /形式:截图回收/);
|
||||
assert.match(imageText.text, /形式:图文/);
|
||||
assert.doesNotMatch(imageText.text, /截图回收/);
|
||||
});
|
||||
|
||||
test("buildTaskGroupPushMessage drops link when share_token missing", () => {
|
||||
|
||||
@@ -62,7 +62,7 @@ test("runDueSoonWecomNotifications filters by taskId when given", async () => {
|
||||
);
|
||||
assert.equal(executed.length, 1);
|
||||
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.digestSent, false);
|
||||
});
|
||||
@@ -73,5 +73,5 @@ test("runDueSoonWecomNotifications keeps global scan without taskId", async () =
|
||||
await runDueSoonWecomNotifications(db, NOTIFY_CONFIG, undefined, now);
|
||||
assert.equal(executed.length, 1);
|
||||
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"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user