Add KOC resource filters and export
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
189
app/api/resources-export/route.ts
Normal file
189
app/api/resources-export/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
129
app/globals.css
129
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user