From 85110bbfcebbb184a35da5424a35270c5c6ea7dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B7=AB=E5=87=A4=E8=90=8D?= Date: Wed, 5 Aug 2026 11:40:29 +0800 Subject: [PATCH] Add recovery Excel export and automatic account detection --- app/admin-app.tsx | 76 ++++- app/api/partner/route.ts | 10 +- app/api/recovery-export/route.ts | 430 ++++++++++++++++++++++++ app/globals.css | 25 ++ koc-portal/README.md | 2 +- koc-portal/app/page.tsx | 14 +- koc-portal/tests/rendered-html.test.mjs | 4 +- lib/date-utils.ts | 13 + lib/partner-utils.ts | 4 +- lib/recovery-workbook.ts | 224 ++++++++++++ package-lock.json | 2 +- package.json | 1 + tests/date-utils.test.mjs | 5 + tests/recovery-workbook.test.mjs | 52 +++ tests/rendered-html.test.mjs | 26 ++ 15 files changed, 852 insertions(+), 36 deletions(-) create mode 100644 app/api/recovery-export/route.ts create mode 100644 lib/recovery-workbook.ts create mode 100644 tests/recovery-workbook.test.mjs diff --git a/app/admin-app.tsx b/app/admin-app.tsx index bf7df79..9c0546d 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -8,7 +8,10 @@ import { useMemo, useState, } from "react"; -import { formatShanghaiDate as formatDate } from "../lib/date-utils"; +import { + formatShanghaiDate as formatDate, + formatShanghaiToday, +} from "../lib/date-utils"; type Partner = { id: string; @@ -317,6 +320,7 @@ export default function Home() { const [sourcePreview, setSourcePreview] = useState(null); const [sourceWorking, setSourceWorking] = useState(false); const [metricForm, setMetricForm] = useState({ exposure: "", views: "" }); + const [exportingTaskId, setExportingTaskId] = useState(null); const loadData = useCallback(async () => { try { @@ -502,6 +506,37 @@ export default function Home() { ); }; + const exportRecoveryData = async (task: Task) => { + try { + setExportingTaskId(task.id); + const response = await fetch( + `/api/recovery-export?task=${encodeURIComponent(task.id)}`, + { cache: "no-store" }, + ); + if (!response.ok) { + const result = await readApiResponse<{ error?: string }>( + response, + "导出失败", + ); + throw new Error(result.error || "导出失败"); + } + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = `${task.name}-数据回收.xlsx`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + setToast("Excel 已导出,图片和截图已嵌入表格"); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "导出失败"); + } finally { + setExportingTaskId(null); + } + }; + const uploadScreenshot = async ( distribution: Distribution, event: ChangeEvent, @@ -654,7 +689,7 @@ export default function Home() { {activeNav === "overview" && (
今天 - 7月27日 + {formatShanghaiToday()}
)} @@ -710,6 +745,8 @@ export default function Home() { onUpload={uploadScreenshot} onFallback={openMetricFallback} onRetryFailed={retryFailedMetrics} + onExport={exportRecoveryData} + exportingTaskId={exportingTaskId} /> )} @@ -1373,6 +1410,8 @@ function RecoveryPage({ onUpload, onFallback, onRetryFailed, + onExport, + exportingTaskId, }: { tasks: Task[]; distributions: Distribution[]; @@ -1386,6 +1425,8 @@ function RecoveryPage({ onUpload: (distribution: Distribution, event: ChangeEvent) => void; onFallback: (distribution: Distribution) => void; onRetryFailed: (taskId: string, failedCount: number) => void; + onExport: (task: Task) => void; + exportingTaskId: string | null; }) { const [selectedTaskId, setSelectedTaskId] = useState(null); const selectedTask = tasks.find((task) => task.id === selectedTaskId); @@ -1431,16 +1472,27 @@ function RecoveryPage({

数据回收队列

{taskDistributions.length} 篇已发布笔记 · 展示最近一次采集结果

- +
+ + +
diff --git a/app/api/partner/route.ts b/app/api/partner/route.ts index 62a5bde..b6fd861 100644 --- a/app/api/partner/route.ts +++ b/app/api/partner/route.ts @@ -36,7 +36,6 @@ type PartnerBody = { claimantIdentifier?: string; claimantName?: string; quantity?: number; - accountNickname?: string; publishUrl?: string; exposure?: number | string; views?: number | string; @@ -762,11 +761,10 @@ async function handlePost(request: Request) { if (body.action === "submit") { const claimToken = textValue(body.claimToken, 80); const distributionId = textValue(body.distributionId, 80); - const accountNickname = textValue(body.accountNickname, 80); const publishInput = textValue(body.publishUrl, 5000); - if (!accountNickname || !publishInput) { + if (!publishInput) { return Response.json( - { error: "请填写发布账号昵称和发布链接" }, + { error: "请填写发布链接" }, { status: 400 }, ); } @@ -777,7 +775,7 @@ async function handlePost(request: Request) { { status: 400 }, ); } - const account = accountFromPublishLink(publishUrl, accountNickname); + const account = accountFromPublishLink(publishUrl); if (!account) { return Response.json({ error: "发布链接格式不正确" }, { status: 400 }); } @@ -881,7 +879,7 @@ async function handlePost(request: Request) { db, assignment.id, publishUrl, - accountNickname, + account.nickname, resolveCollectionMcpConfig( env as unknown as CollectionMcpBindings, ), diff --git a/app/api/recovery-export/route.ts b/app/api/recovery-export/route.ts new file mode 100644 index 0000000..c855b5e --- /dev/null +++ b/app/api/recovery-export/route.ts @@ -0,0 +1,430 @@ +import { env } from "cloudflare:workers"; +import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth"; +import { parseStoredDate } from "../../../lib/date-utils"; +import { + downloadFeishuMedia, + type FeishuBindings, +} from "../../../lib/feishu-client"; +import { + ensureSchema, + getRawDb, + getUploadBucket, +} from "../../../lib/mvp-db"; +import { + buildRecoveryWorkbook, + type RecoveryWorkbookImage, + type RecoveryWorkbookRow, +} from "../../../lib/recovery-workbook"; + +export const runtime = "edge"; + +type StoredAsset = { + index: number; + key: string; + fileToken?: string; + width?: number | null; + height?: number | null; +}; + +type ExportRow = { + content_id: string; + source_row: number | null; + title: string; + body: string; + image_assets: string; + distribution_id: string | null; + publish_url: string | null; + publish_time: string | null; + publish_screenshot_key: string | null; + screenshot_key: string | null; + exposure: number | null; + views: number | null; + d2_likes: number | null; + d2_comments: number | null; + d2_collects: number | null; + d5_likes: number | null; + d5_comments: number | null; + d5_collects: number | null; + d7_likes: number | null; + d7_comments: number | null; + d7_collects: number | null; + latest_likes: number | null; + latest_comments: number | null; + latest_collects: number | null; + collection_status: string | null; + collection_status_description: string | null; + collection_updated_at: string | null; + updated_at: string | null; + account_nickname: string | null; + partner_name: string | null; + claimant_name: string | null; +}; + +type ImageReference = StoredAsset & { + description: string; +}; + +function parseAssets(value: string) { + try { + const parsed = JSON.parse(value || "[]") as StoredAsset[]; + if (!Array.isArray(parsed)) return []; + return parsed + .filter( + (asset) => + Number.isInteger(Number(asset.index)) && + Number(asset.index) > 0 && + typeof asset.key === "string" && + asset.key.startsWith("content-assets/"), + ) + .map((asset) => ({ ...asset, index: Number(asset.index) })) + .sort((left, right) => left.index - right.index); + } catch { + return []; + } +} + +function latestMetrics(row: ExportRow) { + const legacyDay = + row.d7_likes !== null + ? 7 + : row.d5_likes !== null + ? 5 + : row.d2_likes !== null + ? 2 + : null; + const legacyLikes = + legacyDay === 7 + ? row.d7_likes + : legacyDay === 5 + ? row.d5_likes + : legacyDay === 2 + ? row.d2_likes + : null; + const legacyComments = + legacyDay === 7 + ? row.d7_comments + : legacyDay === 5 + ? row.d5_comments + : legacyDay === 2 + ? row.d2_comments + : null; + const legacyCollects = + legacyDay === 7 + ? row.d7_collects + : legacyDay === 5 + ? row.d5_collects + : legacyDay === 2 + ? row.d2_collects + : null; + const likes = row.latest_likes ?? legacyLikes; + const comments = row.latest_comments ?? legacyComments; + const collects = row.latest_collects ?? legacyCollects; + return { + likes, + comments, + collects, + total: + likes === null ? null : likes + (comments ?? 0) + (collects ?? 0), + }; +} + +function formatExportDate(value: string | null) { + if (!value) return ""; + const date = parseStoredDate(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(date); +} + +function collectionLabel(row: ExportRow) { + const labels: Record = { + pending: "待设置", + scheduled: "已计划", + collecting: "采集中", + success: "采集成功", + failed: "采集失败", + }; + const status = row.collection_status || (row.latest_likes !== null ? "success" : "pending"); + const label = labels[status] || status; + return row.collection_status_description + ? `${label}:${row.collection_status_description}` + : label; +} + +function contentTypeFromObject(object: R2ObjectBody) { + const headers = new Headers(); + object.writeHttpMetadata(headers); + return headers.get("Content-Type") || "application/octet-stream"; +} + +async function loadImage(reference: ImageReference) { + const bucket = getUploadBucket(); + let object = await bucket.get(reference.key); + if (!object && reference.fileToken) { + const media = await downloadFeishuMedia( + reference.fileToken, + env as unknown as FeishuBindings, + ); + await bucket.put(reference.key, media.bytes, { + httpMetadata: { contentType: media.contentType }, + customMetadata: { source: "feishu-api" }, + }); + object = await bucket.get(reference.key); + } + if (!object) return null; + return { + bytes: new Uint8Array(await object.arrayBuffer()), + contentType: contentTypeFromObject(object), + width: reference.width, + height: reference.height, + description: reference.description, + } satisfies RecoveryWorkbookImage; +} + +async function loadImages(references: ImageReference[]) { + const unique = new Map(references.map((reference) => [reference.key, reference])); + const queue = [...unique.values()]; + const loaded = new Map(); + const workers = Array.from({ length: Math.min(6, queue.length) }, async () => { + while (queue.length > 0) { + const reference = queue.shift(); + if (!reference) return; + try { + loaded.set(reference.key, await loadImage(reference)); + } catch { + loaded.set(reference.key, null); + } + } + }); + await Promise.all(workers); + return loaded; +} + +function safeFileName(value: string) { + return value.replace(/[\\/:*?"<>|]/g, " ").trim().slice(0, 60) || "KOC数据回收"; +} + +function exactArrayBuffer(bytes: Uint8Array) { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + +export async function GET(request: Request) { + if (!isAdminRequest(request)) return adminForbidden(); + try { + await ensureSchema(); + const taskId = new URL(request.url).searchParams.get("task")?.trim() || ""; + if (!taskId) { + return Response.json({ error: "缺少导出任务" }, { status: 400 }); + } + const db = getRawDb(); + const task = await db + .prepare("SELECT id, name, brand FROM tasks WHERE id = ?") + .bind(taskId) + .first<{ id: string; name: string; brand: string }>(); + if (!task) { + return Response.json({ error: "没有找到这个任务" }, { status: 404 }); + } + const result = await db + .prepare( + `SELECT + c.id AS content_id, + c.source_row, + c.title, + c.body, + c.image_assets, + d.id AS distribution_id, + d.publish_url, + d.publish_time, + d.publish_screenshot_key, + d.screenshot_key, + d.exposure, + d.views, + d.d2_likes, + d.d2_comments, + d.d2_collects, + d.d5_likes, + d.d5_comments, + d.d5_collects, + d.d7_likes, + d.d7_comments, + d.d7_collects, + d.latest_likes, + d.latest_comments, + d.latest_collects, + d.collection_status, + d.collection_status_description, + d.collection_updated_at, + d.updated_at, + a.nickname AS account_nickname, + p.name AS partner_name, + cl.claimant_name + FROM contents c + LEFT JOIN distributions d ON d.content_id = c.id + LEFT JOIN accounts a ON a.id = d.account_id + LEFT JOIN partners p ON p.id = d.partner_id + LEFT JOIN claims cl ON cl.id = d.claim_id + WHERE c.task_id = ? + ORDER BY COALESCE(c.source_row, 999999), c.created_at, d.claimed_at`, + ) + .bind(taskId) + .all(); + const rows = result.results; + const assetsByContent = new Map( + rows.map((row) => [row.content_id, parseAssets(row.image_assets)]), + ); + const maxContentImages = Math.max( + 0, + ...[...assetsByContent.values()].flat().map((asset) => asset.index), + ); + const references: ImageReference[] = []; + rows.forEach((row) => { + for (const asset of assetsByContent.get(row.content_id) ?? []) { + references.push({ + ...asset, + description: `${row.title} 原图${asset.index}`, + }); + } + if (row.screenshot_key) { + references.push({ + index: 0, + key: row.screenshot_key, + description: `${row.title} 创作者中心截图`, + }); + } + if (row.publish_screenshot_key) { + references.push({ + index: 0, + key: row.publish_screenshot_key, + description: `${row.title} 发布截图`, + }); + } + }); + const loadedImages = await loadImages(references); + const headers = [ + "序号(不能改)", + "标题", + "笔记内容(正文+话题)", + ...Array.from({ length: maxContentImages }, (_, index) => `图片${index + 1}`), + "小红书昵称", + "发布链接", + "发布时间", + "点赞", + "收藏", + "评论", + "总互动", + "曝光量-实际(第7天)", + "阅读量-实际(第7天)", + "数据分析截图(单篇笔记数据分析截图)", + "笔记截图", + "数据更新时间", + "采集状态", + "合作方", + "领取人微信号/手机号", + ]; + const originalImageStart = 3; + const accountColumn = originalImageStart + maxContentImages; + const creatorScreenshotColumn = accountColumn + 9; + const publishScreenshotColumn = creatorScreenshotColumn + 1; + const workbookRows: RecoveryWorkbookRow[] = rows.map((row, rowIndex) => { + const metrics = latestMetrics(row); + const contentAssets = assetsByContent.get(row.content_id) ?? []; + const originalImageCells = Array.from( + { length: maxContentImages }, + (_, index) => { + const asset = contentAssets.find((item) => item.index === index + 1); + return asset && loadedImages.get(asset.key) ? "见图" : asset ? "图片读取失败" : ""; + }, + ); + const creatorImage = row.screenshot_key + ? loadedImages.get(row.screenshot_key) ?? null + : null; + const publishImage = row.publish_screenshot_key + ? loadedImages.get(row.publish_screenshot_key) ?? null + : null; + const cells: Array = [ + row.source_row ?? rowIndex + 1, + row.title, + row.body, + ...originalImageCells, + row.account_nickname || "", + row.publish_url || "", + formatExportDate(row.publish_time), + metrics.likes, + metrics.collects, + metrics.comments, + metrics.total, + row.exposure, + row.views, + creatorImage ? "见图" : row.screenshot_key ? "截图读取失败" : "", + publishImage ? "见图" : row.publish_screenshot_key ? "截图读取失败" : "", + formatExportDate(row.collection_updated_at || row.updated_at), + row.distribution_id ? collectionLabel(row) : "未领取", + row.partner_name || "", + row.claimant_name || "", + ]; + const images: RecoveryWorkbookRow["images"] = []; + for (const asset of contentAssets) { + const image = loadedImages.get(asset.key); + if (image) images.push({ column: originalImageStart + asset.index - 1, image }); + } + if (creatorImage) { + images.push({ column: creatorScreenshotColumn, image: creatorImage }); + } + if (publishImage) { + images.push({ column: publishScreenshotColumn, image: publishImage }); + } + return { cells, images }; + }); + const columnWidths = [ + 12, + 28, + 56, + ...Array.from({ length: maxContentImages }, () => 24), + 18, + 40, + 20, + 11, + 11, + 11, + 11, + 18, + 18, + 26, + 24, + 20, + 24, + 18, + 22, + ]; + const workbook = buildRecoveryWorkbook({ + sheetName: `${task.name}-数据回收`, + headers, + columnWidths, + rows: workbookRows, + }); + const fileName = `${safeFileName(task.name)}-数据回收.xlsx`; + return new Response(exactArrayBuffer(workbook), { + headers: { + "Cache-Control": "private, no-store", + "Content-Type": + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="koc-recovery.xlsx"; filename*=UTF-8''${encodeURIComponent(fileName)}`, + "X-Content-Type-Options": "nosniff", + }, + }); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : "导出失败" }, + { status: 500 }, + ); + } +} diff --git a/app/globals.css b/app/globals.css index a577794..22adf42 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1856,6 +1856,31 @@ a { font-weight: 700; } +.recovery-panel-actions { + display: flex; + align-items: center; + gap: 9px; +} + +.export-data-button { + display: inline-flex; + height: 34px; + align-items: center; + padding: 0 13px; + border: 1px solid #a9cdbc; + border-radius: 8px; + color: var(--green-deep); + background: #f2faf6; + font-size: 9px; + font-weight: 700; +} + +.export-data-button:disabled { + color: #94a29c; + background: #f5f7f6; + cursor: wait; +} + .retry-failed-button b { display: grid; min-width: 18px; diff --git a/koc-portal/README.md b/koc-portal/README.md index c17da70..01444ba 100644 --- a/koc-portal/README.md +++ b/koc-portal/README.md @@ -7,7 +7,7 @@ 1. KOC 通过带 `task` 参数的任务链接进入。 2. 只填写具有唯一性的微信号/手机号和领取数量。 3. 领取后只能看到本次领取的笔记。 -4. 在单篇笔记详情页查看标题与正文,并一一回填发布账号昵称、发布链接和发布截图。 +4. 在单篇笔记详情页查看标题与正文,并一一回填发布链接和发布截图;发布账号由系统根据链接识别。 门户不直接连接数据库。浏览器只调用后台隔离开放的 KOC 领取与回填接口,后台仍是任务、笔记和发布数据的唯一数据源;后台管理页面和管理接口需要管理员登录。 diff --git a/koc-portal/app/page.tsx b/koc-portal/app/page.tsx index ca15503..5e62a54 100644 --- a/koc-portal/app/page.tsx +++ b/koc-portal/app/page.tsx @@ -171,7 +171,6 @@ export default function Home() { const [toast, setToast] = useState(""); const [claimantName, setClaimantName] = useState(""); const [quantity, setQuantity] = useState(1); - const [accountNickname, setAccountNickname] = useState(""); const [publishUrl, setPublishUrl] = useState(""); const [screenshot, setScreenshot] = useState(null); const [creatorScreenshot, setCreatorScreenshot] = useState(null); @@ -269,7 +268,6 @@ export default function Home() { useEffect(() => { if (!selected) return; const timer = window.setTimeout(() => { - setAccountNickname(selected.account_nickname ?? ""); setPublishUrl(selected.publish_url ?? ""); setScreenshot(null); setCreatorScreenshot(null); @@ -634,7 +632,6 @@ export default function Home() { claimToken, delegationToken, distributionId: selected.id, - accountNickname, publishUrl, }), }); @@ -840,15 +837,6 @@ export default function Home() { 1 : 1

以下信息只会绑定到当前笔记,不按顺序批量匹配。

-
`; + }) + .join(""); + + const drawingXml = imageEntries.length + ? `${XML_HEADER}${imageEntries + .map((entry, index) => { + const size = imageDisplaySize(entry.image); + const width = size.width * 9525; + const height = size.height * 9525; + return `${entry.column}57150${entry.row}57150`; + }) + .join("")}` + : ""; + const drawingRelationships = imageEntries.length + ? `${XML_HEADER}${imageEntries + .map((entry, index) => { + const format = imageFormat(entry.image.contentType, entry.image.bytes); + return ``; + }) + .join("")}` + : ""; + + const imageFormats = new Map(); + for (const entry of imageEntries) { + const format = imageFormat(entry.image.contentType, entry.image.bytes); + imageFormats.set(format.extension, format.contentType); + } + const imageContentTypes = [...imageFormats.entries()] + .map(([extension, contentType]) => ``) + .join(""); + const contentTypes = `${XML_HEADER}${imageContentTypes}${imageEntries.length ? '' : ""}`; + const worksheet = `${XML_HEADER}${columns}${headerCells}${dataRows}${imageEntries.length ? '' : ""}`; + + const files: Record = { + "[Content_Types].xml": strToU8(contentTypes), + "_rels/.rels": strToU8(`${XML_HEADER}`), + "docProps/app.xml": strToU8(`${XML_HEADER}KOC LOOP`), + "docProps/core.xml": strToU8(`${XML_HEADER}KOC LOOPKOC LOOP${new Date().toISOString()}`), + "xl/workbook.xml": strToU8(`${XML_HEADER}`), + "xl/_rels/workbook.xml.rels": strToU8(`${XML_HEADER}`), + "xl/styles.xml": strToU8(`${XML_HEADER}`), + "xl/worksheets/sheet1.xml": strToU8(worksheet), + }; + if (imageEntries.length) { + files["xl/worksheets/_rels/sheet1.xml.rels"] = strToU8(`${XML_HEADER}`); + files["xl/drawings/drawing1.xml"] = strToU8(drawingXml); + files["xl/drawings/_rels/drawing1.xml.rels"] = strToU8(drawingRelationships); + imageEntries.forEach((entry, index) => { + const format = imageFormat(entry.image.contentType, entry.image.bytes); + files[`xl/media/image${index + 1}.${format.extension}`] = entry.image.bytes; + }); + } + return zipSync(files, { level: 1 }); +} diff --git a/package-lock.json b/package-lock.json index fa86803..265de1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "drizzle-orm": "0.45.2", + "fflate": "0.7.4", "next": "16.2.6", "react": "19.2.6", "react-dom": "19.2.6" @@ -6313,7 +6314,6 @@ "version": "0.7.4", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", - "dev": true, "license": "MIT" }, "node_modules/file-entry-cache": { diff --git a/package.json b/package.json index b7babc9..b9564bf 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "drizzle-orm": "0.45.2", + "fflate": "0.7.4", "next": "16.2.6", "react": "19.2.6", "react-dom": "19.2.6" diff --git a/tests/date-utils.test.mjs b/tests/date-utils.test.mjs index 8b6467b..7e3ff6d 100644 --- a/tests/date-utils.test.mjs +++ b/tests/date-utils.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { formatShanghaiDate, + formatShanghaiToday, parseStoredDate, } from "../lib/date-utils.ts"; @@ -14,3 +15,7 @@ test("treats D1 CURRENT_TIMESTAMP values as UTC and displays Beijing time", () = test("keeps date-only deadlines on the intended Shanghai calendar date", () => { assert.match(formatShanghaiDate("2026-08-12"), /08\/12/); }); + +test("formats the dashboard date from the Shanghai calendar", () => { + assert.equal(formatShanghaiToday(new Date("2026-08-04T16:30:00Z")), "8月5日"); +}); diff --git a/tests/recovery-workbook.test.mjs b/tests/recovery-workbook.test.mjs new file mode 100644 index 0000000..4a2cc27 --- /dev/null +++ b/tests/recovery-workbook.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { strFromU8, unzipSync } from "fflate"; +import { buildRecoveryWorkbook } from "../lib/recovery-workbook.ts"; + +const tinyPng = Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, +]); + +test("creates an xlsx archive whose pictures are embedded in worksheet cells", () => { + const workbook = buildRecoveryWorkbook({ + sheetName: "数据回收", + headers: ["序号", "标题", "原图", "笔记截图"], + columnWidths: [8, 24, 24, 24], + rows: [ + { + cells: [1, "测试笔记", "见图", "见图"], + images: [ + { + column: 2, + image: { + bytes: tinyPng, + contentType: "image/png", + width: 1, + height: 1, + description: "原图1", + }, + }, + { + column: 3, + image: { + bytes: tinyPng, + contentType: "image/png", + width: 1, + height: 1, + description: "发布截图", + }, + }, + ], + }, + ], + }); + const archive = unzipSync(workbook); + + assert.ok(archive["xl/media/image1.png"]); + assert.ok(archive["xl/media/image2.png"]); + assert.match(strFromU8(archive["xl/worksheets/sheet1.xml"]), //); + assert.match(strFromU8(archive["xl/drawings/drawing1.xml"]), /oneCellAnchor/); + assert.match(strFromU8(archive["xl/drawings/_rels/drawing1.xml.rels"]), /image2\.png/); +}); diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs index 1a78362..d38015b 100644 --- a/tests/rendered-html.test.mjs +++ b/tests/rendered-html.test.mjs @@ -13,6 +13,8 @@ test("builds the KOC LOOP product shell", async () => { assert.match(adminApp, /KOC LOOP/); assert.match(adminApp, /内容分发闭环/); assert.match(adminApp, /分发工作台/); + assert.match(adminApp, /formatShanghaiToday/); + assert.doesNotMatch(adminApp, /7月27日/); assert.match(adminApp, /获取KOC领取链接/); assert.match(layout, /KOC LOOP|内容分发闭环/); assert.doesNotMatch(adminApp, /codex-preview|Your site is taking shape/); @@ -114,6 +116,8 @@ test("issues external task links and supports one-to-one note submissions", asyn assert.match(partnerRoute, /微信号或手机号/); assert.match(partnerRoute, /extractXhsPublishUrl/); assert.match(partnerRoute, /小红书长链或短链/); + assert.match(partnerRoute, /请填写发布链接/); + assert.doesNotMatch(partnerRoute, /请填写发布账号昵称和发布链接/); assert.match(partnerRoute, /没有找到领取记录/); assert.match(partnerRoute, /publicImageAssets/); assert.match(partnerRoute, /withPartnerCors/); @@ -224,6 +228,28 @@ test("supports task collection schedules and latest public metrics", async () => assert.match(deployConfig, /"crons":\["0 2 \* \* \*"\]/); }); +test("exports complete task recovery data to Excel with embedded images", async () => { + const [adminApp, exportRoute, workbook] = await Promise.all([ + readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"), + readFile(new URL("../app/api/recovery-export/route.ts", import.meta.url), "utf8"), + readFile(new URL("../lib/recovery-workbook.ts", import.meta.url), "utf8"), + ]); + + assert.match(adminApp, /导出全部数据/); + assert.match(adminApp, /\/api\/recovery-export/); + assert.match(adminApp, /图片和截图已嵌入表格/); + assert.match(exportRoute, /小红书昵称/); + assert.match(exportRoute, /曝光量-实际(第7天)/); + assert.match(exportRoute, /阅读量-实际(第7天)/); + assert.match(exportRoute, /publish_screenshot_key/); + assert.match(exportRoute, /screenshot_key/); + assert.match(exportRoute, /image_assets/); + assert.match(exportRoute, /isAdminRequest/); + assert.match(workbook, /xl\/drawings\/drawing1\.xml/); + assert.match(workbook, /xl\/media\/image/); + assert.match(workbook, /oneCellAnchor/); +}); + test("supports anonymous partner delegation without creating a second data flow", async () => { const [ adminApp,