Files
koc-loop/app/admin-app.tsx

6267 lines
228 KiB
TypeScript
Raw Normal View History

2026-07-30 12:06:41 +08:00
"use client";
import {
ChangeEvent,
FormEvent,
useCallback,
useEffect,
useMemo,
useRef,
2026-07-30 12:06:41 +08:00
useState,
} from "react";
import {
formatShanghaiDate as formatDate,
formatShanghaiToday,
} from "../lib/date-utils";
import type { AuthUser } from "../lib/user-auth";
import { parseResultScreenshotKeys } from "../lib/result-screenshots";
import { platformFromProfileUrl } from "../lib/resource-import";
import {
sortWithNullsLast,
type SortDirection,
} from "../lib/sort-utils";
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;
wecom_external_user_id?: string | null;
partner_type?: string;
wecom_userid?: string;
2026-07-30 12:06:41 +08:00
};
type Task = {
id: string;
name: string;
brand: string;
quantity: number;
claimed_quantity: number;
due_at: string;
status: string;
task_type: "content_publish" | "screenshot_collect";
platform: "小红书" | "抖音";
content_format: "image_text" | "video";
project_id?: string;
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;
latest_publish_url: string;
2026-07-30 12:06:41 +08:00
ip_location: string;
followers: number;
gender: string;
bio: string;
tags: string;
2026-07-30 12:06:41 +08:00
post_count: number;
avg_views: number;
cooperation_source: string;
current_contact: string;
2026-07-30 12:06:41 +08:00
first_seen_at: string;
last_seen_at: string;
};
type FieldRefreshStatus = {
status: "running" | "done";
fields: string[];
total: number;
processed: number;
updated: number;
failed: number;
skipped: number;
startedAt: string;
finishedAt: string | null;
};
const REFRESH_FIELD_OPTIONS = [
{ key: "publicAccountId", label: "账号ID(小红书号/抖音号)" },
{ key: "nickname", label: "昵称" },
{ key: "followers", label: "粉丝数" },
{ key: "ipLocation", label: "IP属地" },
{ key: "gender", label: "性别" },
{ key: "bio", label: "简介" },
{ key: "tags", label: "标签(AI 重新打标,覆盖已有标签)" },
] as const;
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;
gender: string;
bio: string;
tags: string[];
cooperationSource: string;
action: "create" | "update" | "error";
errors: string[];
}>;
truncated: boolean;
deferredEnrichment?: number;
maxRows?: number;
};
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;
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;
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;
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;
task_type: "content_publish" | "screenshot_collect";
task_platform: "小红书" | "抖音";
content_format: "image_text" | "video";
2026-07-30 12:06:41 +08:00
due_at: string;
};
type RecoverySortKey =
| "publish_time"
| "likes"
| "collects"
| "shares"
| "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;
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"
| "resources"
| "partners"
| "recovery"
| "reports"
| "users";
2026-07-30 12:06:41 +08:00
const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
{ key: "overview", label: "工作台", mark: "⌂" },
{ key: "tasks", label: "任务中心", mark: "▤" },
2026-07-30 12:06:41 +08:00
{ key: "resources", label: "KOC资源", mark: "◎" },
{ key: "partners", label: "合作方", mark: "Ⓒ" },
2026-07-30 12:06:41 +08:00
{ key: "recovery", label: "数据回收", mark: "◫" },
{ key: "reports", label: "复盘报表", mark: "▣" },
{ key: "users", label: "用户管理", mark: "♙" },
2026-07-30 12:06:41 +08:00
];
const EMPTY_DATA: DashboardData = {
partners: [],
tasks: [],
accounts: [],
distributions: [],
portal_url: "",
};
async function readApiResponse<T>(response: Response, fallback: string) {
const text = await response.text();
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("json")) {
if (response.status === 403) {
throw new Error("请求被安全网关拦截,请刷新页面后重试");
}
throw new Error(`${fallback}(服务返回异常,HTTP ${response.status})`);
}
try {
return JSON.parse(text) as T & { error?: string };
} catch {
throw new Error(`${fallback}(服务返回的数据无法解析)`);
}
}
function formatNumber(value: number | null | undefined) {
if (value === null || value === undefined) return "—";
if (value >= 10000) return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`;
return new Intl.NumberFormat("zh-CN").format(value);
}
function 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);
const shares = distribution.latest_shares ?? 0;
2026-07-30 12:06:41 +08:00
return {
likes,
comments,
collects,
shares,
2026-07-30 12:06:41 +08:00
total:
likes === null
? null
: likes + (comments ?? 0) + (collects ?? 0) + shares,
2026-07-30 12:06:41 +08:00
};
}
function recoverySortValue(
distribution: Distribution,
key: RecoverySortKey,
): number | null {
const metrics = latestPublicMetrics(distribution);
if (key === "likes") return metrics.likes;
if (key === "collects") return metrics.collects;
if (key === "shares") return metrics.shares;
if (key === "comments") return metrics.comments;
if (key === "total") return metrics.total;
const value =
key === "publish_time"
? distribution.publish_time
: distribution.collection_updated_at ||
(metrics.likes !== null ? distribution.updated_at : null);
if (!value) return null;
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? null : timestamp;
}
function sortRecoveryDistributions(
distributions: Distribution[],
key: RecoverySortKey,
direction: SortDirection,
) {
return sortWithNullsLast(
distributions,
(distribution) => recoverySortValue(distribution, key),
direction,
);
}
function SortableRecoveryHeader({
label,
column,
activeColumn,
direction,
onSort,
}: {
label: string;
column: RecoverySortKey;
activeColumn: RecoverySortKey | null;
direction: SortDirection;
onSort: (column: RecoverySortKey) => void;
}) {
const active = activeColumn === column;
const arrow = active ? (direction === "asc" ? "↑" : "↓") : "↕";
return (
<th aria-sort={active ? (direction === "asc" ? "ascending" : "descending") : "none"}>
<button
type="button"
className={`recovery-sort-button${active ? " active" : ""}`}
onClick={() => onSort(column)}
title={`${label}按${active && direction === "desc" ? "正序" : "倒序"}排列`}
>
<span>{label}</span>
<b aria-hidden="true">{arrow}</b>
</button>
</th>
);
}
2026-07-30 12:06:41 +08:00
function hasCreatorMetrics(distribution: Distribution) {
return distribution.exposure !== null && distribution.views !== null;
}
function parseCollectionDays(value: string | null | undefined) {
try {
const parsed = JSON.parse(value || "[]") as unknown;
if (!Array.isArray(parsed)) return [];
return [...new Set(parsed.map(Number))]
.filter((day) => Number.isInteger(day) && day >= 1 && day <= 7)
.sort((a, b) => a - b);
} catch {
return [];
}
}
function todayInputValue() {
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const value = Object.fromEntries(
parts.map((part) => [part.type, part.value]),
);
return `${value.year}-${value.month}-${value.day}`;
}
function addCalendarDays(value: string, days: number) {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return "";
const date = new Date(
Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + days),
);
return date.toISOString().slice(0, 10);
}
function 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();
const isSupportedHost =
2026-07-30 12:06:41 +08:00
hostname === "xiaohongshu.com" ||
hostname.endsWith(".xiaohongshu.com") ||
hostname === "xhslink.cn" ||
hostname.endsWith(".xhslink.cn") ||
2026-07-30 12:06:41 +08:00
hostname === "xhslink.com" ||
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",
});
}
export default function Home({ currentUser }: { currentUser: AuthUser }) {
2026-07-30 12:06:41 +08:00
const [data, setData] = useState<DashboardData>(EMPTY_DATA);
const [activeNav, setActiveNav] = useState<NavKey>("overview");
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false);
const [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 [groupPushTask, setGroupPushTask] = useState<Task | null>(null);
const [onePushTask, setOnePushTask] = useState<Task | null>(null);
2026-07-30 12:06:41 +08:00
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
const [taskForm, setTaskForm] = useState({
taskType: "content_publish" as "content_publish" | "screenshot_collect",
platform: "小红书" as "小红书" | "抖音",
contentFormat: "image_text" as "image_text" | "video",
2026-07-30 12:06:41 +08:00
name: "",
brand: "",
dueAt: "2026-08-12",
feishuUrl: "",
keyword: "",
instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。",
quantity: 1,
projectId: "",
2026-07-30 12:06:41 +08:00
});
const [taskExampleImage, setTaskExampleImage] = useState<File | null>(null);
const [taskFormProjects, setTaskFormProjects] = useState<Array<{ id: string; name: string }>>([]);
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: "" });
const [exportingTaskId, setExportingTaskId] = useState<string | null>(null);
2026-08-06 10:18:23 +08:00
const [exportingResources, setExportingResources] = useState(false);
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;
if (item.key === "reports" && !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]);
useEffect(() => {
if (!taskModalOpen) return;
const timer = window.setTimeout(() => {
void (async () => {
try {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "project_list" }),
});
const result = await readApiResponse<
{ projects?: Array<{ id: string; name: string }> } & { error?: string }
>(response, "读取项目列表失败");
if (!response.ok) throw new Error(result.error || "读取项目列表失败");
setTaskFormProjects(result.projects ?? []);
} catch {
setTaskFormProjects([]);
}
})();
}, 0);
return () => window.clearTimeout(timer);
}, [taskModalOpen]);
2026-07-30 12:06:41 +08:00
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) =>
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]);
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) => {
if (key === "resources" && currentUser.role === "user") return;
if (key === "users" && !isManager) return;
if (key === "reports" && !isManager) return;
2026-07-30 12:06:41 +08:00
setActiveNav(key);
setMenuOpen(false);
};
const submitTask = async (event: FormEvent) => {
event.preventDefault();
if (taskForm.taskType === "content_publish" && !sourcePreview) {
2026-07-30 12:06:41 +08:00
setToast("请先读取飞书表格");
return;
}
if (
taskForm.taskType === "content_publish" &&
taskForm.contentFormat === "video" &&
(sourcePreview?.videoCount ?? 0) === 0
) {
setToast("当前飞书表格没有识别到视频附件");
return;
}
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(
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,
projectId: taskForm.projectId,
}
: { 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);
setTaskExampleImage(null);
2026-07-30 12:06:41 +08:00
setTaskForm({
taskType: "content_publish",
platform: "小红书",
contentFormat: "image_text",
2026-07-30 12:06:41 +08:00
name: "",
brand: "",
dueAt: "2026-08-12",
feishuUrl: "",
keyword: "",
instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。",
quantity: 1,
projectId: "",
2026-07-30 12:06:41 +08:00
});
}
};
const inspectFeishu = async () => {
if (!taskForm.feishuUrl.trim()) {
setToast("请先粘贴飞书表格链接");
return;
}
try {
setSourceWorking(true);
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "inspect_feishu",
feishuUrl: taskForm.feishuUrl,
}),
});
const result = await readApiResponse<FeishuPreview>(
response,
"读取飞书表格失败",
);
if (!response.ok) throw new Error(result.error || "读取飞书表格失败");
setSourcePreview(result);
setTaskForm((current) => ({
...current,
name: current.name || result.sheetName.replace(/500篇$/, "分发任务"),
}));
setToast(
`已读取 ${result.rowCount} 条有效内容,识别到 ${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);
}
};
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) => {
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
};
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,
},
`采集任务已创建,将在所选日期09:00自动执行`,
2026-07-30 12:06:41 +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);
}
};
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: "按项目组织任务群与总群成员,从飞书同步内容、开放领取并跟进分发进度。",
2026-07-30 12:06:41 +08:00
},
resources: {
title: "KOC资源",
subtitle: "只沉淀真实发布过的账号,不要求提前登记。",
},
partners: {
title: "合作方",
subtitle: "绑定企业微信外部联系人,临期催办自动推送到人。",
},
2026-07-30 12:06:41 +08:00
recovery: {
title: "数据回收",
subtitle: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。",
},
reports: {
title: "复盘报表",
subtitle: "按任务汇总领取、发布与互动数据,支持一键导出 CSV。",
},
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>
{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>
<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>
<strong>{currentUser.username}</strong>
<span>{currentUser.role === "super_admin" ? "超级管理员" : currentUser.role === "admin" ? "管理员" : "普通用户"}</span>
2026-07-30 12:06:41 +08:00
</div>
<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>
<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}
showResources={currentUser.role !== "user"}
2026-07-30 12:06:41 +08:00
/>
)}
{activeNav === "tasks" && (
<TasksPage
tasks={data.tasks}
distributions={data.distributions}
2026-07-30 12:06:41 +08:00
portalUrl={data.portal_url}
canRelease={isManager}
isManager={isManager}
working={working}
setToast={setToast}
onTasksChanged={loadData}
2026-07-30 12:06:41 +08:00
onCreate={() => setTaskModalOpen(true)}
onCopyShare={copyTaskShareLink}
onPushToGroups={(task) => setGroupPushTask(task)}
onOneClickPush={(task) => setOnePushTask(task)}
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}
onImported={completeResourceImport}
2026-07-30 12:06:41 +08:00
/>
)}
{activeNav === "partners" && isManager && (
<PartnersPage
partners={data.partners}
working={working}
onRefresh={loadData}
setToast={setToast}
/>
)}
{activeNav === "users" && isManager && (
<UsersPage currentUser={currentUser} />
)}
{activeNav === "reports" && isManager && (
<ReportsPage setToast={setToast} />
)}
2026-07-30 12:06:41 +08:00
{activeNav === "recovery" && (
<RecoveryPage
tasks={data.tasks}
distributions={data.distributions}
2026-07-30 12:06:41 +08:00
working={working}
collectingDistributionIds={collectingDistributionIds}
canUpdatePublishUrl={isManager}
2026-07-30 12:06:41 +08:00
onCollect={collectMetrics}
onUpdatePublishUrl={updateDistributionPublishUrl}
2026-07-30 12:06:41 +08:00
onSaveSchedule={saveCollectionSchedule}
onUpload={uploadScreenshot}
onFallback={openMetricFallback}
onRetryFailed={retryFailedMetrics}
onExport={exportRecoveryData}
onExportScreenshots={exportScreenshotResults}
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>
<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>
<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" ? (
<>
<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>
<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>
<small>
{taskForm.contentFormat === "video"
? "自动读取标题、正文和视频附件;空标题或无视频的行不会进入任务。"
: "自动读取标题、正文/笔记内容、标签和全部配图;空标题行不会进入任务。"}
</small>
</label>
{sourcePreview && (
<div className="source-preview">
<div className="source-preview-head">
<div>
<span className="source-check">✓</span>
<div>
<strong>{sourcePreview.sheetName}</strong>
<small>工作表 {sourcePreview.sheetId} · 已匹配 {sourcePreview.columns.length} 个字段</small>
</div>
</div>
<b>
{sourcePreview.rowCount} 条 · {taskForm.contentFormat === "video"
? `${sourcePreview.videoCount} 个视频`
: `${sourcePreview.imageCount} 张图片`}
</b>
2026-07-30 12:06:41 +08:00
</div>
<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>
<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>
<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>
<label>
<span>所属项目(可选)</span>
<select
value={taskForm.projectId}
onChange={(event) => setTaskForm({ ...taskForm, projectId: event.target.value })}
>
<option value="">不挂项目</option>
{taskFormProjects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
<small>挂在项目下的任务可在「任务中心」的项目分组中直接推送到项目总群与任务群。</small>
</label>
2026-07-30 12:06:41 +08:00
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setTaskModalOpen(false)}>取消</button>
<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>
)}
{groupPushTask && (
<GroupPushModal
task={groupPushTask}
portalUrl={data.portal_url}
onClose={() => setGroupPushTask(null)}
setToast={setToast}
/>
)}
{onePushTask && (
<OneClickPushModal
task={onePushTask}
onClose={() => setOnePushTask(null)}
setToast={setToast}
/>
)}
2026-07-30 12:06:41 +08:00
{toast && <div className="toast"><span>✓</span>{toast}</div>}
{working && <div className="working-bar" />}
</div>
);
}
type WecomStatus = {
robotConfigured: boolean;
appConfigured: boolean;
dueDays: number;
};
type WecomContact = {
externalUserId: string;
name: string;
avatar: string;
corpName: string;
ownerUserId: string;
};
type WecomGroupChatRow = {
chat_id: string;
name: string;
owner_user_id: string;
member_count: number;
};
type WecomGroupPushResult = {
sender: string;
msgid: string;
chatCount: number;
failList: string[];
};
type WecomGroupPushRecord = {
id: string;
taskId: string;
msgid: string;
sender: string;
chatIds: string[];
failList: string[];
createdAt: string;
};
function PartnersPage({
partners,
working,
onRefresh,
setToast,
}: {
partners: Partner[];
working: boolean;
onRefresh: () => Promise<void>;
setToast: (message: string) => void;
}) {
const [status, setStatus] = useState<WecomStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const [draftId, setDraftId] = useState("");
const [partnerWorkingId, setPartnerWorkingId] = useState<string | null>(null);
const [pickerPartner, setPickerPartner] = useState<Partner | null>(null);
const [pickerContacts, setPickerContacts] = useState<WecomContact[]>([]);
const [pickerFetchedAt, setPickerFetchedAt] = useState(0);
const [pickerLoading, setPickerLoading] = useState(false);
const [pickerError, setPickerError] = useState("");
const [pickerSearch, setPickerSearch] = useState("");
const [pickerBindingId, setPickerBindingId] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<"all" | "group_owner" | "external_koc">("all");
const [createOpen, setCreateOpen] = useState(false);
const [createDraft, setCreateDraft] = useState({ name: "", wecomUserid: "" });
const [createSaving, setCreateSaving] = useState(false);
const filteredPartners = useMemo(
() =>
typeFilter === "all"
? partners
: partners.filter((partner) => (partner.partner_type || "external_koc") === typeFilter),
[partners, typeFilter],
);
const loadStatus = useCallback(async () => {
let response: Response;
try {
response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "wecom_status" }),
});
} catch {
setToast("读取企业微信配置失败");
setStatusLoading(false);
return;
}
const result = await readApiResponse<WecomStatus & { error?: string }>(
response,
"读取企业微信配置失败",
);
if (!response.ok) {
setToast(result.error || "读取企业微信配置失败");
setStatusLoading(false);
return;
}
setStatus({
robotConfigured: result.robotConfigured,
appConfigured: result.appConfigured,
dueDays: result.dueDays,
});
setStatusLoading(false);
}, [setToast]);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadStatus();
}, 0);
return () => window.clearTimeout(timer);
}, [loadStatus]);
const refreshStatus = async () => {
setStatusLoading(true);
await loadStatus();
};
const submitCreate = async () => {
if (!createDraft.name.trim() || !createDraft.wecomUserid.trim()) {
setToast("请填写合作方名称与企业微信 userid");
return;
}
setCreateSaving(true);
try {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "partner_save",
name: createDraft.name,
wecomUserid: createDraft.wecomUserid,
}),
});
const result = await readApiResponse<{ error?: string }>(response, "新建合作方失败");
if (!response.ok) throw new Error(result.error || "新建合作方失败");
setCreateOpen(false);
setCreateDraft({ name: "", wecomUserid: "" });
await onRefresh();
setToast("群主合作方已创建");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "新建合作方失败");
} finally {
setCreateSaving(false);
}
};
const startEdit = (partner: Partner) => {
setEditingId(partner.id);
setDraftId(partner.wecom_external_user_id ?? "");
};
const cancelEdit = () => {
setEditingId(null);
setDraftId("");
};
const saveEdit = async (partner: Partner) => {
const value = draftId.trim();
try {
setPartnerWorkingId(partner.id);
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "bind_wecom_external_id",
partnerId: partner.id,
wecomExternalUserId: value,
}),
});
const result = await readApiResponse<{
partnerId?: string;
wecomExternalUserId?: string | null;
error?: string;
}>(response, "保存失败");
if (!response.ok) throw new Error(result.error || "保存失败");
setEditingId(null);
setDraftId("");
await onRefresh();
setToast("企业微信外部联系人已保存");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "保存失败");
} finally {
setPartnerWorkingId(null);
}
};
const testRobot = async () => {
try {
setPartnerWorkingId("robot");
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "send_test_wecom" }),
});
const result = await readApiResponse<{
robot?: "ok" | "skipped";
app?: "ok" | "skipped" | "failed";
error?: string;
}>(response, "测试失败");
if (!response.ok) throw new Error(result.error || "测试失败");
if (result.robot === "ok") setToast("群机器人测试消息已发送");
else if (result.robot === "skipped") setToast("群机器人未配置,请先填写 WECOM_ROBOT_WEBHOOK");
else setToast("群机器人测试失败");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "测试失败");
} finally {
setPartnerWorkingId(null);
}
};
const triggerDueSoon = async () => {
try {
setPartnerWorkingId("due-soon");
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "trigger_wecom_due_soon" }),
});
const result = await readApiResponse<{
dueSoonAttempted?: number;
dueSoonSent?: number;
dueSoonFailed?: number;
dueSoonSkipped?: number;
digestSent?: boolean;
error?: string;
}>(response, "触发催办失败");
if (!response.ok) throw new Error(result.error || "触发催办失败");
const sent = result.dueSoonSent ?? 0;
const failed = result.dueSoonFailed ?? 0;
const skipped = result.dueSoonSkipped ?? 0;
const attempted = result.dueSoonAttempted ?? 0;
const digest = result.digestSent ? "群汇总已推送" : "群汇总未推送(无内容或未配置 webhook)";
setToast(
`催办完成:尝试 ${attempted} 个合作方,应用消息已发 ${sent}、跳过 ${skipped}、失败 ${failed};${digest}`,
);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "触发催办失败");
} finally {
setPartnerWorkingId(null);
}
};
const triggerOverdueEscalation = async () => {
try {
setPartnerWorkingId("escalation");
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "trigger_wecom_overdue_escalation" }),
});
const result = await readApiResponse<{
escalation?: {
overdueTasks?: number;
overdueDistributions?: number;
robotSent?: boolean;
cardSent?: number;
cardFailed?: number;
};
autoRelease?: { enabled?: boolean; released?: number; blocked?: number; failed?: number };
error?: string;
}>(response, "触发超期升级失败");
if (!response.ok) throw new Error(result.error || "触发超期升级失败");
const esc = result.escalation ?? {};
const release = result.autoRelease ?? {};
const releaseText = release.enabled
? `;自动释放 ${release.released ?? 0} 条(跳过 ${release.blocked ?? 0},失败 ${release.failed ?? 0})`
: ";自动释放未开启(WECOM_AUTO_RELEASE_DAYS)";
setToast(
`超期升级完成:${esc.overdueTasks ?? 0} 个任务 / ${esc.overdueDistributions ?? 0} 条超期,卡片已发 ${esc.cardSent ?? 0} 人${releaseText}`,
);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "触发超期升级失败");
} finally {
setPartnerWorkingId(null);
}
};
const testAppMessage = async (partner: Partner) => {
if (!partner.wecom_external_user_id) {
setToast("请先保存企业微信外部联系人 ID");
return;
}
try {
setPartnerWorkingId(partner.id);
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "send_test_wecom",
partnerId: partner.id,
}),
});
const result = await readApiResponse<{
robot?: "ok" | "skipped";
app?: "ok" | "skipped" | "failed";
error?: string;
}>(response, "测试失败");
if (!response.ok) throw new Error(result.error || "测试失败");
if (result.app === "ok") setToast(`已向 ${partner.name} 发送测试消息`);
else if (result.app === "skipped") setToast("应用消息未配置或未绑定外部联系人");
else setToast(`应用消息发送失败:${partner.name} 的外部联系人 ID 无效`);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "测试失败");
} finally {
setPartnerWorkingId(null);
}
};
const loadContacts = useCallback(async () => {
const cacheFresh = pickerFetchedAt > 0 && Date.now() - pickerFetchedAt < 5 * 60 * 1000;
if (cacheFresh) return;
setPickerLoading(true);
setPickerError("");
let response: Response;
try {
response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "wecom_list_external_contacts" }),
});
} catch {
setPickerError("拉取企业微信外部联系人失败");
setPickerLoading(false);
return;
}
const result = await readApiResponse<{ contacts?: WecomContact[]; error?: string }>(
response,
"拉取企业微信外部联系人失败",
);
if (!response.ok) {
setPickerError(result.error || "拉取企业微信外部联系人失败");
setPickerLoading(false);
return;
}
setPickerContacts(result.contacts ?? []);
setPickerFetchedAt(Date.now());
setPickerLoading(false);
}, [pickerFetchedAt]);
const openPicker = (partner: Partner) => {
setPickerPartner(partner);
setPickerSearch("");
setPickerError("");
void loadContacts();
};
const closePicker = () => {
if (pickerBindingId) return;
setPickerPartner(null);
setPickerSearch("");
setPickerError("");
};
const filteredContacts = pickerContacts.filter((contact) => {
if (!pickerSearch.trim()) return true;
const q = pickerSearch.trim().toLowerCase();
return (
contact.name.toLowerCase().includes(q) ||
contact.corpName.toLowerCase().includes(q) ||
contact.externalUserId.toLowerCase().includes(q) ||
contact.ownerUserId.toLowerCase().includes(q)
);
});
const pickContact = async (contact: WecomContact) => {
if (!pickerPartner) return;
try {
setPickerBindingId(contact.externalUserId);
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "bind_wecom_external_id",
partnerId: pickerPartner.id,
wecomExternalUserId: contact.externalUserId,
wecomName: contact.name,
}),
});
const result = await readApiResponse<{ error?: string }>(response, "绑定失败");
if (!response.ok) throw new Error(result.error || "绑定失败");
setToast(`已为 ${pickerPartner.name} 绑定 ${contact.name}`);
setPickerPartner(null);
setPickerSearch("");
await onRefresh();
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "绑定失败");
} finally {
setPickerBindingId(null);
}
};
return (
<div className="stack">
<section className="panel wecom-status-panel">
<div className="panel-heading">
<div>
<h2>企业微信连通性</h2>
<p>临期催办与应用消息依赖以下配置;不显示 Secret。</p>
</div>
<button
type="button"
className="ghost-button"
disabled={statusLoading || working}
onClick={() => void refreshStatus()}
>
{statusLoading ? "刷新中…" : "刷新状态"}
</button>
</div>
<div className="wecom-status-grid">
<div className={`wecom-status-card ${status?.robotConfigured ? "ok" : "off"}`}>
<span>群机器人</span>
<strong>{status ? (status.robotConfigured ? "已配置" : "未配置") : "加载中"}</strong>
<small>用于当日催办汇总</small>
</div>
<div className={`wecom-status-card ${status?.appConfigured ? "ok" : "off"}`}>
<span>自建应用消息</span>
<strong>{status ? (status.appConfigured ? "已配置" : "未配置") : "加载中"}</strong>
<small>需要 corpId + agentId + secret</small>
</div>
<div className="wecom-status-card">
<span>临期阈值</span>
<strong>{status ? `${status.dueDays} 天` : "—"}</strong>
<small>WECOM_NOTIFY_DUE_DAYS</small>
</div>
</div>
<div className="wecom-status-actions">
<button
type="button"
className="primary-button soft"
disabled={partnerWorkingId === "robot" || working || statusLoading}
onClick={() => void testRobot()}
>
{partnerWorkingId === "robot" ? "发送中…" : "测试群机器人"}
</button>
<button
type="button"
className="ghost-button"
disabled={partnerWorkingId === "due-soon" || working || statusLoading}
onClick={() => void triggerDueSoon()}
>
{partnerWorkingId === "due-soon" ? "执行中…" : "立即跑一次催办"}
</button>
<button
type="button"
className="ghost-button"
disabled={partnerWorkingId === "escalation" || working || statusLoading}
onClick={() => void triggerOverdueEscalation()}
>
{partnerWorkingId === "escalation" ? "执行中…" : "跑一次超期升级"}
</button>
<p className="wecom-status-hint">
立即跑一次 daily 09:00 的催办链路:按 partner 聚合发应用消息,再向群机器人发当日汇总。超期升级会向运营群推送超期清单,并向任务所属项目总群成员发催办卡片。
</p>
{(!status?.robotConfigured || !status?.appConfigured) && (
<p className="wecom-status-hint">
未配置项可在 <code>.dev.vars</code> 或部署环境变量里补齐后重启服务。
</p>
)}
</div>
</section>
<section className="panel table-panel">
<div className="panel-heading">
<div>
<h2>合作方</h2>
<p>群主型合作方用于项目任务群分发;外部KOC由领取流程自动生成,绑定外部联系人后接收催办</p>
</div>
<button
type="button"
className="primary-button soft"
disabled={createSaving || working}
onClick={() => setCreateOpen(true)}
>
+ 新建群主合作方
</button>
</div>
<div className="partner-type-tabs" role="group" aria-label="合作方类型筛选">
{([
{ key: "all", label: `全部(${partners.length})` },
{ key: "group_owner", label: `群主(${partners.filter((partner) => partner.partner_type === "group_owner").length})` },
{ key: "external_koc", label: `外部KOC(${partners.filter((partner) => (partner.partner_type || "external_koc") === "external_koc").length})` },
] as const).map((tab) => (
<button
key={tab.key}
type="button"
className={typeFilter === tab.key ? "active" : ""}
onClick={() => setTypeFilter(tab.key)}
>
{tab.label}
</button>
))}
</div>
<div className="partner-list">
<div className="partner-row head">
<span>合作方</span>
<span>企业微信名 / userid</span>
<span>负责人</span>
<span>外部联系人 ID</span>
<span>操作</span>
</div>
{filteredPartners.length === 0 && (
<div className="partner-row empty">
{partners.length === 0
? "暂无合作方:点击右上角新建群主合作方,或等待 KOC 领取任务后自动生成外部KOC记录"
: "当前筛选下没有合作方"}
</div>
)}
{filteredPartners.map((partner) => {
const editing = editingId === partner.id;
const busy = partnerWorkingId === partner.id;
const isGroupOwner = partner.partner_type === "group_owner";
return (
<div className="partner-row" key={partner.id}>
<span>
<strong>{partner.name}</strong>
<em className={`partner-type-badge ${isGroupOwner ? "group-owner" : "external-koc"}`}>
{isGroupOwner ? "群主" : "外部KOC"}
</em>
</span>
<span>{isGroupOwner ? <code>{partner.wecom_userid || "—"}</code> : partner.wecom_name || "—"}</span>
<span>{partner.owner || "—"}</span>
<span className="partner-external-cell">
{isGroupOwner ? (
<code title="群主合作方不绑定外部联系人">—</code>
) : editing ? (
<input
type="text"
value={draftId}
placeholder="企业微信外部联系人 external_userid"
onChange={(event) => setDraftId(event.target.value)}
disabled={busy}
/>
) : (
<code title={partner.wecom_external_user_id || "未绑定"}>
{partner.wecom_external_user_id || "未绑定"}
</code>
)}
</span>
<span className="partner-actions">
{isGroupOwner ? (
<small className="partner-owner-hint">用于项目任务群分发</small>
) : editing ? (
<>
<button
type="button"
className="primary-button soft"
disabled={busy}
onClick={() => void saveEdit(partner)}
>
{busy ? "保存中…" : "保存"}
</button>
<button
type="button"
className="ghost-button"
disabled={busy}
onClick={cancelEdit}
>
取消
</button>
</>
) : (
<>
<button
type="button"
className="ghost-button"
disabled={busy || working}
onClick={() => startEdit(partner)}
>
{partner.wecom_external_user_id ? "修改" : "绑定"}
</button>
<button
type="button"
className="ghost-button"
disabled={busy || working || pickerLoading}
onClick={() => openPicker(partner)}
>
从企业微信导入
</button>
<button
type="button"
className="ghost-button"
disabled={busy || working || !partner.wecom_external_user_id}
onClick={() => void testAppMessage(partner)}
>
{busy ? "测试中…" : "测试发送"}
</button>
</>
)}
</span>
</div>
);
})}
</div>
</section>
{createOpen && (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setCreateOpen(false)}>
<div className="modal-card" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">合作方 · 群主</p>
<h2>新建群主合作方</h2>
</div>
<button type="button" onClick={() => setCreateOpen(false)} aria-label="关闭">×</button>
</div>
<div className="form-grid two">
<label>
<span>合作方名称 *</span>
<input
value={createDraft.name}
onChange={(event) => setCreateDraft({ ...createDraft, name: event.target.value })}
placeholder="例如:张三(一号群群主)"
/>
</label>
<label>
<span>企业微信 userid *</span>
<input
value={createDraft.wecomUserid}
onChange={(event) => setCreateDraft({ ...createDraft, wecomUserid: event.target.value })}
placeholder="例如:zhangsan"
/>
</label>
</div>
<p className="project-chat-hint">
userid 在企业微信管理后台「通讯录」成员详情中查看。群主合作方是内部员工,
用于在任务中心的项目分组中把任务群映射到负责人。
</p>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setCreateOpen(false)} disabled={createSaving}>取消</button>
<button type="button" className="primary-button" onClick={() => void submitCreate()} disabled={createSaving}>
{createSaving ? "创建中…" : "创建"}
</button>
</div>
</div>
</div>
)}
{pickerPartner && (
<div className="modal-backdrop" role="presentation" onMouseDown={closePicker}>
<div className="modal-card wecom-picker-modal" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<h2>选择 {pickerPartner.name} 的企业微信外部联系人</h2>
<p>从企业微信外部联系人列表中挑选一个,绑定后会自动写入 ID 与企业微信名。</p>
</div>
<button
type="button"
className="ghost-button"
onClick={closePicker}
disabled={!!pickerBindingId}
aria-label="关闭"
>
×
</button>
</div>
<div className="import-guide-strip">
<input
type="search"
className="wecom-picker-search"
placeholder="按名称 / 企业 / ID / 负责人 搜索"
value={pickerSearch}
onChange={(event) => setPickerSearch(event.target.value)}
disabled={pickerLoading || !!pickerBindingId}
/>
<button
type="button"
className="ghost-button"
onClick={() => void loadContacts()}
disabled={pickerLoading || !!pickerBindingId}
>
{pickerLoading ? "拉取中…" : "刷新列表"}
</button>
{pickerFetchedAt > 0 && !pickerLoading && (
<span className="wecom-picker-cache-hint">
缓存于 {new Date(pickerFetchedAt).toLocaleTimeString()}(5 分钟内复用)
</span>
)}
</div>
{pickerError && (
<p className="form-error">{pickerError}</p>
)}
<div className="wecom-picker-list">
{pickerLoading && pickerContacts.length === 0 ? (
<div className="wecom-picker-empty">正在拉取外部联系人…</div>
) : pickerContacts.length === 0 ? (
<div className="wecom-picker-empty">
{pickerError ? "拉取失败,请检查应用配置后重试" : "未拉取到任何外部联系人"}
</div>
) : filteredContacts.length === 0 ? (
<div className="wecom-picker-empty">没有匹配的联系人</div>
) : (
filteredContacts.map((contact) => (
<button
key={contact.externalUserId}
type="button"
className="wecom-picker-row"
onClick={() => void pickContact(contact)}
disabled={!!pickerBindingId}
>
{contact.avatar ? (
<img src={contact.avatar} alt="" className="wecom-picker-avatar" />
) : (
<span className="wecom-picker-avatar placeholder" aria-hidden="true">
{contact.name.slice(0, 1) || "?"}
</span>
)}
<span className="wecom-picker-meta">
<span className="wecom-picker-name">{contact.name || "(未命名)"}</span>
<span className="wecom-picker-sub">
{contact.corpName || "—"} · 负责人 {contact.ownerUserId || "—"}
</span>
<code className="wecom-picker-id">{contact.externalUserId}</code>
</span>
{pickerBindingId === contact.externalUserId ? (
<span className="wecom-picker-binding">绑定中…</span>
) : pickerPartner.wecom_external_user_id === contact.externalUserId ? (
<span className="wecom-picker-current">当前</span>
) : null}
</button>
))
)}
</div>
</div>
</div>
)}
</div>
);
}
function buildGroupPushDraft(task: Task, portalUrl: string) {
const portal = portalUrl.trim().replace(/\/$/, "");
const claimUrl = portal && task.share_token ? `${portal}/?task=${task.share_token}` : "";
const formatLabel =
task.task_type === "screenshot_collect"
? "截图回收"
: task.content_format === "video"
? "视频"
: "图文";
return [
`【新任务】${task.name}`,
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
`平台:${task.platform}|形式:${formatLabel}`,
claimUrl ? `领取链接:${claimUrl}` : "领取链接生成失败,请联系管理员",
].join("\n");
}
function GroupPushModal({
task,
portalUrl,
onClose,
setToast,
}: {
task: Task;
portalUrl: string;
onClose: () => void;
setToast: (message: string) => void;
}) {
const [groups, setGroups] = useState<WecomGroupChatRow[]>([]);
const [groupsLoading, setGroupsLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [draft, setDraft] = useState(() => buildGroupPushDraft(task, portalUrl));
const [sending, setSending] = useState(false);
const [result, setResult] = useState<{ groupCount: number; failCount: number; results: WecomGroupPushResult[] } | null>(null);
const [history, setHistory] = useState<WecomGroupPushRecord[]>([]);
const postAction = useCallback(async <T,>(payload: Record<string, unknown>, fallback: string) => {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const parsed = await readApiResponse<T & { error?: string }>(response, fallback);
if (!response.ok) throw new Error(parsed.error || fallback);
return parsed;
}, []);
const loadHistory = useCallback(async () => {
try {
const parsed = await postAction<{ pushes: WecomGroupPushRecord[] }>(
{ action: "wecom_task_group_pushes", taskId: task.id },
"读取发送记录失败",
);
setHistory(parsed.pushes);
} catch {
// 历史加载失败不阻断弹窗
}
}, [postAction, task.id]);
const refreshGroups = useCallback(async (mode: "auto" | "sync") => {
if (mode === "auto") {
try {
const cached = await postAction<{ groups: WecomGroupChatRow[] }>(
{ action: "wecom_group_chats" },
"读取客户群列表失败",
);
if (cached.groups.length > 0) {
setGroups(cached.groups);
return;
}
} catch {
// 缓存读取失败时继续尝试同步
}
}
setSyncing(true);
try {
const parsed = await postAction<{ groups: WecomGroupChatRow[]; syncedCount: number }>(
{ action: "wecom_sync_group_chats" },
"同步客户群失败",
);
setGroups(parsed.groups);
setSelected((previous) => previous.filter((id) => parsed.groups.some((group) => group.chat_id === id)));
setToast(`已同步 ${parsed.syncedCount} 个客户群`);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "同步客户群失败");
} finally {
setSyncing(false);
}
}, [postAction, setToast]);
useEffect(() => {
const timer = window.setTimeout(() => {
void refreshGroups("auto").finally(() => setGroupsLoading(false));
void loadHistory();
}, 0);
return () => window.clearTimeout(timer);
}, [refreshGroups, loadHistory]);
const toggleGroup = (chatId: string) => {
setSelected((previous) =>
previous.includes(chatId)
? previous.filter((id) => id !== chatId)
: [...previous, chatId],
);
};
const allSelected = groups.length > 0 && selected.length === groups.length;
const submitPush = async () => {
if (selected.length === 0) {
setToast("请选择要发送的客户群");
return;
}
setSending(true);
try {
const parsed = await postAction<{
results: WecomGroupPushResult[];
groupCount: number;
failCount: number;
}>(
{
action: "wecom_push_task_to_groups",
taskId: task.id,
chatIds: selected,
text: draft,
},
"创建企业群发失败",
);
setResult({ results: parsed.results, groupCount: parsed.groupCount, failCount: parsed.failCount });
setToast("群发任务已创建");
await loadHistory();
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "创建企业群发失败");
} finally {
setSending(false);
}
};
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-card group-push-modal" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">企业微信 · 企业群发</p>
<h2>发布「{task.name}」到客户群</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
<div className="group-push-toolbar">
<span className="group-push-count">
{groupsLoading ? "加载群列表…" : `${groups.length} 个客户群 · 已选 ${selected.length} 个`}
</span>
<div className="group-push-toolbar-actions">
<button type="button" className="ghost-button" onClick={() => setSelected(allSelected ? [] : groups.map((group) => group.chat_id))} disabled={groupsLoading || groups.length === 0}>
{allSelected ? "清空选择" : "全选"}
</button>
<button type="button" className="ghost-button" onClick={() => void refreshGroups("sync")} disabled={syncing || groupsLoading}>
{syncing ? "同步中…" : "同步群列表"}
</button>
</div>
</div>
<div className="wecom-picker-list group-push-list">
{groupsLoading ? (
<div className="wecom-picker-empty">正在读取客户群…</div>
) : groups.length === 0 ? (
<div className="wecom-picker-empty">暂无客户群,请先配置企业微信应用并点击「同步群列表」</div>
) : (
groups.map((group) => (
<label key={group.chat_id} className="group-push-row">
<input
type="checkbox"
checked={selected.includes(group.chat_id)}
onChange={() => toggleGroup(group.chat_id)}
disabled={sending}
/>
<span className="wecom-picker-meta">
<span className="wecom-picker-name">{group.name || "(未命名群)"}</span>
<span className="wecom-picker-sub">群主 {group.owner_user_id || "—"} · {group.member_count} 人</span>
</span>
<code className="wecom-picker-id">{group.chat_id}</code>
</label>
))
)}
</div>
<label className="group-push-text">
<span>群发文本(可编辑,发送前请确认)</span>
<textarea rows={7} value={draft} onChange={(event) => setDraft(event.target.value)} disabled={sending} />
</label>
{result && (
<div className="group-push-result">
<p>
已创建 {result.results.length} 个群发任务(覆盖 {result.groupCount} 个群)
{result.failCount > 0 ? `,${result.failCount} 个群发送失败` : ""}。
群主需在企微客户端「群发助手」点击发送后,消息才会送达客户群;每个群每月最多接收当月天数条群发。
</p>
<ul>
{result.results.map((item) => (
<li key={item.msgid || item.sender}>
群主 {item.sender} · {item.chatCount} 个群 · msgid {item.msgid || "—"}
{item.failList.length > 0 && (
<span className="group-push-fail">(失败群:{item.failList.join("、")})</span>
)}
</li>
))}
</ul>
</div>
)}
{history.length > 0 && (
<div className="group-push-history">
<p className="group-push-history-title">发送记录</p>
<ul>
{history.map((record) => (
<li key={record.id}>
{formatDate(record.createdAt, true)} · 群主 {record.sender} · {record.chatIds.length} 个群
{record.failList.length > 0 ? ` · 失败 ${record.failList.length} 个` : ""}
</li>
))}
</ul>
</div>
)}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose} disabled={sending}>关闭</button>
<button type="button" className="primary-button" onClick={() => void submitPush()} disabled={sending || groupsLoading || selected.length === 0}>
{sending ? "创建中…" : `创建群发(${selected.length} 个群)`}
</button>
</div>
</div>
</div>
);
}
type ProjectSummary = {
id: string;
name: string;
brand: string;
status: string;
note: string;
createdAt: string;
mainGroupMemberIds: string[];
taskCount: number;
groupChatCount: number;
};
type ProjectTaskItem = {
id: string;
name: string;
brand: string;
quantity: number;
claimed_quantity: number;
due_at: string;
status: string;
task_type: "content_publish" | "screenshot_collect";
platform: string;
content_format: string;
share_token: string | null;
};
type ProjectGroupChatItem = {
chatId: string;
name: string;
ownerUserId: string;
memberCount: number;
partnerId: string;
partnerName: string;
missing: boolean;
};
type ProjectDetailData = {
project: ProjectSummary;
tasks: ProjectTaskItem[];
groupChats: ProjectGroupChatItem[];
};
type GroupOwnerOption = {
id: string;
name: string;
wecom_userid: string;
};
async function postAction<T>(payload: Record<string, unknown>, fallback: string) {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const parsed = await readApiResponse<T & { error?: string }>(response, fallback);
if (!response.ok) throw new Error(parsed.error || fallback);
return parsed;
}
type ProjectEditorDraft = {
id: string;
name: string;
brand: string;
members: string;
note: string;
};
const EMPTY_PROJECT_DRAFT: ProjectEditorDraft = { id: "", name: "", brand: "", members: "", note: "" };
function ProjectSection({
project,
tasks,
detailVersion,
portalUrl,
setToast,
onProjectsChanged,
onEditProject,
onDeleteProject,
onOpenTaskDetail,
onCopyShare,
onPushToGroups,
onOneClickPush,
}: {
project: ProjectSummary;
tasks: Task[];
detailVersion: number;
portalUrl: string;
setToast: (message: string) => void;
onProjectsChanged: () => Promise<void>;
onEditProject: (project: ProjectSummary) => void;
onDeleteProject: (project: ProjectSummary) => void;
onOpenTaskDetail: (taskId: string) => void;
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void;
}) {
const [expanded, setExpanded] = useState(false);
const [detail, setDetail] = useState<ProjectDetailData | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [chatManaging, setChatManaging] = useState(false);
const [chatSaving, setChatSaving] = useState(false);
const [chatLoading, setChatLoading] = useState(false);
const [chatGroups, setChatGroups] = useState<WecomGroupChatRow[]>([]);
const [chatOwners, setChatOwners] = useState<GroupOwnerOption[]>([]);
const [chatDraft, setChatDraft] = useState<Array<{ chatId: string; partnerId: string }>>([]);
const [pushProject, setPushProject] = useState<ProjectDetailData | null>(null);
const loadDetail = useCallback(async () => {
setDetailLoading(true);
try {
const parsed = await postAction<ProjectDetailData>(
{ action: "project_detail", projectId: project.id },
"读取项目详情失败",
);
setDetail(parsed);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "读取项目详情失败");
} finally {
setDetailLoading(false);
}
}, [project.id, setToast]);
useEffect(() => {
if (!expanded) return;
const timer = window.setTimeout(() => {
void loadDetail();
}, 0);
return () => window.clearTimeout(timer);
}, [expanded, detailVersion, loadDetail]);
const openChatManager = async () => {
if (!detail) return;
setChatManaging(true);
setChatLoading(true);
setChatDraft(
detail.groupChats
.filter((chat) => !chat.missing || chat.partnerId)
.map((chat) => ({ chatId: chat.chatId, partnerId: chat.partnerId })),
);
try {
const [groupResult, ownerResult] = await Promise.all([
postAction<{ groups?: WecomGroupChatRow[] }>(
{ action: "wecom_group_chats" },
"读取客户群列表失败",
),
postAction<{ partners?: GroupOwnerOption[] }>(
{ action: "partner_group_owners" },
"读取群主合作方失败",
),
]);
setChatGroups(groupResult.groups ?? []);
setChatOwners(ownerResult.partners ?? []);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "读取群列表失败");
} finally {
setChatLoading(false);
}
};
const toggleChat = (chatId: string) => {
setChatDraft((previous) =>
previous.some((item) => item.chatId === chatId)
? previous.filter((item) => item.chatId !== chatId)
: [...previous, { chatId, partnerId: "" }],
);
};
const setChatPartner = (chatId: string, partnerId: string) => {
setChatDraft((previous) =>
previous.map((item) => (item.chatId === chatId ? { ...item, partnerId } : item)),
);
};
const saveChatManager = async () => {
if (!detail) return;
setChatSaving(true);
try {
const parsed = await postAction<ProjectDetailData>(
{
action: "project_set_group_chats",
projectId: detail.project.id,
items: chatDraft,
},
"保存任务群关联失败",
);
setDetail(parsed);
setChatManaging(false);
setToast("项目任务群已更新");
await onProjectsChanged();
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "保存任务群关联失败");
} finally {
setChatSaving(false);
}
};
return (
<section className="panel project-panel">
<div className="project-card-head">
<div className="project-card-title">
<div className={`avatar large ${avatarColor(project.name)}`}>
{(project.brand || project.name).slice(0, 1)}
</div>
<div>
<h3>{project.name}</h3>
<small>
{project.brand || "未填品牌"} · {project.taskCount} 个任务 · {project.groupChatCount} 个任务群 · 总群 {project.mainGroupMemberIds.length} 人
</small>
</div>
</div>
<div className="project-card-actions">
<button className="share-link-button" onClick={() => setExpanded((previous) => !previous)}>
{expanded ? "收起详情" : "查看详情"}
</button>
<button className="share-link-button" onClick={() => onEditProject(project)}>编辑</button>
<button className="share-link-button danger" onClick={() => onDeleteProject(project)}>删除</button>
</div>
</div>
{project.note && <p className="project-note">{project.note}</p>}
{tasks.length === 0 ? (
<p className="project-empty-line">项目下还没有任务,新建任务时选择所属项目即可挂入。</p>
) : (
<div className="task-list project-task-list">
{tasks.map((task) => (
<TaskRowCard
key={task.id}
task={task}
portalUrl={portalUrl}
onDetail={() => onOpenTaskDetail(task.id)}
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
/>
))}
</div>
)}
{expanded && (
<div className="project-detail">
{detailLoading || !detail || detail.project.id !== project.id ? (
<div className="wecom-picker-empty">正在读取项目详情…</div>
) : (
<>
<div className="project-detail-grid">
<div className="project-detail-block">
<div className="project-detail-block-head">
<h4>项目总群成员({detail.project.mainGroupMemberIds.length})</h4>
<small>推送任务时逐人发送应用消息</small>
</div>
{detail.project.mainGroupMemberIds.length === 0 ? (
<p className="project-empty-line">未配置,编辑项目可填写成员 userid</p>
) : (
<ul className="project-member-list">
{detail.project.mainGroupMemberIds.map((userid) => (
<li key={userid}><code>{userid}</code></li>
))}
</ul>
)}
</div>
<div className="project-detail-block">
<div className="project-detail-block-head">
<h4>任务群({detail.groupChats.length})</h4>
<button className="ghost-button" onClick={() => void openChatManager()}>管理关联</button>
</div>
{detail.groupChats.length === 0 ? (
<p className="project-empty-line">未关联任务群,点击「管理关联」从客户群中选择</p>
) : (
<table className="data-table project-chat-table">
<thead>
<tr><th>群名称</th><th>群主 userid</th><th>人数</th><th>对应合作方</th></tr>
</thead>
<tbody>
{detail.groupChats.map((chat) => (
<tr key={chat.chatId} className={chat.missing ? "project-chat-missing" : ""}>
<td>{chat.missing ? `${chat.name || chat.chatId}(群不存在)` : chat.name || "(未命名群)"}</td>
<td><code>{chat.ownerUserId || "—"}</code></td>
<td>{chat.missing ? "—" : chat.memberCount}</td>
<td>{chat.partnerId ? chat.partnerName || "(已删除)" : <span className="project-empty-line">未映射</span>}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
<div className="project-push-bar">
<button
className="primary-button"
disabled={detail.tasks.length === 0}
onClick={() => setPushProject(detail)}
>
推送任务到项目群
</button>
{detail.tasks.length === 0 ? (
<small>项目下没有可推送的任务</small>
) : (
<small>可在推送弹层中选择项目下的具体任务</small>
)}
</div>
</>
)}
</div>
)}
{chatManaging && detail && (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setChatManaging(false)}>
<div className="modal-card group-push-modal" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">项目任务群 · 人工映射</p>
<h2>管理「{detail.project.name}」的任务群</h2>
</div>
<button type="button" onClick={() => setChatManaging(false)} aria-label="关闭">×</button>
</div>
<p className="project-chat-hint">
勾选要纳入项目的客户群;每个群选择对应的合作方(群主)。群主信息从企微同步后只读,
真正调整群主请去企业微信客户端操作后重新同步。
</p>
<div className="wecom-picker-list group-push-list">
{chatLoading ? (
<div className="wecom-picker-empty">正在读取客户群与合作方…</div>
) : chatGroups.length === 0 ? (
<div className="wecom-picker-empty">
暂无客户群,请先在「任务分发」里同步客户群列表,或在「合作方」里新建群主合作方。
</div>
) : (
chatGroups.map((group) => {
const selected = chatDraft.some((item) => item.chatId === group.chat_id);
const partnerId = chatDraft.find((item) => item.chatId === group.chat_id)?.partnerId ?? "";
return (
<div key={group.chat_id} className="project-chat-edit-row">
<label className="group-push-row">
<input
type="checkbox"
checked={selected}
onChange={() => toggleChat(group.chat_id)}
disabled={chatSaving}
/>
<span className="wecom-picker-meta">
<span className="wecom-picker-name">{group.name || "(未命名群)"}</span>
<span className="wecom-picker-sub">群主 {group.owner_user_id || "—"} · {group.member_count} 人</span>
</span>
</label>
{selected && (
<select
value={partnerId}
onChange={(event) => setChatPartner(group.chat_id, event.target.value)}
disabled={chatSaving}
>
<option value="">未映射合作方</option>
{chatOwners.map((owner) => (
<option key={owner.id} value={owner.id}>
{owner.name}({owner.wecom_userid})
</option>
))}
</select>
)}
</div>
);
})
)}
</div>
{chatDraft.some((item) => !chatGroups.some((group) => group.chat_id === item.chatId)) && (
<p className="project-chat-hint">
还有已关联但同步不到的群(列表中不存在),保存后会被移除。
</p>
)}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setChatManaging(false)} disabled={chatSaving}>取消</button>
<button type="button" className="primary-button" onClick={() => void saveChatManager()} disabled={chatSaving || chatLoading}>
{chatSaving ? "保存中…" : `保存关联(${chatDraft.length} 个群)`}
</button>
</div>
</div>
</div>
)}
{pushProject && (
<ProjectPushModal
detail={pushProject}
portalUrl={portalUrl}
onClose={() => setPushProject(null)}
setToast={setToast}
/>
)}
</section>
);
}
function ProjectEditorModal({
draft,
saving,
onChange,
onClose,
onSave,
}: {
draft: ProjectEditorDraft;
saving: boolean;
onChange: (draft: ProjectEditorDraft) => void;
onClose: () => void;
onSave: () => void;
}) {
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-card" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">任务中心 · 项目分组</p>
<h2>{draft.id ? "编辑项目" : "新建项目"}</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
<div className="form-grid two">
<label>
<span>项目名称 *</span>
<input
value={draft.name}
onChange={(event) => onChange({ ...draft, name: event.target.value })}
placeholder="例如:暑期焕新计划"
required
/>
</label>
<label>
<span>品牌</span>
<input
value={draft.brand}
onChange={(event) => onChange({ ...draft, brand: event.target.value })}
placeholder="例如:青柠实验室"
/>
</label>
</div>
<label>
<span>项目总群成员 userid(一行一个)</span>
<textarea
rows={5}
value={draft.members}
onChange={(event) => onChange({ ...draft, members: event.target.value })}
placeholder={"zhangsan\nlisi\nwangwu"}
/>
<small>userid 在企业微信管理后台「通讯录」成员详情中查看;推送任务时会逐人发送应用消息。</small>
</label>
<label>
<span>备注</span>
<textarea
rows={2}
value={draft.note}
onChange={(event) => onChange({ ...draft, note: event.target.value })}
placeholder="项目背景、投放节奏等"
/>
</label>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose} disabled={saving}>取消</button>
<button type="button" className="primary-button" onClick={onSave} disabled={saving}>
{saving ? "保存中…" : "保存项目"}
</button>
</div>
</div>
</div>
);
}
function ProjectDeleteModal({
target,
working,
onClose,
onConfirm,
}: {
target: ProjectSummary;
working: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-card" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow danger">删除项目</p>
<h2>确认删除「{target.name}」?</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
<p className="project-delete-warning">
删除后项目下的 {target.taskCount} 个任务会解除项目关联(任务本身保留),
任务群关联记录会被移除。此操作不可撤销。
</p>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose} disabled={working}>取消</button>
<button type="button" className="danger-button" onClick={onConfirm} disabled={working}>
{working ? "删除中…" : "确认删除"}
</button>
</div>
</div>
</div>
);
}
function buildProjectPushDraft(task: ProjectTaskItem, portalUrl: string) {
const portal = portalUrl.trim().replace(/\/$/, "");
const claimUrl = portal && task.share_token ? `${portal}/?task=${task.share_token}` : "";
const formatLabel =
task.task_type === "screenshot_collect"
? "截图回收"
: task.content_format === "video"
? "视频"
: "图文";
return [
`【项目任务】${task.name}`,
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
`平台:${task.platform}|形式:${formatLabel}`,
"请把任务转发到你负责的项目任务群,并跟进群内成员领取与发布。",
claimUrl ? `领取链接:${claimUrl}` : "领取链接生成失败,请联系管理员",
].join("\n");
}
function ProjectPushModal({
detail,
portalUrl,
onClose,
setToast,
}: {
detail: ProjectDetailData;
portalUrl: string;
onClose: () => void;
setToast: (message: string) => void;
}) {
const [taskId, setTaskId] = useState(detail.tasks[0]?.id ?? "");
const [sendMainGroup, setSendMainGroup] = useState(true);
const [sendGroupChats, setSendGroupChats] = useState(true);
const [sending, setSending] = useState(false);
const [outcome, setOutcome] = useState<{
appResults: Array<{ userid: string; ok: boolean; error: string }>;
appSent: number;
appFailedCount: number;
groupResults: WecomGroupPushResult[];
groupChatCount: number;
skippedReasons: string[];
hint?: string;
} | null>(null);
const selectedTask = detail.tasks.find((task) => task.id === taskId) ?? detail.tasks[0];
const [draft, setDraft] = useState(() =>
detail.tasks[0] ? buildProjectPushDraft(detail.tasks[0], portalUrl) : "",
);
const postAction = useCallback(async <T,>(payload: Record<string, unknown>, fallback: string) => {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const parsed = await readApiResponse<T & { error?: string }>(response, fallback);
if (!response.ok) throw new Error(parsed.error || fallback);
return parsed;
}, []);
const changeTask = (nextTaskId: string) => {
setTaskId(nextTaskId);
const task = detail.tasks.find((item) => item.id === nextTaskId);
if (task) setDraft(buildProjectPushDraft(task, portalUrl));
};
const submitPush = async () => {
if (!taskId) {
setToast("请选择要推送的任务");
return;
}
setSending(true);
try {
const parsed = await postAction<NonNullable<typeof outcome>>(
{
action: "project_push_task",
taskId,
sendMainGroup,
sendGroupChats,
text: draft,
},
"推送失败",
);
setOutcome(parsed);
setToast("推送已执行");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "推送失败");
} finally {
setSending(false);
}
};
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-card group-push-modal" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">项目分发</p>
<h2>推送任务 · {detail.project.name}</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
<label>
<span>选择任务</span>
<select value={taskId} onChange={(event) => changeTask(event.target.value)} disabled={sending}>
{detail.tasks.map((task) => (
<option key={task.id} value={task.id}>
{task.name}(截止 {formatDate(task.due_at)})
</option>
))}
</select>
</label>
<div className="project-push-targets">
<label className="project-push-target">
<input
type="checkbox"
checked={sendMainGroup}
onChange={(event) => setSendMainGroup(event.target.checked)}
disabled={sending}
/>
<span>
<b>项目总群</b>
<small>{detail.project.mainGroupMemberIds.length} 名成员,逐人发送应用消息</small>
</span>
</label>
<label className="project-push-target">
<input
type="checkbox"
checked={sendGroupChats}
onChange={(event) => setSendGroupChats(event.target.checked)}
disabled={sending}
/>
<span>
<b>项目任务群</b>
<small>{detail.groupChats.filter((chat) => !chat.missing).length} 个客户群,以群主身份创建群发,群主确认后送达</small>
</span>
</label>
</div>
<label className="group-push-text">
<span>推送文案(可编辑,发送前请确认)</span>
<textarea
rows={8}
value={draft}
onChange={(event) => setDraft(event.target.value)}
disabled={sending}
/>
{selectedTask && !selectedTask.share_token && (
<small className="group-push-fail">该任务缺少领取链接令牌,文案中不会带领取链接。</small>
)}
</label>
{outcome && (
<div className="group-push-result">
<p>
应用消息:成功 {outcome.appSent} 人、失败 {outcome.appFailedCount} 人;
任务群群发:覆盖 {outcome.groupChatCount} 个群。
{outcome.skippedReasons.length > 0 ? `(${outcome.skippedReasons.join(";")})` : ""}
{outcome.hint ? ` ${outcome.hint}。` : ""}
</p>
{outcome.appResults.some((item) => !item.ok) && (
<ul>
{outcome.appResults.filter((item) => !item.ok).map((item) => (
<li key={item.userid} className="group-push-fail">
{item.userid}:{item.error || "发送失败"}
</li>
))}
</ul>
)}
{outcome.groupResults.length > 0 && (
<ul>
{outcome.groupResults.map((item) => (
<li key={item.msgid || item.sender}>
群主 {item.sender} · {item.chatCount} 个群 · msgid {item.msgid || "—"}
{item.failList.length > 0 && (
<span className="group-push-fail">(失败群:{item.failList.join("、")})</span>
)}
</li>
))}
</ul>
)}
</div>
)}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose} disabled={sending}>关闭</button>
<button
type="button"
className="primary-button"
onClick={() => void submitPush()}
disabled={sending || (!sendMainGroup && !sendGroupChats) || !taskId}
>
{sending ? "推送中…" : "立即推送"}
</button>
</div>
</div>
</div>
);
}
type BoundWecomPartnerRow = {
id: string;
name: string;
wecomExternalUserId: string;
};
type TaskPushLogRecord = {
id: string;
task_id: string;
push_kind: string;
main_group_count: number;
koc_ok_count: number;
detail: string;
created_at: string;
};
type OneClickPushOutcomeView = {
linkUrl: string;
kocSent: number;
kocFailed: number;
kocResults: Array<{ partnerId: string; partnerName: string; ok: boolean; error: string }>;
skippedReasons: string[];
hint?: string;
};
const PUSH_KIND_LABELS: Record<string, string> = {
task_open: "任务推送",
card_ack: "卡片确认",
};
type TaskReportRow = {
id: string;
name: string;
task_type: string;
brand: string;
platform: string;
status: string;
due_at: string;
created_at: string;
quantity: number;
project_name: string | null;
claimed_count: number;
published_count: number;
completed_count: number;
exposure_total: number | null;
views_total: 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;
latest_shares: number | null;
};
const REPORT_STATUS_FILTERS = [
{ key: "all", label: "全部状态" },
{ key: "active", label: "进行中" },
{ key: "completed", label: "已完结" },
];
function formatPushLogTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return `${formatDate(date.toISOString().slice(0, 10))} ${date.toTimeString().slice(0, 5)}`;
}
function OneClickPushModal({
task,
onClose,
setToast,
}: {
task: Task;
onClose: () => void;
setToast: (message: string) => void;
}) {
const [partners, setPartners] = useState<BoundWecomPartnerRow[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const [sendMainGroup, setSendMainGroup] = useState(true);
const [createGroupTemplate, setCreateGroupTemplate] = useState(true);
const [sendKoc, setSendKoc] = useState(true);
const [force, setForce] = useState(false);
const [pushedToday, setPushedToday] = useState(false);
const [sending, setSending] = useState(false);
const [result, setResult] = useState<OneClickPushOutcomeView | null>(null);
const [logs, setLogs] = useState<TaskPushLogRecord[]>([]);
const postAction = useCallback(async <T,>(payload: Record<string, unknown>, fallback: string) => {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const parsed = await readApiResponse<T & { error?: string }>(response, fallback);
if (!response.ok) throw new Error(parsed.error || fallback);
return parsed;
}, []);
const loadLogs = useCallback(async () => {
try {
const parsed = await postAction<{ logs: TaskPushLogRecord[] }>(
{ action: "task_push_logs", taskId: task.id },
"读取推送记录失败",
);
setLogs(parsed.logs);
} catch {
// 推送记录加载失败不阻断弹窗
}
}, [postAction, task.id]);
useEffect(() => {
const timer = window.setTimeout(() => {
void (async () => {
try {
const parsed = await postAction<{
partners: BoundWecomPartnerRow[];
pushedToday: unknown;
}>({ action: "wecom_bound_partners", taskId: task.id }, "读取合作方失败");
setPartners(parsed.partners);
setSelected(parsed.partners.map((partner) => partner.id));
setPushedToday(Boolean(parsed.pushedToday));
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "读取合作方失败");
} finally {
setLoading(false);
}
})();
void loadLogs();
}, 0);
return () => window.clearTimeout(timer);
}, [postAction, loadLogs, task.id, setToast]);
const filtered = partners.filter((partner) => {
const keyword = search.trim().toLowerCase();
if (!keyword) return true;
return (
partner.name.toLowerCase().includes(keyword) ||
partner.wecomExternalUserId.toLowerCase().includes(keyword)
);
});
const allSelected = partners.length > 0 && selected.length === partners.length;
const togglePartner = (partnerId: string) => {
setSelected((previous) =>
previous.includes(partnerId)
? previous.filter((id) => id !== partnerId)
: [...previous, partnerId],
);
};
const submitPush = async () => {
if (!sendMainGroup && !sendKoc && !createGroupTemplate) {
setToast("请至少选择一个推送目标");
return;
}
if (sendKoc && selected.length === 0) {
setToast("请至少勾选一位合作方,或取消勾选「KOC 个人直达」");
return;
}
setSending(true);
try {
const parsed = await postAction<OneClickPushOutcomeView>(
{
action: "task_one_click_push",
taskId: task.id,
sendMainGroup,
sendKoc,
createGroupTemplate,
selectedKocPartnerIds: selected,
force,
},
"一键推送失败",
);
setResult(parsed);
setPushedToday(true);
setToast("推送已执行");
await loadLogs();
} catch (reason) {
const message = reason instanceof Error ? reason.message : "一键推送失败";
if (message.includes("仍要推送")) setPushedToday(true);
setToast(message);
} finally {
setSending(false);
}
};
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-card group-push-modal" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">企业微信 · 一键推送</p>
<h2>推送「{task.name}」</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
{pushedToday && (
<label className="project-push-target">
<input
type="checkbox"
checked={force}
onChange={(event) => setForce(event.target.checked)}
disabled={sending}
/>
<span>
<b>今天已推送过,仍要推送</b>
<small>重复推送会再次给合作方发送应用消息,请确认有必要</small>
</span>
</label>
)}
<div className="project-push-targets">
<label className="project-push-target">
<input
type="checkbox"
checked={sendMainGroup}
onChange={(event) => setSendMainGroup(event.target.checked)}
disabled={sending}
/>
<span>
<b>项目总群</b>
<small>应用消息逐人发送(任务未挂项目时自动跳过)</small>
</span>
</label>
<label className="project-push-target">
<input
type="checkbox"
checked={createGroupTemplate}
onChange={(event) => setCreateGroupTemplate(event.target.checked)}
disabled={sending}
/>
<span>
<b>项目任务群</b>
<small>以群主身份创建企业群发,群主确认后送达客户群</small>
</span>
</label>
<label className="project-push-target">
<input
type="checkbox"
checked={sendKoc}
onChange={(event) => setSendKoc(event.target.checked)}
disabled={sending}
/>
<span>
<b>KOC 个人直达</b>
<small>应用消息直发已绑定企微身份的合作方,点开链接免输身份</small>
</span>
</label>
</div>
{sendKoc && (
<div className="koc-picker">
{loading ? (
<small>正在读取已绑定合作方…</small>
) : partners.length === 0 ? (
<small>还没有已绑定企微身份的合作方;合作方在企微内打开领取链接领取后会自动绑定。</small>
) : (
<>
<div className="koc-picker-toolbar">
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索合作方名称 / 企微 ID"
disabled={sending}
/>
<label>
<input
type="checkbox"
checked={allSelected}
onChange={(event) =>
setSelected(event.target.checked ? partners.map((partner) => partner.id) : [])
}
disabled={sending}
/>
全选({selected.length}/{partners.length})
</label>
</div>
<div className="koc-picker-list">
{filtered.map((partner) => (
<label key={partner.id} className="koc-picker-item">
<input
type="checkbox"
checked={selected.includes(partner.id)}
onChange={() => togglePartner(partner.id)}
disabled={sending}
/>
<span>{partner.name || partner.id}</span>
<small>{partner.wecomExternalUserId}</small>
</label>
))}
{filtered.length === 0 && <small>没有匹配的合作方</small>}
</div>
</>
)}
</div>
)}
{result && (
<div className="group-push-result">
<p>
KOC 直达:成功 {result.kocSent} 人、失败 {result.kocFailed} 人。
{result.skippedReasons.length > 0 ? `(${result.skippedReasons.join(";")})` : ""}
{result.hint ? ` ${result.hint}。` : ""}
</p>
{result.kocResults.some((item) => !item.ok) && (
<ul>
{result.kocResults.filter((item) => !item.ok).map((item) => (
<li key={item.partnerId} className="group-push-fail">
{item.partnerName}:{item.error || "发送失败"}
</li>
))}
</ul>
)}
</div>
)}
{logs.length > 0 && (
<div className="group-push-result">
<b>推送记录</b>
<ul>
{logs.slice(0, 5).map((log) => (
<li key={log.id}>
{formatPushLogTime(log.created_at)} · {PUSH_KIND_LABELS[log.push_kind] ?? log.push_kind}
{log.push_kind === "task_open"
? `(总群 ${log.main_group_count} 人,KOC 直达 ${log.koc_ok_count} 人)`
: ""}
</li>
))}
</ul>
</div>
)}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose} disabled={sending}>关闭</button>
<button
type="button"
className="primary-button"
onClick={() => void submitPush()}
disabled={sending || (pushedToday && !force) || !task.share_token}
>
{sending ? "推送中…" : "一键推送"}
</button>
</div>
</div>
</div>
);
}
function ReportsPage({ setToast }: { setToast: (message: string) => void }) {
const [reports, setReports] = useState<TaskReportRow[]>([]);
const [loading, setLoading] = useState(true);
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [typeFilter, setTypeFilter] = useState("all");
const [exporting, setExporting] = useState(false);
const loadReports = useCallback(async () => {
try {
setLoading(true);
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "task_report_list" }),
});
const result = await readApiResponse<
{ reports?: TaskReportRow[] } & { error?: string }
>(response, "读取复盘报表失败");
if (!response.ok) throw new Error(result.error || "读取复盘报表失败");
setReports(result.reports ?? []);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "读取复盘报表失败");
} finally {
setLoading(false);
}
}, [setToast]);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadReports();
}, 0);
return () => window.clearTimeout(timer);
}, [loadReports]);
const filtered = useMemo(() => {
const query = keyword.trim().toLowerCase();
return reports.filter((row) => {
if (statusFilter !== "all" && row.status !== statusFilter) return false;
if (typeFilter !== "all" && row.task_type !== typeFilter) return false;
if (!query) return true;
return [row.name, row.brand, row.project_name ?? "", row.platform]
.join(" ")
.toLowerCase()
.includes(query);
});
}, [reports, keyword, statusFilter, typeFilter]);
const exportCsv = async () => {
try {
setExporting(true);
const response = await fetch("/api/task-report-export", {
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 = `任务复盘报表-${todayInputValue().replaceAll("-", "")}.csv`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
setToast("CSV 已导出,可直接用 Excel 打开");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "导出失败");
} finally {
setExporting(false);
}
};
const totals = useMemo(
() =>
filtered.reduce(
(acc, row) => ({
claimed: acc.claimed + row.claimed_count,
published: acc.published + row.published_count,
completed: acc.completed + row.completed_count,
exposure: acc.exposure + (row.exposure_total ?? 0),
views: acc.views + (row.views_total ?? 0),
}),
{ claimed: 0, published: 0, completed: 0, exposure: 0, views: 0 },
),
[filtered],
);
return (
<section className="panel table-panel">
<div className="panel-heading">
<div>
<h2>任务复盘</h2>
<p>
共 {filtered.length} 个任务 · 领取 {totals.claimed} · 发布 {totals.published} ·
完成 {totals.completed} · 曝光 {formatNumber(totals.exposure || null)} · 阅读{" "}
{formatNumber(totals.views || null)}
</p>
</div>
<div className="resource-heading-actions">
<div className="filter-chips">
{REPORT_STATUS_FILTERS.map((item) => (
<button
key={item.key}
className={statusFilter === item.key ? "active" : ""}
onClick={() => setStatusFilter(item.key)}
>
{item.label}
</button>
))}
</div>
<div className="filter-chips">
{[
{ key: "all", label: "全部类型" },
{ key: "content_publish", label: "内容发布" },
{ key: "screenshot_collect", label: "截图采集" },
].map((item) => (
<button
key={item.key}
className={typeFilter === item.key ? "active" : ""}
onClick={() => setTypeFilter(item.key)}
>
{item.label}
</button>
))}
</div>
<button className="ghost-button" onClick={() => void loadReports()} disabled={loading}>
刷新
</button>
<button
className="primary-button soft"
onClick={() => void exportCsv()}
disabled={exporting || loading}
>
{exporting ? "导出中…" : "导出 CSV"}
</button>
</div>
</div>
<div className="resource-toolbar">
<div className="resource-search">
<span aria-hidden="true">⌕</span>
<input
aria-label="搜索任务名称、品牌或项目"
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索任务名称 / 品牌 / 项目 / 平台"
type="search"
value={keyword}
/>
</div>
</div>
<div className="table-scroll">
<table className="data-table">
<thead>
<tr>
<th>任务</th>
<th>项目</th>
<th>截止日</th>
<th>状态</th>
<th>计划</th>
<th>领取</th>
<th>发布</th>
<th>完成</th>
<th>完成率</th>
<th>曝光</th>
<th>阅读</th>
<th>最新点赞</th>
<th>最新评论</th>
<th>最新收藏</th>
<th>最新转发</th>
</tr>
</thead>
<tbody>
{filtered.map((row) => (
<tr key={row.id}>
<td>
<div className="content-cell">
<span className="content-id">
{row.task_type === "screenshot_collect" ? "搜" : "文"}
</span>
<div>
<strong>{row.name}</strong>
<span>
{row.brand || "未填品牌"} · {row.platform}
</span>
</div>
</div>
</td>
<td>{row.project_name || <span className="muted">—</span>}</td>
<td><span className="date-value">{formatDate(row.due_at)}</span></td>
<td>
<span className={`status-pill ${row.status === "active" ? "claimed" : "complete"}`}>
{row.status === "active" ? "进行中" : "已完结"}
</span>
</td>
<td><strong className="plain-strong">{row.quantity}</strong></td>
<td><strong className="plain-strong">{row.claimed_count}</strong></td>
<td><strong className="plain-strong">{row.published_count}</strong></td>
<td><strong className="plain-strong">{row.completed_count}</strong></td>
<td>
{row.claimed_count
? `${Math.round((row.completed_count / row.claimed_count) * 100)}%`
: "—"}
</td>
<td>{formatNumber(row.exposure_total)}</td>
<td>{formatNumber(row.views_total)}</td>
<td>{formatNumber(row.latest_likes)}</td>
<td>{formatNumber(row.latest_comments)}</td>
<td>{formatNumber(row.latest_collects)}</td>
<td>{formatNumber(row.latest_shares)}</td>
</tr>
))}
</tbody>
</table>
{!loading && filtered.length === 0 && (
<div className="table-empty">暂无符合条件的任务</div>
)}
{loading && <div className="table-empty">正在读取复盘数据…</div>}
</div>
</section>
);
}
2026-07-30 12:06:41 +08:00
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,
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;
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",
},
].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("tasks")}>查看全部</button>
2026-07-30 12:06:41 +08:00
</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>
<p>按任务选择采集日期,系统在当天09:00更新点赞、收藏、评论、转发和总互动。</p>
2026-07-30 12:06:41 +08:00
</div>
<button className="primary-button soft" onClick={() => onNavigate("recovery")}>进入数据回收</button>
</div>
</section>
<section className="panel task-mini-panel">
<div className="panel-heading">
<div><h2>进行中的任务</h2><p>按截止时间排序</p></div>
</div>
<div className="task-mini-list">
{tasks.map((task) => {
const progress = Math.round((task.claimed_quantity / task.quantity) * 100);
return (
<article key={task.id}>
<div className="task-mini-top">
<div className={`avatar ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
<div>
<strong>{task.name}</strong>
<span 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("tasks")}>查看全部</button>
2026-07-30 12:06:41 +08:00
</div>
<DistributionTable distributions={distributions} compact />
</section>
</>
);
}
function TaskRowCard({
task,
portalUrl,
onDetail,
onCopyShare,
onPushToGroups,
onOneClickPush,
}: {
task: Task;
portalUrl: string;
onDetail: () => void;
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void;
}) {
const progress = Math.round((task.claimed_quantity / task.quantity) * 100);
return (
<article className="task-row">
<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>
<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>
</>
)}
</small>
<div className="progress"><span style={{ width: `${progress}%` }} /></div>
</div>
<div className="task-number"><strong>{task.claimed_quantity}</strong><span>/ {task.quantity} {task.task_type === "screenshot_collect" ? "份已领取" : "篇已领取"}</span></div>
<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={onDetail}
>
分发明细
</button>
<button
className="share-link-button"
onClick={() => onCopyShare(task)}
disabled={!portalUrl || !task.share_token}
>
复制领取链接
</button>
<button
className="share-link-button"
onClick={() => onPushToGroups(task)}
>
发布到企微群
</button>
<button
className="share-link-button"
onClick={() => onOneClickPush(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>
);
}
2026-07-30 12:06:41 +08:00
function TasksPage({
tasks,
distributions,
2026-07-30 12:06:41 +08:00
portalUrl,
canRelease,
isManager,
working,
setToast,
onTasksChanged,
2026-07-30 12:06:41 +08:00
onCreate,
onCopyShare,
onPushToGroups,
onOneClickPush,
onRelease,
2026-07-30 12:06:41 +08:00
}: {
tasks: Task[];
distributions: Distribution[];
2026-07-30 12:06:41 +08:00
portalUrl: string;
canRelease: boolean;
isManager: boolean;
working: boolean;
setToast: (message: string) => void;
onTasksChanged: () => Promise<void>;
2026-07-30 12:06:41 +08:00
onCreate: () => void;
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void;
onRelease: (distribution: Distribution) => Promise<boolean>;
2026-07-30 12:06:41 +08:00
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"grouped" | "flat">(isManager ? "grouped" : "flat");
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [detailVersion, setDetailVersion] = useState(0);
const [editorOpen, setEditorOpen] = useState(false);
const [editorSaving, setEditorSaving] = useState(false);
const [editorDraft, setEditorDraft] = useState<ProjectEditorDraft>(EMPTY_PROJECT_DRAFT);
const [deleteTarget, setDeleteTarget] = useState<ProjectSummary | null>(null);
const [deleteWorking, setDeleteWorking] = useState(false);
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
const loadProjects = useCallback(async () => {
setProjectsLoading(true);
try {
const parsed = await postAction<{ projects?: ProjectSummary[] }>(
{ action: "project_list" },
"读取项目列表失败",
);
setProjects(parsed.projects ?? []);
} catch (reason) {
setProjects([]);
setToast(reason instanceof Error ? reason.message : "读取项目列表失败");
} finally {
setProjectsLoading(false);
}
}, [setToast]);
useEffect(() => {
if (!isManager || viewMode !== "grouped") return;
const timer = window.setTimeout(() => {
void loadProjects();
}, 0);
return () => window.clearTimeout(timer);
}, [isManager, viewMode, loadProjects]);
const openEditor = (project?: ProjectSummary) => {
setEditorDraft({
id: project?.id ?? "",
name: project?.name ?? "",
brand: project?.brand ?? "",
members: (project?.mainGroupMemberIds ?? []).join("\n"),
note: project?.note ?? "",
});
setEditorOpen(true);
};
const submitEditor = async () => {
if (!editorDraft.name.trim()) {
setToast("请填写项目名称");
return;
}
setEditorSaving(true);
try {
await postAction(
{
action: "project_save",
id: editorDraft.id,
name: editorDraft.name,
brand: editorDraft.brand,
mainGroupMemberIds: editorDraft.members,
note: editorDraft.note,
},
"保存项目失败",
);
setEditorOpen(false);
setToast(editorDraft.id ? "项目已更新" : "项目已创建");
await loadProjects();
setDetailVersion((version) => version + 1);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "保存项目失败");
} finally {
setEditorSaving(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget) return;
setDeleteWorking(true);
try {
await postAction(
{ action: "project_delete", projectId: deleteTarget.id },
"删除项目失败",
);
setToast(`项目「${deleteTarget.name}」已删除,任务已解除关联`);
setDeleteTarget(null);
await loadProjects();
await onTasksChanged();
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "删除项目失败");
} finally {
setDeleteWorking(false);
}
};
if (selectedTask) {
return (
<TaskDistributionDetail
task={selectedTask}
distributions={distributions}
canRelease={canRelease}
working={working}
onRelease={onRelease}
onBack={() => setSelectedTaskId(null)}
/>
);
}
const ungroupedTasks = tasks.filter((task) => !task.project_id);
const showGrouped = isManager && viewMode === "grouped";
2026-07-30 12:06:41 +08:00
return (
<div className="stack">
<section className="task-hero">
<div>
<p className="eyebrow">两种固定任务类型 · 项目化分发</p>
<h2>发布笔记,或回收任务截图</h2>
<p>内容发布走飞书笔记链路;截图回收只下发关键词和要求,不进入发布数据采集。</p>
2026-07-30 12:06:41 +08:00
</div>
<div className="project-hero-actions">
{isManager && <button className="ghost-button" onClick={() => openEditor()}>+ 新建项目</button>}
<button className="primary-button light" onClick={onCreate}>+ 新建任务</button>
2026-07-30 12:06:41 +08:00
</div>
</section>
{isManager && (
<div className="partner-type-tabs" role="group" aria-label="任务视图切换">
<button type="button" className={viewMode === "grouped" ? "active" : ""} onClick={() => setViewMode("grouped")}>
按项目分组
</button>
<button type="button" className={viewMode === "flat" ? "active" : ""} onClick={() => setViewMode("flat")}>
全部任务({tasks.length})
</button>
</div>
)}
{showGrouped ? (
<>
{projectsLoading && projects.length === 0 ? (
<div className="empty-state">
<div className="empty-icon">…</div>
<h3>正在读取项目</h3>
</div>
) : projects.length === 0 ? (
<div className="empty-state">
<div className="empty-icon">▦</div>
<h3>还没有项目</h3>
<p>新建项目后,把任务挂到项目下,即可一次推送到项目总群与各任务群。</p>
<button className="primary-button" onClick={() => openEditor()}>+ 新建项目</button>
</div>
) : (
projects.map((project) => (
<ProjectSection
key={project.id}
project={project}
tasks={tasks.filter((task) => task.project_id === project.id)}
detailVersion={detailVersion}
portalUrl={portalUrl}
setToast={setToast}
onProjectsChanged={loadProjects}
onEditProject={openEditor}
onDeleteProject={setDeleteTarget}
onOpenTaskDetail={setSelectedTaskId}
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
/>
))
)}
<section className="panel ungrouped-panel">
<div className="panel-heading">
<div><h2>未分组任务</h2><p>{ungroupedTasks.length} 个任务未挂项目</p></div>
</div>
{ungroupedTasks.length === 0 ? (
<p className="project-empty-line">所有任务都已挂在项目下</p>
) : (
<div className="task-list">
{ungroupedTasks.map((task) => (
<TaskRowCard
key={task.id}
task={task}
portalUrl={portalUrl}
onDetail={() => setSelectedTaskId(task.id)}
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
/>
))}
</div>
)}
</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) => (
<TaskRowCard
key={task.id}
task={task}
portalUrl={portalUrl}
onDetail={() => setSelectedTaskId(task.id)}
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
/>
))}
</div>
</section>
)}
{editorOpen && (
<ProjectEditorModal
draft={editorDraft}
saving={editorSaving}
onChange={setEditorDraft}
onClose={() => setEditorOpen(false)}
onSave={() => void submitEditor()}
/>
)}
{deleteTarget && (
<ProjectDeleteModal
target={deleteTarget}
working={deleteWorking}
onClose={() => setDeleteTarget(null)}
onConfirm={() => void confirmDelete()}
/>
)}
2026-07-30 12:06:41 +08:00
</div>
);
}
function TaskScopeList({
tasks,
distributions,
onOpen,
}: {
tasks: Task[];
distributions: Distribution[];
onOpen: (taskId: string) => void;
}) {
return (
<section className="task-scope-grid">
{tasks.map((task) => {
const taskDistributions = distributions.filter(
(item) => item.task_id === task.id,
);
const isScreenshotTask = task.task_type === "screenshot_collect";
2026-07-30 12:06:41 +08:00
const published = taskDistributions.filter((item) => item.publish_url);
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;
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>
<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">
<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>
<div className="task-scope-metrics">
{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>
</>
)}
</div>
2026-07-30 12:06:41 +08:00
<button className="task-scope-open" onClick={() => onOpen(task.id)}>
<span>进入回收队列</span>
2026-07-30 12:06:41 +08:00
<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>
<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>
{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 TaskDistributionDetail({
task,
2026-07-30 12:06:41 +08:00
distributions,
canRelease,
working,
onRelease,
onBack,
2026-07-30 12:06:41 +08:00
}: {
task: Task;
2026-07-30 12:06:41 +08:00
distributions: Distribution[];
canRelease: boolean;
working: boolean;
onRelease: (distribution: Distribution) => Promise<boolean>;
onBack: () => void;
2026-07-30 12:06:41 +08:00
}) {
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
2026-07-30 12:06:41 +08:00
const taskDistributions = distributions.filter(
(item) => item.task_id === task.id,
2026-07-30 12:06:41 +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).length;
const submitted = taskDistributions.filter((item) => item.result_submitted_at).length;
2026-07-30 12:06:41 +08:00
return (
<div className="stack">
<TaskDetailHeader
task={task}
label={isScreenshotTask ? "截图任务分发" : "内容分发"}
onBack={onBack}
2026-07-30 12:06:41 +08:00
/>
<div className="inline-summary">
<div><span>已领取</span><strong>{taskDistributions.length}</strong></div>
<i />
<div><span>{isScreenshotTask ? "已提交截图" : "已回填链接"}</span><strong>{isScreenshotTask ? submitted : published}</strong></div>
2026-07-30 12:06:41 +08:00
<i />
<div><span>{isScreenshotTask ? "待提交" : "待发布"}</span><strong>{taskDistributions.length - (isScreenshotTask ? submitted : published)}</strong></div>
<div className="summary-note"><span className="pulse-dot" />当前仅展示“{task.name}”</div>
2026-07-30 12:06:41 +08:00
</div>
<section className="panel table-panel">
<div className="panel-heading">
<div><h2>{isScreenshotTask ? "截图任务分发表" : "内容分发表"}</h2><p>{isScreenshotTask ? "每份任务与结果截图一一对应" : "实际发布账号在链接回填后自动补齐"}</p></div>
2026-07-30 12:06:41 +08:00
</div>
{isScreenshotTask ? (
<ScreenshotTaskTable distributions={taskDistributions} />
) : (
<DistributionTable
distributions={taskDistributions}
onRelease={canRelease ? setReleaseTarget : undefined}
/>
)}
2026-07-30 12:06:41 +08:00
</section>
{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,
onRelease,
2026-07-30 12:06:41 +08:00
}: {
distributions: Distribution[];
compact?: boolean;
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>
{onRelease && <th>操作</th>}
2026-07-30 12:06:41 +08:00
</tr>
</thead>
<tbody>
{distributions.map((item) => (
<tr key={item.id}>
<td>
<div className="content-cell">
<span className="content-id">{item.content_id.split("-").at(-1)?.toUpperCase()}</span>
<div><strong>{item.content_title}</strong><span>{item.task_name} · {item.task_brand}</span></div>
</div>
</td>
<td><strong className="plain-strong">{item.partner_name}</strong></td>
<td>
{item.account_nickname ? (
<div className="account-cell">
<div className={`avatar small ${avatarColor(item.account_nickname)}`}>{item.account_nickname.slice(0, 1)}</div>
<div><strong>{item.account_nickname}</strong><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>
{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>
);
}
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)}
/>
</>
);
}
const RESOURCE_PAGE_SIZE = 15;
function pageList(current: number, total: number): Array<number | "gap"> {
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1);
}
const items: Array<number | "gap"> = [1];
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
if (start > 2) items.push("gap");
for (let page = start; page <= end; page += 1) items.push(page);
if (end < total - 1) items.push("gap");
items.push(total);
return items;
}
2026-07-30 12:06:41 +08:00
function ResourcesPage({
accounts,
distributions,
2026-08-06 10:18:23 +08:00
exporting,
onExport,
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;
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("");
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
const [importMode, setImportMode] = useState<"bulk" | "single">("bulk");
const [importFile, setImportFile] = useState<File | null>(null);
const [importPreview, setImportPreview] = useState<ResourceImportPreview | null>(null);
const [importWorking, setImportWorking] = useState(false);
const [importError, setImportError] = useState("");
const [singleForm, setSingleForm] = useState({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
const [singleWorking, setSingleWorking] = useState(false);
const [singleError, setSingleError] = useState("");
const [editAccount, setEditAccount] = useState<Account | null>(null);
const [editAccountId, setEditAccountId] = useState("");
const [editWorking, setEditWorking] = useState(false);
const [editError, setEditError] = useState("");
const [idRefresh, setIdRefresh] = useState<FieldRefreshStatus | null>(null);
const [idRefreshStarting, setIdRefreshStarting] = useState(false);
const idRefreshNotifiedRef = useRef(true);
const [idRefreshError, setIdRefreshError] = useState("");
const [fieldPickOpen, setFieldPickOpen] = useState(false);
const [fieldChecks, setFieldChecks] = useState<Record<string, boolean>>(
() =>
Object.fromEntries(
REFRESH_FIELD_OPTIONS.map((option) => [
option.key,
option.key === "publicAccountId",
]),
),
);
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(
[
...deliveries
.filter(
(item) =>
!item.claimant_name ||
item.partner_name !== item.claimant_name,
)
.map((item) => item.partner_name),
...(account.cooperation_source || "")
.split(/[、,,;;|]/)
.map((item) => item.trim()),
].filter(Boolean),
2026-08-06 10:18:23 +08:00
),
].sort((left, right) => left.localeCompare(right, "zh-CN")),
partnerManagedOnly:
deliveries.some((item) => item.delegation_bundle_id) &&
deliveries.every((item) => item.delegation_bundle_id),
};
});
}, [accounts, distributions]);
const platformOptions = useMemo(
() => [
"小红书",
"抖音",
...[...new Set(accounts.map((account) => account.platform).filter(Boolean))]
.filter((platform) => platform !== "小红书" && platform !== "抖音")
2026-08-06 10:18:23 +08:00
.sort((left, right) => left.localeCompare(right, "zh-CN")),
],
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 }) => {
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());
const totalPages = Math.max(
1,
Math.ceil(filteredAccounts.length / RESOURCE_PAGE_SIZE),
);
const currentPage = Math.min(page, totalPages);
const pagedAccounts = filteredAccounts.slice(
(currentPage - 1) * RESOURCE_PAGE_SIZE,
currentPage * RESOURCE_PAGE_SIZE,
);
2026-08-06 10:18:23 +08:00
const clearFilters = () => {
setQuery("");
setPlatformFilter("全部平台");
2026-08-06 11:48:10 +08:00
setIpFilter("");
setSourceFilter("");
setPage(1);
2026-07-30 12:06:41 +08:00
};
const closeImport = () => {
if (importWorking || singleWorking) return;
setImportOpen(false);
setImportFile(null);
setImportPreview(null);
setImportError("");
setSingleForm({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
setSingleError("");
};
const switchImportMode = (mode: "bulk" | "single") => {
if (importWorking || singleWorking || mode === importMode) return;
setImportMode(mode);
setImportFile(null);
setImportPreview(null);
setImportError("");
setSingleForm({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
setSingleError("");
};
const submitSingle = async () => {
if (!singleForm.profileUrl.trim()) {
setSingleError("请填写账号主页链接");
return;
}
try {
setSingleWorking(true);
setSingleError("");
const response = await fetch("/api/resources-insert", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(singleForm),
});
const result = await readApiResponse<{ action: string; message?: string; error?: string }>(
response,
"保存失败",
);
if (!response.ok) throw new Error(result.error || "保存失败");
setImportOpen(false);
setImportMode("bulk");
setSingleForm({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
setSingleError("");
await onImported(result.message || "已保存账号");
} catch (reason) {
setSingleError(reason instanceof Error ? reason.message : "保存失败");
} finally {
setSingleWorking(false);
}
};
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);
}
};
const closeAccountEdit = () => {
if (editWorking) return;
setEditAccount(null);
setEditAccountId("");
setEditError("");
};
const submitAccountEdit = async () => {
if (!editAccount) return;
const value = editAccountId.trim();
if (!value) {
setEditError("请填写新的账号ID");
return;
}
if (!/^[\p{L}\p{N}._-]{2,80}$/u.test(value)) {
setEditError("账号ID需为 2-80 位字母、数字或 . _ - 组合");
return;
}
try {
setEditWorking(true);
setEditError("");
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "set_public_account_ids",
items: [{ accountId: editAccount.id, publicAccountId: value }],
}),
});
const result = await readApiResponse<{ error?: string }>(response, "保存失败");
if (!response.ok) throw new Error(result.error || "保存失败");
const label = editAccount.nickname || editAccount.public_account_id || "该账号";
setEditAccount(null);
setEditAccountId("");
await onImported(`已更新 ${label} 的账号ID`);
} catch (reason) {
setEditError(reason instanceof Error ? reason.message : "保存失败");
} finally {
setEditWorking(false);
}
};
const runningIdRefresh = idRefresh?.status === "running";
const fetchIdRefreshStatus = useCallback(async () => {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "account_field_refresh_status" }),
});
const result = await readApiResponse<{ refresh: FieldRefreshStatus | null }>(
response,
"查询失败",
);
if (!response.ok) throw new Error(result.error || "查询失败");
return result.refresh;
}, []);
const applyIdRefreshStatus = useCallback(
(status: FieldRefreshStatus) => {
if (status.status === "done" && !idRefreshNotifiedRef.current) {
idRefreshNotifiedRef.current = true;
const summary = `账号资料更新完成:成功 ${status.updated} 个${
status.skipped ? `,未识别 ${status.skipped} 个` : ""
}${status.failed ? `,失败 ${status.failed} 个` : ""}`;
void onImported(summary);
}
setIdRefresh(status);
},
[onImported],
);
useEffect(() => {
void fetchIdRefreshStatus()
.then((status) => {
if (status?.status === "running") {
idRefreshNotifiedRef.current = false;
applyIdRefreshStatus(status);
}
})
.catch(() => undefined);
}, [applyIdRefreshStatus, fetchIdRefreshStatus]);
useEffect(() => {
if (!runningIdRefresh) return;
const timer = window.setInterval(() => {
void fetchIdRefreshStatus()
.then((status) => {
if (status) applyIdRefreshStatus(status);
})
.catch(() => undefined);
}, 3000);
return () => window.clearInterval(timer);
}, [applyIdRefreshStatus, fetchIdRefreshStatus, runningIdRefresh]);
const closeFieldPick = () => {
if (idRefreshStarting) return;
setFieldPickOpen(false);
};
const startIdRefresh = async () => {
if (runningIdRefresh || idRefreshStarting) return;
const fields = REFRESH_FIELD_OPTIONS.filter(
(option) => fieldChecks[option.key],
).map((option) => option.key);
if (fields.length === 0) return;
try {
setIdRefreshStarting(true);
setIdRefreshError("");
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "refresh_account_fields", fields }),
});
const result = await readApiResponse<{ refresh: FieldRefreshStatus }>(
response,
"启动失败",
);
if (!response.ok) throw new Error(result.error || "启动失败");
idRefreshNotifiedRef.current = false;
setIdRefresh(result.refresh);
setFieldPickOpen(false);
} catch (reason) {
setIdRefreshError(reason instanceof Error ? reason.message : "启动失败");
} finally {
setIdRefreshStarting(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>
<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">
<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>
<div className="filter-chips">
2026-08-06 10:18:23 +08:00
<button
className={platformFilter === "全部平台" ? "active" : ""}
onClick={() => {
setPlatformFilter("全部平台");
setPage(1);
}}
2026-08-06 10:18:23 +08:00
>
全部平台
2026-08-06 10:18:23 +08:00
</button>
{platformOptions.map((platform) => (
<button
className={platformFilter === platform ? "active" : ""}
key={platform}
onClick={() => {
setPlatformFilter(platform);
setPage(1);
}}
>
<PlatformBadge platform={platform} compact />
</button>
))}
</div>
2026-08-06 10:18:23 +08:00
</div>
</div>
<div className="resource-toolbar">
2026-08-06 11:48:10 +08:00
<div className="resource-search">
2026-08-06 10:18:23 +08:00
<span aria-hidden="true">⌕</span>
<input
aria-label="搜索账号名称、账号ID或当前联系人"
onChange={(event) => {
setQuery(event.target.value);
setPage(1);
}}
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);
setPage(1);
}}
2026-08-06 11:48:10 +08:00
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);
setPage(1);
}}
2026-08-06 11:48:10 +08:00
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> 个结果{totalPages > 1 ? ` · 第 ${currentPage}/${totalPages} 页` : ""}</span>
<button
className="export-data-button"
disabled={runningIdRefresh || idRefreshStarting}
title="爬取全部账号主页,按选择的字段批量更新账号资料"
onClick={() => setFieldPickOpen(true)}
>
{idRefreshStarting
? "正在启动…"
: runningIdRefresh
? `更新中 ${idRefresh?.processed ?? 0}/${idRefresh?.total ?? 0}`
: "批量更新账号资料"}
</button>
2026-08-06 10:18:23 +08:00
<button
className="export-data-button"
disabled={exporting || filteredAccounts.length === 0}
onClick={() =>
onExport(filteredAccounts.map((item) => item.account.id))
}
>
{exporting ? "正在导出…" : "导出筛选结果"}
</button>
</div>
{idRefreshError && <div className="form-error">{idRefreshError}</div>}
2026-07-30 12:06:41 +08:00
</div>
<div className="resource-grid">
{pagedAccounts.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">
<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>
<button
type="button"
className="account-id-edit-button"
title="手动修改账号ID"
onClick={() => {
setEditAccount(account);
setEditAccountId(account.public_account_id || "");
setEditError("");
}}
>
修改
</button>
</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>
<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>
<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>
<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">
<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-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>
)}
{totalPages > 1 && (
<div className="resource-pagination">
<button
disabled={currentPage === 1}
onClick={() => setPage(currentPage - 1)}
>
上一页
</button>
{pageList(currentPage, totalPages).map((item, index) =>
item === "gap" ? (
<span className="gap" key={`gap-${index}`}>…</span>
) : (
<button
className={item === currentPage ? "active" : ""}
key={item}
onClick={() => setPage(item)}
>
{item}
</button>
),
)}
<button
disabled={currentPage === totalPages}
onClick={() => setPage(currentPage + 1)}
>
下一页
</button>
</div>
)}
2026-07-30 12:06:41 +08:00
</section>
{fieldPickOpen && (
<div className="modal-backdrop" role="presentation" onMouseDown={closeFieldPick}>
<div
className="modal-card compact"
role="dialog"
aria-modal="true"
aria-labelledby="field-pick-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow">批量更新</p>
<h2 id="field-pick-title">选择要更新的字段</h2>
</div>
<button type="button" onClick={closeFieldPick} aria-label="关闭">×</button>
</div>
<p className="field-pick-note">
将逐个爬取全部账号主页(小红书 + 抖音),只写入选中的字段;
爬取为空的字段自动跳过,不会清空已有数据。账号较多时需要几分钟。
</p>
<div className="field-pick-list">
{REFRESH_FIELD_OPTIONS.map((option) => (
<label className="field-pick-item" key={option.key}>
<input
checked={Boolean(fieldChecks[option.key])}
onChange={(event) =>
setFieldChecks((checks) => ({
...checks,
[option.key]: event.target.checked,
}))
}
type="checkbox"
/>
<span>{option.label}</span>
</label>
))}
</div>
{idRefreshError && <div className="form-error">{idRefreshError}</div>}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={closeFieldPick}>取消</button>
<button
type="button"
className="primary-button"
disabled={
idRefreshStarting ||
runningIdRefresh ||
!REFRESH_FIELD_OPTIONS.some((option) => fieldChecks[option.key])
}
onClick={() => void startIdRefresh()}
>
{idRefreshStarting
? "正在启动…"
: runningIdRefresh
? `更新中 ${idRefresh?.processed ?? 0}/${idRefresh?.total ?? 0}`
: "开始更新"}
</button>
</div>
</div>
</div>
)}
{editAccount && (
<div className="modal-backdrop" role="presentation" onMouseDown={closeAccountEdit}>
<div
className="modal-card compact"
role="dialog"
aria-modal="true"
aria-labelledby="account-id-edit-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow">{editAccount.nickname || "账号"}</p>
<h2 id="account-id-edit-title">修改账号ID</h2>
</div>
<button type="button" onClick={closeAccountEdit} aria-label="关闭">×</button>
</div>
<div className="account-id-edit-form">
<label>
<span>
{editAccount.platform === "小红书"
? "小红书号"
: editAccount.platform === "抖音"
? "抖音号"
: "账号ID"}
</span>
<input
autoFocus
type="text"
value={editAccountId}
placeholder="2-80 位字母、数字或 . _ -"
onChange={(event) => setEditAccountId(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void submitAccountEdit();
}}
/>
<small>只更新账号ID,昵称、粉丝数等其他资料保持不变</small>
</label>
{editError && <div className="form-error">{editError}</div>}
</div>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={closeAccountEdit}>取消</button>
<button
type="button"
className="primary-button"
disabled={editWorking || !editAccountId.trim()}
onClick={() => void submitAccountEdit()}
>
{editWorking ? "保存中…" : "保存"}
</button>
</div>
</div>
</div>
)}
{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">{importMode === "single" ? "单条新增" : "批量导入"}</p>
<h2 id="resource-import-title">导入已有KOC资源</h2>
</div>
<button type="button" onClick={closeImport} aria-label="关闭">×</button>
</div>
<div className="import-mode-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={importMode === "bulk"}
className={importMode === "bulk" ? "active" : ""}
onClick={() => switchImportMode("bulk")}
>
批量导入
</button>
<button
type="button"
role="tab"
aria-selected={importMode === "single"}
className={importMode === "single" ? "active" : ""}
onClick={() => switchImportMode("single")}
>
单条新增
</button>
</div>
{importMode === "bulk" && (
<>
<div className="import-guide-strip">
<span>1</span><p>账号链接必填,其余资料选填</p>
<i />
<span>2</span><p>系统只补全空缺的公开资料</p>
<i />
<span>3</span><p>确认后写入资源库</p>
</div>
<label className="resource-import-dropzone">
<input
accept=".xlsx,.csv"
type="file"
onChange={(event) => {
setImportFile(event.target.files?.[0] ?? null);
setImportPreview(null);
setImportError("");
}}
/>
<strong>{importFile ? importFile.name : "选择 Excel / CSV 文件"}</strong>
<span>单次最多 10,000 个账号,文件不超过 20MB</span>
</label>
{!importPreview && (
<div className="import-template-note">
<div><strong>还没有模板?</strong><span>只有账号链接必填;性别、简介、标签等资料均可选填,多个标签请用逗号分隔且最多 5 个,系统只补全空缺字段。</span></div>
<a className="ghost-button" download href="/KOC资源导入模板.xlsx">下载模板</a>
</div>
)}
{importPreview && (
<div className="resource-import-preview">
<div className="import-summary-grid">
<div><span>数据总数</span><strong>{importPreview.summary.total}</strong></div>
<div className="create"><span>新增账号</span><strong>{importPreview.summary.create}</strong></div>
<div className="update"><span>更新账号</span><strong>{importPreview.summary.update}</strong></div>
<div className={importPreview.summary.error ? "error" : ""}><span>异常数据</span><strong>{importPreview.summary.error}</strong></div>
</div>
<div className="import-preview-table">
<div className="import-preview-row head">
<span>行号</span><span>账号</span><span>账号ID</span><span>IP / 来源</span><span>结果</span>
</div>
{importPreview.rows.slice(0, 8).map((row) => (
<div className="import-preview-row" key={row.rowNumber}>
<span>{row.rowNumber}</span>
<span><strong>{row.nickname || "—"}</strong><small><PlatformBadge platform={row.platform} compact /></small></span>
<span>{row.publicAccountId || "主页识别"}</span>
<span><strong>{row.ipLocation}</strong><small>{row.cooperationSource || "未填写来源"}</small></span>
<span className={`import-result ${row.action}`}>
{row.action === "create" ? "新增" : row.action === "update" ? "更新" : row.errors.join(";")}
</span>
</div>
))}
</div>
{(importPreview.summary.total > 8 || importPreview.truncated) && (
<p className="import-preview-more">这里只预览 8 行,异常数据会优先展示;确认导入时会处理全部有效数据。</p>
)}
{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>
)}
</div>
)}
{importError && <div className="form-error">{importError}</div>}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={closeImport}>取消</button>
{!importPreview ? (
<button
type="button"
className="primary-button"
disabled={!importFile || importWorking}
onClick={() => void submitImport("preview")}
>
{importWorking ? "校验中…" : "校验并预览"}
</button>
) : (
<button
type="button"
className="primary-button"
disabled={importPreview.summary.create + importPreview.summary.update === 0 || importWorking}
onClick={() => void submitImport("commit")}
>
{importWorking ? "导入中…" : `确认导入 ${importPreview.summary.create + importPreview.summary.update} 个账号`}
</button>
)}
</div>
</>
)}
{importMode === "single" && (
<>
<div className="import-guide-strip">
<span>1</span><p>账号主页链接必填,平台自动识别</p>
<i />
<span>2</span><p>其余资料选填,空缺字段系统会补全</p>
<i />
<span>3</span><p>已存在的账号会更新资料</p>
</div>
<div className="form-grid two">
<label>
<span>账号主页链接 *</span>
<input
type="url"
value={singleForm.profileUrl}
onChange={(event) =>
setSingleForm((form) => ({ ...form, profileUrl: event.target.value }))
}
placeholder="https://www.xiaohongshu.com/user/profile/..."
/>
<small>
{platformFromProfileUrl(singleForm.profileUrl)
? `识别平台:${platformFromProfileUrl(singleForm.profileUrl)}`
: "支持小红书 / 抖音账号主页"}
</small>
</label>
<label>
<span>账号昵称</span>
<input
type="text"
value={singleForm.nickname}
onChange={(event) =>
setSingleForm((form) => ({ ...form, nickname: event.target.value }))
}
placeholder="选填"
/>
</label>
<label>
<span>账号ID</span>
<input
type="text"
value={singleForm.publicAccountId}
onChange={(event) =>
setSingleForm((form) => ({ ...form, publicAccountId: event.target.value }))
}
placeholder="小红书号 / 抖音号"
/>
</label>
<label>
<span>IP属地</span>
<input
type="text"
value={singleForm.ipLocation}
onChange={(event) =>
setSingleForm((form) => ({ ...form, ipLocation: event.target.value }))
}
placeholder="省份 / 地区 / 国家"
/>
</label>
<label>
<span>粉丝数</span>
<input
type="text"
value={singleForm.followers}
onChange={(event) =>
setSingleForm((form) => ({ ...form, followers: event.target.value }))
}
placeholder="如 1.3万 / 8000"
/>
</label>
<label>
<span>性别</span>
<select
value={singleForm.gender}
onChange={(event) =>
setSingleForm((form) => ({ ...form, gender: event.target.value }))
}
>
<option value="">未填写</option>
<option value="男">男</option>
<option value="女">女</option>
</select>
</label>
<label>
<span>合作来源</span>
<input
type="text"
value={singleForm.cooperationSource}
onChange={(event) =>
setSingleForm((form) => ({ ...form, cooperationSource: event.target.value }))
}
placeholder="历史合作来源 / 资源来源"
/>
</label>
<label>
<span>标签</span>
<input
type="text"
value={singleForm.tags}
onChange={(event) =>
setSingleForm((form) => ({ ...form, tags: event.target.value }))
}
placeholder="逗号分隔,最多 5 个"
/>
</label>
</div>
<label>
<span>简介</span>
<textarea
value={singleForm.bio}
onChange={(event) =>
setSingleForm((form) => ({ ...form, bio: event.target.value }))
}
placeholder="选填"
/>
</label>
{singleError && <div className="form-error">{singleError}</div>}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={closeImport}>取消</button>
<button
type="button"
className="primary-button"
disabled={!singleForm.profileUrl.trim() || singleWorking}
onClick={() => void submitSingle()}
>
{singleWorking ? "保存中…" : "保存账号"}
</button>
</div>
</>
)}
</div>
</div>
)}
2026-07-30 12:06:41 +08:00
</div>
);
}
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,
collectingDistributionIds,
canUpdatePublishUrl,
2026-07-30 12:06:41 +08:00
onCollect,
onUpdatePublishUrl,
2026-07-30 12:06:41 +08:00
onSaveSchedule,
onUpload,
onFallback,
onRetryFailed,
onExport,
onExportScreenshots,
exportingTaskId,
2026-07-30 12:06:41 +08:00
}: {
tasks: Task[];
distributions: Distribution[];
working: boolean;
collectingDistributionIds: ReadonlySet<string>;
canUpdatePublishUrl: boolean;
2026-07-30 12:06:41 +08:00
onCollect: (distribution: Distribution) => void;
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;
onExport: (task: Task) => void;
onExportScreenshots: (task: Task) => void;
exportingTaskId: string | null;
2026-07-30 12:06:41 +08:00
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
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}
onOpen={setSelectedTaskId}
/>
</div>
);
}
const taskDistributions = distributions.filter(
(item) => item.task_id === selectedTask.id,
);
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;
const sortedTaskDistributions = recoverySort.column
? sortRecoveryDistributions(
taskDistributions,
recoverySort.column,
recoverySort.direction,
)
: taskDistributions;
const changeRecoverySort = (column: RecoverySortKey) => {
setRecoverySort((current) =>
current.column === column
? {
column,
direction: current.direction === "desc" ? "asc" : "desc",
}
: { column, direction: "desc" },
);
};
2026-07-30 12:06:41 +08:00
return (
<div className="stack">
<TaskDetailHeader
task={selectedTask}
label="数据回收"
onBack={() => setSelectedTaskId(null)}
/>
<CollectionScheduleCard
key={`${selectedTask.id}:${selectedTask.collection_start_date}:${selectedTask.collection_days}`}
task={selectedTask}
working={working}
onSave={onSaveSchedule}
/>
<section className="panel table-panel recovery-panel">
<div className="panel-heading">
<div>
<h2>数据回收队列</h2>
<p>{taskDistributions.length} 篇已发布作品 · 展示最近一次采集结果</p>
2026-07-30 12:06:41 +08:00
</div>
<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>
<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}
/>
{selectedTask.platform === "抖音" && (
<SortableRecoveryHeader
label="转发"
column="shares"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
)}
<SortableRecoveryHeader
label="评论"
column="comments"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="总互动"
column="total"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="数据更新时间"
column="collection_updated_at"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
2026-07-30 12:06:41 +08:00
<th>采集状态</th>
<th>创作者截图</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{sortedTaskDistributions.map((item) => {
2026-07-30 12:06:41 +08:00
const metrics = latestPublicMetrics(item);
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"
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>
)}
<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>
{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>
<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 ? (
<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">
{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>
<div className="recovery-row-actions">
{item.screenshot_key && !hasCreatorMetrics(item) ? (
<button className="text-button" onClick={() => onFallback(item)}>手动填写</button>
) : (
<button
className="collect-button"
disabled={
!noteUrl ||
(working &&
(collectingDistributionIds.size === 0 ||
collectingDistributionIds.has(item.id)))
}
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>
<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>
);
}