2026-07-30 12:06:41 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
ChangeEvent,
|
|
|
|
|
|
FormEvent,
|
|
|
|
|
|
useCallback,
|
|
|
|
|
|
useEffect,
|
|
|
|
|
|
useMemo,
|
|
|
|
|
|
useState,
|
|
|
|
|
|
} from "react";
|
2026-08-05 11:40:29 +08:00
|
|
|
|
import {
|
|
|
|
|
|
formatShanghaiDate as formatDate,
|
|
|
|
|
|
formatShanghaiToday,
|
|
|
|
|
|
} from "../lib/date-utils";
|
2026-08-07 11:06:20 +08:00
|
|
|
|
import type { AuthUser } from "../lib/user-auth";
|
2026-08-11 23:07:26 +08:00
|
|
|
|
import { parseResultScreenshotKeys } from "../lib/result-screenshots";
|
2026-08-12 11:12:23 +08:00
|
|
|
|
import {
|
|
|
|
|
|
sortWithNullsLast,
|
|
|
|
|
|
type SortDirection,
|
|
|
|
|
|
} from "../lib/sort-utils";
|
2026-08-07 11:06:20 +08:00
|
|
|
|
import UsersPage from "./users-page";
|
2026-07-30 12:06:41 +08:00
|
|
|
|
|
|
|
|
|
|
type Partner = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
wecom_name: string;
|
|
|
|
|
|
owner: string;
|
|
|
|
|
|
claimed_total: number;
|
|
|
|
|
|
completed_total: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type Task = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
brand: string;
|
|
|
|
|
|
quantity: number;
|
|
|
|
|
|
claimed_quantity: number;
|
|
|
|
|
|
due_at: string;
|
|
|
|
|
|
status: string;
|
2026-08-11 23:07:26 +08:00
|
|
|
|
task_type: "content_publish" | "screenshot_collect";
|
2026-07-30 12:06:41 +08:00
|
|
|
|
source_url: string;
|
|
|
|
|
|
source_sheet_id: string;
|
|
|
|
|
|
source_sheet_name: string;
|
|
|
|
|
|
source_synced_at: string | null;
|
|
|
|
|
|
share_token: string;
|
|
|
|
|
|
collection_start_date: string | null;
|
|
|
|
|
|
collection_days: string;
|
|
|
|
|
|
collection_schedule_updated_at: string | null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type Account = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
platform: string;
|
|
|
|
|
|
platform_uid: string;
|
|
|
|
|
|
public_account_id: string;
|
|
|
|
|
|
nickname: string;
|
|
|
|
|
|
profile_url: string;
|
|
|
|
|
|
ip_location: string;
|
|
|
|
|
|
followers: number;
|
|
|
|
|
|
post_count: number;
|
|
|
|
|
|
avg_views: number;
|
2026-08-11 23:07:26 +08:00
|
|
|
|
cooperation_source: string;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
first_seen_at: string;
|
|
|
|
|
|
last_seen_at: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
type ResourceImportPreview = {
|
|
|
|
|
|
summary: { total: number; create: number; update: number; error: number };
|
|
|
|
|
|
rows: Array<{
|
|
|
|
|
|
rowNumber: number;
|
|
|
|
|
|
platform: string;
|
|
|
|
|
|
nickname: string;
|
|
|
|
|
|
publicAccountId: string;
|
|
|
|
|
|
ipLocation: string;
|
|
|
|
|
|
followers: number;
|
|
|
|
|
|
cooperationSource: string;
|
|
|
|
|
|
action: "create" | "update" | "error";
|
|
|
|
|
|
errors: string[];
|
|
|
|
|
|
}>;
|
|
|
|
|
|
truncated: boolean;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-30 12:06:41 +08:00
|
|
|
|
type Distribution = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
task_id: string;
|
|
|
|
|
|
content_id: string;
|
|
|
|
|
|
partner_id: string;
|
|
|
|
|
|
delegation_bundle_id: string | null;
|
|
|
|
|
|
account_id: string | null;
|
|
|
|
|
|
publish_url: string | null;
|
|
|
|
|
|
publish_time: string | null;
|
2026-08-11 23:07:26 +08:00
|
|
|
|
result_screenshot_key: string | null;
|
|
|
|
|
|
result_submitted_at: string | null;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
status: string;
|
|
|
|
|
|
claimed_at: string;
|
|
|
|
|
|
screenshot_key: string | null;
|
|
|
|
|
|
ocr_status: string;
|
|
|
|
|
|
exposure: number | null;
|
|
|
|
|
|
views: number | null;
|
|
|
|
|
|
d2_likes: number | null;
|
|
|
|
|
|
d2_comments: number | null;
|
|
|
|
|
|
d2_collects: number | null;
|
|
|
|
|
|
d5_likes: number | null;
|
|
|
|
|
|
d5_comments: number | null;
|
|
|
|
|
|
d5_collects: number | null;
|
|
|
|
|
|
d7_likes: number | null;
|
|
|
|
|
|
d7_comments: number | null;
|
|
|
|
|
|
d7_collects: number | null;
|
|
|
|
|
|
latest_likes: number | null;
|
|
|
|
|
|
latest_comments: number | null;
|
|
|
|
|
|
latest_collects: number | null;
|
|
|
|
|
|
collection_status: string;
|
|
|
|
|
|
collection_status_description: string | null;
|
|
|
|
|
|
collection_updated_at: string | null;
|
|
|
|
|
|
last_collection_day: number | null;
|
|
|
|
|
|
updated_at: string;
|
|
|
|
|
|
content_title: string;
|
|
|
|
|
|
partner_name: string;
|
|
|
|
|
|
account_nickname: string | null;
|
|
|
|
|
|
account_platform: string | null;
|
|
|
|
|
|
task_name: string;
|
|
|
|
|
|
task_brand: string;
|
2026-08-11 23:07:26 +08:00
|
|
|
|
task_type: "content_publish" | "screenshot_collect";
|
2026-07-30 12:06:41 +08:00
|
|
|
|
due_at: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-12 11:12:23 +08:00
|
|
|
|
type RecoverySortKey =
|
|
|
|
|
|
| "publish_time"
|
|
|
|
|
|
| "likes"
|
|
|
|
|
|
| "collects"
|
|
|
|
|
|
| "comments"
|
|
|
|
|
|
| "total"
|
|
|
|
|
|
| "collection_updated_at";
|
|
|
|
|
|
|
2026-07-30 12:06:41 +08:00
|
|
|
|
type DashboardData = {
|
|
|
|
|
|
partners: Partner[];
|
|
|
|
|
|
tasks: Task[];
|
|
|
|
|
|
accounts: Account[];
|
|
|
|
|
|
distributions: Distribution[];
|
|
|
|
|
|
portal_url: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type FeishuPreview = {
|
|
|
|
|
|
sheetId: string;
|
|
|
|
|
|
sheetName: string;
|
|
|
|
|
|
syncedAt: string;
|
|
|
|
|
|
rowCount: number;
|
|
|
|
|
|
columns: string[];
|
|
|
|
|
|
preview: Array<{
|
|
|
|
|
|
sourceRow: number;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
body: string;
|
|
|
|
|
|
}>;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type NavKey =
|
|
|
|
|
|
| "overview"
|
|
|
|
|
|
| "tasks"
|
|
|
|
|
|
| "distributions"
|
|
|
|
|
|
| "resources"
|
2026-08-07 11:06:20 +08:00
|
|
|
|
| "recovery"
|
|
|
|
|
|
| "users";
|
2026-07-30 12:06:41 +08:00
|
|
|
|
|
|
|
|
|
|
const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
|
|
|
|
|
|
{ key: "overview", label: "工作台", mark: "⌂" },
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{ key: "tasks", label: "任务中心", mark: "▤" },
|
|
|
|
|
|
{ key: "distributions", label: "任务分发", mark: "↗" },
|
2026-07-30 12:06:41 +08:00
|
|
|
|
{ key: "resources", label: "KOC资源", mark: "◎" },
|
|
|
|
|
|
{ key: "recovery", label: "数据回收", mark: "◫" },
|
2026-08-07 11:06:20 +08:00
|
|
|
|
{ key: "users", label: "用户管理", mark: "♙" },
|
2026-07-30 12:06:41 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const EMPTY_DATA: DashboardData = {
|
|
|
|
|
|
partners: [],
|
|
|
|
|
|
tasks: [],
|
|
|
|
|
|
accounts: [],
|
|
|
|
|
|
distributions: [],
|
|
|
|
|
|
portal_url: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
async function readApiResponse<T>(response: Response, fallback: string) {
|
|
|
|
|
|
const text = await response.text();
|
|
|
|
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
|
|
|
|
if (!contentType.includes("json")) {
|
|
|
|
|
|
if (response.status === 403) {
|
|
|
|
|
|
throw new Error("请求被安全网关拦截,请刷新页面后重试");
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error(`${fallback}(服务返回异常,HTTP ${response.status})`);
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
return JSON.parse(text) as T & { error?: string };
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
throw new Error(`${fallback}(服务返回的数据无法解析)`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatNumber(value: number | null | undefined) {
|
|
|
|
|
|
if (value === null || value === undefined) return "—";
|
|
|
|
|
|
if (value >= 10000) return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`;
|
|
|
|
|
|
return new Intl.NumberFormat("zh-CN").format(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function statusLabel(status: string) {
|
|
|
|
|
|
const labels: Record<string, string> = {
|
|
|
|
|
|
claimed: "待发布",
|
|
|
|
|
|
published: "已发布",
|
|
|
|
|
|
collecting: "采集中",
|
|
|
|
|
|
complete: "已完成",
|
|
|
|
|
|
active: "分发中",
|
|
|
|
|
|
closed: "已结束",
|
|
|
|
|
|
};
|
|
|
|
|
|
return labels[status] || status;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function latestPublicMetrics(distribution: Distribution) {
|
|
|
|
|
|
const legacyDay =
|
|
|
|
|
|
distribution.d7_likes !== null
|
|
|
|
|
|
? 7
|
|
|
|
|
|
: distribution.d5_likes !== null
|
|
|
|
|
|
? 5
|
|
|
|
|
|
: distribution.d2_likes !== null
|
|
|
|
|
|
? 2
|
|
|
|
|
|
: null;
|
|
|
|
|
|
const likes =
|
|
|
|
|
|
distribution.latest_likes ??
|
|
|
|
|
|
(legacyDay ? distribution[`d${legacyDay}_likes`] : null);
|
|
|
|
|
|
const comments =
|
|
|
|
|
|
distribution.latest_comments ??
|
|
|
|
|
|
(legacyDay ? distribution[`d${legacyDay}_comments`] : null);
|
|
|
|
|
|
const collects =
|
|
|
|
|
|
distribution.latest_collects ??
|
|
|
|
|
|
(legacyDay ? distribution[`d${legacyDay}_collects`] : null);
|
|
|
|
|
|
return {
|
|
|
|
|
|
likes,
|
|
|
|
|
|
comments,
|
|
|
|
|
|
collects,
|
|
|
|
|
|
total:
|
|
|
|
|
|
likes === null
|
|
|
|
|
|
? null
|
|
|
|
|
|
: likes + (comments ?? 0) + (collects ?? 0),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 11:12:23 +08:00
|
|
|
|
function recoverySortValue(
|
|
|
|
|
|
distribution: Distribution,
|
|
|
|
|
|
key: RecoverySortKey,
|
|
|
|
|
|
): number | null {
|
|
|
|
|
|
const metrics = latestPublicMetrics(distribution);
|
|
|
|
|
|
if (key === "likes") return metrics.likes;
|
|
|
|
|
|
if (key === "collects") return metrics.collects;
|
|
|
|
|
|
if (key === "comments") return metrics.comments;
|
|
|
|
|
|
if (key === "total") return metrics.total;
|
|
|
|
|
|
|
|
|
|
|
|
const value =
|
|
|
|
|
|
key === "publish_time"
|
|
|
|
|
|
? distribution.publish_time
|
|
|
|
|
|
: distribution.collection_updated_at ||
|
|
|
|
|
|
(metrics.likes !== null ? distribution.updated_at : null);
|
|
|
|
|
|
if (!value) return null;
|
|
|
|
|
|
const timestamp = Date.parse(value);
|
|
|
|
|
|
return Number.isNaN(timestamp) ? null : timestamp;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sortRecoveryDistributions(
|
|
|
|
|
|
distributions: Distribution[],
|
|
|
|
|
|
key: RecoverySortKey,
|
|
|
|
|
|
direction: SortDirection,
|
|
|
|
|
|
) {
|
|
|
|
|
|
return sortWithNullsLast(
|
|
|
|
|
|
distributions,
|
|
|
|
|
|
(distribution) => recoverySortValue(distribution, key),
|
|
|
|
|
|
direction,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SortableRecoveryHeader({
|
|
|
|
|
|
label,
|
|
|
|
|
|
column,
|
|
|
|
|
|
activeColumn,
|
|
|
|
|
|
direction,
|
|
|
|
|
|
onSort,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
column: RecoverySortKey;
|
|
|
|
|
|
activeColumn: RecoverySortKey | null;
|
|
|
|
|
|
direction: SortDirection;
|
|
|
|
|
|
onSort: (column: RecoverySortKey) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const active = activeColumn === column;
|
|
|
|
|
|
const arrow = active ? (direction === "asc" ? "↑" : "↓") : "↕";
|
|
|
|
|
|
return (
|
|
|
|
|
|
<th aria-sort={active ? (direction === "asc" ? "ascending" : "descending") : "none"}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`recovery-sort-button${active ? " active" : ""}`}
|
|
|
|
|
|
onClick={() => onSort(column)}
|
|
|
|
|
|
title={`${label}按${active && direction === "desc" ? "正序" : "倒序"}排列`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>{label}</span>
|
|
|
|
|
|
<b aria-hidden="true">{arrow}</b>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</th>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 12:06:41 +08:00
|
|
|
|
function hasCreatorMetrics(distribution: Distribution) {
|
|
|
|
|
|
return distribution.exposure !== null && distribution.views !== null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parseCollectionDays(value: string | null | undefined) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = JSON.parse(value || "[]") as unknown;
|
|
|
|
|
|
if (!Array.isArray(parsed)) return [];
|
|
|
|
|
|
return [...new Set(parsed.map(Number))]
|
|
|
|
|
|
.filter((day) => Number.isInteger(day) && day >= 1 && day <= 7)
|
|
|
|
|
|
.sort((a, b) => a - b);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function todayInputValue() {
|
|
|
|
|
|
const parts = new Intl.DateTimeFormat("zh-CN", {
|
|
|
|
|
|
timeZone: "Asia/Shanghai",
|
|
|
|
|
|
year: "numeric",
|
|
|
|
|
|
month: "2-digit",
|
|
|
|
|
|
day: "2-digit",
|
|
|
|
|
|
}).formatToParts(new Date());
|
|
|
|
|
|
const value = Object.fromEntries(
|
|
|
|
|
|
parts.map((part) => [part.type, part.value]),
|
|
|
|
|
|
);
|
|
|
|
|
|
return `${value.year}-${value.month}-${value.day}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function addCalendarDays(value: string, days: number) {
|
|
|
|
|
|
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
|
|
|
|
if (!match) return "";
|
|
|
|
|
|
const date = new Date(
|
|
|
|
|
|
Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + days),
|
|
|
|
|
|
);
|
|
|
|
|
|
return date.toISOString().slice(0, 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function xhsPublishUrl(value: string | null) {
|
|
|
|
|
|
if (!value) return null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = new URL(value);
|
|
|
|
|
|
const hostname = url.hostname.toLowerCase();
|
|
|
|
|
|
const isXhsHost =
|
|
|
|
|
|
hostname === "xiaohongshu.com" ||
|
|
|
|
|
|
hostname.endsWith(".xiaohongshu.com") ||
|
2026-08-11 23:07:26 +08:00
|
|
|
|
hostname === "xhslink.cn" ||
|
|
|
|
|
|
hostname.endsWith(".xhslink.cn") ||
|
2026-07-30 12:06:41 +08:00
|
|
|
|
hostname === "xhslink.com" ||
|
|
|
|
|
|
hostname.endsWith(".xhslink.com");
|
|
|
|
|
|
return ["http:", "https:"].includes(url.protocol) && isXhsHost
|
|
|
|
|
|
? url.toString()
|
|
|
|
|
|
: null;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function collectionStatusLabel(status: string, hasMetrics: boolean) {
|
|
|
|
|
|
if (hasMetrics && (!status || status === "pending")) return "采集成功";
|
|
|
|
|
|
const labels: Record<string, string> = {
|
|
|
|
|
|
pending: "待设置",
|
|
|
|
|
|
scheduled: "已计划",
|
|
|
|
|
|
collecting: "采集中",
|
|
|
|
|
|
success: "采集成功",
|
|
|
|
|
|
failed: "采集失败",
|
|
|
|
|
|
};
|
|
|
|
|
|
return labels[status] || status || "待设置";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function avatarColor(value: string) {
|
|
|
|
|
|
const colors = ["coral", "cyan", "violet", "amber", "mint"];
|
|
|
|
|
|
const index = [...value].reduce((sum, char) => sum + char.charCodeAt(0), 0);
|
|
|
|
|
|
return colors[index % colors.length];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function compressScreenshot(file: File) {
|
|
|
|
|
|
if (file.size < 850_000 || !file.type.startsWith("image/")) return file;
|
|
|
|
|
|
const bitmap = await createImageBitmap(file);
|
|
|
|
|
|
const maxEdge = 1600;
|
|
|
|
|
|
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
|
|
|
|
|
|
const canvas = document.createElement("canvas");
|
|
|
|
|
|
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
|
|
|
|
|
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
|
|
|
|
|
const context = canvas.getContext("2d");
|
|
|
|
|
|
if (!context) {
|
|
|
|
|
|
bitmap.close();
|
|
|
|
|
|
return file;
|
|
|
|
|
|
}
|
|
|
|
|
|
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
|
|
|
|
bitmap.close();
|
|
|
|
|
|
const blob = await new Promise<Blob | null>((resolve) =>
|
|
|
|
|
|
canvas.toBlob(resolve, "image/jpeg", 0.82),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!blob) return file;
|
|
|
|
|
|
return new File([blob], `${file.name.replace(/\.[^.]+$/, "")}.jpg`, {
|
|
|
|
|
|
type: "image/jpeg",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 11:06:20 +08:00
|
|
|
|
export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
2026-07-30 12:06:41 +08:00
|
|
|
|
const [data, setData] = useState<DashboardData>(EMPTY_DATA);
|
|
|
|
|
|
const [activeNav, setActiveNav] = useState<NavKey>("overview");
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [working, setWorking] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState("");
|
|
|
|
|
|
const [toast, setToast] = useState("");
|
|
|
|
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
|
|
|
|
const [taskModalOpen, setTaskModalOpen] = useState(false);
|
|
|
|
|
|
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
|
|
|
|
|
|
const [taskForm, setTaskForm] = useState({
|
2026-08-11 23:07:26 +08:00
|
|
|
|
taskType: "content_publish" as "content_publish" | "screenshot_collect",
|
2026-07-30 12:06:41 +08:00
|
|
|
|
name: "",
|
|
|
|
|
|
brand: "",
|
|
|
|
|
|
dueAt: "2026-08-12",
|
|
|
|
|
|
feishuUrl: "",
|
2026-08-11 23:07:26 +08:00
|
|
|
|
keyword: "",
|
|
|
|
|
|
instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。",
|
|
|
|
|
|
quantity: 1,
|
2026-07-30 12:06:41 +08:00
|
|
|
|
});
|
2026-08-11 23:07:26 +08:00
|
|
|
|
const [taskExampleImage, setTaskExampleImage] = useState<File | null>(null);
|
2026-07-30 12:06:41 +08:00
|
|
|
|
const [sourcePreview, setSourcePreview] = useState<FeishuPreview | null>(null);
|
|
|
|
|
|
const [sourceWorking, setSourceWorking] = useState(false);
|
|
|
|
|
|
const [metricForm, setMetricForm] = useState({ exposure: "", views: "" });
|
2026-08-05 11:40:29 +08:00
|
|
|
|
const [exportingTaskId, setExportingTaskId] = useState<string | null>(null);
|
2026-08-06 10:18:23 +08:00
|
|
|
|
const [exportingResources, setExportingResources] = useState(false);
|
2026-08-07 11:06:20 +08:00
|
|
|
|
const isManager = currentUser.role === "super_admin" || currentUser.role === "admin";
|
|
|
|
|
|
const visibleNavItems = NAV_ITEMS.filter((item) => {
|
|
|
|
|
|
if (item.key === "resources" && currentUser.role === "user") return false;
|
|
|
|
|
|
if (item.key === "users" && !isManager) return false;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
});
|
2026-07-30 12:06:41 +08:00
|
|
|
|
|
|
|
|
|
|
const loadData = useCallback(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
const response = await fetch("/api/bootstrap", { cache: "no-store" });
|
|
|
|
|
|
const result = await readApiResponse<DashboardData>(response, "加载失败");
|
|
|
|
|
|
if (!response.ok) throw new Error(result.error || "加载失败");
|
|
|
|
|
|
setData(result);
|
|
|
|
|
|
} catch (reason) {
|
|
|
|
|
|
setError(reason instanceof Error ? reason.message : "加载失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const timer = window.setTimeout(() => {
|
|
|
|
|
|
void loadData();
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
return () => window.clearTimeout(timer);
|
|
|
|
|
|
}, [loadData]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!toast) return;
|
|
|
|
|
|
const timer = window.setTimeout(() => setToast(""), 2600);
|
|
|
|
|
|
return () => window.clearTimeout(timer);
|
|
|
|
|
|
}, [toast]);
|
|
|
|
|
|
|
|
|
|
|
|
const runAction = async (
|
|
|
|
|
|
payload: Record<string, unknown>,
|
|
|
|
|
|
successMessage: string,
|
|
|
|
|
|
) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setWorking(true);
|
|
|
|
|
|
const response = await fetch("/api/action", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
|
});
|
|
|
|
|
|
const result = await readApiResponse<DashboardData>(response, "操作失败");
|
|
|
|
|
|
if (!response.ok) throw new Error(result.error || "操作失败");
|
|
|
|
|
|
setData(result);
|
|
|
|
|
|
setToast(successMessage);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
} catch (reason) {
|
|
|
|
|
|
setToast(reason instanceof Error ? reason.message : "操作失败");
|
|
|
|
|
|
return false;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setWorking(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const stats = useMemo(() => {
|
|
|
|
|
|
const published = data.distributions.filter((item) =>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
item.task_type !== "screenshot_collect" &&
|
2026-07-30 12:06:41 +08:00
|
|
|
|
["published", "collecting", "complete"].includes(item.status),
|
|
|
|
|
|
).length;
|
|
|
|
|
|
const completed = data.distributions.filter(
|
|
|
|
|
|
(item) => item.status === "complete",
|
|
|
|
|
|
).length;
|
|
|
|
|
|
const pendingRecovery = data.distributions.filter(
|
|
|
|
|
|
(item) =>
|
|
|
|
|
|
item.publish_url &&
|
|
|
|
|
|
(latestPublicMetrics(item).likes === null ||
|
|
|
|
|
|
!hasCreatorMetrics(item)),
|
|
|
|
|
|
).length;
|
|
|
|
|
|
return {
|
|
|
|
|
|
resources: data.accounts.length,
|
|
|
|
|
|
activeTasks: data.tasks.filter((task) => task.status === "active").length,
|
|
|
|
|
|
published,
|
|
|
|
|
|
completed,
|
|
|
|
|
|
pendingRecovery,
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [data]);
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
const recentDistributions = data.distributions
|
|
|
|
|
|
.filter((item) => item.task_type !== "screenshot_collect")
|
|
|
|
|
|
.slice(0, 6);
|
2026-07-30 12:06:41 +08:00
|
|
|
|
|
|
|
|
|
|
const navigate = (key: NavKey) => {
|
2026-08-07 11:06:20 +08:00
|
|
|
|
if (key === "resources" && currentUser.role === "user") return;
|
|
|
|
|
|
if (key === "users" && !isManager) return;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
setActiveNav(key);
|
|
|
|
|
|
setMenuOpen(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const submitTask = async (event: FormEvent) => {
|
|
|
|
|
|
event.preventDefault();
|
2026-08-11 23:07:26 +08:00
|
|
|
|
if (taskForm.taskType === "content_publish" && !sourcePreview) {
|
2026-07-30 12:06:41 +08:00
|
|
|
|
setToast("请先读取飞书表格");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-11 23:07:26 +08:00
|
|
|
|
let exampleImageKey = "";
|
|
|
|
|
|
if (taskForm.taskType === "screenshot_collect" && taskExampleImage) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setWorking(true);
|
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
|
form.set("file", await compressScreenshot(taskExampleImage));
|
|
|
|
|
|
const uploadResponse = await fetch("/api/task-example-upload", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
body: form,
|
|
|
|
|
|
});
|
|
|
|
|
|
const uploadResult = await readApiResponse<{ key?: string }>(
|
|
|
|
|
|
uploadResponse,
|
|
|
|
|
|
"示例截图上传失败",
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!uploadResponse.ok || !uploadResult.key) {
|
|
|
|
|
|
throw new Error(uploadResult.error || "示例截图上传失败");
|
|
|
|
|
|
}
|
|
|
|
|
|
exampleImageKey = uploadResult.key;
|
|
|
|
|
|
} catch (reason) {
|
|
|
|
|
|
setToast(reason instanceof Error ? reason.message : "示例截图上传失败");
|
|
|
|
|
|
setWorking(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
const success = await runAction(
|
2026-08-11 23:07:26 +08:00
|
|
|
|
taskForm.taskType === "screenshot_collect"
|
|
|
|
|
|
? {
|
|
|
|
|
|
action: "create_screenshot_task",
|
|
|
|
|
|
name: taskForm.name,
|
|
|
|
|
|
brand: taskForm.brand,
|
|
|
|
|
|
dueAt: taskForm.dueAt,
|
|
|
|
|
|
keyword: taskForm.keyword,
|
|
|
|
|
|
instructions: taskForm.instructions,
|
|
|
|
|
|
quantity: taskForm.quantity,
|
|
|
|
|
|
exampleImageKey,
|
|
|
|
|
|
}
|
|
|
|
|
|
: { action: "create_task", ...taskForm },
|
|
|
|
|
|
taskForm.taskType === "screenshot_collect"
|
|
|
|
|
|
? `截图回收任务已创建,${taskForm.quantity} 份任务进入可领取池`
|
|
|
|
|
|
: `任务已创建,${sourcePreview?.rowCount ?? 0} 篇内容进入可领取池`,
|
2026-07-30 12:06:41 +08:00
|
|
|
|
);
|
|
|
|
|
|
if (success) {
|
|
|
|
|
|
setTaskModalOpen(false);
|
|
|
|
|
|
setSourcePreview(null);
|
2026-08-11 23:07:26 +08:00
|
|
|
|
setTaskExampleImage(null);
|
2026-07-30 12:06:41 +08:00
|
|
|
|
setTaskForm({
|
2026-08-11 23:07:26 +08:00
|
|
|
|
taskType: "content_publish",
|
2026-07-30 12:06:41 +08:00
|
|
|
|
name: "",
|
|
|
|
|
|
brand: "",
|
|
|
|
|
|
dueAt: "2026-08-12",
|
|
|
|
|
|
feishuUrl: "",
|
2026-08-11 23:07:26 +08:00
|
|
|
|
keyword: "",
|
|
|
|
|
|
instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。",
|
|
|
|
|
|
quantity: 1,
|
2026-07-30 12:06:41 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const inspectFeishu = async () => {
|
|
|
|
|
|
if (!taskForm.feishuUrl.trim()) {
|
|
|
|
|
|
setToast("请先粘贴飞书表格链接");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
setSourceWorking(true);
|
|
|
|
|
|
const response = await fetch("/api/action", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
action: "inspect_feishu",
|
|
|
|
|
|
feishuUrl: taskForm.feishuUrl,
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
const result = await readApiResponse<FeishuPreview>(
|
|
|
|
|
|
response,
|
|
|
|
|
|
"读取飞书表格失败",
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!response.ok) throw new Error(result.error || "读取飞书表格失败");
|
|
|
|
|
|
setSourcePreview(result);
|
|
|
|
|
|
setTaskForm((current) => ({
|
|
|
|
|
|
...current,
|
|
|
|
|
|
name: current.name || result.sheetName.replace(/500篇$/, "分发任务"),
|
|
|
|
|
|
}));
|
|
|
|
|
|
setToast(`已读取 ${result.rowCount} 篇有效内容`);
|
|
|
|
|
|
} catch (reason) {
|
|
|
|
|
|
setSourcePreview(null);
|
|
|
|
|
|
setToast(reason instanceof Error ? reason.message : "读取飞书表格失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSourceWorking(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const copyTaskShareLink = async (task: Task) => {
|
|
|
|
|
|
if (!data.portal_url || !task.share_token) {
|
|
|
|
|
|
setToast("KOC外部站点尚未发布");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const url = `${data.portal_url.replace(/\/$/, "")}/?task=${task.share_token}`;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(url);
|
|
|
|
|
|
setToast("KOC领取链接已复制");
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
window.prompt("复制KOC领取链接", url);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
const exportScreenshotResults = async (task: Task) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setExportingTaskId(task.id);
|
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
|
`/api/screenshot-task-export?task=${encodeURIComponent(task.id)}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const result = await readApiResponse<Record<string, never>>(
|
|
|
|
|
|
response,
|
|
|
|
|
|
"截图打包失败",
|
|
|
|
|
|
);
|
|
|
|
|
|
throw new Error(result.error || "截图打包失败");
|
|
|
|
|
|
}
|
|
|
|
|
|
const blob = await response.blob();
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
const anchor = document.createElement("a");
|
|
|
|
|
|
anchor.href = url;
|
|
|
|
|
|
anchor.download = `${task.name}-截图回收.zip`;
|
|
|
|
|
|
anchor.click();
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
setToast("任务截图和回收清单已打包下载");
|
|
|
|
|
|
} catch (reason) {
|
|
|
|
|
|
setToast(reason instanceof Error ? reason.message : "截图打包失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setExportingTaskId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-30 12:06:41 +08:00
|
|
|
|
const collectMetrics = async (distribution: Distribution) => {
|
|
|
|
|
|
await runAction(
|
|
|
|
|
|
{ action: "collect_now", distributionId: distribution.id },
|
|
|
|
|
|
"公开数据已更新",
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const retryFailedMetrics = async (taskId: string, failedCount: number) => {
|
|
|
|
|
|
if (failedCount === 0) {
|
|
|
|
|
|
setToast("当前没有采集异常的笔记");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await runAction(
|
|
|
|
|
|
{ action: "retry_failed_collections", taskId },
|
|
|
|
|
|
`已完成 ${failedCount} 篇异常笔记的一键补采`,
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const saveCollectionSchedule = async (
|
|
|
|
|
|
taskId: string,
|
|
|
|
|
|
startDate: string,
|
|
|
|
|
|
days: number[],
|
|
|
|
|
|
) => {
|
|
|
|
|
|
return runAction(
|
|
|
|
|
|
{
|
|
|
|
|
|
action: "save_collection_schedule",
|
|
|
|
|
|
taskId,
|
|
|
|
|
|
startDate,
|
|
|
|
|
|
days,
|
|
|
|
|
|
},
|
2026-08-12 11:12:23 +08:00
|
|
|
|
`采集任务已创建,将在所选日期09:00自动执行`,
|
2026-07-30 12:06:41 +08:00
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-05 11:40:29 +08:00
|
|
|
|
const exportRecoveryData = async (task: Task) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setExportingTaskId(task.id);
|
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
|
`/api/recovery-export?task=${encodeURIComponent(task.id)}`,
|
|
|
|
|
|
{ cache: "no-store" },
|
|
|
|
|
|
);
|
|
|
|
|
|
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 = `${task.name}-数据回收.xlsx`;
|
|
|
|
|
|
document.body.appendChild(anchor);
|
|
|
|
|
|
anchor.click();
|
|
|
|
|
|
anchor.remove();
|
|
|
|
|
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
|
|
|
|
|
setToast("Excel 已导出,图片和截图已嵌入表格");
|
|
|
|
|
|
} catch (reason) {
|
|
|
|
|
|
setToast(reason instanceof Error ? reason.message : "导出失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setExportingTaskId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-06 10:18:23 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
const completeResourceImport = async (message: string) => {
|
|
|
|
|
|
await loadData();
|
|
|
|
|
|
setToast(message);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-30 12:06:41 +08:00
|
|
|
|
const uploadScreenshot = async (
|
|
|
|
|
|
distribution: Distribution,
|
|
|
|
|
|
event: ChangeEvent<HTMLInputElement>,
|
|
|
|
|
|
) => {
|
|
|
|
|
|
const file = event.target.files?.[0];
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
setWorking(true);
|
|
|
|
|
|
const uploadFile = await compressScreenshot(file);
|
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
|
form.append("distributionId", distribution.id);
|
|
|
|
|
|
form.append("file", uploadFile);
|
|
|
|
|
|
const response = await fetch("/api/upload", { method: "POST", body: form });
|
|
|
|
|
|
const result = await readApiResponse<DashboardData>(response, "上传失败");
|
|
|
|
|
|
if (!response.ok) throw new Error(result.error || "上传失败");
|
|
|
|
|
|
setData(result);
|
|
|
|
|
|
setToast("创作者中心截图已收回");
|
|
|
|
|
|
} catch (reason) {
|
|
|
|
|
|
setToast(reason instanceof Error ? reason.message : "上传失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setWorking(false);
|
|
|
|
|
|
event.target.value = "";
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const submitManualMetrics = async (event: FormEvent) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
if (!metricModal) return;
|
|
|
|
|
|
const success = await runAction(
|
|
|
|
|
|
{
|
|
|
|
|
|
action: "manual_metrics",
|
|
|
|
|
|
distributionId: metricModal.id,
|
|
|
|
|
|
exposure: metricForm.exposure,
|
|
|
|
|
|
views: metricForm.views,
|
|
|
|
|
|
},
|
|
|
|
|
|
"创作者中心数据已确认",
|
|
|
|
|
|
);
|
|
|
|
|
|
if (success) {
|
|
|
|
|
|
setMetricModal(null);
|
|
|
|
|
|
setMetricForm({ exposure: "", views: "" });
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const openMetricFallback = (distribution: Distribution) => {
|
|
|
|
|
|
setMetricModal(distribution);
|
|
|
|
|
|
setMetricForm({
|
|
|
|
|
|
exposure: distribution.exposure?.toString() || "",
|
|
|
|
|
|
views: distribution.views?.toString() || "",
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const pageTitle: Record<NavKey, { title: string; subtitle: string }> = {
|
|
|
|
|
|
overview: {
|
|
|
|
|
|
title: "分发工作台",
|
|
|
|
|
|
subtitle: "用最少动作,跑完内容领取、发布与数据回收。",
|
|
|
|
|
|
},
|
|
|
|
|
|
tasks: {
|
|
|
|
|
|
title: "内容任务",
|
|
|
|
|
|
subtitle: "从飞书同步内容,按数量开放给合作方领取。",
|
|
|
|
|
|
},
|
|
|
|
|
|
distributions: {
|
|
|
|
|
|
title: "内容分发",
|
|
|
|
|
|
subtitle: "先按任务查看进度,再进入单个任务处理每篇笔记。",
|
|
|
|
|
|
},
|
|
|
|
|
|
resources: {
|
|
|
|
|
|
title: "KOC资源",
|
|
|
|
|
|
subtitle: "只沉淀真实发布过的账号,不要求提前登记。",
|
|
|
|
|
|
},
|
|
|
|
|
|
recovery: {
|
|
|
|
|
|
title: "数据回收",
|
|
|
|
|
|
subtitle: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。",
|
|
|
|
|
|
},
|
2026-08-07 11:06:20 +08:00
|
|
|
|
users: {
|
|
|
|
|
|
title: "用户管理",
|
|
|
|
|
|
subtitle: "由后台统一创建账号和重置密码,不开放自助注册与个人改密。",
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const logout = async () => {
|
|
|
|
|
|
await fetch("/api/auth/logout", { method: "POST" }).catch(() => null);
|
|
|
|
|
|
window.location.assign("/login");
|
2026-07-30 12:06:41 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="app-shell">
|
|
|
|
|
|
<aside className={`sidebar ${menuOpen ? "open" : ""}`}>
|
|
|
|
|
|
<div className="brand-lockup">
|
|
|
|
|
|
<div className="brand-symbol"><span>K</span></div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>KOC LOOP</strong>
|
|
|
|
|
|
<span>内容分发闭环</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<nav className="main-nav" aria-label="主导航">
|
|
|
|
|
|
<p className="nav-caption">运营中心</p>
|
2026-08-07 11:06:20 +08:00
|
|
|
|
{visibleNavItems.map((item) => (
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<button
|
|
|
|
|
|
key={item.key}
|
|
|
|
|
|
className={activeNav === item.key ? "active" : ""}
|
|
|
|
|
|
onClick={() => navigate(item.key)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="nav-mark">{item.mark}</span>
|
|
|
|
|
|
{item.label}
|
|
|
|
|
|
{item.key === "recovery" && stats.pendingRecovery > 0 && (
|
|
|
|
|
|
<span className="nav-badge">{stats.pendingRecovery}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="sidebar-summary">
|
|
|
|
|
|
<div className="pulse-dot" />
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>闭环运行中</strong>
|
|
|
|
|
|
<span>{stats.activeTasks} 个任务正在分发</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-07 11:06:20 +08:00
|
|
|
|
<button className="sidebar-user" onClick={() => void logout()} title="退出登录">
|
|
|
|
|
|
<div className="avatar mint">{currentUser.username.slice(0, 1).toUpperCase()}</div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<div>
|
2026-08-07 11:06:20 +08:00
|
|
|
|
<strong>{currentUser.username}</strong>
|
|
|
|
|
|
<span>{currentUser.role === "super_admin" ? "超级管理员" : currentUser.role === "admin" ? "管理员" : "普通用户"}</span>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
2026-08-07 11:06:20 +08:00
|
|
|
|
<span className="chevron">退出</span>
|
|
|
|
|
|
</button>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
|
|
{menuOpen && <button className="sidebar-scrim" onClick={() => setMenuOpen(false)} aria-label="关闭菜单" />}
|
|
|
|
|
|
|
|
|
|
|
|
<main className="main-area">
|
|
|
|
|
|
<header className="topbar">
|
|
|
|
|
|
<button className="menu-button" onClick={() => setMenuOpen(true)} aria-label="打开菜单">
|
|
|
|
|
|
☰
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div className="breadcrumb">
|
|
|
|
|
|
<span>KOC LOOP</span>
|
|
|
|
|
|
<i>/</i>
|
|
|
|
|
|
<strong>{pageTitle[activeNav].title}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="top-actions">
|
|
|
|
|
|
<span className="sync-state"><i />飞书内容表已接入</span>
|
|
|
|
|
|
<button className="ghost-button" onClick={() => setActiveNav("tasks")}>
|
|
|
|
|
|
获取KOC领取链接
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button className="primary-button" onClick={() => setTaskModalOpen(true)}>
|
|
|
|
|
|
<span>+</span> 新建任务
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<section className="content-frame">
|
|
|
|
|
|
<div className="page-heading">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="eyebrow">MVP · 核心闭环</p>
|
|
|
|
|
|
<h1>{pageTitle[activeNav].title}</h1>
|
|
|
|
|
|
<p>{pageTitle[activeNav].subtitle}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{activeNav === "overview" && (
|
|
|
|
|
|
<div className="date-chip">
|
|
|
|
|
|
<span>今天</span>
|
2026-08-05 11:40:29 +08:00
|
|
|
|
<strong>{formatShanghaiToday()}</strong>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<LoadingState />
|
|
|
|
|
|
) : error ? (
|
|
|
|
|
|
<div className="empty-state">
|
|
|
|
|
|
<div className="empty-icon">!</div>
|
|
|
|
|
|
<h3>暂时无法打开工作台</h3>
|
|
|
|
|
|
<p>{error}</p>
|
|
|
|
|
|
<button className="primary-button" onClick={() => void loadData()}>
|
|
|
|
|
|
重新加载
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{activeNav === "overview" && (
|
|
|
|
|
|
<OverviewPage
|
|
|
|
|
|
stats={stats}
|
|
|
|
|
|
distributions={recentDistributions}
|
|
|
|
|
|
tasks={data.tasks}
|
|
|
|
|
|
onNavigate={navigate}
|
2026-08-07 11:06:20 +08:00
|
|
|
|
showResources={currentUser.role !== "user"}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{activeNav === "tasks" && (
|
|
|
|
|
|
<TasksPage
|
|
|
|
|
|
tasks={data.tasks}
|
|
|
|
|
|
portalUrl={data.portal_url}
|
|
|
|
|
|
onCreate={() => setTaskModalOpen(true)}
|
|
|
|
|
|
onCopyShare={copyTaskShareLink}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{activeNav === "distributions" && (
|
|
|
|
|
|
<DistributionPage
|
|
|
|
|
|
tasks={data.tasks}
|
|
|
|
|
|
distributions={data.distributions}
|
2026-08-12 11:12:23 +08:00
|
|
|
|
canRelease={isManager}
|
|
|
|
|
|
working={working}
|
|
|
|
|
|
onRelease={async (distribution) =>
|
|
|
|
|
|
runAction(
|
|
|
|
|
|
{
|
|
|
|
|
|
action: "release_distribution",
|
|
|
|
|
|
distributionId: distribution.id,
|
|
|
|
|
|
},
|
|
|
|
|
|
`“${distribution.content_title}”已释放,可重新领取`,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{activeNav === "resources" && (
|
|
|
|
|
|
<ResourcesPage
|
|
|
|
|
|
accounts={data.accounts}
|
|
|
|
|
|
distributions={data.distributions}
|
2026-08-06 10:18:23 +08:00
|
|
|
|
exporting={exportingResources}
|
|
|
|
|
|
onExport={exportResources}
|
2026-08-11 23:07:26 +08:00
|
|
|
|
onImported={completeResourceImport}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-08-07 11:06:20 +08:00
|
|
|
|
{activeNav === "users" && isManager && (
|
|
|
|
|
|
<UsersPage currentUser={currentUser} />
|
|
|
|
|
|
)}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
{activeNav === "recovery" && (
|
|
|
|
|
|
<RecoveryPage
|
|
|
|
|
|
tasks={data.tasks}
|
2026-08-11 23:07:26 +08:00
|
|
|
|
distributions={data.distributions}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
working={working}
|
|
|
|
|
|
onCollect={collectMetrics}
|
|
|
|
|
|
onSaveSchedule={saveCollectionSchedule}
|
|
|
|
|
|
onUpload={uploadScreenshot}
|
|
|
|
|
|
onFallback={openMetricFallback}
|
|
|
|
|
|
onRetryFailed={retryFailedMetrics}
|
2026-08-05 11:40:29 +08:00
|
|
|
|
onExport={exportRecoveryData}
|
2026-08-11 23:07:26 +08:00
|
|
|
|
onExportScreenshots={exportScreenshotResults}
|
2026-08-05 11:40:29 +08:00
|
|
|
|
exportingTaskId={exportingTaskId}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
|
|
{taskModalOpen && (
|
|
|
|
|
|
<div className="modal-backdrop" role="presentation" onMouseDown={() => setTaskModalOpen(false)}>
|
|
|
|
|
|
<form className="modal-card" onSubmit={submitTask} onMouseDown={(event) => event.stopPropagation()}>
|
|
|
|
|
|
<div className="modal-heading">
|
|
|
|
|
|
<div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<p className="eyebrow">任务类型明确,回收链路独立</p>
|
|
|
|
|
|
<h2>新建任务</h2>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<button type="button" onClick={() => setTaskModalOpen(false)} aria-label="关闭">×</button>
|
|
|
|
|
|
</div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<div className="task-type-picker" role="group" aria-label="选择任务类型">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={taskForm.taskType === "content_publish" ? "active" : ""}
|
|
|
|
|
|
onClick={() => setTaskForm({ ...taskForm, taskType: "content_publish" })}
|
|
|
|
|
|
>
|
|
|
|
|
|
<b>内容发布</b>
|
|
|
|
|
|
<span>从飞书导入笔记,回收链接与数据</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={taskForm.taskType === "screenshot_collect" ? "active" : ""}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setTaskForm({ ...taskForm, taskType: "screenshot_collect" });
|
|
|
|
|
|
setSourcePreview(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<b>截图回收</b>
|
|
|
|
|
|
<span>下发搜索要求,只回收结果截图</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{taskForm.taskType === "content_publish" ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<span>飞书内容表链接</span>
|
|
|
|
|
|
<div className="source-url-row">
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={taskForm.feishuUrl}
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
setTaskForm({ ...taskForm, feishuUrl: event.target.value });
|
|
|
|
|
|
setSourcePreview(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
placeholder="粘贴飞书 Wiki / Sheets 链接"
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="ghost-button source-read-button"
|
|
|
|
|
|
disabled={sourceWorking || !taskForm.feishuUrl.trim()}
|
|
|
|
|
|
onClick={() => void inspectFeishu()}
|
|
|
|
|
|
>
|
|
|
|
|
|
{sourceWorking ? "读取中…" : "读取表格"}
|
|
|
|
|
|
</button>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<small>自动读取“标题、正文/笔记内容、标签和全部配图”,空标题行不会进入任务。</small>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
{sourcePreview && (
|
|
|
|
|
|
<div className="source-preview">
|
|
|
|
|
|
<div className="source-preview-head">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span className="source-check">✓</span>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>{sourcePreview.sheetName}</strong>
|
|
|
|
|
|
<small>工作表 {sourcePreview.sheetId} · 已匹配 {sourcePreview.columns.length} 个字段</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<b>{sourcePreview.rowCount} 篇</b>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<div className="source-columns">
|
|
|
|
|
|
{sourcePreview.columns.map((column) => <span key={column}>{column}</span>)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="source-samples">
|
|
|
|
|
|
{sourcePreview.preview.map((row) => (
|
|
|
|
|
|
<div key={row.sourceRow}>
|
|
|
|
|
|
<span>{row.sourceRow}</span>
|
|
|
|
|
|
<strong>{row.title}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="screenshot-task-fields">
|
|
|
|
|
|
<div className="form-grid two">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<span>小红书搜索关键词</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={taskForm.keyword}
|
|
|
|
|
|
onChange={(event) => setTaskForm({ ...taskForm, keyword: event.target.value })}
|
|
|
|
|
|
placeholder="例如:暑期亲子酒店"
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<span>任务份数</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="1"
|
|
|
|
|
|
max="500"
|
|
|
|
|
|
value={taskForm.quantity}
|
|
|
|
|
|
onChange={(event) => setTaskForm({ ...taskForm, quantity: Number(event.target.value) })}
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<label>
|
|
|
|
|
|
<span>任务说明</span>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
value={taskForm.instructions}
|
|
|
|
|
|
onChange={(event) => setTaskForm({ ...taskForm, instructions: event.target.value })}
|
|
|
|
|
|
placeholder="说明搜索步骤、截图范围和提交要求"
|
|
|
|
|
|
rows={4}
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<span>示例截图(可选)</span>
|
|
|
|
|
|
<div className={`task-example-picker ${taskExampleImage ? "has-file" : ""}`}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept="image/*"
|
|
|
|
|
|
onChange={(event) => setTaskExampleImage(event.target.files?.[0] ?? null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<strong>{taskExampleImage ? taskExampleImage.name : "点击上传示例截图"}</strong>
|
|
|
|
|
|
<small>帮助KOC确认截图范围,支持 JPG、PNG,最大8MB</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</label>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="form-grid two">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<span>任务名称</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={taskForm.name}
|
|
|
|
|
|
onChange={(event) => setTaskForm({ ...taskForm, name: event.target.value })}
|
|
|
|
|
|
placeholder="例如:夏日轻盈计划"
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<span>品牌/项目</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={taskForm.brand}
|
|
|
|
|
|
onChange={(event) => setTaskForm({ ...taskForm, brand: event.target.value })}
|
|
|
|
|
|
placeholder="例如:青柠实验室"
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<label>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<span>{taskForm.taskType === "screenshot_collect" ? "提交截止日期" : "发布截止日期"}</span>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<input
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
value={taskForm.dueAt}
|
|
|
|
|
|
onChange={(event) => setTaskForm({ ...taskForm, dueAt: event.target.value })}
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div className="modal-actions">
|
|
|
|
|
|
<button type="button" className="ghost-button" onClick={() => setTaskModalOpen(false)}>取消</button>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<button
|
|
|
|
|
|
className="primary-button"
|
|
|
|
|
|
disabled={
|
|
|
|
|
|
working ||
|
|
|
|
|
|
(taskForm.taskType === "content_publish" && !sourcePreview)
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{working ? "创建中…" : "创建并开放领取"}
|
|
|
|
|
|
</button>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{metricModal && (
|
|
|
|
|
|
<div className="modal-backdrop" role="presentation" onMouseDown={() => setMetricModal(null)}>
|
|
|
|
|
|
<form className="modal-card compact" onSubmit={submitManualMetrics} onMouseDown={(event) => event.stopPropagation()}>
|
|
|
|
|
|
<div className="modal-heading">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="eyebrow">OCR异常兜底</p>
|
|
|
|
|
|
<h2>确认创作者数据</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="button" onClick={() => setMetricModal(null)} aria-label="关闭">×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="context-strip">
|
|
|
|
|
|
<strong>{metricModal.account_nickname || "待识别账号"}</strong>
|
|
|
|
|
|
<span>{metricModal.content_title}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-grid two">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<span>曝光量</span>
|
|
|
|
|
|
<input type="number" min="0" value={metricForm.exposure} onChange={(event) => setMetricForm({ ...metricForm, exposure: event.target.value })} required />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<span>阅读量</span>
|
|
|
|
|
|
<input type="number" min="0" value={metricForm.views} onChange={(event) => setMetricForm({ ...metricForm, views: event.target.value })} required />
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="modal-actions">
|
|
|
|
|
|
<button type="button" className="ghost-button" onClick={() => setMetricModal(null)}>取消</button>
|
|
|
|
|
|
<button className="primary-button" disabled={working}>确认数据</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{toast && <div className="toast"><span>✓</span>{toast}</div>}
|
|
|
|
|
|
{working && <div className="working-bar" />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function LoadingState() {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="loading-layout" aria-label="正在加载">
|
|
|
|
|
|
<div className="loading-card wide" />
|
|
|
|
|
|
<div className="loading-card" />
|
|
|
|
|
|
<div className="loading-card" />
|
|
|
|
|
|
<div className="loading-table" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function OverviewPage({
|
|
|
|
|
|
stats,
|
|
|
|
|
|
distributions,
|
|
|
|
|
|
tasks,
|
|
|
|
|
|
onNavigate,
|
2026-08-07 11:06:20 +08:00
|
|
|
|
showResources,
|
2026-07-30 12:06:41 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
stats: {
|
|
|
|
|
|
resources: number;
|
|
|
|
|
|
activeTasks: number;
|
|
|
|
|
|
published: number;
|
|
|
|
|
|
completed: number;
|
|
|
|
|
|
pendingRecovery: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
distributions: Distribution[];
|
|
|
|
|
|
tasks: Task[];
|
|
|
|
|
|
onNavigate: (key: NavKey) => void;
|
2026-08-07 11:06:20 +08:00
|
|
|
|
showResources: boolean;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
const cards = [
|
|
|
|
|
|
{
|
|
|
|
|
|
label: "已沉淀账号资源",
|
|
|
|
|
|
value: stats.resources,
|
|
|
|
|
|
hint: "均来自真实发布",
|
|
|
|
|
|
tone: "ink",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: "正在分发任务",
|
|
|
|
|
|
value: stats.activeTasks,
|
|
|
|
|
|
hint: `${tasks.reduce((sum, task) => sum + task.claimed_quantity, 0)} 篇已被领取`,
|
|
|
|
|
|
tone: "green",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: "已回填发布",
|
|
|
|
|
|
value: stats.published,
|
|
|
|
|
|
hint: "链接自动识别账号",
|
|
|
|
|
|
tone: "blue",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: "待完成数据回收",
|
|
|
|
|
|
value: stats.pendingRecovery,
|
|
|
|
|
|
hint: stats.pendingRecovery > 0 ? "需要跟进" : "全部按时完成",
|
|
|
|
|
|
tone: "orange",
|
|
|
|
|
|
},
|
2026-08-07 11:06:20 +08:00
|
|
|
|
].filter((card) => showResources || card.label !== "已沉淀账号资源");
|
2026-07-30 12:06:41 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="stat-grid">
|
|
|
|
|
|
{cards.map((card) => (
|
|
|
|
|
|
<article className="stat-card" key={card.label}>
|
|
|
|
|
|
<div className={`stat-icon ${card.tone}`}><span /></div>
|
|
|
|
|
|
<p>{card.label}</p>
|
|
|
|
|
|
<div className="stat-value">
|
|
|
|
|
|
<strong>{card.value}</strong>
|
|
|
|
|
|
<span>个</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<small>{card.hint}</small>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="overview-grid">
|
|
|
|
|
|
<section className="panel workflow-panel">
|
|
|
|
|
|
<div className="panel-heading">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>当前闭环</h2>
|
|
|
|
|
|
<p>每篇内容只沿着一条记录向前走</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button className="text-button" onClick={() => onNavigate("distributions")}>查看全部</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="workflow-track">
|
|
|
|
|
|
<div className="workflow-step done">
|
|
|
|
|
|
<span>01</span>
|
|
|
|
|
|
<div><strong>内容已同步</strong><small>{tasks.reduce((sum, task) => sum + task.quantity, 0)} 篇</small></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<i />
|
|
|
|
|
|
<div className="workflow-step active">
|
|
|
|
|
|
<span>02</span>
|
|
|
|
|
|
<div><strong>KOC领取</strong><small>{tasks.reduce((sum, task) => sum + task.claimed_quantity, 0)} 篇</small></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<i />
|
|
|
|
|
|
<div className="workflow-step">
|
|
|
|
|
|
<span>03</span>
|
|
|
|
|
|
<div><strong>发布回填</strong><small>{stats.published} 篇</small></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<i />
|
|
|
|
|
|
<div className="workflow-step">
|
|
|
|
|
|
<span>04</span>
|
|
|
|
|
|
<div><strong>数据回收</strong><small>{stats.completed} 篇完成</small></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="focus-callout">
|
|
|
|
|
|
<div className="callout-ring"><strong>{stats.pendingRecovery}</strong><span>待回收</span></div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="eyebrow">今天最需要推进</p>
|
|
|
|
|
|
<h3>把已发布笔记的数据收回来</h3>
|
2026-08-12 11:12:23 +08:00
|
|
|
|
<p>按任务选择采集日期,系统在当天09:00更新点赞、收藏、评论和总互动。</p>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<button className="primary-button soft" onClick={() => onNavigate("recovery")}>进入数据回收</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section className="panel task-mini-panel">
|
|
|
|
|
|
<div className="panel-heading">
|
|
|
|
|
|
<div><h2>进行中的任务</h2><p>按截止时间排序</p></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="task-mini-list">
|
|
|
|
|
|
{tasks.map((task) => {
|
|
|
|
|
|
const progress = Math.round((task.claimed_quantity / task.quantity) * 100);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<article key={task.id}>
|
|
|
|
|
|
<div className="task-mini-top">
|
|
|
|
|
|
<div className={`avatar ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
|
|
|
|
|
|
<div><strong>{task.name}</strong><span>{task.brand}</span></div>
|
|
|
|
|
|
<b>{progress}%</b>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="progress"><span style={{ width: `${progress}%` }} /></div>
|
|
|
|
|
|
<div className="task-mini-meta">
|
|
|
|
|
|
<span>{task.claimed_quantity}/{task.quantity} 篇已领取</span>
|
|
|
|
|
|
<span>{formatDate(task.due_at)}截止</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button className="full-ghost-button" onClick={() => onNavigate("tasks")}>管理全部任务</button>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<section className="panel table-panel">
|
|
|
|
|
|
<div className="panel-heading">
|
|
|
|
|
|
<div><h2>最近分发</h2><p>领取、发布账号与回收状态一眼看清</p></div>
|
|
|
|
|
|
<button className="text-button" onClick={() => onNavigate("distributions")}>查看全部</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<DistributionTable distributions={distributions} compact />
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function TasksPage({
|
|
|
|
|
|
tasks,
|
|
|
|
|
|
portalUrl,
|
|
|
|
|
|
onCreate,
|
|
|
|
|
|
onCopyShare,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
tasks: Task[];
|
|
|
|
|
|
portalUrl: string;
|
|
|
|
|
|
onCreate: () => void;
|
|
|
|
|
|
onCopyShare: (task: Task) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="stack">
|
|
|
|
|
|
<section className="task-hero">
|
|
|
|
|
|
<div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<p className="eyebrow">两种固定任务类型</p>
|
|
|
|
|
|
<h2>发布笔记,或回收任务截图</h2>
|
|
|
|
|
|
<p>内容发布走飞书笔记链路;截图回收只下发关键词和要求,不进入发布数据采集。</p>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<button className="primary-button light" onClick={onCreate}>+ 新建任务</button>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
<section className="panel table-panel">
|
|
|
|
|
|
<div className="panel-heading">
|
|
|
|
|
|
<div><h2>任务列表</h2><p>{tasks.length} 个任务</p></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="task-list">
|
|
|
|
|
|
{tasks.map((task) => {
|
|
|
|
|
|
const progress = Math.round((task.claimed_quantity / task.quantity) * 100);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<article className="task-row" key={task.id}>
|
|
|
|
|
|
<div className={`avatar large ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
|
|
|
|
|
|
<div className="task-row-main">
|
|
|
|
|
|
<div><h3>{task.name}</h3><span>{task.brand}</span></div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<small>
|
|
|
|
|
|
{task.task_type === "screenshot_collect"
|
|
|
|
|
|
? "截图回收 · 关键词任务"
|
|
|
|
|
|
: task.source_sheet_name
|
|
|
|
|
|
? `飞书 · ${task.source_sheet_name}`
|
|
|
|
|
|
: "历史示例内容表"}
|
|
|
|
|
|
</small>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<div className="progress"><span style={{ width: `${progress}%` }} /></div>
|
|
|
|
|
|
</div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<div className="task-number"><strong>{task.claimed_quantity}</strong><span>/ {task.quantity} {task.task_type === "screenshot_collect" ? "份已领取" : "篇已领取"}</span></div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<div className="task-date"><span>截止日期</span><strong>{formatDate(task.due_at)}</strong></div>
|
|
|
|
|
|
<span className={`status-pill ${task.status}`}>{statusLabel(task.status)}</span>
|
|
|
|
|
|
<div className="task-share-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="share-link-button"
|
|
|
|
|
|
onClick={() => onCopyShare(task)}
|
|
|
|
|
|
disabled={!portalUrl || !task.share_token}
|
|
|
|
|
|
>
|
|
|
|
|
|
复制领取链接
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{portalUrl && task.share_token && (
|
|
|
|
|
|
<a
|
|
|
|
|
|
className="round-arrow"
|
|
|
|
|
|
href={`${portalUrl.replace(/\/$/, "")}/?task=${task.share_token}`}
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
|
aria-label="打开外部KOC领取页"
|
|
|
|
|
|
>
|
|
|
|
|
|
↗
|
|
|
|
|
|
</a>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function TaskScopeList({
|
|
|
|
|
|
tasks,
|
|
|
|
|
|
distributions,
|
|
|
|
|
|
mode,
|
|
|
|
|
|
onOpen,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
tasks: Task[];
|
|
|
|
|
|
distributions: Distribution[];
|
|
|
|
|
|
mode: "distribution" | "recovery";
|
|
|
|
|
|
onOpen: (taskId: string) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section className="task-scope-grid">
|
|
|
|
|
|
{tasks.map((task) => {
|
|
|
|
|
|
const taskDistributions = distributions.filter(
|
|
|
|
|
|
(item) => item.task_id === task.id,
|
|
|
|
|
|
);
|
2026-08-11 23:07:26 +08:00
|
|
|
|
const isScreenshotTask = task.task_type === "screenshot_collect";
|
2026-07-30 12:06:41 +08:00
|
|
|
|
const published = taskDistributions.filter((item) => item.publish_url);
|
2026-08-11 23:07:26 +08:00
|
|
|
|
const submitted = taskDistributions.filter(
|
|
|
|
|
|
(item) => item.result_submitted_at,
|
|
|
|
|
|
);
|
2026-07-30 12:06:41 +08:00
|
|
|
|
const publicDataComplete = published.filter(
|
|
|
|
|
|
(item) => latestPublicMetrics(item).likes !== null,
|
|
|
|
|
|
).length;
|
|
|
|
|
|
const creatorComplete = published.filter(
|
|
|
|
|
|
(item) => hasCreatorMetrics(item),
|
|
|
|
|
|
).length;
|
|
|
|
|
|
const progress = task.quantity
|
|
|
|
|
|
? Math.round((task.claimed_quantity / task.quantity) * 100)
|
|
|
|
|
|
: 0;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<article className="task-scope-card" key={task.id}>
|
|
|
|
|
|
<div className="task-scope-head">
|
|
|
|
|
|
<div className={`avatar large ${avatarColor(task.name)}`}>
|
|
|
|
|
|
{task.brand.slice(0, 1)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3>{task.name}</h3>
|
|
|
|
|
|
<span>{task.brand} · {formatDate(task.due_at)} 截止</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className={`status-pill ${task.status}`}>{statusLabel(task.status)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="task-source-line">
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<span>{isScreenshotTask ? "任务类型" : "飞书来源"}</span>
|
|
|
|
|
|
{isScreenshotTask ? (
|
|
|
|
|
|
<strong>截图回收 · 只收结果截图</strong>
|
|
|
|
|
|
) : task.source_url ? (
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<a href={task.source_url} target="_blank" rel="noreferrer">
|
|
|
|
|
|
{task.source_sheet_name || `工作表 ${task.source_sheet_id}`} ↗
|
|
|
|
|
|
</a>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<strong>历史示例内容表</strong>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{mode === "distribution" ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="task-scope-metrics">
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<div><span>{isScreenshotTask ? "任务总量" : "内容总量"}</span><strong>{task.quantity}</strong></div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<div><span>已领取</span><strong>{task.claimed_quantity}</strong></div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<div><span>{isScreenshotTask ? "已提交" : "已发布"}</span><strong>{isScreenshotTask ? submitted.length : published.length}</strong></div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="progress task-scope-progress">
|
|
|
|
|
|
<span style={{ width: `${progress}%` }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="task-scope-metrics">
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{isScreenshotTask ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div><span>已领取</span><strong>{taskDistributions.length}</strong></div>
|
|
|
|
|
|
<div><span>已提交</span><strong>{submitted.length}</strong></div>
|
|
|
|
|
|
<div><span>待提交</span><strong>{taskDistributions.length - submitted.length}</strong></div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div><span>已发布</span><strong>{published.length}</strong></div>
|
|
|
|
|
|
<div><span>公开数据</span><strong>{publicDataComplete}</strong></div>
|
|
|
|
|
|
<div><span>创作者数据</span><strong>{creatorComplete}</strong></div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button className="task-scope-open" onClick={() => onOpen(task.id)}>
|
|
|
|
|
|
<span>{mode === "distribution" ? "查看分发明细" : "进入回收队列"}</span>
|
|
|
|
|
|
<b>→</b>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function TaskDetailHeader({
|
|
|
|
|
|
task,
|
|
|
|
|
|
label,
|
|
|
|
|
|
onBack,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
task: Task;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
onBack: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section className="task-detail-header">
|
|
|
|
|
|
<button className="detail-back" onClick={onBack}>← 返回任务列表</button>
|
|
|
|
|
|
<div className={`avatar large ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p>{label}</p>
|
|
|
|
|
|
<h2>{task.name}</h2>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<span>{task.brand} · {task.quantity} {task.task_type === "screenshot_collect" ? "份" : "篇"} · {formatDate(task.due_at)} 截止</span>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{task.task_type !== "screenshot_collect" && task.source_url && (
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<a href={task.source_url} target="_blank" rel="noreferrer">
|
|
|
|
|
|
打开飞书原表 ↗
|
|
|
|
|
|
</a>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function DistributionPage({
|
|
|
|
|
|
tasks,
|
|
|
|
|
|
distributions,
|
2026-08-12 11:12:23 +08:00
|
|
|
|
canRelease,
|
|
|
|
|
|
working,
|
|
|
|
|
|
onRelease,
|
2026-07-30 12:06:41 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
tasks: Task[];
|
|
|
|
|
|
distributions: Distribution[];
|
2026-08-12 11:12:23 +08:00
|
|
|
|
canRelease: boolean;
|
|
|
|
|
|
working: boolean;
|
|
|
|
|
|
onRelease: (distribution: Distribution) => Promise<boolean>;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
2026-08-12 11:12:23 +08:00
|
|
|
|
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
|
2026-07-30 12:06:41 +08:00
|
|
|
|
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
|
|
|
|
|
|
if (!selectedTask) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="stack">
|
|
|
|
|
|
<div className="section-intro">
|
|
|
|
|
|
<div><p className="eyebrow">按任务管理</p><h2>选择一个分发任务</h2></div>
|
|
|
|
|
|
<p>每个任务独立查看领取、发布和账号识别进度,不混排不同项目的笔记。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<TaskScopeList
|
|
|
|
|
|
tasks={tasks}
|
|
|
|
|
|
distributions={distributions}
|
|
|
|
|
|
mode="distribution"
|
|
|
|
|
|
onOpen={setSelectedTaskId}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const taskDistributions = distributions.filter(
|
|
|
|
|
|
(item) => item.task_id === selectedTask.id,
|
|
|
|
|
|
);
|
2026-08-11 23:07:26 +08:00
|
|
|
|
const isScreenshotTask = selectedTask.task_type === "screenshot_collect";
|
2026-07-30 12:06:41 +08:00
|
|
|
|
const published = taskDistributions.filter((item) => item.publish_url).length;
|
2026-08-11 23:07:26 +08:00
|
|
|
|
const submitted = taskDistributions.filter((item) => item.result_submitted_at).length;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="stack">
|
|
|
|
|
|
<TaskDetailHeader
|
|
|
|
|
|
task={selectedTask}
|
2026-08-11 23:07:26 +08:00
|
|
|
|
label={isScreenshotTask ? "截图任务分发" : "内容分发"}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
onBack={() => setSelectedTaskId(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="inline-summary">
|
|
|
|
|
|
<div><span>已领取</span><strong>{taskDistributions.length}</strong></div>
|
|
|
|
|
|
<i />
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<div><span>{isScreenshotTask ? "已提交截图" : "已回填链接"}</span><strong>{isScreenshotTask ? submitted : published}</strong></div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<i />
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<div><span>{isScreenshotTask ? "待提交" : "待发布"}</span><strong>{taskDistributions.length - (isScreenshotTask ? submitted : published)}</strong></div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<div className="summary-note"><span className="pulse-dot" />当前仅展示“{selectedTask.name}”</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<section className="panel table-panel">
|
|
|
|
|
|
<div className="panel-heading">
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<div><h2>{isScreenshotTask ? "截图任务分发表" : "内容分发表"}</h2><p>{isScreenshotTask ? "每份任务与结果截图一一对应" : "实际发布账号在链接回填后自动补齐"}</p></div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{isScreenshotTask ? (
|
|
|
|
|
|
<ScreenshotTaskTable distributions={taskDistributions} />
|
|
|
|
|
|
) : (
|
2026-08-12 11:12:23 +08:00
|
|
|
|
<DistributionTable
|
|
|
|
|
|
distributions={taskDistributions}
|
|
|
|
|
|
onRelease={canRelease ? setReleaseTarget : undefined}
|
|
|
|
|
|
/>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
)}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</section>
|
2026-08-12 11:12:23 +08:00
|
|
|
|
{releaseTarget && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="modal-backdrop"
|
|
|
|
|
|
role="presentation"
|
|
|
|
|
|
onMouseDown={() => { if (!working) setReleaseTarget(null); }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="modal-card compact release-distribution-modal"
|
|
|
|
|
|
role="alertdialog"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
aria-labelledby="release-distribution-title"
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="modal-heading">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="eyebrow warning">释放领取</p>
|
|
|
|
|
|
<h2 id="release-distribution-title">确认释放这篇笔记?</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setReleaseTarget(null)}
|
|
|
|
|
|
aria-label="关闭"
|
|
|
|
|
|
disabled={working}
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="release-distribution-summary">
|
|
|
|
|
|
<strong>{releaseTarget.content_title}</strong>
|
|
|
|
|
|
<span>当前领取合作方:{releaseTarget.partner_name}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="release-distribution-warning">
|
|
|
|
|
|
释放后,这篇笔记会从当前领取记录中移除并回到可领取池,其他 KOC 可以重新领取。此操作不能撤销。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="modal-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="ghost-button"
|
|
|
|
|
|
onClick={() => setReleaseTarget(null)}
|
|
|
|
|
|
disabled={working}
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="release-confirm-button"
|
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
|
const released = await onRelease(releaseTarget);
|
|
|
|
|
|
if (released) setReleaseTarget(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={working}
|
|
|
|
|
|
>
|
|
|
|
|
|
{working ? "释放中…" : "确认释放"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function DistributionTable({
|
|
|
|
|
|
distributions,
|
|
|
|
|
|
compact = false,
|
2026-08-12 11:12:23 +08:00
|
|
|
|
onRelease,
|
2026-07-30 12:06:41 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
distributions: Distribution[];
|
|
|
|
|
|
compact?: boolean;
|
2026-08-12 11:12:23 +08:00
|
|
|
|
onRelease?: (distribution: Distribution) => void;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="table-scroll">
|
|
|
|
|
|
<table className="data-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>内容</th>
|
|
|
|
|
|
<th>领取合作方</th>
|
|
|
|
|
|
<th>实际发布账号</th>
|
|
|
|
|
|
<th>发布时间</th>
|
|
|
|
|
|
<th>数据状态</th>
|
|
|
|
|
|
<th>状态</th>
|
2026-08-12 11:12:23 +08:00
|
|
|
|
{onRelease && <th>操作</th>}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{distributions.map((item) => (
|
|
|
|
|
|
<tr key={item.id}>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div className="content-cell">
|
|
|
|
|
|
<span className="content-id">{item.content_id.split("-").at(-1)?.toUpperCase()}</span>
|
|
|
|
|
|
<div><strong>{item.content_title}</strong><span>{item.task_name} · {item.task_brand}</span></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td><strong className="plain-strong">{item.partner_name}</strong></td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
{item.account_nickname ? (
|
|
|
|
|
|
<div className="account-cell">
|
|
|
|
|
|
<div className={`avatar small ${avatarColor(item.account_nickname)}`}>{item.account_nickname.slice(0, 1)}</div>
|
|
|
|
|
|
<div><strong>{item.account_nickname}</strong><span>{item.account_platform}</span></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="muted">回填链接后识别</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td><span className="date-value">{formatDate(item.publish_time, true)}</span></td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
{item.d7_likes !== null ? (
|
|
|
|
|
|
<span className="metric-ready">D7 已回收</span>
|
|
|
|
|
|
) : item.d2_likes !== null ? (
|
|
|
|
|
|
<span className="metric-waiting">采集中</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="muted">{item.publish_url ? "等待D2" : "尚未发布"}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td><span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span></td>
|
2026-08-12 11:12:23 +08:00
|
|
|
|
{onRelease && (
|
|
|
|
|
|
<td>
|
|
|
|
|
|
{!item.publish_url && !item.result_submitted_at ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="release-distribution-button"
|
|
|
|
|
|
onClick={() => onRelease(item)}
|
|
|
|
|
|
>
|
|
|
|
|
|
释放
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="muted">—</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
)}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
{!compact && distributions.length === 0 && <div className="table-empty">暂无分发记录</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
type AdminPreviewImage = { src: string; title: string };
|
|
|
|
|
|
|
|
|
|
|
|
function AdminImageLightbox({
|
|
|
|
|
|
images,
|
|
|
|
|
|
activeIndex,
|
|
|
|
|
|
onIndexChange,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
images: AdminPreviewImage[];
|
|
|
|
|
|
activeIndex: number;
|
|
|
|
|
|
onIndexChange?: (index: number) => void;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const image = images[activeIndex] ?? null;
|
|
|
|
|
|
const canNavigate = images.length > 1 && Boolean(onIndexChange);
|
|
|
|
|
|
const showPrevious = () => {
|
|
|
|
|
|
if (!canNavigate) return;
|
|
|
|
|
|
onIndexChange?.((activeIndex - 1 + images.length) % images.length);
|
|
|
|
|
|
};
|
|
|
|
|
|
const showNext = () => {
|
|
|
|
|
|
if (!canNavigate) return;
|
|
|
|
|
|
onIndexChange?.((activeIndex + 1) % images.length);
|
|
|
|
|
|
};
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!image) return;
|
|
|
|
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
|
|
|
|
if (event.key === "Escape") onClose();
|
|
|
|
|
|
if (event.key === "ArrowLeft" && canNavigate) {
|
|
|
|
|
|
onIndexChange?.((activeIndex - 1 + images.length) % images.length);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (event.key === "ArrowRight" && canNavigate) {
|
|
|
|
|
|
onIndexChange?.((activeIndex + 1) % images.length);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
document.addEventListener("keydown", onKeyDown);
|
|
|
|
|
|
const previousOverflow = document.body.style.overflow;
|
|
|
|
|
|
document.body.style.overflow = "hidden";
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
|
|
|
|
document.body.style.overflow = previousOverflow;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [activeIndex, canNavigate, image, images.length, onClose, onIndexChange]);
|
|
|
|
|
|
if (!image) return null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="admin-image-lightbox"
|
|
|
|
|
|
role="dialog"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
aria-label={image.title}
|
|
|
|
|
|
onMouseDown={onClose}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="admin-image-lightbox-card"
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="admin-image-lightbox-heading">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>{image.title}</strong>
|
|
|
|
|
|
{canNavigate && (
|
|
|
|
|
|
<span aria-live="polite">{activeIndex + 1} / {images.length}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="button" onClick={onClose}>关闭</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="admin-image-lightbox-stage">
|
|
|
|
|
|
{canNavigate && (
|
|
|
|
|
|
<button type="button" className="previous" onClick={showPrevious} aria-label="上一张">
|
|
|
|
|
|
上一张
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
|
|
|
|
<img src={image.src} alt={image.title} />
|
|
|
|
|
|
{canNavigate && (
|
|
|
|
|
|
<button type="button" className="next" onClick={showNext} aria-label="下一张">
|
|
|
|
|
|
下一张
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ScreenshotTaskTable({ distributions }: { distributions: Distribution[] }) {
|
|
|
|
|
|
const [previewGallery, setPreviewGallery] = useState<{
|
|
|
|
|
|
images: AdminPreviewImage[];
|
|
|
|
|
|
activeIndex: number;
|
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="table-scroll">
|
|
|
|
|
|
<table className="data-table screenshot-task-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>搜索关键词</th>
|
|
|
|
|
|
<th>领取合作方</th>
|
|
|
|
|
|
<th>领取时间</th>
|
|
|
|
|
|
<th>提交时间</th>
|
|
|
|
|
|
<th>结果截图</th>
|
|
|
|
|
|
<th>状态</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{distributions.map((item) => (
|
|
|
|
|
|
<tr key={item.id}>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div className="content-cell">
|
|
|
|
|
|
<span className="content-id">搜</span>
|
|
|
|
|
|
<div><strong>{item.content_title}</strong><span>{item.task_name}</span></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td><strong className="plain-strong">{item.partner_name}</strong></td>
|
|
|
|
|
|
<td><span className="date-value">{formatDate(item.claimed_at, true)}</span></td>
|
|
|
|
|
|
<td><span className="date-value">{formatDate(item.result_submitted_at, true)}</span></td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
{parseResultScreenshotKeys(item.result_screenshot_key).length > 0 ? (
|
|
|
|
|
|
<div className="admin-result-gallery">
|
|
|
|
|
|
{parseResultScreenshotKeys(item.result_screenshot_key).map((_, index, screenshotKeys) => {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
setPreviewGallery({
|
|
|
|
|
|
images: screenshotKeys.map((__, imageIndex) => ({
|
|
|
|
|
|
src: `/api/task-result-image?distribution=${encodeURIComponent(item.id)}&index=${imageIndex + 1}`,
|
|
|
|
|
|
title: `${item.content_title} · 第${imageIndex + 1}张结果截图`,
|
|
|
|
|
|
})),
|
|
|
|
|
|
activeIndex: index,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
key={`${item.id}-${index}`}
|
|
|
|
|
|
title={`查看第${index + 1}张截图`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={`/api/task-result-image?distribution=${encodeURIComponent(item.id)}&index=${index + 1}`}
|
|
|
|
|
|
alt={`任务结果截图 ${index + 1}`}
|
|
|
|
|
|
loading="lazy"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)})}
|
|
|
|
|
|
<span>{parseResultScreenshotKeys(item.result_screenshot_key).length}张</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="muted">待KOC上传</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<span className={`status-pill ${item.result_submitted_at ? "complete" : "claimed"}`}>
|
|
|
|
|
|
{item.result_submitted_at ? "已提交" : "待提交"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
{distributions.length === 0 && <div className="table-empty">暂无领取记录</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<AdminImageLightbox
|
|
|
|
|
|
images={previewGallery?.images ?? []}
|
|
|
|
|
|
activeIndex={previewGallery?.activeIndex ?? 0}
|
|
|
|
|
|
onIndexChange={(activeIndex) =>
|
|
|
|
|
|
setPreviewGallery((current) => current ? { ...current, activeIndex } : current)
|
|
|
|
|
|
}
|
|
|
|
|
|
onClose={() => setPreviewGallery(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 12:06:41 +08:00
|
|
|
|
function ResourcesPage({
|
|
|
|
|
|
accounts,
|
|
|
|
|
|
distributions,
|
2026-08-06 10:18:23 +08:00
|
|
|
|
exporting,
|
|
|
|
|
|
onExport,
|
2026-08-11 23:07:26 +08:00
|
|
|
|
onImported,
|
2026-07-30 12:06:41 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
accounts: Account[];
|
|
|
|
|
|
distributions: Distribution[];
|
2026-08-06 10:18:23 +08:00
|
|
|
|
exporting: boolean;
|
|
|
|
|
|
onExport: (accountIds: string[]) => void;
|
2026-08-11 23:07:26 +08:00
|
|
|
|
onImported: (message: string) => Promise<void>;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
}) {
|
2026-08-06 10:18:23 +08:00
|
|
|
|
const [query, setQuery] = useState("");
|
|
|
|
|
|
const [platformFilter, setPlatformFilter] = useState("全部平台");
|
2026-08-06 11:48:10 +08:00
|
|
|
|
const [ipFilter, setIpFilter] = useState("");
|
|
|
|
|
|
const [sourceFilter, setSourceFilter] = useState("");
|
2026-08-11 23:07:26 +08:00
|
|
|
|
const [importOpen, setImportOpen] = useState(false);
|
|
|
|
|
|
const [importFile, setImportFile] = useState<File | null>(null);
|
|
|
|
|
|
const [importPreview, setImportPreview] = useState<ResourceImportPreview | null>(null);
|
|
|
|
|
|
const [importWorking, setImportWorking] = useState(false);
|
|
|
|
|
|
const [importError, setImportError] = useState("");
|
2026-08-06 10:18:23 +08:00
|
|
|
|
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(
|
2026-08-11 23:07:26 +08:00
|
|
|
|
[
|
|
|
|
|
|
...deliveries.map((item) => item.partner_name),
|
|
|
|
|
|
...(account.cooperation_source || "")
|
|
|
|
|
|
.split(/[、,,;;|]/)
|
|
|
|
|
|
.map((item) => item.trim()),
|
|
|
|
|
|
].filter(Boolean),
|
2026-08-06 10:18:23 +08:00
|
|
|
|
),
|
|
|
|
|
|
].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");
|
2026-08-06 11:48:10 +08:00
|
|
|
|
const ipKeyword = ipFilter.trim().toLocaleLowerCase("zh-CN");
|
|
|
|
|
|
const sourceKeyword = sourceFilter.trim().toLocaleLowerCase("zh-CN");
|
2026-08-06 10:18:23 +08:00
|
|
|
|
return resourceAccounts.filter(({ account, sources }) => {
|
|
|
|
|
|
const searchable = `${account.nickname} ${account.public_account_id}`.toLocaleLowerCase(
|
|
|
|
|
|
"zh-CN",
|
|
|
|
|
|
);
|
2026-08-06 11:48:10 +08:00
|
|
|
|
const ipLocation = (account.ip_location || "待识别").toLocaleLowerCase(
|
|
|
|
|
|
"zh-CN",
|
|
|
|
|
|
);
|
2026-08-06 10:18:23 +08:00
|
|
|
|
return (
|
|
|
|
|
|
(!keyword || searchable.includes(keyword)) &&
|
|
|
|
|
|
(platformFilter === "全部平台" ||
|
|
|
|
|
|
account.platform === platformFilter) &&
|
2026-08-06 11:48:10 +08:00
|
|
|
|
(!ipKeyword || ipLocation.includes(ipKeyword)) &&
|
|
|
|
|
|
(!sourceKeyword ||
|
|
|
|
|
|
sources.some((source) =>
|
|
|
|
|
|
source.toLocaleLowerCase("zh-CN").includes(sourceKeyword),
|
|
|
|
|
|
))
|
2026-08-06 10:18:23 +08:00
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [ipFilter, platformFilter, query, resourceAccounts, sourceFilter]);
|
|
|
|
|
|
const hasActiveFilters =
|
|
|
|
|
|
Boolean(query.trim()) ||
|
|
|
|
|
|
platformFilter !== "全部平台" ||
|
2026-08-06 11:48:10 +08:00
|
|
|
|
Boolean(ipFilter.trim()) ||
|
|
|
|
|
|
Boolean(sourceFilter.trim());
|
2026-08-06 10:18:23 +08:00
|
|
|
|
const clearFilters = () => {
|
|
|
|
|
|
setQuery("");
|
|
|
|
|
|
setPlatformFilter("全部平台");
|
2026-08-06 11:48:10 +08:00
|
|
|
|
setIpFilter("");
|
|
|
|
|
|
setSourceFilter("");
|
2026-07-30 12:06:41 +08:00
|
|
|
|
};
|
2026-08-11 23:07:26 +08:00
|
|
|
|
const closeImport = () => {
|
|
|
|
|
|
if (importWorking) return;
|
|
|
|
|
|
setImportOpen(false);
|
|
|
|
|
|
setImportFile(null);
|
|
|
|
|
|
setImportPreview(null);
|
|
|
|
|
|
setImportError("");
|
|
|
|
|
|
};
|
|
|
|
|
|
const submitImport = async (mode: "preview" | "commit") => {
|
|
|
|
|
|
if (!importFile) {
|
|
|
|
|
|
setImportError("请先选择需要导入的 Excel 文件");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
setImportWorking(true);
|
|
|
|
|
|
setImportError("");
|
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
|
form.append("file", importFile);
|
|
|
|
|
|
form.append("mode", mode);
|
|
|
|
|
|
const response = await fetch("/api/resources-import", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
body: form,
|
|
|
|
|
|
});
|
|
|
|
|
|
const result = await readApiResponse<
|
|
|
|
|
|
ResourceImportPreview & { error?: string; message?: string }
|
|
|
|
|
|
>(response, "导入失败");
|
|
|
|
|
|
if (!response.ok) throw new Error(result.error || "导入失败");
|
|
|
|
|
|
if (mode === "preview") setImportPreview(result);
|
|
|
|
|
|
else {
|
|
|
|
|
|
setImportOpen(false);
|
|
|
|
|
|
setImportFile(null);
|
|
|
|
|
|
setImportPreview(null);
|
|
|
|
|
|
await onImported(result.message || "KOC资源已导入");
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (reason) {
|
|
|
|
|
|
setImportError(reason instanceof Error ? reason.message : "导入失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setImportWorking(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-07-30 12:06:41 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="stack">
|
|
|
|
|
|
<section className="resource-intro">
|
|
|
|
|
|
<div className="resource-figure">
|
|
|
|
|
|
<strong>{accounts.length}</strong>
|
|
|
|
|
|
<span>个真实发布账号</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<h2>资源从合作与历史资产中持续沉淀</h2>
|
|
|
|
|
|
<p>发布链接会自动建档,已有KOC资源也可以使用标准模板批量导入。</p>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="resource-rule">
|
|
|
|
|
|
<span>唯一去重规则</span>
|
|
|
|
|
|
<strong>平台 + 账号主页</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
<section className="panel table-panel">
|
|
|
|
|
|
<div className="panel-heading">
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<div><h2>账号资源库</h2><p>统一管理合作沉淀与历史导入账号</p></div>
|
|
|
|
|
|
<div className="resource-heading-actions">
|
|
|
|
|
|
<a className="ghost-button" download href="/KOC资源导入模板.xlsx">
|
|
|
|
|
|
下载导入模板
|
|
|
|
|
|
</a>
|
|
|
|
|
|
<button className="primary-button soft" onClick={() => setImportOpen(true)}>
|
|
|
|
|
|
导入KOC资源
|
2026-08-06 10:18:23 +08:00
|
|
|
|
</button>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<div className="filter-chips">
|
2026-08-06 10:18:23 +08:00
|
|
|
|
<button
|
2026-08-11 23:07:26 +08:00
|
|
|
|
className={platformFilter === "全部平台" ? "active" : ""}
|
|
|
|
|
|
onClick={() => setPlatformFilter("全部平台")}
|
2026-08-06 10:18:23 +08:00
|
|
|
|
>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
全部平台
|
2026-08-06 10:18:23 +08:00
|
|
|
|
</button>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{platformOptions.map((platform) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={platformFilter === platform ? "active" : ""}
|
|
|
|
|
|
key={platform}
|
|
|
|
|
|
onClick={() => setPlatformFilter(platform)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{platform}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-08-06 10:18:23 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="resource-toolbar">
|
2026-08-06 11:48:10 +08:00
|
|
|
|
<div className="resource-search">
|
2026-08-06 10:18:23 +08:00
|
|
|
|
<span aria-hidden="true">⌕</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
aria-label="搜索账号名称或账号ID"
|
|
|
|
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
|
|
|
|
placeholder="搜索账号名称 / 账号ID"
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
value={query}
|
|
|
|
|
|
/>
|
2026-08-06 11:48:10 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="resource-filter-search">
|
|
|
|
|
|
<span aria-hidden="true">⌕</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
aria-label="模糊搜索IP地区"
|
|
|
|
|
|
list="resource-ip-options"
|
|
|
|
|
|
onChange={(event) => setIpFilter(event.target.value)}
|
|
|
|
|
|
placeholder="搜索IP地区"
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
value={ipFilter}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<datalist id="resource-ip-options">
|
|
|
|
|
|
{ipOptions.map((ip) => <option key={ip} value={ip} />)}
|
|
|
|
|
|
</datalist>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="resource-filter-search">
|
|
|
|
|
|
<span aria-hidden="true">⌕</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
aria-label="模糊搜索合作来源"
|
|
|
|
|
|
list="resource-source-options"
|
|
|
|
|
|
onChange={(event) => setSourceFilter(event.target.value)}
|
|
|
|
|
|
placeholder="搜索合作来源"
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
value={sourceFilter}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<datalist id="resource-source-options">
|
|
|
|
|
|
{sourceOptions.map((source) => (
|
|
|
|
|
|
<option key={source} value={source} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</datalist>
|
|
|
|
|
|
</div>
|
2026-08-06 10:18:23 +08:00
|
|
|
|
<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>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="resource-grid">
|
2026-08-06 10:18:23 +08:00
|
|
|
|
{filteredAccounts.map(({ account, sources, partnerManagedOnly }) => (
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
<div><h3>{account.nickname}</h3><span>{account.platform} · {account.ip_location}</span></div>
|
|
|
|
|
|
<span className="verified-dot">✓</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="resource-account-id">
|
|
|
|
|
|
<span>{account.platform === "小红书" ? "小红书号" : account.platform === "抖音" ? "抖音号" : "账号"}</span>
|
|
|
|
|
|
<strong>{account.public_account_id || "待识别"}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="resource-metrics">
|
|
|
|
|
|
<div><span>粉丝</span><strong>{formatNumber(account.followers)}</strong></div>
|
|
|
|
|
|
<div><span>合作发布</span><strong>{account.post_count}</strong></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="resource-source">
|
|
|
|
|
|
<span>历史合作来源</span>
|
|
|
|
|
|
<div>
|
2026-08-06 10:18:23 +08:00
|
|
|
|
{sources.map((source) => <b key={source}>{source}</b>)}
|
|
|
|
|
|
{partnerManagedOnly && (
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<b className="partner-managed">合作社资源 · 不可直联</b>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="resource-card-foot">
|
|
|
|
|
|
<span>最近合作 {formatDate(account.last_seen_at)}</span>
|
|
|
|
|
|
{account.profile_url && <a href={account.profile_url} target="_blank" rel="noreferrer">查看主页 ↗</a>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-08-06 10:18:23 +08:00
|
|
|
|
{filteredAccounts.length === 0 && (
|
|
|
|
|
|
<div className="resource-empty">
|
|
|
|
|
|
<strong>没有符合条件的KOC</strong>
|
|
|
|
|
|
<span>可以调整搜索内容或清空筛选条件</span>
|
|
|
|
|
|
<button className="ghost-button" onClick={clearFilters}>
|
|
|
|
|
|
清空筛选
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</section>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{importOpen && (
|
|
|
|
|
|
<div className="modal-backdrop" role="presentation" onMouseDown={closeImport}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="modal-card resource-import-modal"
|
|
|
|
|
|
role="dialog"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
aria-labelledby="resource-import-title"
|
|
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="modal-heading">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="eyebrow">批量导入</p>
|
|
|
|
|
|
<h2 id="resource-import-title">导入已有KOC资源</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="button" onClick={closeImport} aria-label="关闭">×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="import-guide-strip">
|
|
|
|
|
|
<span>1</span><p>填写账号主页与合作来源</p>
|
|
|
|
|
|
<i />
|
|
|
|
|
|
<span>2</span><p>系统解析公开账号资料</p>
|
|
|
|
|
|
<i />
|
|
|
|
|
|
<span>3</span><p>确认后写入资源库</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<label className="resource-import-dropzone">
|
|
|
|
|
|
<input
|
|
|
|
|
|
accept=".xlsx,.csv"
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
setImportFile(event.target.files?.[0] ?? null);
|
|
|
|
|
|
setImportPreview(null);
|
|
|
|
|
|
setImportError("");
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<strong>{importFile ? importFile.name : "选择 Excel / CSV 文件"}</strong>
|
|
|
|
|
|
<span>单次最多 100 个账号,文件不超过 5MB</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
{!importPreview && (
|
|
|
|
|
|
<div className="import-template-note">
|
|
|
|
|
|
<div><strong>还没有模板?</strong><span>只需填写小红书账号主页;合作来源可选填,账号名称、小红书号、IP属地和粉丝数会自动解析。</span></div>
|
|
|
|
|
|
<a className="ghost-button" download href="/KOC资源导入模板.xlsx">下载模板</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{importPreview && (
|
|
|
|
|
|
<div className="resource-import-preview">
|
|
|
|
|
|
<div className="import-summary-grid">
|
|
|
|
|
|
<div><span>数据总数</span><strong>{importPreview.summary.total}</strong></div>
|
|
|
|
|
|
<div className="create"><span>新增账号</span><strong>{importPreview.summary.create}</strong></div>
|
|
|
|
|
|
<div className="update"><span>更新账号</span><strong>{importPreview.summary.update}</strong></div>
|
|
|
|
|
|
<div className={importPreview.summary.error ? "error" : ""}><span>异常数据</span><strong>{importPreview.summary.error}</strong></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="import-preview-table">
|
|
|
|
|
|
<div className="import-preview-row head">
|
|
|
|
|
|
<span>行号</span><span>账号</span><span>账号ID</span><span>IP / 来源</span><span>结果</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{importPreview.rows.slice(0, 8).map((row) => (
|
|
|
|
|
|
<div className="import-preview-row" key={row.rowNumber}>
|
|
|
|
|
|
<span>{row.rowNumber}</span>
|
|
|
|
|
|
<span><strong>{row.nickname || "—"}</strong><small>{row.platform || "未填写平台"}</small></span>
|
|
|
|
|
|
<span>{row.publicAccountId || "主页识别"}</span>
|
|
|
|
|
|
<span><strong>{row.ipLocation}</strong><small>{row.cooperationSource || "未填写来源"}</small></span>
|
|
|
|
|
|
<span className={`import-result ${row.action}`}>
|
|
|
|
|
|
{row.action === "create" ? "新增" : row.action === "update" ? "更新" : row.errors.join(";")}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{(importPreview.summary.total > 8 || importPreview.truncated) && (
|
|
|
|
|
|
<p className="import-preview-more">这里只预览前 8 行,确认导入时会处理全部数据。</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{importError && <div className="form-error">{importError}</div>}
|
|
|
|
|
|
<div className="modal-actions">
|
|
|
|
|
|
<button type="button" className="ghost-button" onClick={closeImport}>取消</button>
|
|
|
|
|
|
{!importPreview ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="primary-button"
|
|
|
|
|
|
disabled={!importFile || importWorking}
|
|
|
|
|
|
onClick={() => void submitImport("preview")}
|
|
|
|
|
|
>
|
|
|
|
|
|
{importWorking ? "校验中…" : "校验并预览"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="primary-button"
|
|
|
|
|
|
disabled={importPreview.summary.error > 0 || importWorking}
|
|
|
|
|
|
onClick={() => void submitImport("commit")}
|
|
|
|
|
|
>
|
|
|
|
|
|
{importWorking ? "导入中…" : `确认导入 ${importPreview.summary.create + importPreview.summary.update} 个账号`}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
function CreatorScreenshotPreview({
|
|
|
|
|
|
distributionId,
|
|
|
|
|
|
title,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
distributionId: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
|
const imageUrl = `/api/creator-screenshot?distribution=${encodeURIComponent(distributionId)}`;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="creator-screenshot-link"
|
|
|
|
|
|
onClick={() => setOpen(true)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
|
|
|
|
<img src={imageUrl} alt={`${title} 创作者中心截图`} loading="lazy" />
|
|
|
|
|
|
<span>点击查看</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<AdminImageLightbox
|
|
|
|
|
|
images={open ? [{ src: imageUrl, title: `${title} · 创作者中心截图` }] : []}
|
|
|
|
|
|
activeIndex={0}
|
|
|
|
|
|
onClose={() => setOpen(false)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 12:06:41 +08:00
|
|
|
|
function RecoveryPage({
|
|
|
|
|
|
tasks,
|
|
|
|
|
|
distributions,
|
|
|
|
|
|
working,
|
|
|
|
|
|
onCollect,
|
|
|
|
|
|
onSaveSchedule,
|
|
|
|
|
|
onUpload,
|
|
|
|
|
|
onFallback,
|
|
|
|
|
|
onRetryFailed,
|
2026-08-05 11:40:29 +08:00
|
|
|
|
onExport,
|
2026-08-11 23:07:26 +08:00
|
|
|
|
onExportScreenshots,
|
2026-08-05 11:40:29 +08:00
|
|
|
|
exportingTaskId,
|
2026-07-30 12:06:41 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
tasks: Task[];
|
|
|
|
|
|
distributions: Distribution[];
|
|
|
|
|
|
working: boolean;
|
|
|
|
|
|
onCollect: (distribution: Distribution) => void;
|
|
|
|
|
|
onSaveSchedule: (
|
|
|
|
|
|
taskId: string,
|
|
|
|
|
|
startDate: string,
|
|
|
|
|
|
days: number[],
|
|
|
|
|
|
) => Promise<boolean>;
|
|
|
|
|
|
onUpload: (distribution: Distribution, event: ChangeEvent<HTMLInputElement>) => void;
|
|
|
|
|
|
onFallback: (distribution: Distribution) => void;
|
|
|
|
|
|
onRetryFailed: (taskId: string, failedCount: number) => void;
|
2026-08-05 11:40:29 +08:00
|
|
|
|
onExport: (task: Task) => void;
|
2026-08-11 23:07:26 +08:00
|
|
|
|
onExportScreenshots: (task: Task) => void;
|
2026-08-05 11:40:29 +08:00
|
|
|
|
exportingTaskId: string | null;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
2026-08-12 11:12:23 +08:00
|
|
|
|
const [recoverySort, setRecoverySort] = useState<{
|
|
|
|
|
|
column: RecoverySortKey | null;
|
|
|
|
|
|
direction: SortDirection;
|
|
|
|
|
|
}>({ column: null, direction: "desc" });
|
2026-07-30 12:06:41 +08:00
|
|
|
|
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
|
|
|
|
|
|
if (!selectedTask) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="stack">
|
|
|
|
|
|
<div className="section-intro">
|
|
|
|
|
|
<div><p className="eyebrow">按任务回收</p><h2>选择一个数据回收任务</h2></div>
|
|
|
|
|
|
<p>进入任务后设置自动采集日期,统一查看每篇笔记的最新互动数据。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<TaskScopeList
|
|
|
|
|
|
tasks={tasks}
|
|
|
|
|
|
distributions={distributions}
|
|
|
|
|
|
mode="recovery"
|
|
|
|
|
|
onOpen={setSelectedTaskId}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const taskDistributions = distributions.filter(
|
|
|
|
|
|
(item) => item.task_id === selectedTask.id,
|
|
|
|
|
|
);
|
2026-08-11 23:07:26 +08:00
|
|
|
|
if (selectedTask.task_type === "screenshot_collect") {
|
|
|
|
|
|
const submitted = taskDistributions.filter(
|
|
|
|
|
|
(item) => item.result_submitted_at,
|
|
|
|
|
|
).length;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="stack">
|
|
|
|
|
|
<TaskDetailHeader
|
|
|
|
|
|
task={selectedTask}
|
|
|
|
|
|
label="截图回收"
|
|
|
|
|
|
onBack={() => setSelectedTaskId(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="inline-summary">
|
|
|
|
|
|
<div><span>已领取</span><strong>{taskDistributions.length}</strong></div>
|
|
|
|
|
|
<i />
|
|
|
|
|
|
<div><span>已提交截图</span><strong>{submitted}</strong></div>
|
|
|
|
|
|
<i />
|
|
|
|
|
|
<div><span>待提交</span><strong>{taskDistributions.length - submitted}</strong></div>
|
|
|
|
|
|
<div className="summary-note"><span className="pulse-dot" />截图任务不进入自动采集</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<section className="panel table-panel recovery-panel screenshot-recovery-panel">
|
|
|
|
|
|
<div className="panel-heading">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>截图回收队列</h2>
|
|
|
|
|
|
<p>直接查看每份任务的结果截图;不回收发布链接和互动数据。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="export-data-button"
|
|
|
|
|
|
disabled={exportingTaskId === selectedTask.id || submitted === 0}
|
|
|
|
|
|
onClick={() => onExportScreenshots(selectedTask)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{exportingTaskId === selectedTask.id ? "正在打包…" : "批量下载截图"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ScreenshotTaskTable distributions={taskDistributions} />
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
const failedCollectionCount = taskDistributions.filter(
|
|
|
|
|
|
(item) => item.collection_status === "failed",
|
|
|
|
|
|
).length;
|
2026-08-12 11:12:23 +08:00
|
|
|
|
const sortedTaskDistributions = recoverySort.column
|
|
|
|
|
|
? sortRecoveryDistributions(
|
|
|
|
|
|
taskDistributions,
|
|
|
|
|
|
recoverySort.column,
|
|
|
|
|
|
recoverySort.direction,
|
|
|
|
|
|
)
|
|
|
|
|
|
: taskDistributions;
|
|
|
|
|
|
const changeRecoverySort = (column: RecoverySortKey) => {
|
|
|
|
|
|
setRecoverySort((current) =>
|
|
|
|
|
|
current.column === column
|
|
|
|
|
|
? {
|
|
|
|
|
|
column,
|
|
|
|
|
|
direction: current.direction === "desc" ? "asc" : "desc",
|
|
|
|
|
|
}
|
|
|
|
|
|
: { column, direction: "desc" },
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
2026-07-30 12:06:41 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="stack">
|
|
|
|
|
|
<TaskDetailHeader
|
|
|
|
|
|
task={selectedTask}
|
|
|
|
|
|
label="数据回收"
|
|
|
|
|
|
onBack={() => setSelectedTaskId(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<CollectionScheduleCard
|
|
|
|
|
|
key={`${selectedTask.id}:${selectedTask.collection_start_date}:${selectedTask.collection_days}`}
|
|
|
|
|
|
task={selectedTask}
|
|
|
|
|
|
working={working}
|
|
|
|
|
|
onSave={onSaveSchedule}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<section className="panel table-panel recovery-panel">
|
|
|
|
|
|
<div className="panel-heading">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>数据回收队列</h2>
|
|
|
|
|
|
<p>{taskDistributions.length} 篇已发布笔记 · 展示最近一次采集结果</p>
|
|
|
|
|
|
</div>
|
2026-08-05 11:40:29 +08:00
|
|
|
|
<div className="recovery-panel-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="export-data-button"
|
|
|
|
|
|
disabled={working || exportingTaskId === selectedTask.id}
|
|
|
|
|
|
onClick={() => onExport(selectedTask)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{exportingTaskId === selectedTask.id
|
|
|
|
|
|
? "正在生成 Excel…"
|
|
|
|
|
|
: "导出全部数据"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="retry-failed-button"
|
|
|
|
|
|
disabled={working || failedCollectionCount === 0}
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
onRetryFailed(selectedTask.id, failedCollectionCount)
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
一键补采异常数据
|
|
|
|
|
|
{failedCollectionCount > 0 && <b>{failedCollectionCount}</b>}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="table-scroll">
|
|
|
|
|
|
<table className="data-table recovery-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>内容 / 发布账号</th>
|
2026-08-12 11:12:23 +08:00
|
|
|
|
<SortableRecoveryHeader
|
|
|
|
|
|
label="发布时间"
|
|
|
|
|
|
column="publish_time"
|
|
|
|
|
|
activeColumn={recoverySort.column}
|
|
|
|
|
|
direction={recoverySort.direction}
|
|
|
|
|
|
onSort={changeRecoverySort}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<SortableRecoveryHeader
|
|
|
|
|
|
label="点赞"
|
|
|
|
|
|
column="likes"
|
|
|
|
|
|
activeColumn={recoverySort.column}
|
|
|
|
|
|
direction={recoverySort.direction}
|
|
|
|
|
|
onSort={changeRecoverySort}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<SortableRecoveryHeader
|
|
|
|
|
|
label="收藏"
|
|
|
|
|
|
column="collects"
|
|
|
|
|
|
activeColumn={recoverySort.column}
|
|
|
|
|
|
direction={recoverySort.direction}
|
|
|
|
|
|
onSort={changeRecoverySort}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<SortableRecoveryHeader
|
|
|
|
|
|
label="评论"
|
|
|
|
|
|
column="comments"
|
|
|
|
|
|
activeColumn={recoverySort.column}
|
|
|
|
|
|
direction={recoverySort.direction}
|
|
|
|
|
|
onSort={changeRecoverySort}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<SortableRecoveryHeader
|
|
|
|
|
|
label="总互动"
|
|
|
|
|
|
column="total"
|
|
|
|
|
|
activeColumn={recoverySort.column}
|
|
|
|
|
|
direction={recoverySort.direction}
|
|
|
|
|
|
onSort={changeRecoverySort}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<SortableRecoveryHeader
|
|
|
|
|
|
label="数据更新时间"
|
|
|
|
|
|
column="collection_updated_at"
|
|
|
|
|
|
activeColumn={recoverySort.column}
|
|
|
|
|
|
direction={recoverySort.direction}
|
|
|
|
|
|
onSort={changeRecoverySort}
|
|
|
|
|
|
/>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<th>采集状态</th>
|
|
|
|
|
|
<th>创作者截图</th>
|
|
|
|
|
|
<th>操作</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
2026-08-12 11:12:23 +08:00
|
|
|
|
{sortedTaskDistributions.map((item) => {
|
2026-07-30 12:06:41 +08:00
|
|
|
|
const metrics = latestPublicMetrics(item);
|
|
|
|
|
|
const noteUrl = xhsPublishUrl(item.publish_url);
|
|
|
|
|
|
const hasMetrics = metrics.likes !== null;
|
|
|
|
|
|
const collectionStatus =
|
|
|
|
|
|
item.collection_status ||
|
|
|
|
|
|
(hasMetrics ? "success" : "pending");
|
|
|
|
|
|
return (
|
|
|
|
|
|
<tr key={item.id}>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div className="account-cell recovery-content">
|
|
|
|
|
|
<div className={`avatar small ${avatarColor(item.content_title)}`}>{Array.from(item.content_title)[0] || "笔"}</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{noteUrl ? (
|
|
|
|
|
|
<a
|
|
|
|
|
|
className="recovery-title-link"
|
|
|
|
|
|
href={noteUrl}
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
|
aria-label={`打开小红书笔记:${item.content_title}`}
|
|
|
|
|
|
title="打开小红书笔记"
|
|
|
|
|
|
>
|
|
|
|
|
|
<strong>{item.content_title}</strong>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<strong>{item.content_title}</strong>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{item.account_nickname || "待识别账号"}
|
|
|
|
|
|
{item.account_platform ? ` · ${item.account_platform}` : ""}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div className="metric-cell publish-time">
|
|
|
|
|
|
<strong>{formatDate(item.publish_time, true)}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td><div className="metric-cell"><strong>{formatNumber(metrics.likes)}</strong></div></td>
|
|
|
|
|
|
<td><div className="metric-cell"><strong>{formatNumber(metrics.collects)}</strong></div></td>
|
|
|
|
|
|
<td><div className="metric-cell"><strong>{formatNumber(metrics.comments)}</strong></div></td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div className="metric-cell total">
|
|
|
|
|
|
<strong>{formatNumber(metrics.total)}</strong>
|
|
|
|
|
|
<span>赞 + 藏 + 评</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div className="metric-cell update-time">
|
|
|
|
|
|
<strong>{formatDate(item.collection_updated_at || (hasMetrics ? item.updated_at : null), true)}</strong>
|
|
|
|
|
|
{item.last_collection_day && <span>第{item.last_collection_day}天采集</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div className="collection-status-cell">
|
|
|
|
|
|
<span className={`collection-status ${collectionStatus}`}>
|
|
|
|
|
|
{collectionStatusLabel(collectionStatus, hasMetrics)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<small>
|
|
|
|
|
|
{item.collection_status_description ||
|
|
|
|
|
|
(hasMetrics ? "成功" : "等待采集计划")}
|
|
|
|
|
|
</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div className="creator-screenshot-cell">
|
|
|
|
|
|
{item.screenshot_key ? (
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<CreatorScreenshotPreview
|
|
|
|
|
|
distributionId={item.id}
|
|
|
|
|
|
title={item.content_title}
|
|
|
|
|
|
/>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
) : (
|
|
|
|
|
|
<span className="creator-screenshot-pending">待KOC上传</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{hasCreatorMetrics(item) ? (
|
|
|
|
|
|
<span className="creator-data-summary">
|
|
|
|
|
|
<b>曝光 {formatNumber(item.exposure)}</b>
|
|
|
|
|
|
<b>阅读 {formatNumber(item.views)}</b>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : item.screenshot_key ? (
|
|
|
|
|
|
<span className="creator-ocr-failed">
|
|
|
|
|
|
待KOC填写数据
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{item.screenshot_key &&
|
|
|
|
|
|
!hasCreatorMetrics(item) && (
|
|
|
|
|
|
<label className="upload-button retry-upload">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept="image/*"
|
|
|
|
|
|
onChange={(event) => onUpload(item, event)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
重新上传
|
|
|
|
|
|
</label>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
{item.screenshot_key && !hasCreatorMetrics(item) ? (
|
|
|
|
|
|
<button className="text-button" onClick={() => onFallback(item)}>手动填写</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="collect-button"
|
|
|
|
|
|
disabled={working}
|
|
|
|
|
|
onClick={() => onCollect(item)}
|
|
|
|
|
|
>
|
|
|
|
|
|
立即采集
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
{taskDistributions.length === 0 && (
|
|
|
|
|
|
<div className="table-empty compact">当前任务还没有已发布笔记</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function CollectionScheduleCard({
|
|
|
|
|
|
task,
|
|
|
|
|
|
working,
|
|
|
|
|
|
onSave,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
task: Task;
|
|
|
|
|
|
working: boolean;
|
|
|
|
|
|
onSave: (
|
|
|
|
|
|
taskId: string,
|
|
|
|
|
|
startDate: string,
|
|
|
|
|
|
days: number[],
|
|
|
|
|
|
) => Promise<boolean>;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [startDate, setStartDate] = useState(
|
|
|
|
|
|
task.collection_start_date || todayInputValue(),
|
|
|
|
|
|
);
|
|
|
|
|
|
const [days, setDays] = useState<number[]>(
|
|
|
|
|
|
parseCollectionDays(task.collection_days),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const toggleDay = (day: number) => {
|
|
|
|
|
|
setDays((current) =>
|
|
|
|
|
|
current.includes(day)
|
|
|
|
|
|
? current.filter((item) => item !== day)
|
|
|
|
|
|
: [...current, day].sort((a, b) => a - b),
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section className="panel collection-schedule-panel">
|
|
|
|
|
|
<div className="collection-schedule-heading">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="eyebrow">自动采集计划</p>
|
|
|
|
|
|
<h2>选择开始日期与采集日</h2>
|
2026-08-12 11:12:23 +08:00
|
|
|
|
<span>勾选第1天到第7天,所选日期均在北京时间09:00自动采集。</span>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="schedule-save-area">
|
|
|
|
|
|
<span>已选择 {days.length} 天</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="primary-button"
|
|
|
|
|
|
disabled={working || !startDate || days.length === 0}
|
|
|
|
|
|
onClick={() => void onSave(task.id, startDate, days)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{working ? "保存中…" : "保存采集计划"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="schedule-grid">
|
|
|
|
|
|
<label className="schedule-start">
|
|
|
|
|
|
<span>开始采集日期</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
value={startDate}
|
|
|
|
|
|
onChange={(event) => setStartDate(event.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div className="schedule-days" role="group" aria-label="选择自动采集日">
|
|
|
|
|
|
{Array.from({ length: 7 }, (_, index) => index + 1).map((day) => {
|
|
|
|
|
|
const selected = days.includes(day);
|
|
|
|
|
|
const date = addCalendarDays(startDate, day - 1);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={day}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={selected ? "selected" : ""}
|
|
|
|
|
|
aria-pressed={selected}
|
|
|
|
|
|
onClick={() => toggleDay(day)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>第{day}天</span>
|
|
|
|
|
|
<b aria-hidden="true">{selected ? "✓" : ""}</b>
|
|
|
|
|
|
<small>{date ? date.slice(5).replace("-", "/") : "—"}</small>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="schedule-mobile-hint">左右滑动查看第1—7天</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{task.collection_schedule_updated_at && (
|
|
|
|
|
|
<p className="schedule-updated">
|
|
|
|
|
|
当前计划更新于 {formatDate(task.collection_schedule_updated_at, true)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|