2026-07-30 12:06:41 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
ChangeEvent,
|
|
|
|
|
|
FormEvent,
|
|
|
|
|
|
useCallback,
|
|
|
|
|
|
useEffect,
|
|
|
|
|
|
useMemo,
|
2026-08-15 03:53:09 +08:00
|
|
|
|
useRef,
|
2026-07-30 12:06:41 +08:00
|
|
|
|
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-08-15 03:53:09 +08:00
|
|
|
|
platform: "小红书" | "抖音";
|
|
|
|
|
|
content_format: "image_text" | "video";
|
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;
|
2026-08-15 03:53:09 +08:00
|
|
|
|
latest_publish_url: string;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
ip_location: string;
|
|
|
|
|
|
followers: number;
|
2026-08-15 03:53:09 +08:00
|
|
|
|
gender: string;
|
|
|
|
|
|
bio: string;
|
|
|
|
|
|
tags: string;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
post_count: number;
|
|
|
|
|
|
avg_views: number;
|
2026-08-11 23:07:26 +08:00
|
|
|
|
cooperation_source: string;
|
2026-08-15 03:53:09 +08:00
|
|
|
|
current_contact: 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;
|
2026-08-15 03:53:09 +08:00
|
|
|
|
gender: string;
|
|
|
|
|
|
bio: string;
|
|
|
|
|
|
tags: string[];
|
2026-08-11 23:07:26 +08:00
|
|
|
|
cooperationSource: string;
|
|
|
|
|
|
action: "create" | "update" | "error";
|
|
|
|
|
|
errors: string[];
|
|
|
|
|
|
}>;
|
|
|
|
|
|
truncated: boolean;
|
2026-08-15 03:53:09 +08:00
|
|
|
|
deferredEnrichment?: number;
|
|
|
|
|
|
maxRows?: number;
|
2026-08-11 23:07:26 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
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;
|
2026-08-15 03:53:09 +08:00
|
|
|
|
latest_shares: number | null;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
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;
|
2026-08-15 03:53:09 +08:00
|
|
|
|
claimant_name: string | null;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
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-08-15 03:53:09 +08:00
|
|
|
|
task_platform: "小红书" | "抖音";
|
|
|
|
|
|
content_format: "image_text" | "video";
|
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"
|
2026-08-15 03:53:09 +08:00
|
|
|
|
| "shares"
|
2026-08-12 11:12:23 +08:00
|
|
|
|
| "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;
|
2026-08-15 03:53:09 +08:00
|
|
|
|
imageCount: number;
|
|
|
|
|
|
videoCount: number;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 03:53:09 +08:00
|
|
|
|
function resourceProfileLink(account: Account) {
|
|
|
|
|
|
const storedProfileUrl = account.profile_url?.trim();
|
|
|
|
|
|
if (account.platform !== "抖音") {
|
|
|
|
|
|
return storedProfileUrl
|
|
|
|
|
|
? { href: storedProfileUrl, label: "查看主页 ↗" }
|
|
|
|
|
|
: null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const publicAccountId = account.public_account_id?.trim();
|
|
|
|
|
|
const hasResolvedPublicId = Boolean(
|
|
|
|
|
|
publicAccountId && publicAccountId !== "待识别",
|
|
|
|
|
|
);
|
|
|
|
|
|
if (storedProfileUrl && hasResolvedPublicId) {
|
|
|
|
|
|
return { href: storedProfileUrl, label: "查看主页 ↗" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const latestPublishUrl = account.latest_publish_url?.trim();
|
|
|
|
|
|
if (latestPublishUrl) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
href: latestPublishUrl,
|
|
|
|
|
|
label: "通过作品查看主页 ↗",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const keyword = hasResolvedPublicId ? publicAccountId : account.nickname.trim();
|
|
|
|
|
|
return keyword
|
|
|
|
|
|
? {
|
|
|
|
|
|
href: `https://www.douyin.com/search/${encodeURIComponent(keyword)}?type=user`,
|
|
|
|
|
|
label: "搜索主页 ↗",
|
|
|
|
|
|
}
|
|
|
|
|
|
: null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function PlatformBadge({
|
|
|
|
|
|
platform,
|
|
|
|
|
|
compact = false,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
platform: string | null | undefined;
|
|
|
|
|
|
compact?: boolean;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const normalized = platform === "抖音" ? "抖音" : platform === "小红书" ? "小红书" : "其他";
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className={`platform-badge ${compact ? "compact" : ""}`}>
|
|
|
|
|
|
<span className={`platform-logo ${normalized === "抖音" ? "douyin" : normalized === "小红书" ? "xiaohongshu" : "other"}`} aria-hidden="true">
|
|
|
|
|
|
{normalized === "抖音" ? (
|
|
|
|
|
|
<svg viewBox="0 0 24 24" focusable="false">
|
|
|
|
|
|
<path className="douyin-cyan" d="M14.2 3.2v9.5a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V3.2h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1V10c-1.7-.1-3.2-.7-4.3-1.7V3.2Z" />
|
|
|
|
|
|
<path className="douyin-red" d="M15.2 2.5V12a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V2.5h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1v2.7c-1.7-.1-3.2-.7-4.3-1.7V2.5Z" />
|
|
|
|
|
|
<path className="douyin-white" d="M14.7 2.9v9.5a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V2.9h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1v2.7c-1.7-.1-3.2-.7-4.3-1.7V2.9Z" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
) : normalized === "小红书" ? (
|
|
|
|
|
|
<b>小红书</b>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<b>媒</b>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span>{platform || "未知平台"}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 12:06:41 +08:00
|
|
|
|
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);
|
2026-08-15 03:53:09 +08:00
|
|
|
|
const shares = distribution.latest_shares ?? 0;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
return {
|
|
|
|
|
|
likes,
|
|
|
|
|
|
comments,
|
|
|
|
|
|
collects,
|
2026-08-15 03:53:09 +08:00
|
|
|
|
shares,
|
2026-07-30 12:06:41 +08:00
|
|
|
|
total:
|
|
|
|
|
|
likes === null
|
|
|
|
|
|
? null
|
2026-08-15 03:53:09 +08:00
|
|
|
|
: likes + (comments ?? 0) + (collects ?? 0) + shares,
|
2026-07-30 12:06:41 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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;
|
2026-08-15 03:53:09 +08:00
|
|
|
|
if (key === "shares") return metrics.shares;
|
2026-08-12 11:12:23 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 03:53:09 +08:00
|
|
|
|
function publicPublishUrl(value: string | null) {
|
2026-07-30 12:06:41 +08:00
|
|
|
|
if (!value) return null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = new URL(value);
|
|
|
|
|
|
const hostname = url.hostname.toLowerCase();
|
2026-08-15 03:53:09 +08:00
|
|
|
|
const isSupportedHost =
|
2026-07-30 12:06:41 +08:00
|
|
|
|
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" ||
|
2026-08-15 03:53:09 +08:00
|
|
|
|
hostname.endsWith(".xhslink.com") ||
|
|
|
|
|
|
hostname === "douyin.com" ||
|
|
|
|
|
|
hostname.endsWith(".douyin.com");
|
|
|
|
|
|
return ["http:", "https:"].includes(url.protocol) && isSupportedHost
|
2026-07-30 12:06:41 +08:00
|
|
|
|
? 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);
|
2026-08-19 15:08:17 +08:00
|
|
|
|
const [collectingDistributionIds, setCollectingDistributionIds] = useState<Set<string>>(
|
|
|
|
|
|
new Set(),
|
|
|
|
|
|
);
|
2026-07-30 12:06:41 +08:00
|
|
|
|
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-08-15 03:53:09 +08:00
|
|
|
|
platform: "小红书" as "小红书" | "抖音",
|
|
|
|
|
|
contentFormat: "image_text" as "image_text" | "video",
|
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-15 03:53:09 +08:00
|
|
|
|
if (
|
|
|
|
|
|
taskForm.taskType === "content_publish" &&
|
|
|
|
|
|
taskForm.contentFormat === "video" &&
|
|
|
|
|
|
(sourcePreview?.videoCount ?? 0) === 0
|
|
|
|
|
|
) {
|
|
|
|
|
|
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-08-15 03:53:09 +08:00
|
|
|
|
platform: "小红书",
|
|
|
|
|
|
contentFormat: "image_text",
|
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篇$/, "分发任务"),
|
|
|
|
|
|
}));
|
2026-08-15 03:53:09 +08:00
|
|
|
|
setToast(
|
|
|
|
|
|
`已读取 ${result.rowCount} 条有效内容,识别到 ${result.videoCount} 个视频`,
|
|
|
|
|
|
);
|
2026-07-30 12:06:41 +08:00
|
|
|
|
} 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) => {
|
2026-08-19 15:08:17 +08:00
|
|
|
|
setCollectingDistributionIds((current) => {
|
|
|
|
|
|
const next = new Set(current);
|
|
|
|
|
|
next.add(distribution.id);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
try {
|
|
|
|
|
|
await runAction(
|
|
|
|
|
|
{ action: "collect_now", distributionId: distribution.id },
|
|
|
|
|
|
"公开数据已更新",
|
|
|
|
|
|
);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setCollectingDistributionIds((current) => {
|
|
|
|
|
|
const next = new Set(current);
|
|
|
|
|
|
next.delete(distribution.id);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-15 03:53:09 +08:00
|
|
|
|
const updateDistributionPublishUrl = async (
|
|
|
|
|
|
distribution: Distribution,
|
|
|
|
|
|
) => {
|
|
|
|
|
|
const publishInput = window.prompt(
|
|
|
|
|
|
distribution.publish_url
|
|
|
|
|
|
? "更新小红书笔记链接(支持长链、短链或完整分享文案)"
|
|
|
|
|
|
: "填写小红书笔记链接(支持长链、短链或完整分享文案)",
|
|
|
|
|
|
distribution.publish_url || "",
|
|
|
|
|
|
);
|
|
|
|
|
|
if (publishInput === null) return;
|
|
|
|
|
|
if (!publishInput.trim()) {
|
|
|
|
|
|
setToast("请填写笔记链接");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await runAction(
|
|
|
|
|
|
{
|
|
|
|
|
|
action: "update_distribution_publish_url",
|
|
|
|
|
|
distributionId: distribution.id,
|
|
|
|
|
|
publishUrl: publishInput.trim(),
|
|
|
|
|
|
},
|
|
|
|
|
|
distribution.publish_url
|
|
|
|
|
|
? "笔记链接已更新,旧公开采集数据已清空"
|
|
|
|
|
|
: "笔记链接已补充,可开始数据采集",
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-30 12:06:41 +08:00
|
|
|
|
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}
|
2026-08-19 15:08:17 +08:00
|
|
|
|
collectingDistributionIds={collectingDistributionIds}
|
2026-08-15 03:53:09 +08:00
|
|
|
|
canUpdatePublishUrl={isManager}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
onCollect={collectMetrics}
|
2026-08-15 03:53:09 +08:00
|
|
|
|
onUpdatePublishUrl={updateDistributionPublishUrl}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
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" ? (
|
|
|
|
|
|
<>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<div className="form-grid two">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<span className="platform-meta-line">
|
|
|
|
|
|
<span>发布平台</span>
|
|
|
|
|
|
<PlatformBadge platform={taskForm.platform} compact />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={taskForm.platform}
|
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
|
setTaskForm({
|
|
|
|
|
|
...taskForm,
|
|
|
|
|
|
platform: event.target.value as "小红书" | "抖音",
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="小红书">小红书</option>
|
|
|
|
|
|
<option value="抖音">抖音</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<span>内容类型</span>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={taskForm.contentFormat}
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
setTaskForm({
|
|
|
|
|
|
...taskForm,
|
|
|
|
|
|
contentFormat: event.target.value as "image_text" | "video",
|
|
|
|
|
|
});
|
|
|
|
|
|
setSourcePreview(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="image_text">图文</option>
|
|
|
|
|
|
<option value="video">视频</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<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-15 03:53:09 +08:00
|
|
|
|
<small>
|
|
|
|
|
|
{taskForm.contentFormat === "video"
|
|
|
|
|
|
? "自动读取标题、正文和视频附件;空标题或无视频的行不会进入任务。"
|
|
|
|
|
|
: "自动读取标题、正文/笔记内容、标签和全部配图;空标题行不会进入任务。"}
|
|
|
|
|
|
</small>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
</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>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<b>
|
|
|
|
|
|
{sourcePreview.rowCount} 条 · {taskForm.contentFormat === "video"
|
|
|
|
|
|
? `${sourcePreview.videoCount} 个视频`
|
|
|
|
|
|
: `${sourcePreview.imageCount} 张图片`}
|
|
|
|
|
|
</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-15 03:53:09 +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>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<div>
|
|
|
|
|
|
<strong>{task.name}</strong>
|
|
|
|
|
|
<span className="platform-meta-line">
|
|
|
|
|
|
<span>{task.brand}</span>
|
|
|
|
|
|
<PlatformBadge platform={task.platform} compact />
|
|
|
|
|
|
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<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-15 03:53:09 +08:00
|
|
|
|
<small className="platform-meta-line">
|
|
|
|
|
|
{task.task_type === "screenshot_collect" ? (
|
|
|
|
|
|
<span>截图回收 · 关键词任务</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<PlatformBadge platform={task.platform} compact />
|
|
|
|
|
|
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
|
|
|
|
|
|
<span>{task.source_sheet_name ? `飞书 ${task.source_sheet_name}` : "历史示例内容表"}</span>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-08-11 23:07:26 +08:00
|
|
|
|
</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>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<div className="task-scope-subline">
|
|
|
|
|
|
<span>{task.brand} · {formatDate(task.due_at)} 截止</span>
|
|
|
|
|
|
<PlatformBadge platform={task.platform} compact />
|
|
|
|
|
|
<span className={`content-format-badge ${isScreenshotTask ? "screenshot" : task.content_format === "video" ? "video" : "image-text"}`}>
|
|
|
|
|
|
{isScreenshotTask ? "截图回收" : task.content_format === "video" ? "视频" : "图文"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</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-15 03:53:09 +08:00
|
|
|
|
<span className="platform-meta-line">
|
|
|
|
|
|
<span>{task.brand}</span>
|
|
|
|
|
|
{task.task_type === "screenshot_collect" ? (
|
|
|
|
|
|
<span>截图回收</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<PlatformBadge platform={task.platform} compact />
|
|
|
|
|
|
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span>{task.quantity} {task.task_type === "screenshot_collect" ? "份" : "篇"}</span>
|
|
|
|
|
|
<span>{formatDate(task.due_at)} 截止</span>
|
|
|
|
|
|
</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-15 03:53:09 +08:00
|
|
|
|
const [taskQuery, setTaskQuery] = useState("");
|
|
|
|
|
|
const [brandFilter, setBrandFilter] = useState("");
|
|
|
|
|
|
const [contentTypeFilter, setContentTypeFilter] = useState("");
|
|
|
|
|
|
const [platformFilter, setPlatformFilter] = useState("");
|
|
|
|
|
|
const [openTaskFilter, setOpenTaskFilter] = useState<"brand" | "content" | "platform" | null>(null);
|
|
|
|
|
|
const taskFiltersRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
const brandOptions = useMemo(
|
|
|
|
|
|
() => Array.from(new Set(tasks.map((task) => task.brand).filter(Boolean))).sort((a, b) => a.localeCompare(b, "zh-CN")),
|
|
|
|
|
|
[tasks],
|
|
|
|
|
|
);
|
|
|
|
|
|
const brandFilterOptions = useMemo(
|
|
|
|
|
|
() => [{ value: "", label: "全部品牌/项目" }, ...brandOptions.map((brand) => ({ value: brand, label: brand }))],
|
|
|
|
|
|
[brandOptions],
|
|
|
|
|
|
);
|
|
|
|
|
|
const contentTypeOptions = [
|
|
|
|
|
|
{ value: "", label: "全部内容类型" },
|
|
|
|
|
|
{ value: "图文", label: "图文" },
|
|
|
|
|
|
{ value: "视频", label: "视频" },
|
|
|
|
|
|
{ value: "截图回收", label: "截图回收" },
|
|
|
|
|
|
];
|
|
|
|
|
|
const platformOptions = [
|
|
|
|
|
|
{ value: "", label: "全部平台" },
|
|
|
|
|
|
{ value: "小红书", label: "小红书" },
|
|
|
|
|
|
{ value: "抖音", label: "抖音" },
|
|
|
|
|
|
];
|
|
|
|
|
|
const taskFilterControls = [
|
|
|
|
|
|
{ key: "brand" as const, label: "品牌/项目", value: brandFilter, options: brandFilterOptions },
|
|
|
|
|
|
{ key: "content" as const, label: "内容类型", value: contentTypeFilter, options: contentTypeOptions },
|
|
|
|
|
|
{ key: "platform" as const, label: "平台", value: platformFilter, options: platformOptions },
|
|
|
|
|
|
];
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const closeOnOutsideClick = (event: MouseEvent) => {
|
|
|
|
|
|
if (!taskFiltersRef.current?.contains(event.target as Node)) {
|
|
|
|
|
|
setOpenTaskFilter(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
document.addEventListener("mousedown", closeOnOutsideClick);
|
|
|
|
|
|
return () => document.removeEventListener("mousedown", closeOnOutsideClick);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
const setTaskFilterValue = (key: "brand" | "content" | "platform", value: string) => {
|
|
|
|
|
|
if (key === "brand") setBrandFilter(value);
|
|
|
|
|
|
if (key === "content") setContentTypeFilter(value);
|
|
|
|
|
|
if (key === "platform") setPlatformFilter(value);
|
|
|
|
|
|
};
|
|
|
|
|
|
const selectTaskFilter = (key: "brand" | "content" | "platform", value: string) => {
|
|
|
|
|
|
setTaskFilterValue(key, value);
|
|
|
|
|
|
setOpenTaskFilter(null);
|
|
|
|
|
|
};
|
|
|
|
|
|
const filteredTasks = useMemo(() => {
|
|
|
|
|
|
const query = taskQuery.trim().toLocaleLowerCase("zh-CN");
|
|
|
|
|
|
const brandQuery = brandFilter.trim().toLocaleLowerCase("zh-CN");
|
|
|
|
|
|
const contentTypeQuery = contentTypeFilter.trim().toLocaleLowerCase("zh-CN");
|
|
|
|
|
|
const platformQuery = platformFilter.trim().toLocaleLowerCase("zh-CN");
|
|
|
|
|
|
return tasks.filter((task) => {
|
|
|
|
|
|
const taskContentType = task.task_type === "screenshot_collect"
|
|
|
|
|
|
? "截图回收"
|
|
|
|
|
|
: task.content_format === "video" ? "视频" : "图文";
|
|
|
|
|
|
return (
|
|
|
|
|
|
(!query || task.name.toLocaleLowerCase("zh-CN").includes(query)) &&
|
|
|
|
|
|
(!brandQuery || task.brand.toLocaleLowerCase("zh-CN").includes(brandQuery)) &&
|
|
|
|
|
|
(!contentTypeQuery || taskContentType.toLocaleLowerCase("zh-CN").includes(contentTypeQuery)) &&
|
|
|
|
|
|
(!platformQuery || task.platform.toLocaleLowerCase("zh-CN").includes(platformQuery))
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [brandFilter, contentTypeFilter, platformFilter, taskQuery, tasks]);
|
|
|
|
|
|
const hasTaskFilters = Boolean(
|
|
|
|
|
|
taskQuery.trim() || brandFilter.trim() || contentTypeFilter.trim() || platformFilter.trim(),
|
|
|
|
|
|
);
|
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>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<section className="distribution-task-toolbar" aria-label="筛选分发任务">
|
|
|
|
|
|
<label className="distribution-task-search">
|
|
|
|
|
|
<span aria-hidden="true">⌕</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
value={taskQuery}
|
|
|
|
|
|
onChange={(event) => setTaskQuery(event.target.value)}
|
|
|
|
|
|
placeholder="搜索任务名称"
|
|
|
|
|
|
aria-label="按任务名称模糊搜索"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div className="distribution-task-filter-group" ref={taskFiltersRef}>
|
|
|
|
|
|
{taskFilterControls.map((filter) => {
|
|
|
|
|
|
const normalizedFilterValue = filter.value.trim().toLocaleLowerCase("zh-CN");
|
|
|
|
|
|
const visibleOptions = filter.options.filter((option) => (
|
|
|
|
|
|
!normalizedFilterValue ||
|
|
|
|
|
|
option.label.toLocaleLowerCase("zh-CN").includes(normalizedFilterValue)
|
|
|
|
|
|
));
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`distribution-task-filter-combobox ${filter.key === "brand" ? "brand" : ""}`}
|
|
|
|
|
|
key={filter.key}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="distribution-task-filter-input">
|
|
|
|
|
|
<span aria-hidden="true">⌕</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
role="combobox"
|
|
|
|
|
|
aria-autocomplete="list"
|
|
|
|
|
|
aria-expanded={openTaskFilter === filter.key}
|
|
|
|
|
|
aria-controls={`distribution-task-${filter.key}-menu`}
|
|
|
|
|
|
value={filter.value}
|
|
|
|
|
|
placeholder={`搜索${filter.label}`}
|
|
|
|
|
|
aria-label={`模糊搜索${filter.label}`}
|
|
|
|
|
|
onFocus={() => setOpenTaskFilter(filter.key)}
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
setTaskFilterValue(filter.key, event.target.value);
|
|
|
|
|
|
setOpenTaskFilter(filter.key);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onKeyDown={(event) => {
|
|
|
|
|
|
if (event.key === "Escape") setOpenTaskFilter(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{openTaskFilter === filter.key ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
id={`distribution-task-${filter.key}-menu`}
|
|
|
|
|
|
className="distribution-task-filter-menu"
|
|
|
|
|
|
role="listbox"
|
|
|
|
|
|
aria-label={`选择${filter.label}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="distribution-task-filter-menu-options">
|
|
|
|
|
|
{visibleOptions.map((option) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={option.value}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="option"
|
|
|
|
|
|
aria-selected={filter.value === option.value}
|
|
|
|
|
|
className={filter.value === option.value ? "selected" : ""}
|
|
|
|
|
|
onClick={() => selectTaskFilter(filter.key, option.value)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>{option.label}</span>
|
|
|
|
|
|
{filter.value === option.value ? <small>✓</small> : null}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{visibleOptions.length === 0 ? (
|
|
|
|
|
|
<span className="distribution-task-filter-empty">没有匹配项</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="distribution-task-clear"
|
|
|
|
|
|
disabled={!hasTaskFilters}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setTaskQuery("");
|
|
|
|
|
|
setBrandFilter("");
|
|
|
|
|
|
setContentTypeFilter("");
|
|
|
|
|
|
setPlatformFilter("");
|
|
|
|
|
|
setOpenTaskFilter(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
清空筛选
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span className="distribution-task-count">共 <b>{filteredTasks.length}</b> 个任务</span>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
{filteredTasks.length > 0 ? (
|
|
|
|
|
|
<TaskScopeList
|
|
|
|
|
|
tasks={filteredTasks}
|
|
|
|
|
|
distributions={distributions}
|
|
|
|
|
|
mode="distribution"
|
|
|
|
|
|
onOpen={setSelectedTaskId}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<section className="distribution-task-empty">
|
|
|
|
|
|
<strong>没有符合条件的任务</strong>
|
|
|
|
|
|
<span>可以调整任务名称或清空筛选条件</span>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</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>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<div><strong>{item.account_nickname}</strong><PlatformBadge platform={item.account_platform} compact /></div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</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
|
|
|
|
[
|
2026-08-15 03:53:09 +08:00
|
|
|
|
...deliveries
|
|
|
|
|
|
.filter(
|
|
|
|
|
|
(item) =>
|
|
|
|
|
|
!item.claimant_name ||
|
|
|
|
|
|
item.partner_name !== item.claimant_name,
|
|
|
|
|
|
)
|
|
|
|
|
|
.map((item) => item.partner_name),
|
2026-08-11 23:07:26 +08:00
|
|
|
|
...(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(
|
2026-08-15 03:53:09 +08:00
|
|
|
|
() => [
|
|
|
|
|
|
"小红书",
|
|
|
|
|
|
"抖音",
|
|
|
|
|
|
...[...new Set(accounts.map((account) => account.platform).filter(Boolean))]
|
|
|
|
|
|
.filter((platform) => platform !== "小红书" && platform !== "抖音")
|
2026-08-06 10:18:23 +08:00
|
|
|
|
.sort((left, right) => left.localeCompare(right, "zh-CN")),
|
2026-08-15 03:53:09 +08:00
|
|
|
|
],
|
2026-08-06 10:18:23 +08:00
|
|
|
|
[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 }) => {
|
2026-08-15 03:53:09 +08:00
|
|
|
|
const searchable = `${account.nickname} ${account.public_account_id} ${account.current_contact || ""} ${account.bio || ""} ${account.tags || ""}`.toLocaleLowerCase(
|
2026-08-06 10:18:23 +08:00
|
|
|
|
"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)}
|
|
|
|
|
|
>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<PlatformBadge platform={platform} compact />
|
2026-08-11 23:07:26 +08:00
|
|
|
|
</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
|
2026-08-15 03:53:09 +08:00
|
|
|
|
aria-label="搜索账号名称、账号ID或当前联系人"
|
2026-08-06 10:18:23 +08:00
|
|
|
|
onChange={(event) => setQuery(event.target.value)}
|
2026-08-15 03:53:09 +08:00
|
|
|
|
placeholder="搜索账号名称 / 账号ID / 当前联系人 / 标签"
|
2026-08-06 10:18:23 +08:00
|
|
|
|
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-15 03:53:09 +08:00
|
|
|
|
{filteredAccounts.map(({ account, sources, partnerManagedOnly }) => {
|
|
|
|
|
|
const profileLink = resourceProfileLink(account);
|
|
|
|
|
|
const accountTags = (account.tags || "")
|
|
|
|
|
|
.split(/[,,、;;|]/)
|
|
|
|
|
|
.map((tag) => tag.trim())
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.slice(0, 5);
|
|
|
|
|
|
const rawBio = (account.bio || "").trim();
|
|
|
|
|
|
const hasBio = Boolean(
|
|
|
|
|
|
rawBio &&
|
|
|
|
|
|
!/^(未填写简介|还没有简介|暂无简介|未填写|待识别)$/u.test(rawBio),
|
|
|
|
|
|
);
|
|
|
|
|
|
const accountIdLabel =
|
|
|
|
|
|
account.platform === "小红书"
|
|
|
|
|
|
? "小红书号"
|
|
|
|
|
|
: account.platform === "抖音"
|
|
|
|
|
|
? "抖音号"
|
|
|
|
|
|
: "账号";
|
|
|
|
|
|
return (
|
|
|
|
|
|
<article
|
|
|
|
|
|
className={`resource-card ${account.platform === "小红书" ? "platform-xhs" : account.platform === "抖音" ? "platform-douyin" : "platform-other"}`}
|
|
|
|
|
|
key={account.id}
|
|
|
|
|
|
>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<div className="resource-card-head">
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<div className={`avatar xlarge resource-profile-avatar ${avatarColor(account.nickname)}`}>{account.nickname.slice(0, 1)}</div>
|
|
|
|
|
|
<div className="resource-profile-main">
|
|
|
|
|
|
<div className="resource-name-line">
|
|
|
|
|
|
<h3>{account.nickname}</h3>
|
|
|
|
|
|
{account.gender === "男" && (
|
|
|
|
|
|
<span className="gender-icon male" aria-label="男性" title="男性">♂</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{account.gender === "女" && (
|
|
|
|
|
|
<span className="gender-icon female" aria-label="女性" title="女性">♀</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="resource-account-number">
|
|
|
|
|
|
<span>{accountIdLabel}</span>
|
|
|
|
|
|
<strong>{account.public_account_id || "待识别"}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="resource-platform-line">
|
|
|
|
|
|
<PlatformBadge platform={account.platform} compact />
|
|
|
|
|
|
<span className="resource-location">IP属地 · {account.ip_location || "待识别"}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="resource-current-contact">
|
|
|
|
|
|
<span>当前联系人</span>
|
|
|
|
|
|
<strong>{account.current_contact || "待补充"}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<p className={`resource-bio ${hasBio ? "" : "empty"}`} title={hasBio ? rawBio : undefined}>
|
|
|
|
|
|
{hasBio ? rawBio : "暂无简介"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className={`resource-tags ${accountTags.length === 0 ? "empty" : ""}`}>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{accountTags.length > 0
|
|
|
|
|
|
? accountTags.map((tag) => <b key={tag}>{tag}</b>)
|
|
|
|
|
|
: <b>待打标</b>}
|
|
|
|
|
|
</div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<div className="resource-metrics" aria-label="账号数据">
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<div><span>粉丝</span><strong>{formatNumber(account.followers)}</strong></div>
|
|
|
|
|
|
<div><span>合作发布</span><strong>{account.post_count}</strong></div>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<div className="resource-latest"><span>最近合作</span><strong>{formatDate(account.last_seen_at)}</strong></div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="resource-card-foot">
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<div className="resource-source">
|
|
|
|
|
|
<span>合作来源</span>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{sources.map((source) => <b key={source}>{source}</b>)}
|
|
|
|
|
|
{partnerManagedOnly && (
|
|
|
|
|
|
<b className="partner-managed">合作社资源 · 不可直联</b>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{sources.length === 0 && !partnerManagedOnly && <b className="empty">暂无</b>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{profileLink && <a href={profileLink.href} target="_blank" rel="noreferrer">{profileLink.label}</a>}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</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">
|
2026-08-12 17:51:57 +08:00
|
|
|
|
<span>1</span><p>账号链接必填,其余资料选填</p>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<i />
|
2026-08-12 17:51:57 +08:00
|
|
|
|
<span>2</span><p>系统只补全空缺的公开资料</p>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<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>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<span>单次最多 10,000 个账号,文件不超过 20MB</span>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
{!importPreview && (
|
|
|
|
|
|
<div className="import-template-note">
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<div><strong>还没有模板?</strong><span>只有账号链接必填;性别、简介、标签等资料均可选填,多个标签请用逗号分隔且最多 5 个,系统只补全空缺字段。</span></div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<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>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<span><strong>{row.nickname || "—"}</strong><small><PlatformBadge platform={row.platform} compact /></small></span>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<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) && (
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<p className="import-preview-more">这里只预览 8 行,异常数据会优先展示;确认导入时会处理全部有效数据。</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{importPreview.summary.error > 0 && (
|
|
|
|
|
|
<p className="import-preview-warning">
|
|
|
|
|
|
{importPreview.summary.error} 条异常数据将自动跳过,不会导入;其余 {importPreview.summary.create + importPreview.summary.update} 个有效账号可正常导入。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{Boolean(importPreview.deferredEnrichment) && (
|
|
|
|
|
|
<p className="import-preview-more">
|
|
|
|
|
|
其中 {importPreview.deferredEnrichment} 个账号缺少公开资料;确认后会先导入资源库,再在后台逐步补全。
|
|
|
|
|
|
</p>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</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"
|
2026-08-15 03:53:09 +08:00
|
|
|
|
disabled={importPreview.summary.create + importPreview.summary.update === 0 || importWorking}
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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,
|
2026-08-19 15:08:17 +08:00
|
|
|
|
collectingDistributionIds,
|
2026-08-15 03:53:09 +08:00
|
|
|
|
canUpdatePublishUrl,
|
2026-07-30 12:06:41 +08:00
|
|
|
|
onCollect,
|
2026-08-15 03:53:09 +08:00
|
|
|
|
onUpdatePublishUrl,
|
2026-07-30 12:06:41 +08:00
|
|
|
|
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;
|
2026-08-19 15:08:17 +08:00
|
|
|
|
collectingDistributionIds: ReadonlySet<string>;
|
2026-08-15 03:53:09 +08:00
|
|
|
|
canUpdatePublishUrl: boolean;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
onCollect: (distribution: Distribution) => void;
|
2026-08-15 03:53:09 +08:00
|
|
|
|
onUpdatePublishUrl: (distribution: Distribution) => void;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
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>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<p>{taskDistributions.length} 篇已发布作品 · 展示最近一次采集结果</p>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</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}
|
|
|
|
|
|
/>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
{selectedTask.platform === "抖音" && (
|
|
|
|
|
|
<SortableRecoveryHeader
|
|
|
|
|
|
label="转发"
|
|
|
|
|
|
column="shares"
|
|
|
|
|
|
activeColumn={recoverySort.column}
|
|
|
|
|
|
direction={recoverySort.direction}
|
|
|
|
|
|
onSort={changeRecoverySort}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-08-12 11:12:23 +08:00
|
|
|
|
<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);
|
2026-08-15 03:53:09 +08:00
|
|
|
|
const noteUrl = publicPublishUrl(item.publish_url);
|
2026-07-30 12:06:41 +08:00
|
|
|
|
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"
|
2026-08-15 03:53:09 +08:00
|
|
|
|
aria-label={`打开${selectedTask.platform}作品:${item.content_title}`}
|
|
|
|
|
|
title={`打开${selectedTask.platform}作品`}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
>
|
|
|
|
|
|
<strong>{item.content_title}</strong>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<strong>{item.content_title}</strong>
|
|
|
|
|
|
)}
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<span className="platform-meta-line">
|
|
|
|
|
|
<span>{item.account_nickname || "待识别账号"}</span>
|
|
|
|
|
|
{item.account_platform && <PlatformBadge platform={item.account_platform} compact />}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</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>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
{selectedTask.platform === "抖音" && (
|
|
|
|
|
|
<td><div className="metric-cell"><strong>{formatNumber(metrics.shares)}</strong></div></td>
|
|
|
|
|
|
)}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<td><div className="metric-cell"><strong>{formatNumber(metrics.comments)}</strong></div></td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div className="metric-cell total">
|
|
|
|
|
|
<strong>{formatNumber(metrics.total)}</strong>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<span>{selectedTask.platform === "抖音" ? "赞 + 藏 + 转 + 评" : "赞 + 藏 + 评"}</span>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</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">
|
2026-08-19 15:08:17 +08:00
|
|
|
|
{item.ocr_status === "processing"
|
|
|
|
|
|
? "正在识别数据"
|
|
|
|
|
|
: "识别失败,请手动填写"}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</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>
|
2026-08-15 03:53:09 +08:00
|
|
|
|
<div className="recovery-row-actions">
|
|
|
|
|
|
{item.screenshot_key && !hasCreatorMetrics(item) ? (
|
|
|
|
|
|
<button className="text-button" onClick={() => onFallback(item)}>手动填写</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="collect-button"
|
2026-08-19 15:08:17 +08:00
|
|
|
|
disabled={
|
|
|
|
|
|
!noteUrl ||
|
|
|
|
|
|
(working &&
|
|
|
|
|
|
(collectingDistributionIds.size === 0 ||
|
|
|
|
|
|
collectingDistributionIds.has(item.id)))
|
|
|
|
|
|
}
|
2026-08-15 03:53:09 +08:00
|
|
|
|
onClick={() => onCollect(item)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{noteUrl ? "立即采集" : "待填链接"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{canUpdatePublishUrl && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="publish-url-edit-button"
|
|
|
|
|
|
disabled={working}
|
|
|
|
|
|
onClick={() => onUpdatePublishUrl(item)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{noteUrl ? "更新链接" : "填写链接"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|