Add recovery Excel export and automatic account detection
This commit is contained in:
@@ -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>7月27日</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,16 +1472,27 @@ function RecoveryPage({
|
|||||||
<h2>数据回收队列</h2>
|
<h2>数据回收队列</h2>
|
||||||
<p>{taskDistributions.length} 篇已发布笔记 · 展示最近一次采集结果</p>
|
<p>{taskDistributions.length} 篇已发布笔记 · 展示最近一次采集结果</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="recovery-panel-actions">
|
||||||
className="retry-failed-button"
|
<button
|
||||||
disabled={working || failedCollectionCount === 0}
|
className="export-data-button"
|
||||||
onClick={() =>
|
disabled={working || exportingTaskId === selectedTask.id}
|
||||||
onRetryFailed(selectedTask.id, failedCollectionCount)
|
onClick={() => onExport(selectedTask)}
|
||||||
}
|
>
|
||||||
>
|
{exportingTaskId === selectedTask.id
|
||||||
一键补采异常数据
|
? "正在生成 Excel…"
|
||||||
{failedCollectionCount > 0 && <b>{failedCollectionCount}</b>}
|
: "导出全部数据"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="retry-failed-button"
|
||||||
|
disabled={working || failedCollectionCount === 0}
|
||||||
|
onClick={() =>
|
||||||
|
onRetryFailed(selectedTask.id, failedCollectionCount)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
一键补采异常数据
|
||||||
|
{failedCollectionCount > 0 && <b>{failedCollectionCount}</b>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table className="data-table recovery-table">
|
<table className="data-table recovery-table">
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
1. KOC 通过带 `task` 参数的任务链接进入。
|
1. KOC 通过带 `task` 参数的任务链接进入。
|
||||||
2. 只填写具有唯一性的微信号/手机号和领取数量。
|
2. 只填写具有唯一性的微信号/手机号和领取数量。
|
||||||
3. 领取后只能看到本次领取的笔记。
|
3. 领取后只能看到本次领取的笔记。
|
||||||
4. 在单篇笔记详情页查看标题与正文,并一一回填发布账号昵称、发布链接和发布截图。
|
4. 在单篇笔记详情页查看标题与正文,并一一回填发布链接和发布截图;发布账号由系统根据链接识别。
|
||||||
|
|
||||||
门户不直接连接数据库。浏览器只调用后台隔离开放的 KOC 领取与回填接口,后台仍是任务、笔记和发布数据的唯一数据源;后台管理页面和管理接口需要管理员登录。
|
门户不直接连接数据库。浏览器只调用后台隔离开放的 KOC 领取与回填接口,后台仍是任务、笔记和发布数据的唯一数据源;后台管理页面和管理接口需要管理员登录。
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"/);
|
||||||
|
|||||||
@@ -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}日`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
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",
|
"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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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日");
|
||||||
|
});
|
||||||
|
|||||||
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, /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,
|
||||||
|
|||||||
Reference in New Issue
Block a user