Add KOC resource filters and export

This commit is contained in:
巫凤萍
2026-08-06 10:18:23 +08:00
parent 85110bbfce
commit 72f8862f8a
6 changed files with 623 additions and 18 deletions

View File

@@ -321,6 +321,7 @@ export default function Home() {
const [sourceWorking, setSourceWorking] = useState(false);
const [metricForm, setMetricForm] = useState({ exposure: "", views: "" });
const [exportingTaskId, setExportingTaskId] = useState<string | null>(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<HTMLInputElement>,
@@ -733,6 +770,8 @@ export default function Home() {
<ResourcesPage
accounts={data.accounts}
distributions={data.distributions}
exporting={exportingResources}
onExport={exportResources}
/>
)}
{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<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");
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 (
<div className="stack">
@@ -1362,10 +1475,73 @@ function ResourcesPage({
<section className="panel table-panel">
<div className="panel-heading">
<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">
<label className="resource-search">
<span aria-hidden="true"></span>
<input
aria-label="搜索账号名称或账号ID"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索账号名称 / 账号ID"
type="search"
value={query}
/>
</label>
<select
aria-label="按IP地区筛选"
onChange={(event) => setIpFilter(event.target.value)}
value={ipFilter}
>
<option>IP地区</option>
{ipOptions.map((ip) => <option key={ip}>{ip}</option>)}
</select>
<select
aria-label="按合作来源筛选"
onChange={(event) => setSourceFilter(event.target.value)}
value={sourceFilter}
>
<option></option>
{sourceOptions.map((source) => <option key={source}>{source}</option>)}
</select>
<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 className="resource-grid">
{accounts.map((account) => (
{filteredAccounts.map(({ account, sources, partnerManagedOnly }) => (
<article className="resource-card" key={account.id}>
<div className="resource-card-head">
<div className={`avatar xlarge ${avatarColor(account.nickname)}`}>{account.nickname.slice(0, 1)}</div>
@@ -1383,8 +1559,8 @@ function ResourcesPage({
<div className="resource-source">
<span></span>
<div>
{sources(account.id).map((source) => <b key={source}>{source}</b>)}
{isPartnerManagedOnly(account.id) && (
{sources.map((source) => <b key={source}>{source}</b>)}
{partnerManagedOnly && (
<b className="partner-managed"> · </b>
)}
</div>
@@ -1396,6 +1572,15 @@ function ResourcesPage({
</article>
))}
</div>
{filteredAccounts.length === 0 && (
<div className="resource-empty">
<strong>KOC</strong>
<span></span>
<button className="ghost-button" onClick={clearFilters}>
</button>
</div>
)}
</section>
</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,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;
}