2 Commits

Author SHA1 Message Date
巫凤萍
1f910c975d Fix KOC resource fuzzy filters 2026-08-06 11:48:10 +08:00
巫凤萍
72f8862f8a Add KOC resource filters and export 2026-08-06 10:18:23 +08:00
6 changed files with 653 additions and 18 deletions

View File

@@ -321,6 +321,7 @@ export default function Home() {
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 [exportingTaskId, setExportingTaskId] = useState<string | null>(null);
const [exportingResources, setExportingResources] = useState(false);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
@@ -537,6 +538,42 @@ export default function Home() {
} }
}; };
const exportResources = async (accountIds: string[]) => {
if (accountIds.length === 0) {
setToast("当前筛选结果为空");
return;
}
try {
setExportingResources(true);
const response = await fetch("/api/resources-export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accountIds }),
});
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 = `KOC资源库-${todayInputValue().replaceAll("-", "")}.xlsx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
setToast(`已导出 ${accountIds.length} 个KOC账号`);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "导出失败");
} finally {
setExportingResources(false);
}
};
const uploadScreenshot = async ( const uploadScreenshot = async (
distribution: Distribution, distribution: Distribution,
event: ChangeEvent<HTMLInputElement>, event: ChangeEvent<HTMLInputElement>,
@@ -733,6 +770,8 @@ export default function Home() {
<ResourcesPage <ResourcesPage
accounts={data.accounts} accounts={data.accounts}
distributions={data.distributions} distributions={data.distributions}
exporting={exportingResources}
onExport={exportResources}
/> />
)} )}
{activeNav === "recovery" && ( {activeNav === "recovery" && (
@@ -1328,20 +1367,100 @@ function DistributionTable({
function ResourcesPage({ function ResourcesPage({
accounts, accounts,
distributions, distributions,
exporting,
onExport,
}: { }: {
accounts: Account[]; accounts: Account[];
distributions: Distribution[]; distributions: Distribution[];
exporting: boolean;
onExport: (accountIds: string[]) => void;
}) { }) {
const sources = (accountId: string) => const [query, setQuery] = useState("");
[...new Set(distributions.filter((item) => item.account_id === accountId).map((item) => item.partner_name))]; const [platformFilter, setPlatformFilter] = useState("全部平台");
const isPartnerManagedOnly = (accountId: string) => { const [ipFilter, setIpFilter] = useState("");
const deliveries = distributions.filter( const [sourceFilter, setSourceFilter] = useState("");
(item) => item.account_id === accountId, const resourceAccounts = useMemo(() => {
const deliveriesByAccount = new Map<string, Distribution[]>();
distributions.forEach((distribution) => {
if (!distribution.account_id) return;
const current = deliveriesByAccount.get(distribution.account_id) ?? [];
current.push(distribution);
deliveriesByAccount.set(distribution.account_id, current);
});
return accounts.map((account) => {
const deliveries = deliveriesByAccount.get(account.id) ?? [];
return {
account,
sources: [
...new Set(
deliveries.map((item) => item.partner_name).filter(Boolean),
),
].sort((left, right) => left.localeCompare(right, "zh-CN")),
partnerManagedOnly:
deliveries.some((item) => item.delegation_bundle_id) &&
deliveries.every((item) => item.delegation_bundle_id),
};
});
}, [accounts, distributions]);
const platformOptions = useMemo(
() =>
[...new Set(accounts.map((account) => account.platform).filter(Boolean))]
.sort((left, right) => left.localeCompare(right, "zh-CN")),
[accounts],
);
const ipOptions = useMemo(
() =>
[
...new Set(
accounts.map((account) => account.ip_location || "待识别"),
),
].sort((left, right) => {
if (left === "待识别") return 1;
if (right === "待识别") return -1;
return left.localeCompare(right, "zh-CN");
}),
[accounts],
);
const sourceOptions = useMemo(
() =>
[...new Set(resourceAccounts.flatMap((item) => item.sources))].sort(
(left, right) => left.localeCompare(right, "zh-CN"),
),
[resourceAccounts],
);
const filteredAccounts = useMemo(() => {
const keyword = query.trim().toLocaleLowerCase("zh-CN");
const ipKeyword = ipFilter.trim().toLocaleLowerCase("zh-CN");
const sourceKeyword = sourceFilter.trim().toLocaleLowerCase("zh-CN");
return resourceAccounts.filter(({ account, sources }) => {
const searchable = `${account.nickname} ${account.public_account_id}`.toLocaleLowerCase(
"zh-CN",
);
const ipLocation = (account.ip_location || "待识别").toLocaleLowerCase(
"zh-CN",
); );
return ( return (
deliveries.some((item) => item.delegation_bundle_id) && (!keyword || searchable.includes(keyword)) &&
deliveries.every((item) => item.delegation_bundle_id) (platformFilter === "全部平台" ||
account.platform === platformFilter) &&
(!ipKeyword || ipLocation.includes(ipKeyword)) &&
(!sourceKeyword ||
sources.some((source) =>
source.toLocaleLowerCase("zh-CN").includes(sourceKeyword),
))
); );
});
}, [ipFilter, platformFilter, query, resourceAccounts, sourceFilter]);
const hasActiveFilters =
Boolean(query.trim()) ||
platformFilter !== "全部平台" ||
Boolean(ipFilter.trim()) ||
Boolean(sourceFilter.trim());
const clearFilters = () => {
setQuery("");
setPlatformFilter("全部平台");
setIpFilter("");
setSourceFilter("");
}; };
return ( return (
<div className="stack"> <div className="stack">
@@ -1362,10 +1481,87 @@ function ResourcesPage({
<section className="panel table-panel"> <section className="panel table-panel">
<div className="panel-heading"> <div className="panel-heading">
<div><h2></h2><p></p></div> <div><h2></h2><p></p></div>
<div className="filter-chips"><button className="active"></button><button></button></div> <div className="filter-chips">
<button
className={platformFilter === "全部平台" ? "active" : ""}
onClick={() => setPlatformFilter("全部平台")}
>
</button>
{platformOptions.map((platform) => (
<button
className={platformFilter === platform ? "active" : ""}
key={platform}
onClick={() => setPlatformFilter(platform)}
>
{platform}
</button>
))}
</div>
</div>
<div className="resource-toolbar">
<div className="resource-search">
<span aria-hidden="true"></span>
<input
aria-label="搜索账号名称或账号ID"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索账号名称 / 账号ID"
type="search"
value={query}
/>
</div>
<div className="resource-filter-search">
<span aria-hidden="true"></span>
<input
aria-label="模糊搜索IP地区"
list="resource-ip-options"
onChange={(event) => setIpFilter(event.target.value)}
placeholder="搜索IP地区"
type="search"
value={ipFilter}
/>
<datalist id="resource-ip-options">
{ipOptions.map((ip) => <option key={ip} value={ip} />)}
</datalist>
</div>
<div className="resource-filter-search">
<span aria-hidden="true"></span>
<input
aria-label="模糊搜索合作来源"
list="resource-source-options"
onChange={(event) => setSourceFilter(event.target.value)}
placeholder="搜索合作来源"
type="search"
value={sourceFilter}
/>
<datalist id="resource-source-options">
{sourceOptions.map((source) => (
<option key={source} value={source} />
))}
</datalist>
</div>
<button
className="resource-clear-button"
disabled={!hasActiveFilters}
onClick={clearFilters}
>
</button>
<div className="resource-toolbar-summary">
<span> <b>{filteredAccounts.length}</b> </span>
<button
className="export-data-button"
disabled={exporting || filteredAccounts.length === 0}
onClick={() =>
onExport(filteredAccounts.map((item) => item.account.id))
}
>
{exporting ? "正在导出…" : "导出筛选结果"}
</button>
</div>
</div> </div>
<div className="resource-grid"> <div className="resource-grid">
{accounts.map((account) => ( {filteredAccounts.map(({ account, sources, partnerManagedOnly }) => (
<article className="resource-card" key={account.id}> <article className="resource-card" key={account.id}>
<div className="resource-card-head"> <div className="resource-card-head">
<div className={`avatar xlarge ${avatarColor(account.nickname)}`}>{account.nickname.slice(0, 1)}</div> <div className={`avatar xlarge ${avatarColor(account.nickname)}`}>{account.nickname.slice(0, 1)}</div>
@@ -1383,8 +1579,8 @@ function ResourcesPage({
<div className="resource-source"> <div className="resource-source">
<span></span> <span></span>
<div> <div>
{sources(account.id).map((source) => <b key={source}>{source}</b>)} {sources.map((source) => <b key={source}>{source}</b>)}
{isPartnerManagedOnly(account.id) && ( {partnerManagedOnly && (
<b className="partner-managed"> · </b> <b className="partner-managed"> · </b>
)} )}
</div> </div>
@@ -1396,6 +1592,15 @@ function ResourcesPage({
</article> </article>
))} ))}
</div> </div>
{filteredAccounts.length === 0 && (
<div className="resource-empty">
<strong>KOC</strong>
<span></span>
<button className="ghost-button" onClick={clearFilters}>
</button>
</div>
)}
</section> </section>
</div> </div>
); );

View File

@@ -0,0 +1,189 @@
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { parseStoredDate } from "../../../lib/date-utils";
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
import {
buildRecoveryWorkbook,
type RecoveryWorkbookRow,
} from "../../../lib/recovery-workbook";
export const runtime = "edge";
type AccountRow = {
id: string;
platform: string;
public_account_id: string;
nickname: string;
profile_url: string;
ip_location: string;
followers: number;
post_count: number;
first_seen_at: string;
last_seen_at: string;
};
type CooperationRow = {
account_id: string;
partner_name: string;
delegation_bundle_id: string | null;
};
function formatExportDate(value: string) {
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 exactArrayBuffer(bytes: Uint8Array) {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}
export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
try {
const body = (await request.json()) as { accountIds?: unknown };
if (!Array.isArray(body.accountIds)) {
return Response.json({ error: "缺少需要导出的账号" }, { status: 400 });
}
const accountIds = [
...new Set(
body.accountIds
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean),
),
].slice(0, 5000);
if (accountIds.length === 0) {
return Response.json({ error: "当前筛选结果为空" }, { status: 400 });
}
await ensureSchema();
const db = getRawDb();
const [accountResult, cooperationResult] = await Promise.all([
db.prepare("SELECT * FROM accounts ORDER BY last_seen_at DESC").all<AccountRow>(),
db
.prepare(
`SELECT
d.account_id,
p.name AS partner_name,
d.delegation_bundle_id
FROM distributions d
JOIN partners p ON p.id = d.partner_id
WHERE d.account_id IS NOT NULL`,
)
.all<CooperationRow>(),
]);
const accountIdSet = new Set(accountIds);
const order = new Map(accountIds.map((id, index) => [id, index]));
const accounts = accountResult.results
.filter((account) => accountIdSet.has(account.id))
.sort(
(left, right) =>
(order.get(left.id) ?? Number.MAX_SAFE_INTEGER) -
(order.get(right.id) ?? Number.MAX_SAFE_INTEGER),
);
if (accounts.length === 0) {
return Response.json({ error: "没有找到可导出的账号" }, { status: 404 });
}
const cooperationByAccount = new Map<string, CooperationRow[]>();
for (const cooperation of cooperationResult.results) {
if (!accountIdSet.has(cooperation.account_id)) continue;
const current = cooperationByAccount.get(cooperation.account_id) ?? [];
current.push(cooperation);
cooperationByAccount.set(cooperation.account_id, current);
}
const headers = [
"序号",
"平台",
"账号名称",
"小红书号/抖音号",
"账号主页",
"IP地",
"粉丝数",
"合作发布数",
"历史合作来源",
"资源归属",
"首次合作时间",
"最近合作时间",
];
const rows: RecoveryWorkbookRow[] = accounts.map((account, index) => {
const cooperation = cooperationByAccount.get(account.id) ?? [];
const sources = [...new Set(cooperation.map((item) => item.partner_name))];
const partnerManagedOnly =
cooperation.some((item) => item.delegation_bundle_id) &&
cooperation.every((item) => item.delegation_bundle_id);
return {
cells: [
index + 1,
account.platform,
account.nickname,
account.public_account_id || "待识别",
account.profile_url || "",
account.ip_location || "待识别",
account.followers,
account.post_count,
sources.join("、"),
partnerManagedOnly ? "合作社资源 · 不可直联" : "可直联",
formatExportDate(account.first_seen_at),
formatExportDate(account.last_seen_at),
],
images: [],
hyperlinks: account.profile_url
? [{ column: 4, url: account.profile_url }]
: [],
};
});
const workbook = buildRecoveryWorkbook({
sheetName: "KOC资源库",
headers,
columnWidths: [
9,
12,
22,
22,
44,
14,
14,
14,
32,
22,
21,
21,
],
rows,
});
const date = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.format(new Date())
.replace(/\D/g, "");
const fileName = `KOC资源库-${date}.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-resources.xlsx"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "导出失败" },
{ status: 500 },
);
}
}

View File

@@ -1426,6 +1426,98 @@ a {
font-size: 11px; font-size: 11px;
} }
.resource-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 9px;
margin: -3px 0 18px;
padding: 13px;
border: 1px solid #e9eeeb;
border-radius: 12px;
background: #f8faf8;
}
.resource-search {
display: flex;
flex-direction: row;
min-width: 230px;
flex: 1 1 280px;
align-items: center;
gap: 8px;
margin: 0;
height: 38px;
padding: 0 12px;
border: 1px solid #dfe6e2;
border-radius: 9px;
background: white;
}
.resource-search:focus-within,
.resource-filter-search:focus-within {
border-color: #74b79f;
box-shadow: 0 0 0 3px rgb(31 143 107 / 0.08);
}
.resource-search > span,
.resource-filter-search > span {
color: #83918c;
font-size: 17px;
font-weight: 400;
}
.resource-search input,
.resource-filter-search input {
width: 100%;
height: 100%;
padding: 0;
border: 0;
outline: 0;
background: transparent;
font-size: 10px;
}
.resource-filter-search {
display: flex;
flex: 0 1 180px;
align-items: center;
gap: 7px;
height: 38px;
padding: 0 11px;
border: 1px solid #dfe6e2;
border-radius: 9px;
background: white;
}
.resource-clear-button {
height: 38px;
padding: 0 6px;
border: 0;
color: var(--green-deep);
background: transparent;
font-size: 9px;
font-weight: 650;
}
.resource-clear-button:disabled {
color: #a5aeaa;
}
.resource-toolbar-summary {
display: flex;
align-items: center;
gap: 12px;
margin-left: auto;
color: #8c9894;
font-size: 9px;
white-space: nowrap;
}
.resource-toolbar-summary b {
color: var(--ink);
font-size: 11px;
}
.resource-grid { .resource-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1580,6 +1672,27 @@ a {
font-weight: 650; font-weight: 650;
} }
.resource-empty {
display: flex;
min-height: 240px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 7px;
color: #939e9a;
text-align: center;
}
.resource-empty strong {
color: #354640;
font-size: 13px;
}
.resource-empty span {
margin-bottom: 8px;
font-size: 9px;
}
.recovery-legend { .recovery-legend {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -2749,6 +2862,11 @@ label small {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.resource-toolbar-summary {
width: 100%;
justify-content: flex-end;
}
.task-scope-grid { .task-scope-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -2876,6 +2994,25 @@ label small {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.resource-toolbar {
align-items: stretch;
}
.resource-search,
.resource-filter-search {
width: 100%;
flex-basis: 100%;
}
.resource-clear-button {
padding-inline: 4px;
text-align: left;
}
.resource-toolbar-summary {
justify-content: space-between;
}
.collection-schedule-panel { .collection-schedule-panel {
padding: 18px; padding: 18px;
} }

View File

@@ -14,6 +14,10 @@ export type RecoveryWorkbookRow = {
column: number; column: number;
image: RecoveryWorkbookImage; image: RecoveryWorkbookImage;
}>; }>;
hyperlinks?: Array<{
column: number;
url: string;
}>;
}; };
type WorkbookOptions = { type WorkbookOptions = {
@@ -53,6 +57,15 @@ function safeSheetName(value: string) {
return (cleaned || "数据回收").slice(0, 31); return (cleaned || "数据回收").slice(0, 31);
} }
function safeHyperlink(value: string) {
try {
const url = new URL(value);
return ["http:", "https:"].includes(url.protocol) ? url.toString() : "";
} catch {
return "";
}
}
function imageFormat(contentType: string, bytes: Uint8Array) { function imageFormat(contentType: string, bytes: Uint8Array) {
const normalized = contentType.toLowerCase(); const normalized = contentType.toLowerCase();
if (normalized.includes("png") || (bytes[0] === 0x89 && bytes[1] === 0x50)) { if (normalized.includes("png") || (bytes[0] === 0x89 && bytes[1] === 0x50)) {
@@ -139,6 +152,15 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) {
const imageEntries = options.rows.flatMap((row, rowIndex) => const imageEntries = options.rows.flatMap((row, rowIndex) =>
row.images.map((item) => ({ ...item, row: rowIndex + 1 })), row.images.map((item) => ({ ...item, row: rowIndex + 1 })),
); );
const hyperlinkEntries = options.rows.flatMap((row, rowIndex) =>
(row.hyperlinks ?? [])
.map((item) => ({
...item,
row: rowIndex + 2,
url: safeHyperlink(item.url),
}))
.filter((item) => item.url),
);
const lastColumn = columnName(Math.max(0, options.headers.length - 1)); const lastColumn = columnName(Math.max(0, options.headers.length - 1));
const lastRow = Math.max(1, options.rows.length + 1); const lastRow = Math.max(1, options.rows.length + 1);
const headerCells = options.headers const headerCells = options.headers
@@ -148,6 +170,9 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) {
.map((row, rowIndex) => { .map((row, rowIndex) => {
const number = rowIndex + 2; const number = rowIndex + 2;
const imageColumns = new Set(row.images.map((item) => item.column)); const imageColumns = new Set(row.images.map((item) => item.column));
const hyperlinkColumns = new Set(
(row.hyperlinks ?? []).map((item) => item.column),
);
const cells = options.headers const cells = options.headers
.map((_, columnIndex) => { .map((_, columnIndex) => {
const reference = `${columnName(columnIndex)}${number}`; const reference = `${columnName(columnIndex)}${number}`;
@@ -157,7 +182,11 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) {
} }
return typeof value === "number" return typeof value === "number"
? numberCell(reference, value, 3) ? numberCell(reference, value, 3)
: inlineCell(reference, value, 2); : inlineCell(
reference,
value,
hyperlinkColumns.has(columnIndex) ? 5 : 2,
);
}) })
.join(""); .join("");
const rowHeight = row.images.length > 0 ? IMAGE_ROW_HEIGHT : 42; const rowHeight = row.images.length > 0 ? IMAGE_ROW_HEIGHT : 42;
@@ -199,7 +228,28 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) {
.map(([extension, contentType]) => `<Default Extension="${extension}" ContentType="${contentType}"/>`) .map(([extension, contentType]) => `<Default Extension="${extension}" ContentType="${contentType}"/>`)
.join(""); .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 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 hyperlinkRelationshipOffset = imageEntries.length ? 2 : 1;
const hyperlinksXml = hyperlinkEntries.length
? `<hyperlinks>${hyperlinkEntries
.map(
(entry, index) =>
`<hyperlink ref="${columnName(entry.column)}${entry.row}" r:id="rId${hyperlinkRelationshipOffset + index}"/>`,
)
.join("")}</hyperlinks>`
: "";
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}"/>${hyperlinksXml}${imageEntries.length ? '<drawing r:id="rId1"/>' : ""}</worksheet>`;
const sheetRelationships = [
...(imageEntries.length
? [
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/>',
]
: []),
...hyperlinkEntries.map(
(entry, index) =>
`<Relationship Id="rId${hyperlinkRelationshipOffset + index}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="${cleanXmlText(entry.url)}" TargetMode="External"/>`,
),
];
const files: Record<string, Uint8Array> = { const files: Record<string, Uint8Array> = {
"[Content_Types].xml": strToU8(contentTypes), "[Content_Types].xml": strToU8(contentTypes),
@@ -208,11 +258,13 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) {
"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>`), "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/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/_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/styles.xml": strToU8(`${XML_HEADER}<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="4"><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><font><u/><color rgb="FF1F8F6B"/><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="6"><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><xf numFmtId="0" fontId="3" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`),
"xl/worksheets/sheet1.xml": strToU8(worksheet), "xl/worksheets/sheet1.xml": strToU8(worksheet),
}; };
if (sheetRelationships.length) {
files["xl/worksheets/_rels/sheet1.xml.rels"] = strToU8(`${XML_HEADER}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${sheetRelationships.join("")}</Relationships>`);
}
if (imageEntries.length) { 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/drawing1.xml"] = strToU8(drawingXml);
files["xl/drawings/_rels/drawing1.xml.rels"] = strToU8(drawingRelationships); files["xl/drawings/_rels/drawing1.xml.rels"] = strToU8(drawingRelationships);
imageEntries.forEach((entry, index) => { imageEntries.forEach((entry, index) => {

View File

@@ -50,3 +50,34 @@ test("creates an xlsx archive whose pictures are embedded in worksheet cells", (
assert.match(strFromU8(archive["xl/drawings/drawing1.xml"]), /oneCellAnchor/); assert.match(strFromU8(archive["xl/drawings/drawing1.xml"]), /oneCellAnchor/);
assert.match(strFromU8(archive["xl/drawings/_rels/drawing1.xml.rels"]), /image2\.png/); assert.match(strFromU8(archive["xl/drawings/_rels/drawing1.xml.rels"]), /image2\.png/);
}); });
test("creates clickable external hyperlinks for resource exports", () => {
const workbook = buildRecoveryWorkbook({
sheetName: "KOC资源库",
headers: ["账号名称", "账号主页"],
columnWidths: [20, 40],
rows: [
{
cells: ["小满的轻生活", "https://www.xiaohongshu.com/user/profile/test?x=1&y=2"],
images: [],
hyperlinks: [
{
column: 1,
url: "https://www.xiaohongshu.com/user/profile/test?x=1&y=2",
},
],
},
],
});
const archive = unzipSync(workbook);
const sheet = strFromU8(archive["xl/worksheets/sheet1.xml"]);
const relationships = strFromU8(
archive["xl/worksheets/_rels/sheet1.xml.rels"],
);
assert.match(sheet, /<hyperlink ref="B2" r:id="rId1"\/>/);
assert.match(sheet, /<c r="B2" t="inlineStr" s="5">/);
assert.match(relationships, /relationships\/hyperlink/);
assert.match(relationships, /TargetMode="External"/);
assert.match(relationships, /x=1&amp;y=2/);
});

View File

@@ -250,6 +250,27 @@ test("exports complete task recovery data to Excel with embedded images", async
assert.match(workbook, /oneCellAnchor/); assert.match(workbook, /oneCellAnchor/);
}); });
test("filters and exports the current KOC resource result set", async () => {
const [adminApp, exportRoute, workbook] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/api/resources-export/route.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/recovery-workbook.ts", import.meta.url), "utf8"),
]);
assert.match(adminApp, /搜索账号名称 \/ 账号ID/);
assert.match(adminApp, /搜索IP地区/);
assert.match(adminApp, /搜索合作来源/);
assert.match(adminApp, /ipLocation\.includes\(ipKeyword\)/);
assert.match(adminApp, /includes\(sourceKeyword\)/);
assert.match(adminApp, /导出筛选结果/);
assert.match(adminApp, /filteredAccounts\.map\(\(item\) => item\.account\.id\)/);
assert.match(exportRoute, /小红书号\/抖音号/);
assert.match(exportRoute, /历史合作来源/);
assert.match(exportRoute, /合作社资源 · 不可直联/);
assert.match(exportRoute, /isAdminRequest/);
assert.match(workbook, /relationships\/hyperlink/);
});
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,