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 [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,94 @@ 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("全部IP地区");
|
||||||
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");
|
||||||
|
return resourceAccounts.filter(({ account, sources }) => {
|
||||||
|
const searchable = `${account.nickname} ${account.public_account_id}`.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) &&
|
||||||
|
(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 (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
@@ -1362,10 +1475,73 @@ 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">
|
||||||
|
<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>
|
||||||
<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 +1559,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 +1572,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>
|
||||||
);
|
);
|
||||||
|
|||||||
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;
|
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 {
|
.resource-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -1580,6 +1664,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 +2854,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 +2986,25 @@ label small {
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.collection-schedule-panel {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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&y=2/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -250,6 +250,25 @@ 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, /导出筛选结果/);
|
||||||
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user