feat: 完善视频任务与 KOC 资源库
This commit is contained in:
341
koc-portal/app/batch-workbook-upload.ts
Normal file
341
koc-portal/app/batch-workbook-upload.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { strFromU8, unzipSync, zipSync } from "fflate";
|
||||
|
||||
export const PARTNER_BATCH_UPLOAD_MAX_BYTES = 80_000_000;
|
||||
|
||||
type WorkbookCell = {
|
||||
reference: string;
|
||||
row: number;
|
||||
column: number;
|
||||
attributes: string;
|
||||
body: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type ScreenshotColumns = {
|
||||
headerRow: number;
|
||||
columns: Set<number>;
|
||||
};
|
||||
|
||||
function decodeXml(value: string) {
|
||||
return value
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, code) =>
|
||||
String.fromCodePoint(Number.parseInt(code, 16)),
|
||||
);
|
||||
}
|
||||
|
||||
function textNodes(xml: string) {
|
||||
return [...xml.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)]
|
||||
.map((match) => decodeXml(match[1]))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function columnIndex(reference: string) {
|
||||
const letters = reference.match(/^[A-Z]+/i)?.[0]?.toUpperCase() ?? "";
|
||||
let result = 0;
|
||||
for (const letter of letters) result = result * 26 + letter.charCodeAt(0) - 64;
|
||||
return Math.max(0, result - 1);
|
||||
}
|
||||
|
||||
function normalizeHeader(value: string) {
|
||||
return value.replace(/[\s_\-()()]/g, "").toLocaleLowerCase("zh-CN");
|
||||
}
|
||||
|
||||
function parseCells(worksheetXml: string, sharedStrings: string[]) {
|
||||
const cells: WorkbookCell[] = [];
|
||||
for (const match of worksheetXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
||||
const attributes = match[1];
|
||||
const body = match[2];
|
||||
const reference = attributes.match(/\br="([A-Z]+\d+)"/i)?.[1] ?? "";
|
||||
if (!reference) continue;
|
||||
const type = attributes.match(/\bt="([^"]+)"/)?.[1] ?? "";
|
||||
const rawValue = body.match(/<v>([\s\S]*?)<\/v>/)?.[1] ?? "";
|
||||
const value =
|
||||
type === "s"
|
||||
? sharedStrings[Number(rawValue)] ?? ""
|
||||
: type === "inlineStr"
|
||||
? textNodes(body)
|
||||
: decodeXml(rawValue);
|
||||
cells.push({
|
||||
reference,
|
||||
row: Number(reference.match(/\d+$/)?.[0] ?? 0),
|
||||
column: columnIndex(reference),
|
||||
attributes,
|
||||
body,
|
||||
value: value.trim(),
|
||||
});
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function findScreenshotColumns(cells: WorkbookCell[]): ScreenshotColumns {
|
||||
for (let row = 1; row <= 8; row += 1) {
|
||||
const columns = new Set<number>();
|
||||
for (const cell of cells) {
|
||||
if (cell.row !== row) continue;
|
||||
const header = normalizeHeader(cell.value);
|
||||
if (
|
||||
header === normalizeHeader("笔记截图") ||
|
||||
header === normalizeHeader("发布截图") ||
|
||||
header === normalizeHeader("数据分析截图") ||
|
||||
header === normalizeHeader("数据分析截图(单篇笔记数据分析截图)") ||
|
||||
header === normalizeHeader("创作者中心截图")
|
||||
) {
|
||||
columns.add(cell.column);
|
||||
}
|
||||
}
|
||||
if (columns.size >= 2) return { headerRow: row, columns };
|
||||
}
|
||||
throw new Error("表格结构不正确,请使用本领取页面导出的批量回填表");
|
||||
}
|
||||
|
||||
function relationshipMap(xml: string) {
|
||||
const relationships = new Map<string, string>();
|
||||
for (const match of xml.matchAll(/<Relationship\b([^>]*)\/?\s*>/g)) {
|
||||
const id = match[1].match(/\bId="([^"]+)"/)?.[1] ?? "";
|
||||
const target = match[1].match(/\bTarget="([^"]+)"/)?.[1] ?? "";
|
||||
if (id && target) relationships.set(id, decodeXml(target));
|
||||
}
|
||||
return relationships;
|
||||
}
|
||||
|
||||
function normalizeZipPath(value: string) {
|
||||
const result: string[] = [];
|
||||
for (const part of value.split("/")) {
|
||||
if (!part || part === ".") continue;
|
||||
if (part === "..") result.pop();
|
||||
else result.push(part);
|
||||
}
|
||||
return result.join("/");
|
||||
}
|
||||
|
||||
function resolveZipPath(base: string, target: string) {
|
||||
const slash = base.lastIndexOf("/");
|
||||
const directory = slash >= 0 ? base.slice(0, slash + 1) : "";
|
||||
return normalizeZipPath(`${directory}${target}`);
|
||||
}
|
||||
|
||||
function wpsScreenshotMedia(
|
||||
entries: Record<string, Uint8Array>,
|
||||
cells: WorkbookCell[],
|
||||
screenshotColumns: ScreenshotColumns,
|
||||
) {
|
||||
const result = new Set<string>();
|
||||
let expected = 0;
|
||||
const screenshotIds = new Set<string>();
|
||||
for (const cell of cells) {
|
||||
if (
|
||||
cell.row <= screenshotColumns.headerRow ||
|
||||
!screenshotColumns.columns.has(cell.column)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const id = decodeXml(cell.body).match(/DISPIMG\("([^"]+)"/i)?.[1];
|
||||
if (id) {
|
||||
expected += 1;
|
||||
screenshotIds.add(id);
|
||||
}
|
||||
}
|
||||
if (screenshotIds.size === 0) return { result, expected, resolved: 0 };
|
||||
|
||||
const cellImagesXml = entries["xl/cellimages.xml"]
|
||||
? strFromU8(entries["xl/cellimages.xml"])
|
||||
: "";
|
||||
const relationships = relationshipMap(
|
||||
entries["xl/_rels/cellimages.xml.rels"]
|
||||
? strFromU8(entries["xl/_rels/cellimages.xml.rels"])
|
||||
: "",
|
||||
);
|
||||
let resolved = 0;
|
||||
for (const match of cellImagesXml.matchAll(
|
||||
/<(?:etc:)?cellImage\b[^>]*>([\s\S]*?)<\/(?:etc:)?cellImage>/g,
|
||||
)) {
|
||||
const id = match[1].match(/<(?:xdr:)?cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
||||
const relationshipId = match[1].match(
|
||||
/<(?:a:)?blip\b[^>]*\br:embed="([^"]+)"/,
|
||||
)?.[1];
|
||||
if (!id || !relationshipId || !screenshotIds.has(id)) continue;
|
||||
const target = relationships.get(relationshipId);
|
||||
if (!target) continue;
|
||||
result.add(resolveZipPath("xl/cellimages.xml", target));
|
||||
resolved += 1;
|
||||
}
|
||||
return { result, expected, resolved };
|
||||
}
|
||||
|
||||
function drawingScreenshotMedia(
|
||||
entries: Record<string, Uint8Array>,
|
||||
screenshotColumns: ScreenshotColumns,
|
||||
) {
|
||||
const result = new Set<string>();
|
||||
let expected = 0;
|
||||
let resolved = 0;
|
||||
const worksheetXml = entries["xl/worksheets/sheet1.xml"]
|
||||
? strFromU8(entries["xl/worksheets/sheet1.xml"])
|
||||
: "";
|
||||
const sheetRelationships = relationshipMap(
|
||||
entries["xl/worksheets/_rels/sheet1.xml.rels"]
|
||||
? strFromU8(entries["xl/worksheets/_rels/sheet1.xml.rels"])
|
||||
: "",
|
||||
);
|
||||
const drawingId = worksheetXml.match(/<drawing\b[^>]*r:id="([^"]+)"/)?.[1];
|
||||
const drawingTarget = drawingId ? sheetRelationships.get(drawingId) : undefined;
|
||||
if (!drawingTarget) return { result, expected, resolved };
|
||||
const drawingPath = resolveZipPath("xl/worksheets/sheet1.xml", drawingTarget);
|
||||
const drawingXml = entries[drawingPath] ? strFromU8(entries[drawingPath]) : "";
|
||||
const relationshipPath = `${drawingPath.slice(0, drawingPath.lastIndexOf("/") + 1)}_rels/${drawingPath.slice(drawingPath.lastIndexOf("/") + 1)}.rels`;
|
||||
const drawingRelationships = relationshipMap(
|
||||
entries[relationshipPath] ? strFromU8(entries[relationshipPath]) : "",
|
||||
);
|
||||
for (const anchor of drawingXml.matchAll(
|
||||
/<xdr:(?:oneCellAnchor|twoCellAnchor)\b[^>]*>[\s\S]*?<xdr:from>([\s\S]*?)<\/xdr:from>[\s\S]*?<a:blip\b[^>]*r:embed="([^"]+)"[\s\S]*?<\/xdr:(?:oneCellAnchor|twoCellAnchor)>/g,
|
||||
)) {
|
||||
const column = Number(anchor[1].match(/<xdr:col>(\d+)<\/xdr:col>/)?.[1]);
|
||||
const zeroBasedRow = Number(anchor[1].match(/<xdr:row>(\d+)<\/xdr:row>/)?.[1]);
|
||||
if (
|
||||
!Number.isInteger(column) ||
|
||||
!Number.isInteger(zeroBasedRow) ||
|
||||
zeroBasedRow + 1 <= screenshotColumns.headerRow ||
|
||||
!screenshotColumns.columns.has(column)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
expected += 1;
|
||||
const target = drawingRelationships.get(anchor[2]);
|
||||
if (!target) continue;
|
||||
result.add(resolveZipPath(drawingPath, target));
|
||||
resolved += 1;
|
||||
}
|
||||
return { result, expected, resolved };
|
||||
}
|
||||
|
||||
function richValueScreenshotMedia(
|
||||
entries: Record<string, Uint8Array>,
|
||||
cells: WorkbookCell[],
|
||||
screenshotColumns: ScreenshotColumns,
|
||||
) {
|
||||
const result = new Set<string>();
|
||||
let expected = 0;
|
||||
let resolved = 0;
|
||||
const metadataXml = entries["xl/metadata.xml"]
|
||||
? strFromU8(entries["xl/metadata.xml"])
|
||||
: "";
|
||||
const richValueXml = entries["xl/richData/rdrichvalue.xml"]
|
||||
? strFromU8(entries["xl/richData/rdrichvalue.xml"])
|
||||
: "";
|
||||
const richValueRelXml = entries["xl/richData/richValueRel.xml"]
|
||||
? strFromU8(entries["xl/richData/richValueRel.xml"])
|
||||
: "";
|
||||
const relationships = relationshipMap(
|
||||
entries["xl/richData/_rels/richValueRel.xml.rels"]
|
||||
? strFromU8(entries["xl/richData/_rels/richValueRel.xml.rels"])
|
||||
: "",
|
||||
);
|
||||
if (!metadataXml || !richValueXml || !richValueRelXml || !relationships.size) {
|
||||
return { result, expected, resolved };
|
||||
}
|
||||
const valueMetadataXml =
|
||||
metadataXml.match(/<valueMetadata\b[^>]*>([\s\S]*?)<\/valueMetadata>/)?.[1] ??
|
||||
"";
|
||||
const metadataToRichValue = [
|
||||
...valueMetadataXml.matchAll(
|
||||
/<bk\b[^>]*>[\s\S]*?<rc\b[^>]*\bv="(\d+)"[^>]*\/>[\s\S]*?<\/bk>/g,
|
||||
),
|
||||
].map((match) => Number(match[1]));
|
||||
const richValueToRelationship = [
|
||||
...richValueXml.matchAll(/<rv\b[^>]*>([\s\S]*?)<\/rv>/g),
|
||||
].map((match) => Number(match[1].match(/<v>(\d+)<\/v>/)?.[1] ?? -1));
|
||||
const relationshipIds = [
|
||||
...richValueRelXml.matchAll(/<rel\b[^>]*\br:id="([^"]+)"[^>]*\/>/g),
|
||||
].map((match) => match[1]);
|
||||
|
||||
for (const cell of cells) {
|
||||
if (
|
||||
cell.row <= screenshotColumns.headerRow ||
|
||||
!screenshotColumns.columns.has(cell.column)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const metadataIndex = Number(cell.attributes.match(/\bvm="(\d+)"/)?.[1] ?? 0);
|
||||
if (!metadataIndex) continue;
|
||||
expected += 1;
|
||||
const richValueIndex = metadataToRichValue[metadataIndex - 1];
|
||||
const relationshipIndex = richValueToRelationship[richValueIndex];
|
||||
const relationshipId = relationshipIds[relationshipIndex];
|
||||
const target = relationships.get(relationshipId);
|
||||
if (!target) continue;
|
||||
result.add(resolveZipPath("xl/richData/richValueRel.xml", target));
|
||||
resolved += 1;
|
||||
}
|
||||
return { result, expected, resolved };
|
||||
}
|
||||
|
||||
export type CompactedPartnerBatchWorkbook = {
|
||||
bytes: Uint8Array;
|
||||
removedMediaCount: number;
|
||||
preservedScreenshotCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Oversized exports are usually caused by full-resolution source images. The
|
||||
* upload only needs the two screenshot columns, so retain those image entries
|
||||
* and omit source media from the temporary upload copy.
|
||||
*/
|
||||
export function compactPartnerBatchWorkbookForUpload(
|
||||
input: Uint8Array,
|
||||
): CompactedPartnerBatchWorkbook {
|
||||
const isMediaFile = (name: string) =>
|
||||
name.startsWith("xl/media/") && !name.endsWith("/");
|
||||
const structure = unzipSync(input, {
|
||||
filter: (file) => !isMediaFile(file.name),
|
||||
});
|
||||
const worksheetXml = structure["xl/worksheets/sheet1.xml"]
|
||||
? strFromU8(structure["xl/worksheets/sheet1.xml"])
|
||||
: "";
|
||||
if (!worksheetXml) throw new Error("Excel 中没有可读取的批量回填工作表");
|
||||
const sharedXml = structure["xl/sharedStrings.xml"]
|
||||
? strFromU8(structure["xl/sharedStrings.xml"])
|
||||
: "";
|
||||
const sharedStrings = [
|
||||
...sharedXml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g),
|
||||
].map((match) => textNodes(match[1]));
|
||||
const cells = parseCells(worksheetXml, sharedStrings);
|
||||
const screenshotColumns = findScreenshotColumns(cells);
|
||||
const formats = [
|
||||
wpsScreenshotMedia(structure, cells, screenshotColumns),
|
||||
drawingScreenshotMedia(structure, screenshotColumns),
|
||||
richValueScreenshotMedia(structure, cells, screenshotColumns),
|
||||
];
|
||||
const screenshotMedia = new Set<string>();
|
||||
let expectedScreenshotCount = 0;
|
||||
let resolvedScreenshotCount = 0;
|
||||
for (const format of formats) {
|
||||
expectedScreenshotCount += format.expected;
|
||||
resolvedScreenshotCount += format.resolved;
|
||||
for (const name of format.result) screenshotMedia.add(name);
|
||||
}
|
||||
if (resolvedScreenshotCount < expectedScreenshotCount) {
|
||||
throw new Error("表格中的回填截图无法完整识别,请重新导出最新版回填表");
|
||||
}
|
||||
|
||||
let mediaCount = 0;
|
||||
const entries = unzipSync(input, {
|
||||
filter: (file) => {
|
||||
if (!isMediaFile(file.name)) return true;
|
||||
mediaCount += 1;
|
||||
return screenshotMedia.has(file.name);
|
||||
},
|
||||
});
|
||||
const bytes = zipSync(entries, { level: 6 });
|
||||
return {
|
||||
bytes,
|
||||
removedMediaCount: Math.max(0, mediaCount - screenshotMedia.size),
|
||||
preservedScreenshotCount: screenshotMedia.size,
|
||||
};
|
||||
}
|
||||
@@ -48,6 +48,104 @@ button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.platform-badge {
|
||||
display: inline-flex !important;
|
||||
width: auto !important;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
gap: 5px;
|
||||
margin: 0 !important;
|
||||
padding: 2px 7px 2px 3px;
|
||||
border: 1px solid #e1e7e4;
|
||||
border-radius: 8px;
|
||||
color: #52615c !important;
|
||||
background: rgb(255 255 255 / 0.92);
|
||||
font-size: 9px !important;
|
||||
font-weight: 720;
|
||||
line-height: 1 !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platform-badge.compact {
|
||||
height: 19px;
|
||||
gap: 4px;
|
||||
padding: 2px 5px 2px 2px;
|
||||
border-radius: 6px;
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
.platform-logo {
|
||||
display: grid !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
margin: 0 !important;
|
||||
border-radius: 5px;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.platform-badge.compact .platform-logo {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.platform-logo.xiaohongshu {
|
||||
color: white !important;
|
||||
background: #ff2442;
|
||||
}
|
||||
|
||||
.platform-logo.xiaohongshu b {
|
||||
color: inherit;
|
||||
font-size: 5px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.12em;
|
||||
transform: translateX(-0.2px);
|
||||
}
|
||||
|
||||
.platform-badge.compact .platform-logo.xiaohongshu b {
|
||||
font-size: 4px;
|
||||
}
|
||||
|
||||
.platform-logo.douyin {
|
||||
background: #080b12;
|
||||
}
|
||||
|
||||
.platform-logo.douyin svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.platform-badge.compact .platform-logo.douyin svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.platform-logo.douyin .douyin-cyan { fill: #25f4ee; transform: translate(-0.7px, 0.7px); }
|
||||
.platform-logo.douyin .douyin-red { fill: #fe2c55; transform: translate(0.7px, -0.3px); }
|
||||
.platform-logo.douyin .douyin-white { fill: #fff; }
|
||||
|
||||
.platform-meta-line,
|
||||
.hero-platform-line {
|
||||
display: inline-flex !important;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.platform-meta-line > span,
|
||||
.hero-platform-line > * {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.hero-platform-line {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.portal-shell {
|
||||
width: min(100%, 1120px);
|
||||
min-height: 100vh;
|
||||
@@ -679,6 +777,15 @@ footer {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.batch-workbook-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.share-composer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(190px, 0.45fr) auto;
|
||||
@@ -843,6 +950,34 @@ footer {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note-thumb.platform-video-thumb {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.platform-badge.logo-only {
|
||||
width: 34px !important;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.platform-badge.logo-only .platform-logo {
|
||||
width: 34px !important;
|
||||
height: 34px !important;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.platform-badge.logo-only .platform-logo.douyin svg {
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
}
|
||||
|
||||
.platform-badge.logo-only .platform-logo.xiaohongshu b {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.note-index {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
@@ -1135,12 +1270,32 @@ footer {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.note-images {
|
||||
.note-images,
|
||||
.note-videos {
|
||||
margin-top: 30px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #edf0ee;
|
||||
}
|
||||
|
||||
.note-video-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.note-video-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid #e4e9e6;
|
||||
border-radius: 12px;
|
||||
background: #102a22;
|
||||
}
|
||||
|
||||
.note-video-card video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 680px;
|
||||
background: #0b1f19;
|
||||
}
|
||||
|
||||
.note-images-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -1234,7 +1389,8 @@ footer {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.note-image-actions button {
|
||||
.note-image-actions button,
|
||||
.note-image-actions a {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #cfe1d9;
|
||||
@@ -1243,9 +1399,12 @@ footer {
|
||||
background: #f2f8f5;
|
||||
font-size: 8px;
|
||||
font-weight: 680;
|
||||
line-height: 26px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.note-image-actions button:hover {
|
||||
.note-image-actions button:hover,
|
||||
.note-image-actions a:hover {
|
||||
border-color: #9fc9b8;
|
||||
background: #eaf5f0;
|
||||
}
|
||||
@@ -1782,9 +1941,14 @@ footer {
|
||||
|
||||
.section-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.section-actions > span {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.share-composer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
formatShanghaiDate as formatDate,
|
||||
parseStoredDate,
|
||||
} from "./date-utils";
|
||||
import {
|
||||
compactPartnerBatchWorkbookForUpload,
|
||||
PARTNER_BATCH_UPLOAD_MAX_BYTES,
|
||||
} from "./batch-workbook-upload";
|
||||
|
||||
type Assignment = {
|
||||
id: string;
|
||||
@@ -30,6 +34,11 @@ type Assignment = {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}>;
|
||||
videos: Array<{
|
||||
index: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
type DelegationSummary = {
|
||||
@@ -63,6 +72,8 @@ type TaskPayload = {
|
||||
dueAt: string;
|
||||
status: string;
|
||||
type: "content_publish" | "screenshot_collect";
|
||||
platform: "小红书" | "抖音";
|
||||
contentFormat: "image_text" | "video";
|
||||
};
|
||||
claim: null | {
|
||||
id: string;
|
||||
@@ -92,7 +103,12 @@ function resolveAdminOrigin() {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
function partnerApi(path: "/api/partner" | "/api/partner-upload") {
|
||||
function partnerApi(
|
||||
path:
|
||||
| "/api/partner"
|
||||
| "/api/partner-upload"
|
||||
| "/api/partner-batch-workbook",
|
||||
) {
|
||||
return `${resolveAdminOrigin()}${path}`;
|
||||
}
|
||||
|
||||
@@ -155,6 +171,36 @@ function safeFileBase(item: Assignment) {
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformBadge({
|
||||
platform,
|
||||
compact = false,
|
||||
logoOnly = false,
|
||||
}: {
|
||||
platform: "小红书" | "抖音";
|
||||
compact?: boolean;
|
||||
logoOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`platform-badge ${compact ? "compact" : ""} ${logoOnly ? "logo-only" : ""}`}
|
||||
aria-label={logoOnly ? platform : undefined}
|
||||
>
|
||||
<span className={`platform-logo ${platform === "抖音" ? "douyin" : "xiaohongshu"}`} aria-hidden="true">
|
||||
{platform === "抖音" ? (
|
||||
<svg viewBox="0 0 24 24" focusable="false">
|
||||
<path className="douyin-cyan" d="M14.2 3.2v9.5a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V3.2h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1V10c-1.7-.1-3.2-.7-4.3-1.7V3.2Z" />
|
||||
<path className="douyin-red" d="M15.2 2.5V12a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V2.5h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1v2.7c-1.7-.1-3.2-.7-4.3-1.7V2.5Z" />
|
||||
<path className="douyin-white" d="M14.7 2.9v9.5a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V2.9h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1v2.7c-1.7-.1-3.2-.7-4.3-1.7V2.9Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<b>小红书</b>
|
||||
)}
|
||||
</span>
|
||||
{!logoOnly && <span>{platform}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function exactArrayBuffer(bytes: Uint8Array) {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
@@ -277,6 +323,7 @@ export default function Home() {
|
||||
const [adminOrigin, setAdminOrigin] = useState("");
|
||||
const [downloadingImage, setDownloadingImage] = useState<number | null>(null);
|
||||
const [batchDownloading, setBatchDownloading] = useState(false);
|
||||
const [batchWorkbookWorking, setBatchWorkbookWorking] = useState(false);
|
||||
const [creatorWorking, setCreatorWorking] = useState(false);
|
||||
const [creatorStage, setCreatorStage] = useState("");
|
||||
const [selectedForShare, setSelectedForShare] = useState<string[]>([]);
|
||||
@@ -289,6 +336,7 @@ export default function Home() {
|
||||
} | null>(null);
|
||||
const [noteContentCollapsed, setNoteContentCollapsed] = useState(false);
|
||||
const submitCardRef = useRef<HTMLFormElement | null>(null);
|
||||
const batchWorkbookInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const publishScreenshotPreview = useFilePreview(screenshot);
|
||||
const creatorScreenshotPreview = useFilePreview(creatorScreenshot);
|
||||
const taskResultPreviews = useMemo(
|
||||
@@ -504,6 +552,25 @@ export default function Home() {
|
||||
return `${adminOrigin}/api/partner-image?${params}`;
|
||||
};
|
||||
|
||||
const noteVideoUrl = (
|
||||
item: Assignment,
|
||||
videoIndex: number,
|
||||
download = false,
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
distribution: item.id,
|
||||
index: String(videoIndex),
|
||||
kind: "video",
|
||||
});
|
||||
if (download) params.set("download", "1");
|
||||
if (delegationToken) params.set("share", delegationToken);
|
||||
else {
|
||||
params.set("task", taskToken);
|
||||
params.set("claim", claimToken);
|
||||
}
|
||||
return `${adminOrigin}/api/partner-image?${params}`;
|
||||
};
|
||||
|
||||
const taskResultImageUrl = (item: Assignment, imageIndex: number) => {
|
||||
const params = new URLSearchParams({
|
||||
distribution: item.id,
|
||||
@@ -644,6 +711,112 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const batchWorkbookParams = () => {
|
||||
const params = new URLSearchParams();
|
||||
if (delegationToken) params.set("share", delegationToken);
|
||||
else {
|
||||
params.set("task", taskToken);
|
||||
params.set("claim", claimToken);
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
const exportBatchWorkbook = async () => {
|
||||
try {
|
||||
setBatchWorkbookWorking(true);
|
||||
const response = await fetch(
|
||||
`${partnerApi("/api/partner-batch-workbook")}?${batchWorkbookParams()}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
const result = (await response.json()) as { error?: string };
|
||||
throw new Error(result.error || "Excel导出失败");
|
||||
}
|
||||
const disposition = response.headers.get("Content-Disposition") || "";
|
||||
const encodedName = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
||||
const fileName = encodedName
|
||||
? decodeURIComponent(encodedName)
|
||||
: `${payload?.task.name || "领取笔记"}-批量回填.xlsx`;
|
||||
downloadBlob(await response.blob(), fileName);
|
||||
setToast("Excel已导出,填写后从本页面上传即可批量回填");
|
||||
} catch (reason) {
|
||||
setToast(reason instanceof Error ? reason.message : "Excel导出失败");
|
||||
} finally {
|
||||
setBatchWorkbookWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const importBatchWorkbook = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
try {
|
||||
setBatchWorkbookWorking(true);
|
||||
let uploadBody: Blob = file;
|
||||
let compacted = false;
|
||||
if (file.size > PARTNER_BATCH_UPLOAD_MAX_BYTES) {
|
||||
setToast("文件较大,正在保留回填截图并精简原图…");
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const compactedWorkbook = compactPartnerBatchWorkbookForUpload(
|
||||
new Uint8Array(await file.arrayBuffer()),
|
||||
);
|
||||
if (compactedWorkbook.bytes.byteLength > PARTNER_BATCH_UPLOAD_MAX_BYTES) {
|
||||
throw new Error("精简后的回填表仍超过80MB,请重新导出最新版回填表");
|
||||
}
|
||||
uploadBody = new Blob([exactArrayBuffer(compactedWorkbook.bytes)], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
compacted = true;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.set("file", uploadBody, file.name);
|
||||
form.set("taskToken", taskToken);
|
||||
form.set("claimToken", claimToken);
|
||||
form.set("delegationToken", delegationToken);
|
||||
const response = await fetch(partnerApi("/api/partner-batch-workbook"), {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
const contentType = response.headers.get("Content-Type") || "";
|
||||
const result = (contentType.includes("application/json")
|
||||
? await response.json()
|
||||
: {
|
||||
error:
|
||||
response.status === 413
|
||||
? "回填表超过上传限制,请重新导出最新版回填表"
|
||||
: "批量回填服务暂时不可用,请稍后重试",
|
||||
}) as {
|
||||
error?: string;
|
||||
updatedRows?: number;
|
||||
publishedCount?: number;
|
||||
noteScreenshotCount?: number;
|
||||
analysisScreenshotCount?: number;
|
||||
};
|
||||
if (!response.ok) throw new Error(result.error || "批量回填失败");
|
||||
await loadTask(taskToken, claimToken, delegationToken);
|
||||
const details = [
|
||||
result.publishedCount
|
||||
? `${result.publishedCount}篇发布信息`
|
||||
: "",
|
||||
result.noteScreenshotCount
|
||||
? `${result.noteScreenshotCount}张笔记截图`
|
||||
: "",
|
||||
result.analysisScreenshotCount
|
||||
? `${result.analysisScreenshotCount}张数据分析截图`
|
||||
: "",
|
||||
].filter(Boolean);
|
||||
setToast(
|
||||
details.length > 0
|
||||
? `${compacted ? "文件已自动精简," : ""}已更新${details.join("、")}`
|
||||
: `${compacted ? "文件已自动精简," : ""}表格已读取,没有需要更新的数据`,
|
||||
);
|
||||
} catch (reason) {
|
||||
setToast(reason instanceof Error ? reason.message : "批量回填失败");
|
||||
} finally {
|
||||
setBatchWorkbookWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const prepareImage = async (item: Assignment, imageIndex: number) => {
|
||||
const response = await fetch(noteImageUrl(item, imageIndex));
|
||||
if (!response.ok) throw new Error("图片读取失败");
|
||||
@@ -987,7 +1160,10 @@ export default function Home() {
|
||||
<span>截图任务 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
|
||||
<span>只需上传结果截图</span>
|
||||
</div>
|
||||
<p className="document-label">小红书搜索关键词</p>
|
||||
<div className="document-label-row">
|
||||
<p className="document-label">小红书搜索关键词</p>
|
||||
<PlatformBadge platform={payload.task.platform} compact />
|
||||
</div>
|
||||
<div className="note-title-row">
|
||||
<h1>{selected.title}</h1>
|
||||
<button
|
||||
@@ -1136,7 +1312,9 @@ export default function Home() {
|
||||
<span>笔记内容已收起</span>
|
||||
<strong>{selected.title}</strong>
|
||||
<small>
|
||||
{selected.images.length} 张配图 · 已完成首次回填
|
||||
{selected.videos.length > 0
|
||||
? `${selected.videos.length} 个视频`
|
||||
: `${selected.images.length} 张配图`} · 已完成首次回填
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
@@ -1150,6 +1328,7 @@ export default function Home() {
|
||||
<div className="note-document-content">
|
||||
<div className="note-document-meta">
|
||||
<span>笔记 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
|
||||
<PlatformBadge platform={payload.task.platform} compact />
|
||||
<span>飞书源行 {selected.source_row ?? "—"}</span>
|
||||
</div>
|
||||
{selected.publish_url && (
|
||||
@@ -1237,6 +1416,38 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{selected.videos.length > 0 && (
|
||||
<section className="note-videos">
|
||||
<div className="note-images-heading">
|
||||
<div>
|
||||
<p className="document-label">发布视频</p>
|
||||
<span>点击播放,可使用播放器菜单保存原视频</span>
|
||||
</div>
|
||||
<b>{selected.videos.length} 个</b>
|
||||
</div>
|
||||
<div className="note-video-grid">
|
||||
{selected.videos.map((video, index) => (
|
||||
<div className="note-video-card" key={video.index}>
|
||||
<video
|
||||
controls
|
||||
preload="metadata"
|
||||
playsInline
|
||||
src={noteVideoUrl(selected, video.index)}
|
||||
/>
|
||||
<div className="note-image-actions">
|
||||
<span>视频 {index + 1}</span>
|
||||
<a
|
||||
href={noteVideoUrl(selected, video.index, true)}
|
||||
download={`${safeFileBase(selected)}-视频-${index + 1}.mp4`}
|
||||
>
|
||||
下载视频
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1261,7 +1472,7 @@ export default function Home() {
|
||||
inputMode="url"
|
||||
value={publishUrl}
|
||||
onChange={(event) => setPublishUrl(event.target.value)}
|
||||
placeholder="可粘贴小红书长链、短链或整段分享文案"
|
||||
placeholder={`可粘贴${payload.task.platform}作品链接或整段分享文案`}
|
||||
required
|
||||
/>
|
||||
<small className="field-hint">
|
||||
@@ -1478,8 +1689,10 @@ export default function Home() {
|
||||
: "合作社转派发布包"}
|
||||
</p>
|
||||
<h1>{payload.task.name}</h1>
|
||||
<p>
|
||||
{payload.task.brand} · {formatDate(payload.task.dueAt)}前{isScreenshotTask ? "提交" : "发布"}
|
||||
<p className="platform-meta-line">
|
||||
<span>{payload.task.brand}</span>
|
||||
<PlatformBadge platform={payload.task.platform} compact />
|
||||
<span>{formatDate(payload.task.dueAt)}前{isScreenshotTask ? "提交" : "发布"}</span>
|
||||
{payload.delegation ? ` · ${payload.delegation.label}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1500,7 +1713,7 @@ export default function Home() {
|
||||
{isClaimOwner
|
||||
? isScreenshotTask
|
||||
? "打开一份查看关键词和要求,完成后单独上传截图;也可以转派给底层KOC"
|
||||
: "打开一篇,查看内容并单独回填;也可以选择笔记转派给底层KOC"
|
||||
: "可逐篇回填,也可导出Excel填写后批量上传;还可以选择笔记转派给底层KOC"
|
||||
: isScreenshotTask
|
||||
? "打开任务查看搜索关键词和要求,完成后逐份上传截图"
|
||||
: "打开一篇查看完整内容,发布后逐篇回填"}
|
||||
@@ -1508,6 +1721,31 @@ export default function Home() {
|
||||
</div>
|
||||
<div className="section-actions">
|
||||
<span>{activeBatch.assignments.length} {isScreenshotTask ? "份" : "篇"}</span>
|
||||
{!isScreenshotTask && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchWorkbookWorking}
|
||||
onClick={() => void exportBatchWorkbook()}
|
||||
>
|
||||
{batchWorkbookWorking ? "处理中…" : "导出Excel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchWorkbookWorking}
|
||||
onClick={() => batchWorkbookInputRef.current?.click()}
|
||||
>
|
||||
上传回填表
|
||||
</button>
|
||||
<input
|
||||
ref={batchWorkbookInputRef}
|
||||
className="batch-workbook-input"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
onChange={(event) => void importBatchWorkbook(event)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isClaimOwner && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1609,6 +1847,10 @@ export default function Home() {
|
||||
loading="lazy"
|
||||
/>
|
||||
</>
|
||||
) : item.videos.length > 0 ? (
|
||||
<span className="note-thumb platform-video-thumb">
|
||||
<PlatformBadge platform={payload.task.platform} logoOnly />
|
||||
</span>
|
||||
) : (
|
||||
<span className="note-thumb empty">无图</span>
|
||||
)}
|
||||
@@ -1617,7 +1859,11 @@ export default function Home() {
|
||||
<p>
|
||||
{isScreenshotTask
|
||||
? `搜索关键词 · ${statusLabel(item, payload.task.type)}`
|
||||
: `${item.images.length} 张配图 · 飞书源行 ${item.source_row ?? "—"} · ${statusLabel(item, payload.task.type)}`}
|
||||
: <>
|
||||
<span>{item.videos.length > 0 ? `${item.videos.length} 个视频` : `${item.images.length} 张配图`}</span>
|
||||
<PlatformBadge platform={payload.task.platform} compact />
|
||||
<span>飞书源行 {item.source_row ?? "—"} · {statusLabel(item, payload.task.type)}</span>
|
||||
</>}
|
||||
{!isScreenshotTask && item.creator_screenshot_key ? " · D7截图已交" : ""}
|
||||
{item.delegation_label ? ` · 已转派给 ${item.delegation_label}` : ""}
|
||||
</p>
|
||||
@@ -1724,7 +1970,10 @@ export default function Home() {
|
||||
</header>
|
||||
<section className="task-hero">
|
||||
<div className="hero-copy">
|
||||
<p className="micro">正在招募 · {payload.task.brand}</p>
|
||||
<div className="hero-platform-line">
|
||||
<p className="micro">正在招募 · {payload.task.brand}</p>
|
||||
<PlatformBadge platform={payload.task.platform} />
|
||||
</div>
|
||||
<h1>{payload.task.name}</h1>
|
||||
<p>
|
||||
{payload.task.type === "screenshot_collect"
|
||||
|
||||
Reference in New Issue
Block a user