diff --git a/app/admin-app.tsx b/app/admin-app.tsx index 9c0546d..7de5315 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -321,6 +321,7 @@ export default function Home() { const [sourceWorking, setSourceWorking] = useState(false); const [metricForm, setMetricForm] = useState({ exposure: "", views: "" }); const [exportingTaskId, setExportingTaskId] = useState(null); + const [exportingResources, setExportingResources] = useState(false); const loadData = useCallback(async () => { 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 ( distribution: Distribution, event: ChangeEvent, @@ -733,6 +770,8 @@ export default function Home() { )} {activeNav === "recovery" && ( @@ -1328,20 +1367,94 @@ function DistributionTable({ function ResourcesPage({ accounts, distributions, + exporting, + onExport, }: { accounts: Account[]; distributions: Distribution[]; + exporting: boolean; + onExport: (accountIds: string[]) => void; }) { - const sources = (accountId: string) => - [...new Set(distributions.filter((item) => item.account_id === accountId).map((item) => item.partner_name))]; - const isPartnerManagedOnly = (accountId: string) => { - const deliveries = distributions.filter( - (item) => item.account_id === accountId, - ); - return ( - deliveries.some((item) => item.delegation_bundle_id) && - deliveries.every((item) => item.delegation_bundle_id) - ); + const [query, setQuery] = useState(""); + const [platformFilter, setPlatformFilter] = useState("全部平台"); + const [ipFilter, setIpFilter] = useState("全部IP地区"); + const [sourceFilter, setSourceFilter] = useState("全部合作来源"); + const resourceAccounts = useMemo(() => { + const deliveriesByAccount = new Map(); + 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"); + return resourceAccounts.filter(({ account, sources }) => { + const searchable = `${account.nickname} ${account.public_account_id}`.toLocaleLowerCase( + "zh-CN", + ); + return ( + (!keyword || searchable.includes(keyword)) && + (platformFilter === "全部平台" || + account.platform === platformFilter) && + (ipFilter === "全部IP地区" || + (account.ip_location || "待识别") === ipFilter) && + (sourceFilter === "全部合作来源" || + sources.includes(sourceFilter)) + ); + }); + }, [ipFilter, platformFilter, query, resourceAccounts, sourceFilter]); + const hasActiveFilters = + Boolean(query.trim()) || + platformFilter !== "全部平台" || + ipFilter !== "全部IP地区" || + sourceFilter !== "全部合作来源"; + const clearFilters = () => { + setQuery(""); + setPlatformFilter("全部平台"); + setIpFilter("全部IP地区"); + setSourceFilter("全部合作来源"); }; return (
@@ -1362,10 +1475,73 @@ function ResourcesPage({

账号资源库

只展示真实交付过的账号

-
+
+ + {platformOptions.map((platform) => ( + + ))} +
+
+
+ + + + +
+ {filteredAccounts.length} 个结果 + +
- {accounts.map((account) => ( + {filteredAccounts.map(({ account, sources, partnerManagedOnly }) => (
{account.nickname.slice(0, 1)}
@@ -1383,8 +1559,8 @@ function ResourcesPage({
历史合作来源
- {sources(account.id).map((source) => {source})} - {isPartnerManagedOnly(account.id) && ( + {sources.map((source) => {source})} + {partnerManagedOnly && ( 合作社资源 · 不可直联 )}
@@ -1396,6 +1572,15 @@ function ResourcesPage({
))}
+ {filteredAccounts.length === 0 && ( +
+ 没有符合条件的KOC + 可以调整搜索内容或清空筛选条件 + +
+ )}
); diff --git a/app/api/resources-export/route.ts b/app/api/resources-export/route.ts new file mode 100644 index 0000000..b5016c1 --- /dev/null +++ b/app/api/resources-export/route.ts @@ -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(), + 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(), + ]); + 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(); + 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 }, + ); + } +} diff --git a/app/globals.css b/app/globals.css index 22adf42..f701981 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1426,6 +1426,90 @@ a { 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; + min-width: 230px; + flex: 1 1 280px; + align-items: center; + gap: 8px; + height: 38px; + padding: 0 12px; + border: 1px solid #dfe6e2; + border-radius: 9px; + background: white; +} + +.resource-search:focus-within { + border-color: #74b79f; + box-shadow: 0 0 0 3px rgb(31 143 107 / 0.08); +} + +.resource-search > span { + color: #83918c; + font-size: 17px; +} + +.resource-search input { + width: 100%; + height: 100%; + padding: 0; + border: 0; + outline: 0; + background: transparent; + font-size: 10px; +} + +.resource-toolbar > select { + width: 160px; + height: 38px; + padding: 0 31px 0 11px; + border: 1px solid #dfe6e2; + border-radius: 9px; + background-color: white; + font-size: 10px; +} + +.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 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -1580,6 +1664,27 @@ a { 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 { display: grid; grid-template-columns: repeat(3, 1fr); @@ -2749,6 +2854,11 @@ label small { grid-template-columns: repeat(2, 1fr); } + .resource-toolbar-summary { + width: 100%; + justify-content: flex-end; + } + .task-scope-grid { grid-template-columns: 1fr; } @@ -2876,6 +2986,25 @@ label small { grid-template-columns: 1fr; } + .resource-toolbar { + align-items: stretch; + } + + .resource-search, + .resource-toolbar > select { + width: 100%; + flex-basis: 100%; + } + + .resource-clear-button { + padding-inline: 4px; + text-align: left; + } + + .resource-toolbar-summary { + justify-content: space-between; + } + .collection-schedule-panel { padding: 18px; } diff --git a/lib/recovery-workbook.ts b/lib/recovery-workbook.ts index 7cc464d..b3f062f 100644 --- a/lib/recovery-workbook.ts +++ b/lib/recovery-workbook.ts @@ -14,6 +14,10 @@ export type RecoveryWorkbookRow = { column: number; image: RecoveryWorkbookImage; }>; + hyperlinks?: Array<{ + column: number; + url: string; + }>; }; type WorkbookOptions = { @@ -53,6 +57,15 @@ function safeSheetName(value: string) { 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) { const normalized = contentType.toLowerCase(); 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) => 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 lastRow = Math.max(1, options.rows.length + 1); const headerCells = options.headers @@ -148,6 +170,9 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) { .map((row, rowIndex) => { const number = rowIndex + 2; const imageColumns = new Set(row.images.map((item) => item.column)); + const hyperlinkColumns = new Set( + (row.hyperlinks ?? []).map((item) => item.column), + ); const cells = options.headers .map((_, columnIndex) => { const reference = `${columnName(columnIndex)}${number}`; @@ -157,7 +182,11 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) { } return typeof value === "number" ? numberCell(reference, value, 3) - : inlineCell(reference, value, 2); + : inlineCell( + reference, + value, + hyperlinkColumns.has(columnIndex) ? 5 : 2, + ); }) .join(""); const rowHeight = row.images.length > 0 ? IMAGE_ROW_HEIGHT : 42; @@ -199,7 +228,28 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) { .map(([extension, contentType]) => ``) .join(""); const contentTypes = `${XML_HEADER}${imageContentTypes}${imageEntries.length ? '' : ""}`; - const worksheet = `${XML_HEADER}${columns}${headerCells}${dataRows}${imageEntries.length ? '' : ""}`; + const hyperlinkRelationshipOffset = imageEntries.length ? 2 : 1; + const hyperlinksXml = hyperlinkEntries.length + ? `${hyperlinkEntries + .map( + (entry, index) => + ``, + ) + .join("")}` + : ""; + const worksheet = `${XML_HEADER}${columns}${headerCells}${dataRows}${hyperlinksXml}${imageEntries.length ? '' : ""}`; + + const sheetRelationships = [ + ...(imageEntries.length + ? [ + '', + ] + : []), + ...hyperlinkEntries.map( + (entry, index) => + ``, + ), + ]; const files: Record = { "[Content_Types].xml": strToU8(contentTypes), @@ -208,11 +258,13 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) { "docProps/core.xml": strToU8(`${XML_HEADER}KOC LOOPKOC LOOP${new Date().toISOString()}`), "xl/workbook.xml": strToU8(`${XML_HEADER}`), "xl/_rels/workbook.xml.rels": strToU8(`${XML_HEADER}`), - "xl/styles.xml": strToU8(`${XML_HEADER}`), + "xl/styles.xml": strToU8(`${XML_HEADER}`), "xl/worksheets/sheet1.xml": strToU8(worksheet), }; + if (sheetRelationships.length) { + files["xl/worksheets/_rels/sheet1.xml.rels"] = strToU8(`${XML_HEADER}${sheetRelationships.join("")}`); + } if (imageEntries.length) { - files["xl/worksheets/_rels/sheet1.xml.rels"] = strToU8(`${XML_HEADER}`); files["xl/drawings/drawing1.xml"] = strToU8(drawingXml); files["xl/drawings/_rels/drawing1.xml.rels"] = strToU8(drawingRelationships); imageEntries.forEach((entry, index) => { diff --git a/tests/recovery-workbook.test.mjs b/tests/recovery-workbook.test.mjs index 4a2cc27..d630774 100644 --- a/tests/recovery-workbook.test.mjs +++ b/tests/recovery-workbook.test.mjs @@ -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/_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, //); + assert.match(sheet, //); + assert.match(relationships, /relationships\/hyperlink/); + assert.match(relationships, /TargetMode="External"/); + assert.match(relationships, /x=1&y=2/); +}); diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs index d38015b..c24e777 100644 --- a/tests/rendered-html.test.mjs +++ b/tests/rendered-html.test.mjs @@ -250,6 +250,25 @@ test("exports complete task recovery data to Excel with embedded images", async 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, /导出筛选结果/); + 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 () => { const [ adminApp,