Files
koc-loop/app/admin-app.tsx
ABAPPLO bd80b2d7d8 feat: 项目负责人+内部任务群+portal 内部身份领取+群主链接归属
- 迁移 0012:projects.owner_userid / tasks.task_chat_id / claims.owner_partner_id
- lib/task-group-service.ts:懒建 appchat 任务群(首个内部群主领取时创建,成员 diff 补员),播报/卡片文案
- wecom-client 新增 appchat create/update/get/send 封装
- OAuth ticket 增可选 uid(v:1 兼容),内部成员领取按 wecom_userid 解析群主身份
- 外部 OAuth 领取人 upsert 打 partner_type='group_owner'
- portal 链接带 ?owner= 穿透 OAuth 往返,claims 记录归属群主
- 后台任务卡「群主链接」面板:复制链接/推送内部群主/任务群状态
- 催办与超期升级同步播报进任务群
2026-09-03 11:30:26 +08:00

6488 lines
235 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
ChangeEvent,
FormEvent,
useCallback,
useEffect,
useMemo,
useRef,
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";
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;
};
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;
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;
ip_location: string;
followers: number;
gender: string;
bio: string;
tags: string;
post_count: number;
avg_views: number;
cooperation_source: string;
current_contact: string;
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;
};
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;
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;
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;
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";
due_at: string;
};
type RecoverySortKey =
| "publish_time"
| "likes"
| "collects"
| "shares"
| "comments"
| "total"
| "collection_updated_at";
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;
columns: string[];
preview: Array<{
sourceRow: number;
title: string;
body: string;
}>;
};
type NavKey =
| "overview"
| "tasks"
| "resources"
| "partners"
| "recovery"
| "reports"
| "users";
const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
{ key: "overview", label: "工作台", mark: "⌂" },
{ key: "tasks", label: "任务中心", mark: "▤" },
{ key: "resources", label: "KOC资源", mark: "◎" },
{ key: "partners", label: "合作方", mark: "Ⓒ" },
{ key: "recovery", label: "数据回收", mark: "◫" },
{ key: "reports", label: "复盘报表", mark: "▣" },
{ key: "users", label: "用户管理", mark: "♙" },
];
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>
);
}
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;
return {
likes,
comments,
collects,
shares,
total:
likes === null
? null
: likes + (comments ?? 0) + (collects ?? 0) + shares,
};
}
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>
);
}
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) {
if (!value) return null;
try {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
const isSupportedHost =
hostname === "xiaohongshu.com" ||
hostname.endsWith(".xiaohongshu.com") ||
hostname === "xhslink.cn" ||
hostname.endsWith(".xhslink.cn") ||
hostname === "xhslink.com" ||
hostname.endsWith(".xhslink.com") ||
hostname === "douyin.com" ||
hostname.endsWith(".douyin.com");
return ["http:", "https:"].includes(url.protocol) && isSupportedHost
? 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 }) {
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(),
);
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);
const [ownerLinksTask, setOwnerLinksTask] = useState<Task | null>(null);
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",
name: "",
brand: "",
dueAt: "2026-08-12",
feishuUrl: "",
keyword: "",
instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。",
quantity: 1,
projectId: "",
});
const [taskExampleImage, setTaskExampleImage] = useState<File | null>(null);
const [taskFormProjects, setTaskFormProjects] = useState<Array<{ id: string; name: string }>>([]);
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);
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;
});
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]);
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" &&
["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);
const navigate = (key: NavKey) => {
if (key === "resources" && currentUser.role === "user") return;
if (key === "users" && !isManager) return;
if (key === "reports" && !isManager) return;
setActiveNav(key);
setMenuOpen(false);
};
const submitTask = async (event: FormEvent) => {
event.preventDefault();
if (taskForm.taskType === "content_publish" && !sourcePreview) {
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;
}
}
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} 篇内容进入可领取池`,
);
if (success) {
setTaskModalOpen(false);
setSourcePreview(null);
setTaskExampleImage(null);
setTaskForm({
taskType: "content_publish",
platform: "小红书",
contentFormat: "image_text",
name: "",
brand: "",
dueAt: "2026-08-12",
feishuUrl: "",
keyword: "",
instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。",
quantity: 1,
projectId: "",
});
}
};
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} 个视频`,
);
} 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);
}
};
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;
});
}
};
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
? "笔记链接已更新,旧公开采集数据已清空"
: "笔记链接已补充,可开始数据采集",
);
};
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自动执行`,
);
};
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);
}
};
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);
};
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: "按项目组织任务群与总群成员,从飞书同步内容、开放领取并跟进分发进度。",
},
resources: {
title: "KOC资源",
subtitle: "只沉淀真实发布过的账号,不要求提前登记。",
},
partners: {
title: "合作方",
subtitle: "绑定企业微信外部联系人,临期催办自动推送到人。",
},
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");
};
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) => (
<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>
<div>
<strong>{currentUser.username}</strong>
<span>{currentUser.role === "super_admin" ? "超级管理员" : currentUser.role === "admin" ? "管理员" : "普通用户"}</span>
</div>
<span className="chevron">退出</span>
</button>
</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>
</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"}
/>
)}
{activeNav === "tasks" && (
<TasksPage
tasks={data.tasks}
distributions={data.distributions}
portalUrl={data.portal_url}
canRelease={isManager}
isManager={isManager}
working={working}
setToast={setToast}
onTasksChanged={loadData}
onCreate={() => setTaskModalOpen(true)}
onCopyShare={copyTaskShareLink}
onPushToGroups={(task) => setGroupPushTask(task)}
onOneClickPush={(task) => setOnePushTask(task)}
onOwnerLinks={(task) => setOwnerLinksTask(task)}
onRelease={async (distribution) =>
runAction(
{
action: "release_distribution",
distributionId: distribution.id,
},
`“${distribution.content_title}”已释放,可重新领取`,
)
}
/>
)}
{activeNav === "resources" && (
<ResourcesPage
accounts={data.accounts}
distributions={data.distributions}
exporting={exportingResources}
onExport={exportResources}
onImported={completeResourceImport}
/>
)}
{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} />
)}
{activeNav === "recovery" && (
<RecoveryPage
tasks={data.tasks}
distributions={data.distributions}
working={working}
collectingDistributionIds={collectingDistributionIds}
canUpdatePublishUrl={isManager}
onCollect={collectMetrics}
onUpdatePublishUrl={updateDistributionPublishUrl}
onSaveSchedule={saveCollectionSchedule}
onUpload={uploadScreenshot}
onFallback={openMetricFallback}
onRetryFailed={retryFailedMetrics}
onExport={exportRecoveryData}
onExportScreenshots={exportScreenshotResults}
exportingTaskId={exportingTaskId}
/>
)}
</>
)}
</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>
</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>
</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>
</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>
</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>
</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>
<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>
<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>
</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}
/>
)}
{ownerLinksTask && (
<OwnerLinksModal
task={ownerLinksTask}
onClose={() => setOwnerLinksTask(null)}
setToast={setToast}
/>
)}
{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[];
ownerUserid: 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;
owner: string;
members: string;
note: string;
};
const EMPTY_PROJECT_DRAFT: ProjectEditorDraft = { id: "", name: "", brand: "", owner: "", members: "", note: "" };
function ProjectSection({
project,
tasks,
detailVersion,
portalUrl,
setToast,
onProjectsChanged,
onEditProject,
onDeleteProject,
onOpenTaskDetail,
onCopyShare,
onPushToGroups,
onOneClickPush,
onOwnerLinks,
}: {
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;
onOwnerLinks: (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}
onOwnerLinks={onOwnerLinks}
/>
))}
</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>
<input
value={draft.owner}
onChange={(event) => onChange({ ...draft, owner: event.target.value })}
placeholder="例如:zhangsan"
/>
<small>任务群创建时自动把负责人拉进群;请确保该成员在自建应用可见范围内。</small>
</label>
<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>
);
}
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,
}: {
stats: {
resources: number;
activeTasks: number;
published: number;
completed: number;
pendingRecovery: number;
};
distributions: Distribution[];
tasks: Task[];
onNavigate: (key: NavKey) => void;
showResources: boolean;
}) {
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 !== "已沉淀账号资源");
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>
</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>
</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>
<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>
</div>
<DistributionTable distributions={distributions} compact />
</section>
</>
);
}
type OwnerLinkRow = {
partnerId: string;
name: string;
wecomUserid: string;
isInternal: boolean;
link: string;
};
type TaskGroupLinksView = {
taskName: string;
taskChatId: string;
claimUrl: string;
owners: OwnerLinkRow[];
};
function OwnerLinksModal({
task,
onClose,
setToast,
}: {
task: Task;
onClose: () => void;
setToast: (message: string) => void;
}) {
const [view, setView] = useState<TaskGroupLinksView | null>(null);
const [loading, setLoading] = useState(true);
const [pushing, setPushing] = useState(false);
const [pushResult, setPushResult] = useState<{
sent: number;
failed: number;
failList: string[];
} | null>(null);
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;
},
[],
);
useEffect(() => {
const timer = window.setTimeout(() => {
void (async () => {
try {
const parsed = await postAction<TaskGroupLinksView>(
{ action: "task_group_links", taskId: task.id },
"读取群主链接失败",
);
setView(parsed);
} catch (reason) {
setToast(
reason instanceof Error ? reason.message : "读取群主链接失败",
);
} finally {
setLoading(false);
}
})();
}, 0);
return () => window.clearTimeout(timer);
}, [postAction, task.id, setToast]);
const copyLink = async (label: string, link: string) => {
try {
await navigator.clipboard.writeText(link);
setToast(`${label}已复制`);
} catch {
window.prompt(`复制${label}`, link);
}
};
const pushLinks = async () => {
setPushing(true);
try {
const parsed = await postAction<{
sent: number;
failed: number;
failList: string[];
}>({ action: "task_group_push", taskId: task.id }, "推送群主链接失败");
setPushResult(parsed);
setToast(
parsed.failList.length > 0
? `已送达 ${parsed.sent} 位群主;失败:${parsed.failList.join("、")}`
: `已送达 ${parsed.sent} 位群主`,
);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "推送群主链接失败");
} finally {
setPushing(false);
}
};
const internalCount = view?.owners.filter((owner) => owner.isInternal).length ?? 0;
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>{task.name}</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
{loading ? (
<div className="wecom-picker-empty">正在读取群主列表…</div>
) : !view || view.owners.length === 0 ? (
<div className="wecom-picker-empty">
还没有登记群主,请先在「合作方管理」新建群主合作方(需填写企业微信 userid)。
</div>
) : (
<>
<div className="project-detail-block-head">
<h4>任务群状态</h4>
<small>
{view.taskChatId
? "已建群,群主领取后自动进群"
: "未建群:内部群主领取任务后自动创建(需项目已填负责人)"}
</small>
</div>
{view.claimUrl && (
<p className="project-empty-line">
通用领取链接(不带群主归属):
<button
type="button"
className="share-link-button"
onClick={() => void copyLink("通用领取链接", view.claimUrl)}
>
复制
</button>
</p>
)}
<ul className="project-member-list">
{view.owners.map((owner) => (
<li key={owner.partnerId}>
<span>
{owner.name}
{owner.isInternal ? "" : "(外部群主)"}
</span>
<button
type="button"
className="share-link-button"
onClick={() => void copyLink(`${owner.name} 的领取链接`, owner.link)}
>
复制链接
</button>
</li>
))}
</ul>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose} disabled={pushing}>
关闭
</button>
<button
type="button"
className="primary-button"
onClick={() => void pushLinks()}
disabled={pushing || internalCount === 0}
>
{pushing ? "推送中…" : "推送内部群主(应用消息)"}
</button>
</div>
{pushResult && pushResult.failList.length > 0 && (
<small>推送失败:{pushResult.failList.join("、")}</small>
)}
</>
)}
</div>
</div>
);
}
function TaskRowCard({
task,
portalUrl,
onDetail,
onCopyShare,
onPushToGroups,
onOneClickPush,
onOwnerLinks,
}: {
task: Task;
portalUrl: string;
onDetail: () => void;
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void;
onOwnerLinks: (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>
<button
className="share-link-button"
onClick={() => onOwnerLinks(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>
);
}
function TasksPage({
tasks,
distributions,
portalUrl,
canRelease,
isManager,
working,
setToast,
onTasksChanged,
onCreate,
onCopyShare,
onPushToGroups,
onOneClickPush,
onOwnerLinks,
onRelease,
}: {
tasks: Task[];
distributions: Distribution[];
portalUrl: string;
canRelease: boolean;
isManager: boolean;
working: boolean;
setToast: (message: string) => void;
onTasksChanged: () => Promise<void>;
onCreate: () => void;
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void;
onOwnerLinks: (task: Task) => void;
onRelease: (distribution: Distribution) => Promise<boolean>;
}) {
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 ?? "",
owner: project?.ownerUserid ?? "",
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,
ownerUserid: editorDraft.owner,
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";
return (
<div className="stack">
<section className="task-hero">
<div>
<p className="eyebrow">两种固定任务类型 · 项目化分发</p>
<h2>发布笔记,或回收任务截图</h2>
<p>内容发布走飞书笔记链路;截图回收只下发关键词和要求,不进入发布数据采集。</p>
</div>
<div className="project-hero-actions">
{isManager && <button className="ghost-button" onClick={() => openEditor()}>+ 新建项目</button>}
<button className="primary-button light" onClick={onCreate}>+ 新建任务</button>
</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}
onOwnerLinks={onOwnerLinks}
/>
))
)}
<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}
onOwnerLinks={onOwnerLinks}
/>
))}
</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}
onOwnerLinks={onOwnerLinks}
/>
))}
</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()}
/>
)}
</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";
const published = taskDistributions.filter((item) => item.publish_url);
const submitted = taskDistributions.filter(
(item) => item.result_submitted_at,
);
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>
</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 ? (
<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>
<button className="task-scope-open" onClick={() => onOpen(task.id)}>
<span>进入回收队列</span>
<b>→</b>
</button>
</article>
);
})}
</section>
);
}
function TaskDetailHeader({
task,
label,
onBack,
}: {
task: Task;
label: string;
onBack: () => void;
}) {
return (
<section className="task-detail-header">
<button className="detail-back" onClick={onBack}>← 返回任务列表</button>
<div className={`avatar large ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
<div>
<p>{label}</p>
<h2>{task.name}</h2>
<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>
</div>
{task.task_type !== "screenshot_collect" && task.source_url && (
<a href={task.source_url} target="_blank" rel="noreferrer">
打开飞书原表 ↗
</a>
)}
</section>
);
}
function TaskDistributionDetail({
task,
distributions,
canRelease,
working,
onRelease,
onBack,
}: {
task: Task;
distributions: Distribution[];
canRelease: boolean;
working: boolean;
onRelease: (distribution: Distribution) => Promise<boolean>;
onBack: () => void;
}) {
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
const taskDistributions = distributions.filter(
(item) => item.task_id === task.id,
);
const isScreenshotTask = task.task_type === "screenshot_collect";
const published = taskDistributions.filter((item) => item.publish_url).length;
const submitted = taskDistributions.filter((item) => item.result_submitted_at).length;
return (
<div className="stack">
<TaskDetailHeader
task={task}
label={isScreenshotTask ? "截图任务分发" : "内容分发"}
onBack={onBack}
/>
<div className="inline-summary">
<div><span>已领取</span><strong>{taskDistributions.length}</strong></div>
<i />
<div><span>{isScreenshotTask ? "已提交截图" : "已回填链接"}</span><strong>{isScreenshotTask ? submitted : published}</strong></div>
<i />
<div><span>{isScreenshotTask ? "待提交" : "待发布"}</span><strong>{taskDistributions.length - (isScreenshotTask ? submitted : published)}</strong></div>
<div className="summary-note"><span className="pulse-dot" />当前仅展示“{task.name}”</div>
</div>
<section className="panel table-panel">
<div className="panel-heading">
<div><h2>{isScreenshotTask ? "截图任务分发表" : "内容分发表"}</h2><p>{isScreenshotTask ? "每份任务与结果截图一一对应" : "实际发布账号在链接回填后自动补齐"}</p></div>
</div>
{isScreenshotTask ? (
<ScreenshotTaskTable distributions={taskDistributions} />
) : (
<DistributionTable
distributions={taskDistributions}
onRelease={canRelease ? setReleaseTarget : undefined}
/>
)}
</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>
)}
</div>
);
}
function DistributionTable({
distributions,
compact = false,
onRelease,
}: {
distributions: Distribution[];
compact?: boolean;
onRelease?: (distribution: Distribution) => void;
}) {
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>}
</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>
</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>
)}
</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;
}
function ResourcesPage({
accounts,
distributions,
exporting,
onExport,
onImported,
}: {
accounts: Account[];
distributions: Distribution[];
exporting: boolean;
onExport: (accountIds: string[]) => void;
onImported: (message: string) => Promise<void>;
}) {
const [query, setQuery] = useState("");
const [platformFilter, setPlatformFilter] = useState("全部平台");
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",
]),
),
);
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),
),
].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 !== "抖音")
.sort((left, right) => left.localeCompare(right, "zh-CN")),
],
[accounts],
);
const ipOptions = useMemo(
() =>
[
...new Set(
accounts.map((account) => account.ip_location || "待识别"),
),
].sort((left, right) => {
if (left === "待识别") return 1;
if (right === "待识别") return -1;
return left.localeCompare(right, "zh-CN");
}),
[accounts],
);
const sourceOptions = useMemo(
() =>
[...new Set(resourceAccounts.flatMap((item) => item.sources))].sort(
(left, right) => left.localeCompare(right, "zh-CN"),
),
[resourceAccounts],
);
const filteredAccounts = useMemo(() => {
const keyword = query.trim().toLocaleLowerCase("zh-CN");
const ipKeyword = ipFilter.trim().toLocaleLowerCase("zh-CN");
const sourceKeyword = sourceFilter.trim().toLocaleLowerCase("zh-CN");
return resourceAccounts.filter(({ account, sources }) => {
const searchable = `${account.nickname} ${account.public_account_id} ${account.current_contact || ""} ${account.bio || ""} ${account.tags || ""}`.toLocaleLowerCase(
"zh-CN",
);
const ipLocation = (account.ip_location || "待识别").toLocaleLowerCase(
"zh-CN",
);
return (
(!keyword || searchable.includes(keyword)) &&
(platformFilter === "全部平台" ||
account.platform === platformFilter) &&
(!ipKeyword || ipLocation.includes(ipKeyword)) &&
(!sourceKeyword ||
sources.some((source) =>
source.toLocaleLowerCase("zh-CN").includes(sourceKeyword),
))
);
});
}, [ipFilter, platformFilter, query, resourceAccounts, sourceFilter]);
const hasActiveFilters =
Boolean(query.trim()) ||
platformFilter !== "全部平台" ||
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,
);
const clearFilters = () => {
setQuery("");
setPlatformFilter("全部平台");
setIpFilter("");
setSourceFilter("");
setPage(1);
};
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);
}
};
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>
</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资源
</button>
<div className="filter-chips">
<button
className={platformFilter === "全部平台" ? "active" : ""}
onClick={() => {
setPlatformFilter("全部平台");
setPage(1);
}}
>
全部平台
</button>
{platformOptions.map((platform) => (
<button
className={platformFilter === platform ? "active" : ""}
key={platform}
onClick={() => {
setPlatformFilter(platform);
setPage(1);
}}
>
<PlatformBadge platform={platform} compact />
</button>
))}
</div>
</div>
</div>
<div className="resource-toolbar">
<div className="resource-search">
<span aria-hidden="true">⌕</span>
<input
aria-label="搜索账号名称、账号ID或当前联系人"
onChange={(event) => {
setQuery(event.target.value);
setPage(1);
}}
placeholder="搜索账号名称 / 账号ID / 当前联系人 / 标签"
type="search"
value={query}
/>
</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);
}}
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);
}}
placeholder="搜索合作来源"
type="search"
value={sourceFilter}
/>
<datalist id="resource-source-options">
{sourceOptions.map((source) => (
<option key={source} value={source} />
))}
</datalist>
</div>
<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>
<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>}
</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}
>
<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>
</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>
</div>
<div className="resource-metrics" aria-label="账号数据">
<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>
</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>}
</div>
</article>
);
})}
</div>
{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>
)}
</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>
)}
</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)}
/>
</>
);
}
function RecoveryPage({
tasks,
distributions,
working,
collectingDistributionIds,
canUpdatePublishUrl,
onCollect,
onUpdatePublishUrl,
onSaveSchedule,
onUpload,
onFallback,
onRetryFailed,
onExport,
onExportScreenshots,
exportingTaskId,
}: {
tasks: Task[];
distributions: Distribution[];
working: boolean;
collectingDistributionIds: ReadonlySet<string>;
canUpdatePublishUrl: boolean;
onCollect: (distribution: Distribution) => void;
onUpdatePublishUrl: (distribution: Distribution) => void;
onSaveSchedule: (
taskId: string,
startDate: string,
days: number[],
) => Promise<boolean>;
onUpload: (distribution: Distribution, event: ChangeEvent<HTMLInputElement>) => void;
onFallback: (distribution: Distribution) => void;
onRetryFailed: (taskId: string, failedCount: number) => void;
onExport: (task: Task) => void;
onExportScreenshots: (task: Task) => void;
exportingTaskId: string | null;
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [recoverySort, setRecoverySort] = useState<{
column: RecoverySortKey | null;
direction: SortDirection;
}>({ column: null, direction: "desc" });
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>
);
}
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" },
);
};
return (
<div className="stack">
<TaskDetailHeader
task={selectedTask}
label="数据回收"
onBack={() => setSelectedTaskId(null)}
/>
<CollectionScheduleCard
key={`${selectedTask.id}:${selectedTask.collection_start_date}:${selectedTask.collection_days}`}
task={selectedTask}
working={working}
onSave={onSaveSchedule}
/>
<section className="panel table-panel recovery-panel">
<div className="panel-heading">
<div>
<h2>数据回收队列</h2>
<p>{taskDistributions.length} 篇已发布作品 · 展示最近一次采集结果</p>
</div>
<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>
</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}
/>
<th>采集状态</th>
<th>创作者截图</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{sortedTaskDistributions.map((item) => {
const metrics = latestPublicMetrics(item);
const noteUrl = publicPublishUrl(item.publish_url);
const hasMetrics = metrics.likes !== null;
const collectionStatus =
item.collection_status ||
(hasMetrics ? "success" : "pending");
return (
<tr key={item.id}>
<td>
<div className="account-cell recovery-content">
<div className={`avatar small ${avatarColor(item.content_title)}`}>{Array.from(item.content_title)[0] || "笔"}</div>
<div>
{noteUrl ? (
<a
className="recovery-title-link"
href={noteUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`打开${selectedTask.platform}作品:${item.content_title}`}
title={`打开${selectedTask.platform}作品`}
>
<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 />}
</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>
)}
<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>
</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}
/>
) : (
<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"
? "正在识别数据"
: "识别失败,请手动填写"}
</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>
</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>
</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>
);
}