Add recovery Excel export and automatic account detection
This commit is contained in:
@@ -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<FeishuPreview | null>(null);
|
||||
const [sourceWorking, setSourceWorking] = useState(false);
|
||||
const [metricForm, setMetricForm] = useState({ exposure: "", views: "" });
|
||||
const [exportingTaskId, setExportingTaskId] = useState<string | null>(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<HTMLInputElement>,
|
||||
@@ -654,7 +689,7 @@ export default function Home() {
|
||||
{activeNav === "overview" && (
|
||||
<div className="date-chip">
|
||||
<span>今天</span>
|
||||
<strong>7月27日</strong>
|
||||
<strong>{formatShanghaiToday()}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -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<HTMLInputElement>) => void;
|
||||
onFallback: (distribution: Distribution) => void;
|
||||
onRetryFailed: (taskId: string, failedCount: number) => void;
|
||||
onExport: (task: Task) => void;
|
||||
exportingTaskId: string | null;
|
||||
}) {
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
|
||||
@@ -1431,16 +1472,27 @@ function RecoveryPage({
|
||||
<h2>数据回收队列</h2>
|
||||
<p>{taskDistributions.length} 篇已发布笔记 · 展示最近一次采集结果</p>
|
||||
</div>
|
||||
<button
|
||||
className="retry-failed-button"
|
||||
disabled={working || failedCollectionCount === 0}
|
||||
onClick={() =>
|
||||
onRetryFailed(selectedTask.id, failedCollectionCount)
|
||||
}
|
||||
>
|
||||
一键补采异常数据
|
||||
{failedCollectionCount > 0 && <b>{failedCollectionCount}</b>}
|
||||
</button>
|
||||
<div className="recovery-panel-actions">
|
||||
<button
|
||||
className="export-data-button"
|
||||
disabled={working || exportingTaskId === selectedTask.id}
|
||||
onClick={() => onExport(selectedTask)}
|
||||
>
|
||||
{exportingTaskId === selectedTask.id
|
||||
? "正在生成 Excel…"
|
||||
: "导出全部数据"}
|
||||
</button>
|
||||
<button
|
||||
className="retry-failed-button"
|
||||
disabled={working || failedCollectionCount === 0}
|
||||
onClick={() =>
|
||||
onRetryFailed(selectedTask.id, failedCollectionCount)
|
||||
}
|
||||
>
|
||||
一键补采异常数据
|
||||
{failedCollectionCount > 0 && <b>{failedCollectionCount}</b>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<table className="data-table recovery-table">
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
430
app/api/recovery-export/route.ts
Normal file
430
app/api/recovery-export/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
1. KOC 通过带 `task` 参数的任务链接进入。
|
||||
2. 只填写具有唯一性的微信号/手机号和领取数量。
|
||||
3. 领取后只能看到本次领取的笔记。
|
||||
4. 在单篇笔记详情页查看标题与正文,并一一回填发布账号昵称、发布链接和发布截图。
|
||||
4. 在单篇笔记详情页查看标题与正文,并一一回填发布链接和发布截图;发布账号由系统根据链接识别。
|
||||
|
||||
门户不直接连接数据库。浏览器只调用后台隔离开放的 KOC 领取与回填接口,后台仍是任务、笔记和发布数据的唯一数据源;后台管理页面和管理接口需要管理员登录。
|
||||
|
||||
|
||||
@@ -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<File | null>(null);
|
||||
const [creatorScreenshot, setCreatorScreenshot] = useState<File | null>(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() {
|
||||
<span>1 : 1</span>
|
||||
<p>以下信息只会绑定到当前笔记,不按顺序批量匹配。</p>
|
||||
</div>
|
||||
<label>
|
||||
<span>发布账号昵称</span>
|
||||
<input
|
||||
value={accountNickname}
|
||||
onChange={(event) => setAccountNickname(event.target.value)}
|
||||
placeholder="例如:小满的轻生活"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>发布链接</span>
|
||||
<input
|
||||
@@ -860,7 +848,7 @@ export default function Home() {
|
||||
required
|
||||
/>
|
||||
<small className="field-hint">
|
||||
系统会自动提取分享文案中的小红书链接
|
||||
系统会自动提取链接,并识别发布账号
|
||||
</small>
|
||||
</label>
|
||||
<label>
|
||||
|
||||
@@ -31,8 +31,10 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
|
||||
assert.match(page, /claimantIdentifier:\s*claimantName/);
|
||||
assert.match(page, /const \[quantity, setQuantity\] = useState\(1\)/);
|
||||
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, /inputMode="url"/);
|
||||
assert.match(page, /长链、短链或整段分享文案/);
|
||||
assert.doesNotMatch(page, /type="url"/);
|
||||
|
||||
@@ -24,3 +24,16 @@ export function formatShanghaiDate(
|
||||
...(withTime ? { hour: "2-digit", minute: "2-digit" } : {}),
|
||||
}).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}日`;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
|
||||
export { extractXhsPublishUrl } from "./publish-url";
|
||||
|
||||
export function accountFromPublishLink(input: string, nickname: string) {
|
||||
export function accountFromPublishLink(input: string) {
|
||||
const url = safeHttpUrl(extractXhsPublishUrl(input));
|
||||
if (!url) return null;
|
||||
const platform =
|
||||
@@ -23,7 +23,7 @@ export function accountFromPublishLink(input: string, nickname: string) {
|
||||
return {
|
||||
platform,
|
||||
platformUid,
|
||||
nickname,
|
||||
nickname: "待识别账号",
|
||||
profileUrl: "",
|
||||
};
|
||||
}
|
||||
|
||||
224
lib/recovery-workbook.ts
Normal file
224
lib/recovery-workbook.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
2
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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日");
|
||||
});
|
||||
|
||||
52
tests/recovery-workbook.test.mjs
Normal file
52
tests/recovery-workbook.test.mjs
Normal 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/);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user