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>
);