Add recovery Excel export and automatic account detection

This commit is contained in:
巫凤萍
2026-08-05 11:40:29 +08:00
parent b081946752
commit 85110bbfce
15 changed files with 852 additions and 36 deletions

View File

@@ -8,7 +8,10 @@ import {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { formatShanghaiDate as formatDate } from "../lib/date-utils"; import {
formatShanghaiDate as formatDate,
formatShanghaiToday,
} from "../lib/date-utils";
type Partner = { type Partner = {
id: string; id: string;
@@ -317,6 +320,7 @@ export default function Home() {
const [sourcePreview, setSourcePreview] = useState<FeishuPreview | null>(null); const [sourcePreview, setSourcePreview] = useState<FeishuPreview | null>(null);
const [sourceWorking, setSourceWorking] = useState(false); const [sourceWorking, setSourceWorking] = useState(false);
const [metricForm, setMetricForm] = useState({ exposure: "", views: "" }); const [metricForm, setMetricForm] = useState({ exposure: "", views: "" });
const [exportingTaskId, setExportingTaskId] = useState<string | null>(null);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { 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 ( const uploadScreenshot = async (
distribution: Distribution, distribution: Distribution,
event: ChangeEvent<HTMLInputElement>, event: ChangeEvent<HTMLInputElement>,
@@ -654,7 +689,7 @@ export default function Home() {
{activeNav === "overview" && ( {activeNav === "overview" && (
<div className="date-chip"> <div className="date-chip">
<span></span> <span></span>
<strong>727</strong> <strong>{formatShanghaiToday()}</strong>
</div> </div>
)} )}
</div> </div>
@@ -710,6 +745,8 @@ export default function Home() {
onUpload={uploadScreenshot} onUpload={uploadScreenshot}
onFallback={openMetricFallback} onFallback={openMetricFallback}
onRetryFailed={retryFailedMetrics} onRetryFailed={retryFailedMetrics}
onExport={exportRecoveryData}
exportingTaskId={exportingTaskId}
/> />
)} )}
</> </>
@@ -1373,6 +1410,8 @@ function RecoveryPage({
onUpload, onUpload,
onFallback, onFallback,
onRetryFailed, onRetryFailed,
onExport,
exportingTaskId,
}: { }: {
tasks: Task[]; tasks: Task[];
distributions: Distribution[]; distributions: Distribution[];
@@ -1386,6 +1425,8 @@ function RecoveryPage({
onUpload: (distribution: Distribution, event: ChangeEvent<HTMLInputElement>) => void; onUpload: (distribution: Distribution, event: ChangeEvent<HTMLInputElement>) => void;
onFallback: (distribution: Distribution) => void; onFallback: (distribution: Distribution) => void;
onRetryFailed: (taskId: string, failedCount: number) => void; onRetryFailed: (taskId: string, failedCount: number) => void;
onExport: (task: Task) => void;
exportingTaskId: string | null;
}) { }) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null); const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const selectedTask = tasks.find((task) => task.id === selectedTaskId); const selectedTask = tasks.find((task) => task.id === selectedTaskId);
@@ -1431,6 +1472,16 @@ function RecoveryPage({
<h2></h2> <h2></h2>
<p>{taskDistributions.length} · </p> <p>{taskDistributions.length} · </p>
</div> </div>
<div className="recovery-panel-actions">
<button
className="export-data-button"
disabled={working || exportingTaskId === selectedTask.id}
onClick={() => onExport(selectedTask)}
>
{exportingTaskId === selectedTask.id
? "正在生成 Excel…"
: "导出全部数据"}
</button>
<button <button
className="retry-failed-button" className="retry-failed-button"
disabled={working || failedCollectionCount === 0} disabled={working || failedCollectionCount === 0}
@@ -1442,6 +1493,7 @@ function RecoveryPage({
{failedCollectionCount > 0 && <b>{failedCollectionCount}</b>} {failedCollectionCount > 0 && <b>{failedCollectionCount}</b>}
</button> </button>
</div> </div>
</div>
<div className="table-scroll"> <div className="table-scroll">
<table className="data-table recovery-table"> <table className="data-table recovery-table">
<thead> <thead>

View File

@@ -36,7 +36,6 @@ type PartnerBody = {
claimantIdentifier?: string; claimantIdentifier?: string;
claimantName?: string; claimantName?: string;
quantity?: number; quantity?: number;
accountNickname?: string;
publishUrl?: string; publishUrl?: string;
exposure?: number | string; exposure?: number | string;
views?: number | string; views?: number | string;
@@ -762,11 +761,10 @@ async function handlePost(request: Request) {
if (body.action === "submit") { if (body.action === "submit") {
const claimToken = textValue(body.claimToken, 80); const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80); const distributionId = textValue(body.distributionId, 80);
const accountNickname = textValue(body.accountNickname, 80);
const publishInput = textValue(body.publishUrl, 5000); const publishInput = textValue(body.publishUrl, 5000);
if (!accountNickname || !publishInput) { if (!publishInput) {
return Response.json( return Response.json(
{ error: "请填写发布账号昵称和发布链接" }, { error: "请填写发布链接" },
{ status: 400 }, { status: 400 },
); );
} }
@@ -777,7 +775,7 @@ async function handlePost(request: Request) {
{ status: 400 }, { status: 400 },
); );
} }
const account = accountFromPublishLink(publishUrl, accountNickname); const account = accountFromPublishLink(publishUrl);
if (!account) { if (!account) {
return Response.json({ error: "发布链接格式不正确" }, { status: 400 }); return Response.json({ error: "发布链接格式不正确" }, { status: 400 });
} }
@@ -881,7 +879,7 @@ async function handlePost(request: Request) {
db, db,
assignment.id, assignment.id,
publishUrl, publishUrl,
accountNickname, account.nickname,
resolveCollectionMcpConfig( resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings, env as unknown as CollectionMcpBindings,
), ),

View File

@@ -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<string, string> = {
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<string, RecoveryWorkbookImage | null>();
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<ExportRow>();
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<string | number | null> = [
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 },
);
}
}

View File

@@ -1856,6 +1856,31 @@ a {
font-weight: 700; 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 { .retry-failed-button b {
display: grid; display: grid;
min-width: 18px; min-width: 18px;

View File

@@ -7,7 +7,7 @@
1. KOC 通过带 `task` 参数的任务链接进入。 1. KOC 通过带 `task` 参数的任务链接进入。
2. 只填写具有唯一性的微信号/手机号和领取数量。 2. 只填写具有唯一性的微信号/手机号和领取数量。
3. 领取后只能看到本次领取的笔记。 3. 领取后只能看到本次领取的笔记。
4. 在单篇笔记详情页查看标题与正文,并一一回填发布账号昵称、发布链接和发布截图。 4. 在单篇笔记详情页查看标题与正文,并一一回填发布链接和发布截图;发布账号由系统根据链接识别
门户不直接连接数据库。浏览器只调用后台隔离开放的 KOC 领取与回填接口,后台仍是任务、笔记和发布数据的唯一数据源;后台管理页面和管理接口需要管理员登录。 门户不直接连接数据库。浏览器只调用后台隔离开放的 KOC 领取与回填接口,后台仍是任务、笔记和发布数据的唯一数据源;后台管理页面和管理接口需要管理员登录。

View File

@@ -171,7 +171,6 @@ export default function Home() {
const [toast, setToast] = useState(""); const [toast, setToast] = useState("");
const [claimantName, setClaimantName] = useState(""); const [claimantName, setClaimantName] = useState("");
const [quantity, setQuantity] = useState(1); const [quantity, setQuantity] = useState(1);
const [accountNickname, setAccountNickname] = useState("");
const [publishUrl, setPublishUrl] = useState(""); const [publishUrl, setPublishUrl] = useState("");
const [screenshot, setScreenshot] = useState<File | null>(null); const [screenshot, setScreenshot] = useState<File | null>(null);
const [creatorScreenshot, setCreatorScreenshot] = useState<File | null>(null); const [creatorScreenshot, setCreatorScreenshot] = useState<File | null>(null);
@@ -269,7 +268,6 @@ export default function Home() {
useEffect(() => { useEffect(() => {
if (!selected) return; if (!selected) return;
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
setAccountNickname(selected.account_nickname ?? "");
setPublishUrl(selected.publish_url ?? ""); setPublishUrl(selected.publish_url ?? "");
setScreenshot(null); setScreenshot(null);
setCreatorScreenshot(null); setCreatorScreenshot(null);
@@ -634,7 +632,6 @@ export default function Home() {
claimToken, claimToken,
delegationToken, delegationToken,
distributionId: selected.id, distributionId: selected.id,
accountNickname,
publishUrl, publishUrl,
}), }),
}); });
@@ -840,15 +837,6 @@ export default function Home() {
<span>1 : 1</span> <span>1 : 1</span>
<p></p> <p></p>
</div> </div>
<label>
<span></span>
<input
value={accountNickname}
onChange={(event) => setAccountNickname(event.target.value)}
placeholder="例如:小满的轻生活"
required
/>
</label>
<label> <label>
<span></span> <span></span>
<input <input
@@ -860,7 +848,7 @@ export default function Home() {
required required
/> />
<small className="field-hint"> <small className="field-hint">
</small> </small>
</label> </label>
<label> <label>

View File

@@ -31,8 +31,10 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
assert.match(page, /claimantIdentifier:\s*claimantName/); assert.match(page, /claimantIdentifier:\s*claimantName/);
assert.match(page, /const \[quantity, setQuantity\] = useState\(1\)/); assert.match(page, /const \[quantity, setQuantity\] = useState\(1\)/);
assert.match(page, /distributionId:\s*selected\.id/); assert.match(page, /distributionId:\s*selected\.id/);
assert.match(page, /发布账号昵称/); assert.doesNotMatch(page, /发布账号昵称/);
assert.doesNotMatch(page, /accountNickname/);
assert.match(page, /发布链接/); assert.match(page, /发布链接/);
assert.match(page, /识别发布账号/);
assert.match(page, /inputMode="url"/); assert.match(page, /inputMode="url"/);
assert.match(page, /长链、短链或整段分享文案/); assert.match(page, /长链、短链或整段分享文案/);
assert.doesNotMatch(page, /type="url"/); assert.doesNotMatch(page, /type="url"/);

View File

@@ -24,3 +24,16 @@ export function formatShanghaiDate(
...(withTime ? { hour: "2-digit", minute: "2-digit" } : {}), ...(withTime ? { hour: "2-digit", minute: "2-digit" } : {}),
}).format(date); }).format(date);
} }
export function formatShanghaiToday(value: Date | number = Date.now()) {
const date = value instanceof Date ? value : new Date(value);
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "numeric",
day: "numeric",
}).formatToParts(date);
const calendar = Object.fromEntries(
parts.map((part) => [part.type, part.value]),
);
return `${calendar.month}${calendar.day}`;
}

View File

@@ -6,7 +6,7 @@ import {
export { extractXhsPublishUrl } from "./publish-url"; export { extractXhsPublishUrl } from "./publish-url";
export function accountFromPublishLink(input: string, nickname: string) { export function accountFromPublishLink(input: string) {
const url = safeHttpUrl(extractXhsPublishUrl(input)); const url = safeHttpUrl(extractXhsPublishUrl(input));
if (!url) return null; if (!url) return null;
const platform = const platform =
@@ -23,7 +23,7 @@ export function accountFromPublishLink(input: string, nickname: string) {
return { return {
platform, platform,
platformUid, platformUid,
nickname, nickname: "待识别账号",
profileUrl: "", profileUrl: "",
}; };
} }

224
lib/recovery-workbook.ts Normal file
View File

@@ -0,0 +1,224 @@
import { strToU8, zipSync } from "fflate";
export type RecoveryWorkbookImage = {
bytes: Uint8Array;
contentType: string;
width?: number | null;
height?: number | null;
description: string;
};
export type RecoveryWorkbookRow = {
cells: Array<string | number | null>;
images: Array<{
column: number;
image: RecoveryWorkbookImage;
}>;
};
type WorkbookOptions = {
sheetName: string;
headers: string[];
columnWidths: number[];
rows: RecoveryWorkbookRow[];
};
const XML_HEADER = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
const IMAGE_ROW_HEIGHT = 126;
function cleanXmlText(value: unknown) {
return String(value ?? "")
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "")
.slice(0, 32767)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function columnName(index: number) {
let value = index + 1;
let result = "";
while (value > 0) {
value -= 1;
result = String.fromCharCode(65 + (value % 26)) + result;
value = Math.floor(value / 26);
}
return result;
}
function safeSheetName(value: string) {
const cleaned = value.replace(/[\\/?*\[\]:]/g, " ").trim();
return (cleaned || "数据回收").slice(0, 31);
}
function imageFormat(contentType: string, bytes: Uint8Array) {
const normalized = contentType.toLowerCase();
if (normalized.includes("png") || (bytes[0] === 0x89 && bytes[1] === 0x50)) {
return { extension: "png", contentType: "image/png" };
}
if (
normalized.includes("gif") ||
(bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46)
) {
return { extension: "gif", contentType: "image/gif" };
}
if (
normalized.includes("webp") ||
String.fromCharCode(...bytes.slice(8, 12)) === "WEBP"
) {
return { extension: "webp", contentType: "image/webp" };
}
return { extension: "jpg", contentType: "image/jpeg" };
}
function imageDimensions(image: RecoveryWorkbookImage) {
if (image.width && image.height) {
return { width: image.width, height: image.height };
}
const bytes = image.bytes;
if (bytes.length >= 24 && bytes[0] === 0x89 && bytes[1] === 0x50) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
return { width: view.getUint32(16), height: view.getUint32(20) };
}
if (
bytes.length >= 10 &&
bytes[0] === 0x47 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46
) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
}
if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
let offset = 2;
while (offset + 9 < bytes.length) {
if (bytes[offset] !== 0xff) {
offset += 1;
continue;
}
const marker = bytes[offset + 1];
const length = (bytes[offset + 2] << 8) + bytes[offset + 3];
if (
[0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(
marker,
)
) {
return {
width: (bytes[offset + 7] << 8) + bytes[offset + 8],
height: (bytes[offset + 5] << 8) + bytes[offset + 6],
};
}
if (!length) break;
offset += length + 2;
}
}
return { width: 4, height: 3 };
}
function imageDisplaySize(image: RecoveryWorkbookImage) {
const dimensions = imageDimensions(image);
const scale = Math.min(160 / dimensions.width, 150 / dimensions.height, 1);
return {
width: Math.max(28, Math.round(dimensions.width * scale)),
height: Math.max(28, Math.round(dimensions.height * scale)),
};
}
function inlineCell(reference: string, value: unknown, style: number) {
return `<c r="${reference}" t="inlineStr" s="${style}"><is><t xml:space="preserve">${cleanXmlText(value)}</t></is></c>`;
}
function numberCell(reference: string, value: number, style: number) {
return `<c r="${reference}" s="${style}"><v>${Number.isFinite(value) ? value : 0}</v></c>`;
}
export function buildRecoveryWorkbook(options: WorkbookOptions) {
const sheetName = safeSheetName(options.sheetName);
const imageEntries = options.rows.flatMap((row, rowIndex) =>
row.images.map((item) => ({ ...item, row: rowIndex + 1 })),
);
const lastColumn = columnName(Math.max(0, options.headers.length - 1));
const lastRow = Math.max(1, options.rows.length + 1);
const headerCells = options.headers
.map((value, index) => inlineCell(`${columnName(index)}1`, value, 1))
.join("");
const dataRows = options.rows
.map((row, rowIndex) => {
const number = rowIndex + 2;
const imageColumns = new Set(row.images.map((item) => item.column));
const cells = options.headers
.map((_, columnIndex) => {
const reference = `${columnName(columnIndex)}${number}`;
const value = row.cells[columnIndex] ?? "";
if (imageColumns.has(columnIndex)) {
return inlineCell(reference, value || "见图", 4);
}
return typeof value === "number"
? numberCell(reference, value, 3)
: inlineCell(reference, value, 2);
})
.join("");
const rowHeight = row.images.length > 0 ? IMAGE_ROW_HEIGHT : 42;
return `<row r="${number}" ht="${rowHeight}" customHeight="1">${cells}</row>`;
})
.join("");
const columns = options.headers
.map((_, index) => {
const width = Math.min(70, Math.max(8, options.columnWidths[index] ?? 14));
return `<col min="${index + 1}" max="${index + 1}" width="${width}" customWidth="1"/>`;
})
.join("");
const drawingXml = imageEntries.length
? `${XML_HEADER}<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${imageEntries
.map((entry, index) => {
const size = imageDisplaySize(entry.image);
const width = size.width * 9525;
const height = size.height * 9525;
return `<xdr:oneCellAnchor><xdr:from><xdr:col>${entry.column}</xdr:col><xdr:colOff>57150</xdr:colOff><xdr:row>${entry.row}</xdr:row><xdr:rowOff>57150</xdr:rowOff></xdr:from><xdr:ext cx="${width}" cy="${height}"/><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="${index + 1}" name="${cleanXmlText(entry.image.description || `图片${index + 1}`)}"/><xdr:cNvPicPr><a:picLocks noChangeAspect="1"/></xdr:cNvPicPr></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId${index + 1}"/><a:stretch><a:fillRect/></a:stretch></xdr:blipFill><xdr:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr></xdr:pic><xdr:clientData/></xdr:oneCellAnchor>`;
})
.join("")}</xdr:wsDr>`
: "";
const drawingRelationships = imageEntries.length
? `${XML_HEADER}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${imageEntries
.map((entry, index) => {
const format = imageFormat(entry.image.contentType, entry.image.bytes);
return `<Relationship Id="rId${index + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image${index + 1}.${format.extension}"/>`;
})
.join("")}</Relationships>`
: "";
const imageFormats = new Map<string, string>();
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]) => `<Default Extension="${extension}" ContentType="${contentType}"/>`)
.join("");
const contentTypes = `${XML_HEADER}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>${imageContentTypes}<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${imageEntries.length ? '<Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>' : ""}<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>`;
const worksheet = `${XML_HEADER}<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><dimension ref="A1:${lastColumn}${lastRow}"/><sheetViews><sheetView workbookViewId="0"><pane xSplit="3" ySplit="1" topLeftCell="D2" activePane="bottomRight" state="frozen"/></sheetView></sheetViews><sheetFormatPr defaultRowHeight="18"/><cols>${columns}</cols><sheetData><row r="1" ht="30" customHeight="1">${headerCells}</row>${dataRows}</sheetData><autoFilter ref="A1:${lastColumn}${lastRow}"/>${imageEntries.length ? '<drawing r:id="rId1"/>' : ""}</worksheet>`;
const files: Record<string, Uint8Array> = {
"[Content_Types].xml": strToU8(contentTypes),
"_rels/.rels": strToU8(`${XML_HEADER}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/></Relationships>`),
"docProps/app.xml": strToU8(`${XML_HEADER}<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>KOC LOOP</Application></Properties>`),
"docProps/core.xml": strToU8(`${XML_HEADER}<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:creator>KOC LOOP</dc:creator><cp:lastModifiedBy>KOC LOOP</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${new Date().toISOString()}</dcterms:created></cp:coreProperties>`),
"xl/workbook.xml": strToU8(`${XML_HEADER}<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="${cleanXmlText(sheetName)}" sheetId="1" r:id="rId1"/></sheets></workbook>`),
"xl/_rels/workbook.xml.rels": strToU8(`${XML_HEADER}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`),
"xl/styles.xml": strToU8(`${XML_HEADER}<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="3"><font><sz val="10"/><name val="Arial"/></font><font><b/><color rgb="FFFFFFFF"/><sz val="10"/><name val="Arial"/></font><font><color rgb="FF153B31"/><sz val="10"/><name val="Arial"/></font></fonts><fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FF1F8F6B"/><bgColor indexed="64"/></patternFill></fill></fills><borders count="2"><border><left/><right/><top/><bottom/><diagonal/></border><border><left style="thin"><color rgb="FFDDE5E1"/></left><right style="thin"><color rgb="FFDDE5E1"/></right><top style="thin"><color rgb="FFDDE5E1"/></top><bottom style="thin"><color rgb="FFDDE5E1"/></bottom><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="5"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="2" borderId="1" xfId="0" applyFont="1" applyFill="1" applyBorder="1" applyAlignment="1"><alignment horizontal="center" vertical="center" wrapText="1"/></xf><xf numFmtId="0" fontId="2" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf><xf numFmtId="0" fontId="2" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1" applyAlignment="1"><alignment horizontal="center" vertical="center"/></xf><xf numFmtId="0" fontId="2" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1" applyAlignment="1"><alignment horizontal="center" vertical="center" wrapText="1"/></xf></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`),
"xl/worksheets/sheet1.xml": strToU8(worksheet),
};
if (imageEntries.length) {
files["xl/worksheets/_rels/sheet1.xml.rels"] = strToU8(`${XML_HEADER}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/></Relationships>`);
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 });
}

2
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"fflate": "0.7.4",
"next": "16.2.6", "next": "16.2.6",
"react": "19.2.6", "react": "19.2.6",
"react-dom": "19.2.6" "react-dom": "19.2.6"
@@ -6313,7 +6314,6 @@
"version": "0.7.4", "version": "0.7.4",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz",
"integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {

View File

@@ -15,6 +15,7 @@
}, },
"dependencies": { "dependencies": {
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"fflate": "0.7.4",
"next": "16.2.6", "next": "16.2.6",
"react": "19.2.6", "react": "19.2.6",
"react-dom": "19.2.6" "react-dom": "19.2.6"

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { import {
formatShanghaiDate, formatShanghaiDate,
formatShanghaiToday,
parseStoredDate, parseStoredDate,
} from "../lib/date-utils.ts"; } 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", () => { test("keeps date-only deadlines on the intended Shanghai calendar date", () => {
assert.match(formatShanghaiDate("2026-08-12"), /08\/12/); 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日");
});

View File

@@ -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"]), /<drawing r:id="rId1"\/>/);
assert.match(strFromU8(archive["xl/drawings/drawing1.xml"]), /oneCellAnchor/);
assert.match(strFromU8(archive["xl/drawings/_rels/drawing1.xml.rels"]), /image2\.png/);
});

View File

@@ -13,6 +13,8 @@ test("builds the KOC LOOP product shell", async () => {
assert.match(adminApp, /KOC LOOP/); assert.match(adminApp, /KOC LOOP/);
assert.match(adminApp, /内容分发闭环/); assert.match(adminApp, /内容分发闭环/);
assert.match(adminApp, /分发工作台/); assert.match(adminApp, /分发工作台/);
assert.match(adminApp, /formatShanghaiToday/);
assert.doesNotMatch(adminApp, /7月27日/);
assert.match(adminApp, /获取KOC领取链接/); assert.match(adminApp, /获取KOC领取链接/);
assert.match(layout, /KOC LOOP内容分发闭环/); assert.match(layout, /KOC LOOP内容分发闭环/);
assert.doesNotMatch(adminApp, /codex-preview|Your site is taking shape/); 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, /微信号或手机号/);
assert.match(partnerRoute, /extractXhsPublishUrl/); assert.match(partnerRoute, /extractXhsPublishUrl/);
assert.match(partnerRoute, /小红书长链或短链/); assert.match(partnerRoute, /小红书长链或短链/);
assert.match(partnerRoute, /请填写发布链接/);
assert.doesNotMatch(partnerRoute, /请填写发布账号昵称和发布链接/);
assert.match(partnerRoute, /没有找到领取记录/); assert.match(partnerRoute, /没有找到领取记录/);
assert.match(partnerRoute, /publicImageAssets/); assert.match(partnerRoute, /publicImageAssets/);
assert.match(partnerRoute, /withPartnerCors/); assert.match(partnerRoute, /withPartnerCors/);
@@ -224,6 +228,28 @@ test("supports task collection schedules and latest public metrics", async () =>
assert.match(deployConfig, /"crons":\["0 2 \* \* \*"\]/); 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 () => { test("supports anonymous partner delegation without creating a second data flow", async () => {
const [ const [
adminApp, adminApp,