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

6928 lines
253 KiB
TypeScript
Raw Normal View History

2026-07-30 12:06:41 +08:00
"use client";
import {
ChangeEvent,
FormEvent,
useCallback,
useEffect,
useMemo,
useRef,
2026-07-30 12:06:41 +08:00
useState,
} from "react";
import {
formatShanghaiDate as formatDate,
formatShanghaiToday,
} from "../lib/date-utils";
import type { AuthUser } from "../lib/user-auth";
import { parseResultScreenshotKeys } from "../lib/result-screenshots";
import { platformFromProfileUrl } from "../lib/resource-import";
import {
sortWithNullsLast,
type SortDirection,
} from "../lib/sort-utils";
import UsersPage from "./users-page";
import {
readApiResponse,
WecomUserPickerModal,
} from "./wecom-user-picker";
import { TaskCustomerChatModal } from "./task-customer-chat-modal";
2026-07-30 12:06:41 +08:00
type Partner = {
id: string;
name: string;
wecom_name: string;
owner: string;
claimed_total: number;
completed_total: number;
wecom_external_user_id?: string | null;
partner_type?: string;
wecom_userid?: string;
account_id?: string | null;
account_nickname?: string | null;
account_public_id?: string | null;
account_contact?: string | null;
account_profile_url?: string | null;
2026-07-30 12:06:41 +08:00
};
type Task = {
id: string;
name: string;
brand: string;
quantity: number;
claimed_quantity: number;
due_at: string;
status: string;
platform: "小红书" | "抖音";
content_format: "image_text" | "video";
project_id?: string;
owner_userid?: string;
task_chat_id?: string;
customer_chat_id?: string;
customer_chat_name?: string;
join_config_id?: string;
join_qr_code?: string;
keyword?: string;
instructions?: string;
recovery_enabled?: number;
recovery_due_at?: string;
recovery_instructions?: string | null;
dist_total?: number;
dist_complete?: number;
dist_submitted?: number;
2026-07-30 12:06:41 +08:00
source_url: string;
source_sheet_id: string;
source_sheet_name: string;
source_synced_at: string | null;
share_token: string;
collection_start_date: string | null;
collection_days: string;
collection_schedule_updated_at: string | null;
};
type Account = {
id: string;
platform: string;
platform_uid: string;
public_account_id: string;
nickname: string;
profile_url: string;
latest_publish_url: string;
2026-07-30 12:06:41 +08:00
ip_location: string;
followers: number;
gender: string;
bio: string;
tags: string;
2026-07-30 12:06:41 +08:00
post_count: number;
avg_views: number;
cooperation_source: string;
current_contact: string;
2026-07-30 12:06:41 +08:00
first_seen_at: string;
last_seen_at: string;
};
type FieldRefreshStatus = {
status: "running" | "done";
fields: string[];
total: number;
processed: number;
updated: number;
failed: number;
skipped: number;
startedAt: string;
finishedAt: string | null;
};
const REFRESH_FIELD_OPTIONS = [
{ key: "publicAccountId", label: "账号ID(小红书号/抖音号)" },
{ key: "nickname", label: "昵称" },
{ key: "followers", label: "粉丝数" },
{ key: "ipLocation", label: "IP属地" },
{ key: "gender", label: "性别" },
{ key: "bio", label: "简介" },
{ key: "tags", label: "标签(AI 重新打标,覆盖已有标签)" },
] as const;
type ResourceImportPreview = {
summary: { total: number; create: number; update: number; error: number };
rows: Array<{
rowNumber: number;
platform: string;
nickname: string;
publicAccountId: string;
ipLocation: string;
followers: number;
gender: string;
bio: string;
tags: string[];
cooperationSource: string;
action: "create" | "update" | "error";
errors: string[];
}>;
truncated: boolean;
deferredEnrichment?: number;
maxRows?: number;
};
2026-07-30 12:06:41 +08:00
type Distribution = {
id: string;
task_id: string;
content_id: string;
partner_id: string;
delegation_bundle_id: string | null;
account_id: string | null;
publish_url: string | null;
publish_time: string | null;
result_screenshot_key: string | null;
result_submitted_at: string | null;
2026-07-30 12:06:41 +08:00
status: string;
claimed_at: string;
screenshot_key: string | null;
ocr_status: string;
exposure: number | null;
views: number | null;
d2_likes: number | null;
d2_comments: number | null;
d2_collects: number | null;
d5_likes: number | null;
d5_comments: number | null;
d5_collects: number | null;
d7_likes: number | null;
d7_comments: number | null;
d7_collects: number | null;
latest_likes: number | null;
latest_comments: number | null;
latest_collects: number | null;
latest_shares: number | null;
2026-07-30 12:06:41 +08:00
collection_status: string;
collection_status_description: string | null;
collection_updated_at: string | null;
last_collection_day: number | null;
updated_at: string;
content_title: string;
partner_name: string;
claimant_name: string | null;
2026-07-30 12:06:41 +08:00
account_nickname: string | null;
account_platform: string | null;
task_name: string;
task_brand: string;
task_platform: "小红书" | "抖音";
content_format: "image_text" | "video";
2026-07-30 12:06:41 +08:00
due_at: string;
task_collection_days: string | null;
task_collection_start_date: string | null;
2026-07-30 12:06:41 +08:00
};
type RecoverySortKey =
| "publish_time"
| "likes"
| "collects"
| "shares"
| "comments"
| "total"
| "collection_updated_at";
2026-07-30 12:06:41 +08:00
type DashboardData = {
partners: Partner[];
tasks: Task[];
accounts: Account[];
distributions: Distribution[];
portal_url: string;
};
type FeishuPreview = {
sheetId: string;
sheetName: string;
syncedAt: string;
rowCount: number;
imageCount: number;
videoCount: number;
2026-07-30 12:06:41 +08:00
columns: string[];
preview: Array<{
sourceRow: number;
title: string;
body: string;
}>;
};
type NavKey =
| "overview"
| "tasks"
| "resources"
| "partners"
| "recovery"
| "users";
2026-07-30 12:06:41 +08:00
const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
{ key: "overview", label: "工作台", mark: "⌂" },
{ key: "tasks", label: "任务中心", mark: "▤" },
2026-07-30 12:06:41 +08:00
{ key: "resources", label: "KOC资源", mark: "◎" },
{ key: "partners", label: "合作方", mark: "Ⓒ" },
2026-07-30 12:06:41 +08:00
{ key: "recovery", label: "数据回收", mark: "◫" },
{ key: "users", label: "用户管理", mark: "♙" },
2026-07-30 12:06:41 +08:00
];
const EMPTY_DATA: DashboardData = {
partners: [],
tasks: [],
accounts: [],
distributions: [],
portal_url: "",
};
function formatNumber(value: number | null | undefined) {
if (value === null || value === undefined) return "—";
if (value >= 10000) return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`;
return new Intl.NumberFormat("zh-CN").format(value);
}
function resourceProfileLink(account: Account) {
const storedProfileUrl = account.profile_url?.trim();
if (account.platform !== "抖音") {
return storedProfileUrl
? { href: storedProfileUrl, label: "查看主页 ↗" }
: null;
}
const publicAccountId = account.public_account_id?.trim();
const hasResolvedPublicId = Boolean(
publicAccountId && publicAccountId !== "待识别",
);
if (storedProfileUrl && hasResolvedPublicId) {
return { href: storedProfileUrl, label: "查看主页 ↗" };
}
const latestPublishUrl = account.latest_publish_url?.trim();
if (latestPublishUrl) {
return {
href: latestPublishUrl,
label: "通过作品查看主页 ↗",
};
}
const keyword = hasResolvedPublicId ? publicAccountId : account.nickname.trim();
return keyword
? {
href: `https://www.douyin.com/search/${encodeURIComponent(keyword)}?type=user`,
label: "搜索主页 ↗",
}
: null;
}
function PlatformBadge({
platform,
compact = false,
}: {
platform: string | null | undefined;
compact?: boolean;
}) {
const normalized = platform === "抖音" ? "抖音" : platform === "小红书" ? "小红书" : "其他";
return (
<span className={`platform-badge ${compact ? "compact" : ""}`}>
<span className={`platform-logo ${normalized === "抖音" ? "douyin" : normalized === "小红书" ? "xiaohongshu" : "other"}`} aria-hidden="true">
{normalized === "抖音" ? (
<svg viewBox="0 0 24 24" focusable="false">
<path className="douyin-cyan" d="M14.2 3.2v9.5a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V3.2h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1V10c-1.7-.1-3.2-.7-4.3-1.7V3.2Z" />
<path className="douyin-red" d="M15.2 2.5V12a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V2.5h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1v2.7c-1.7-.1-3.2-.7-4.3-1.7V2.5Z" />
<path className="douyin-white" d="M14.7 2.9v9.5a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V2.9h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1v2.7c-1.7-.1-3.2-.7-4.3-1.7V2.9Z" />
</svg>
) : normalized === "小红书" ? (
<b>小红书</b>
) : (
<b>媒</b>
)}
</span>
<span>{platform || "未知平台"}</span>
</span>
);
}
2026-07-30 12:06:41 +08:00
function statusLabel(status: string) {
const labels: Record<string, string> = {
claimed: "待发布",
published: "已发布",
collecting: "采集中",
complete: "已完成",
active: "分发中",
completed: "已完结",
2026-07-30 12:06:41 +08:00
closed: "已结束",
};
return labels[status] || status;
}
function dueBadgeState(dueAt: string) {
const time = new Date(dueAt).getTime();
if (Number.isNaN(time)) return "";
if (time < Date.now()) return " overdue";
return time - Date.now() <= 3 * 24 * 60 * 60 * 1000 ? " soon" : "";
}
2026-07-30 12:06:41 +08:00
function latestPublicMetrics(distribution: Distribution) {
const legacyDay =
distribution.d7_likes !== null
? 7
: distribution.d5_likes !== null
? 5
: distribution.d2_likes !== null
? 2
: null;
const likes =
distribution.latest_likes ??
(legacyDay ? distribution[`d${legacyDay}_likes`] : null);
const comments =
distribution.latest_comments ??
(legacyDay ? distribution[`d${legacyDay}_comments`] : null);
const collects =
distribution.latest_collects ??
(legacyDay ? distribution[`d${legacyDay}_collects`] : null);
const shares = distribution.latest_shares ?? 0;
2026-07-30 12:06:41 +08:00
return {
likes,
comments,
collects,
shares,
2026-07-30 12:06:41 +08:00
total:
likes === null
? null
: likes + (comments ?? 0) + (collects ?? 0) + shares,
2026-07-30 12:06:41 +08:00
};
}
function recoverySortValue(
distribution: Distribution,
key: RecoverySortKey,
): number | null {
const metrics = latestPublicMetrics(distribution);
if (key === "likes") return metrics.likes;
if (key === "collects") return metrics.collects;
if (key === "shares") return metrics.shares;
if (key === "comments") return metrics.comments;
if (key === "total") return metrics.total;
const value =
key === "publish_time"
? distribution.publish_time
: distribution.collection_updated_at ||
(metrics.likes !== null ? distribution.updated_at : null);
if (!value) return null;
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? null : timestamp;
}
function sortRecoveryDistributions(
distributions: Distribution[],
key: RecoverySortKey,
direction: SortDirection,
) {
return sortWithNullsLast(
distributions,
(distribution) => recoverySortValue(distribution, key),
direction,
);
}
function SortableRecoveryHeader({
label,
column,
activeColumn,
direction,
onSort,
}: {
label: string;
column: RecoverySortKey;
activeColumn: RecoverySortKey | null;
direction: SortDirection;
onSort: (column: RecoverySortKey) => void;
}) {
const active = activeColumn === column;
const arrow = active ? (direction === "asc" ? "↑" : "↓") : "↕";
return (
<th aria-sort={active ? (direction === "asc" ? "ascending" : "descending") : "none"}>
<button
type="button"
className={`recovery-sort-button${active ? " active" : ""}`}
onClick={() => onSort(column)}
title={`${label}按${active && direction === "desc" ? "正序" : "倒序"}排列`}
>
<span>{label}</span>
<b aria-hidden="true">{arrow}</b>
</button>
</th>
);
}
2026-07-30 12:06:41 +08:00
function hasCreatorMetrics(distribution: Distribution) {
return distribution.exposure !== null && distribution.views !== null;
}
function parseCollectionDays(value: string | null | undefined) {
try {
const parsed = JSON.parse(value || "[]") as unknown;
if (!Array.isArray(parsed)) return [];
return [...new Set(parsed.map(Number))]
.filter((day) => Number.isInteger(day) && day >= 1 && day <= 7)
.sort((a, b) => a - b);
} catch {
return [];
}
}
function todayInputValue() {
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const value = Object.fromEntries(
parts.map((part) => [part.type, part.value]),
);
return `${value.year}-${value.month}-${value.day}`;
}
function addCalendarDays(value: string, days: number) {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return "";
const date = new Date(
Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + days),
);
return date.toISOString().slice(0, 10);
}
function publicPublishUrl(value: string | null) {
2026-07-30 12:06:41 +08:00
if (!value) return null;
try {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
const isSupportedHost =
2026-07-30 12:06:41 +08:00
hostname === "xiaohongshu.com" ||
hostname.endsWith(".xiaohongshu.com") ||
hostname === "xhslink.cn" ||
hostname.endsWith(".xhslink.cn") ||
2026-07-30 12:06:41 +08:00
hostname === "xhslink.com" ||
hostname.endsWith(".xhslink.com") ||
hostname === "douyin.com" ||
hostname.endsWith(".douyin.com");
return ["http:", "https:"].includes(url.protocol) && isSupportedHost
2026-07-30 12:06:41 +08:00
? url.toString()
: null;
} catch {
return null;
}
}
function collectionStatusLabel(status: string, hasMetrics: boolean) {
if (hasMetrics && (!status || status === "pending")) return "采集成功";
const labels: Record<string, string> = {
pending: "待设置",
scheduled: "已计划",
collecting: "采集中",
success: "采集成功",
failed: "采集失败",
};
return labels[status] || status || "待设置";
}
function avatarColor(value: string) {
const colors = ["coral", "cyan", "violet", "amber", "mint"];
const index = [...value].reduce((sum, char) => sum + char.charCodeAt(0), 0);
return colors[index % colors.length];
}
async function compressScreenshot(file: File) {
if (file.size < 850_000 || !file.type.startsWith("image/")) return file;
const bitmap = await createImageBitmap(file);
const maxEdge = 1600;
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const context = canvas.getContext("2d");
if (!context) {
bitmap.close();
return file;
}
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/jpeg", 0.82),
);
if (!blob) return file;
return new File([blob], `${file.name.replace(/\.[^.]+$/, "")}.jpg`, {
type: "image/jpeg",
});
}
export default function Home({ currentUser }: { currentUser: AuthUser }) {
2026-07-30 12:06:41 +08:00
const [data, setData] = useState<DashboardData>(EMPTY_DATA);
const [activeNav, setActiveNav] = useState<NavKey>("overview");
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false);
const [collectingDistributionIds, setCollectingDistributionIds] = useState<Set<string>>(
new Set(),
);
2026-07-30 12:06:41 +08:00
const [error, setError] = useState("");
const [toast, setToast] = useState("");
const [menuOpen, setMenuOpen] = useState(false);
const [taskModalOpen, setTaskModalOpen] = useState(false);
const [groupPushTask, setGroupPushTask] = useState<Task | null>(null);
const [ownerLinksTask, setOwnerLinksTask] = useState<Task | null>(null);
const [customerChatTask, setCustomerChatTask] = useState<Task | null>(null);
2026-07-30 12:06:41 +08:00
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
const [taskForm, setTaskForm] = useState({
id: "",
platform: "小红书" as "小红书" | "抖音",
contentFormat: "image_text" as "image_text" | "video",
2026-07-30 12:06:41 +08:00
name: "",
brand: "",
dueAt: "2026-08-12",
feishuUrl: "",
projectId: "",
ownerUserid: "",
recoveryEnabled: true,
recoveryDueAt: "",
recoveryInstructions: "",
2026-07-30 12:06:41 +08:00
});
const [taskOwnerPickerOpen, setTaskOwnerPickerOpen] = useState(false);
const [taskExampleImage, setTaskExampleImage] = useState<File | null>(null);
const [taskFormProjects, setTaskFormProjects] = useState<Array<{ id: string; name: string }>>([]);
2026-07-30 12:06:41 +08:00
const [sourcePreview, setSourcePreview] = useState<FeishuPreview | null>(null);
const [sourceWorking, setSourceWorking] = useState(false);
const [metricForm, setMetricForm] = useState({ exposure: "", views: "" });
const [exportingTaskId, setExportingTaskId] = useState<string | null>(null);
2026-08-06 10:18:23 +08:00
const [exportingResources, setExportingResources] = useState(false);
const [creatingPartnerId, setCreatingPartnerId] = useState<string | null>(null);
const isManager = currentUser.role === "super_admin" || currentUser.role === "admin";
const isTaskOwnerView = currentUser.role === "user";
const visibleNavItems = NAV_ITEMS.filter((item) => {
if (isTaskOwnerView && ["resources", "partners"].includes(item.key)) {
return false;
}
if (item.key === "users" && !isManager) return false;
return true;
});
2026-07-30 12:06:41 +08:00
const loadData = useCallback(async () => {
try {
setLoading(true);
const response = await fetch("/api/bootstrap", { cache: "no-store" });
const result = await readApiResponse<DashboardData>(response, "加载失败");
if (!response.ok) throw new Error(result.error || "加载失败");
setData(result);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadData();
}, 0);
return () => window.clearTimeout(timer);
}, [loadData]);
useEffect(() => {
if (!toast) return;
const timer = window.setTimeout(() => setToast(""), 2600);
return () => window.clearTimeout(timer);
}, [toast]);
useEffect(() => {
if (!taskModalOpen) return;
const timer = window.setTimeout(() => {
void (async () => {
try {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "project_list" }),
});
const result = await readApiResponse<
{ projects?: Array<{ id: string; name: string }> } & { error?: string }
>(response, "读取项目列表失败");
if (!response.ok) throw new Error(result.error || "读取项目列表失败");
setTaskFormProjects(result.projects ?? []);
} catch {
setTaskFormProjects([]);
}
})();
}, 0);
return () => window.clearTimeout(timer);
}, [taskModalOpen]);
2026-07-30 12:06:41 +08:00
const runAction = async (
payload: Record<string, unknown>,
successMessage: string,
) => {
try {
setWorking(true);
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await readApiResponse<
DashboardData & { ownerWarning?: string }
>(response, "操作失败");
2026-07-30 12:06:41 +08:00
if (!response.ok) throw new Error(result.error || "操作失败");
setData(result);
setToast(
result.ownerWarning ? `${successMessage};${result.ownerWarning}` : successMessage,
);
return result;
2026-07-30 12:06:41 +08:00
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "操作失败");
return false;
} finally {
setWorking(false);
}
};
const stats = useMemo(() => {
const published = data.distributions.filter((item) =>
["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;
const recoveryTotals = data.tasks.reduce(
(acc, task) => {
if (Number(task.recovery_enabled ?? 1) !== 1) return acc;
return {
submitted: acc.submitted + (task.dist_submitted ?? 0),
total: acc.total + (task.dist_total ?? 0),
};
},
{ submitted: 0, total: 0 },
);
const screenshotPending = data.distributions.filter(
(item) => item.publish_url && !item.result_submitted_at,
).length;
2026-07-30 12:06:41 +08:00
return {
resources: data.accounts.length,
activeTasks: data.tasks.filter((task) => task.status === "active").length,
published,
completed,
pendingRecovery,
completedTasks: data.tasks.filter((task) => task.status === "completed")
.length,
screenshotPending,
pairedSubmitted: recoveryTotals.submitted,
pairedTotal: recoveryTotals.total,
2026-07-30 12:06:41 +08:00
};
}, [data]);
const navigate = (key: NavKey) => {
if (
currentUser.role === "user" &&
(key === "resources" || key === "partners")
) {
return;
}
if (key === "users" && !isManager) return;
2026-07-30 12:06:41 +08:00
setActiveNav(key);
setMenuOpen(false);
};
const submitTask = async (event: FormEvent) => {
event.preventDefault();
const editing = Boolean(taskForm.id);
if (!editing && !isManager && !taskForm.projectId.trim()) {
setToast("请选择所属项目");
return;
}
if (!editing && !sourcePreview) {
2026-07-30 12:06:41 +08:00
setToast("请先读取飞书表格");
return;
}
if (
!editing &&
taskForm.contentFormat === "video" &&
(sourcePreview?.videoCount ?? 0) === 0
) {
setToast("当前飞书表格没有识别到视频附件");
return;
}
let recoveryExampleImageKey = "";
if (taskForm.recoveryEnabled && 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 || "示例截图上传失败");
}
recoveryExampleImageKey = uploadResult.key;
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "示例截图上传失败");
setWorking(false);
return;
}
}
const fields = {
name: taskForm.name,
brand: taskForm.brand,
dueAt: taskForm.dueAt,
projectId: taskForm.projectId,
ownerUserid: taskForm.ownerUserid,
platform: taskForm.platform,
contentFormat: taskForm.contentFormat,
feishuUrl: taskForm.feishuUrl,
recoveryEnabled: taskForm.recoveryEnabled,
recoveryDueAt: taskForm.recoveryEnabled
? taskForm.recoveryDueAt || taskForm.dueAt
: "",
recoveryInstructions: taskForm.recoveryInstructions,
...(recoveryExampleImageKey ? { recoveryExampleImageKey } : {}),
};
const success = editing
? await runAction(
{
action: "task_update",
taskId: taskForm.id,
...fields,
},
"任务已更新",
)
: await runAction(
{ action: "create_task", ...fields },
`任务已创建,${sourcePreview?.rowCount ?? 0} 篇内容进入可领取池`,
);
2026-07-30 12:06:41 +08:00
if (success) {
setTaskModalOpen(false);
setSourcePreview(null);
setTaskExampleImage(null);
2026-07-30 12:06:41 +08:00
setTaskForm({
id: "",
platform: "小红书",
contentFormat: "image_text",
2026-07-30 12:06:41 +08:00
name: "",
brand: "",
dueAt: "2026-08-12",
feishuUrl: "",
projectId: "",
ownerUserid: "",
recoveryEnabled: true,
recoveryDueAt: "",
recoveryInstructions: "",
2026-07-30 12:06:41 +08:00
});
}
};
const inspectFeishu = async () => {
if (!taskForm.feishuUrl.trim()) {
setToast("请先粘贴飞书表格链接");
return;
}
try {
setSourceWorking(true);
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "inspect_feishu",
feishuUrl: taskForm.feishuUrl,
}),
});
const result = await readApiResponse<FeishuPreview>(
response,
"读取飞书表格失败",
);
if (!response.ok) throw new Error(result.error || "读取飞书表格失败");
setSourcePreview(result);
setTaskForm((current) => ({
...current,
name: current.name || result.sheetName.replace(/500篇$/, "分发任务"),
}));
setToast(
`已读取 ${result.rowCount} 条有效内容,识别到 ${result.videoCount} 个视频`,
);
2026-07-30 12:06:41 +08:00
} catch (reason) {
setSourcePreview(null);
setToast(reason instanceof Error ? reason.message : "读取飞书表格失败");
} finally {
setSourceWorking(false);
}
};
const copyTaskShareLink = async (task: Task) => {
if (!data.portal_url || !task.share_token) {
setToast("KOC外部站点尚未发布");
return;
}
const url = `${data.portal_url.replace(/\/$/, "")}/?task=${task.share_token}`;
try {
await navigator.clipboard.writeText(url);
setToast("KOC领取链接已复制");
} catch {
window.prompt("复制KOC领取链接", url);
}
};
const exportScreenshotResults = async (task: Task) => {
try {
setExportingTaskId(task.id);
const response = await fetch(
`/api/screenshot-task-export?task=${encodeURIComponent(task.id)}`,
);
if (!response.ok) {
const result = await readApiResponse<Record<string, never>>(
response,
"截图打包失败",
);
throw new Error(result.error || "截图打包失败");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${task.name}-截图回收.zip`;
anchor.click();
URL.revokeObjectURL(url);
setToast("任务截图和回收清单已打包下载");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "截图打包失败");
} finally {
setExportingTaskId(null);
}
};
2026-07-30 12:06:41 +08:00
const collectMetrics = async (distribution: Distribution) => {
setCollectingDistributionIds((current) => {
const next = new Set(current);
next.add(distribution.id);
return next;
});
try {
await runAction(
{ action: "collect_now", distributionId: distribution.id },
"公开数据已更新",
);
} finally {
setCollectingDistributionIds((current) => {
const next = new Set(current);
next.delete(distribution.id);
return next;
});
}
2026-07-30 12:06:41 +08:00
};
const updateDistributionPublishUrl = async (
distribution: Distribution,
) => {
const publishInput = window.prompt(
distribution.publish_url
? "更新小红书笔记链接(支持长链、短链或完整分享文案)"
: "填写小红书笔记链接(支持长链、短链或完整分享文案)",
distribution.publish_url || "",
);
if (publishInput === null) return;
if (!publishInput.trim()) {
setToast("请填写笔记链接");
return;
}
await runAction(
{
action: "update_distribution_publish_url",
distributionId: distribution.id,
publishUrl: publishInput.trim(),
},
distribution.publish_url
? "笔记链接已更新,旧公开采集数据已清空"
: "笔记链接已补充,可开始数据采集",
);
};
2026-07-30 12:06:41 +08:00
const retryFailedMetrics = async (taskId: string, failedCount: number) => {
if (failedCount === 0) {
setToast("当前没有采集异常的笔记");
return;
}
await runAction(
{ action: "retry_failed_collections", taskId },
`已完成 ${failedCount} 篇异常笔记的一键补采`,
);
};
const remindDueSoon = async (taskId: string, pendingCount: number) => {
if (pendingCount === 0) {
setToast("当前没有待发布的笔记");
return;
}
setWorking(true);
try {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "trigger_wecom_due_soon", taskId }),
});
const result = await readApiResponse<{
dueSoonSent?: number;
dueSoonSkipped?: number;
dueSoonFailed?: number;
error?: string;
}>(response, "催办发送失败");
if (!response.ok) throw new Error(result.error || "催办发送失败");
const parts = [`送达 ${result.dueSoonSent ?? 0} 位`];
if (result.dueSoonSkipped) parts.push(`未绑定企微 ${result.dueSoonSkipped} 位`);
if (result.dueSoonFailed) parts.push(`失败 ${result.dueSoonFailed} 位`);
setToast(`催办已发出:${parts.join(",")}`);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "催办发送失败");
} finally {
setWorking(false);
}
};
2026-07-30 12:06:41 +08:00
const saveCollectionSchedule = async (
taskId: string,
startDate: string,
days: number[],
): Promise<boolean> => {
const result = await runAction(
2026-07-30 12:06:41 +08:00
{
action: "save_collection_schedule",
taskId,
startDate,
days,
},
`采集任务已创建,将在所选日期09:00自动执行`,
2026-07-30 12:06:41 +08:00
);
return result !== false;
2026-07-30 12:06:41 +08:00
};
const exportRecoveryData = async (task: Task) => {
try {
setExportingTaskId(task.id);
const response = await fetch(
`/api/recovery-export?task=${encodeURIComponent(task.id)}`,
{ cache: "no-store" },
);
if (!response.ok) {
const result = await readApiResponse<{ error?: string }>(
response,
"导出失败",
);
throw new Error(result.error || "导出失败");
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `${task.name}-数据回收.xlsx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
setToast("Excel 已导出,图片和截图已嵌入表格");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "导出失败");
} finally {
setExportingTaskId(null);
}
};
2026-08-06 10:18:23 +08:00
const exportResources = async (accountIds: string[]) => {
if (accountIds.length === 0) {
setToast("当前筛选结果为空");
return;
}
try {
setExportingResources(true);
const response = await fetch("/api/resources-export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accountIds }),
});
if (!response.ok) {
const result = await readApiResponse<{ error?: string }>(
response,
"导出失败",
);
throw new Error(result.error || "导出失败");
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `KOC资源库-${todayInputValue().replaceAll("-", "")}.xlsx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
setToast(`已导出 ${accountIds.length} 个KOC账号`);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "导出失败");
} finally {
setExportingResources(false);
}
};
const completeResourceImport = async (message: string) => {
await loadData();
setToast(message);
};
const partnerNameByAccountId = useMemo(() => {
const map = new Map<string, string>();
for (const partner of data.partners) {
if ((partner.partner_type || "external_koc") !== "group_owner" && partner.account_id) {
map.set(partner.account_id, partner.name);
}
}
return map;
}, [data.partners]);
const createPartnerFromAccount = async (account: Account) => {
if (creatingPartnerId) return;
setCreatingPartnerId(account.id);
try {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "partner_create_from_account",
accountId: account.id,
}),
});
const result = await readApiResponse<{ error?: string }>(response, "创建合作方失败");
if (!response.ok) throw new Error(result.error || "创建合作方失败");
setToast(`已创建合作方「${account.nickname || account.public_account_id || account.id}」`);
await loadData();
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "创建合作方失败");
} finally {
setCreatingPartnerId(null);
}
};
2026-07-30 12:06:41 +08:00
const uploadScreenshot = async (
distribution: Distribution,
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
try {
setWorking(true);
const uploadFile = await compressScreenshot(file);
const form = new FormData();
form.append("distributionId", distribution.id);
form.append("file", uploadFile);
const response = await fetch("/api/upload", { method: "POST", body: form });
const result = await readApiResponse<DashboardData>(response, "上传失败");
if (!response.ok) throw new Error(result.error || "上传失败");
setData(result);
setToast("创作者中心截图已收回");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "上传失败");
} finally {
setWorking(false);
event.target.value = "";
}
};
const submitManualMetrics = async (event: FormEvent) => {
event.preventDefault();
if (!metricModal) return;
const success = await runAction(
{
action: "manual_metrics",
distributionId: metricModal.id,
exposure: metricForm.exposure,
views: metricForm.views,
},
"创作者中心数据已确认",
);
if (success) {
setMetricModal(null);
setMetricForm({ exposure: "", views: "" });
}
};
const openMetricFallback = (distribution: Distribution) => {
setMetricModal(distribution);
setMetricForm({
exposure: distribution.exposure?.toString() || "",
views: distribution.views?.toString() || "",
});
};
const pageTitle: Record<NavKey, { title: string; subtitle: string }> = {
overview: {
title: "分发工作台",
subtitle: "用最少动作,跑完内容领取、发布与数据回收。",
},
tasks: {
title: "任务中心",
subtitle: "按项目组织任务群与总群成员,从飞书同步内容、开放领取并跟进分发进度。",
2026-07-30 12:06:41 +08:00
},
resources: {
title: "KOC资源",
subtitle: "只沉淀真实发布过的账号,不要求提前登记。",
},
partners: {
title: "合作方",
subtitle: "绑定企业微信外部联系人,临期催办自动推送到人。",
},
2026-07-30 12:06:41 +08:00
recovery: {
title: "数据回收",
subtitle: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。",
},
users: {
title: "用户管理",
subtitle: "由后台统一创建账号和重置密码,不开放自助注册与个人改密。",
},
};
const logout = async () => {
await fetch("/api/auth/logout", { method: "POST" }).catch(() => null);
window.location.assign("/login");
2026-07-30 12:06:41 +08:00
};
return (
<div className="app-shell">
<aside className={`sidebar ${menuOpen ? "open" : ""}`}>
<div className="brand-lockup">
<div className="brand-symbol"><span>K</span></div>
<div>
<strong>KOC LOOP</strong>
<span>内容分发闭环</span>
</div>
</div>
<nav className="main-nav" aria-label="主导航">
<p className="nav-caption">运营中心</p>
{visibleNavItems.map((item) => (
2026-07-30 12:06:41 +08:00
<button
key={item.key}
className={activeNav === item.key ? "active" : ""}
onClick={() => navigate(item.key)}
>
<span className="nav-mark">{item.mark}</span>
{item.label}
{item.key === "recovery" && stats.pendingRecovery > 0 && (
<span className="nav-badge">{stats.pendingRecovery}</span>
)}
</button>
))}
</nav>
<div className="sidebar-summary">
<div className="pulse-dot" />
<div>
<strong>闭环运行中</strong>
<span>{stats.activeTasks} 个任务正在分发</span>
</div>
</div>
<button className="sidebar-user" onClick={() => void logout()} title="退出登录">
<div className="avatar mint">{currentUser.username.slice(0, 1).toUpperCase()}</div>
2026-07-30 12:06:41 +08:00
<div>
<strong>{currentUser.username}</strong>
<span>{currentUser.role === "super_admin" ? "超级管理员" : currentUser.role === "admin" ? "管理员" : "普通用户"}</span>
2026-07-30 12:06:41 +08:00
</div>
<span className="chevron">退出</span>
</button>
2026-07-30 12:06:41 +08:00
</aside>
{menuOpen && <button className="sidebar-scrim" onClick={() => setMenuOpen(false)} aria-label="关闭菜单" />}
<main className="main-area">
<header className="topbar">
<button className="menu-button" onClick={() => setMenuOpen(true)} aria-label="打开菜单">
☰
</button>
<div className="breadcrumb">
<span>KOC LOOP</span>
<i>/</i>
<strong>{pageTitle[activeNav].title}</strong>
</div>
<div className="top-actions">
<span className="sync-state"><i />飞书内容表已接入</span>
<button className="ghost-button" onClick={() => setActiveNav("tasks")}>
获取KOC领取链接
</button>
{isManager && (
<button className="primary-button" onClick={() => setTaskModalOpen(true)}>
<span>+</span> 新建任务
</button>
)}
2026-07-30 12:06:41 +08:00
</div>
</header>
<section className="content-frame">
<div className="page-heading">
<div>
<p className="eyebrow">MVP · 核心闭环</p>
<h1>{pageTitle[activeNav].title}</h1>
<p>{pageTitle[activeNav].subtitle}</p>
</div>
{activeNav === "overview" && (
<div className="date-chip">
<span>今天</span>
<strong>{formatShanghaiToday()}</strong>
2026-07-30 12:06:41 +08:00
</div>
)}
</div>
{loading ? (
<LoadingState />
) : error ? (
<div className="empty-state">
<div className="empty-icon">!</div>
<h3>暂时无法打开工作台</h3>
<p>{error}</p>
<button className="primary-button" onClick={() => void loadData()}>
重新加载
</button>
</div>
) : (
<>
{isTaskOwnerView && !currentUser.wecom_userid && (
<div className="panel owner-unbound-banner">
<div>
<h2>账号尚未绑定企业微信</h2>
<p>绑定任务负责人的企微后,这里会展示您负责的任务进度、领取明细与数据回收情况。点击右侧按钮用企业微信授权绑定(需在应用可见范围内),或联系管理员在「用户管理」中绑定。</p>
</div>
<a className="owner-bind-button" href="/api/wecom-bind">企业微信授权绑定</a>
</div>
)}
2026-07-30 12:06:41 +08:00
{activeNav === "overview" && (
<OverviewPage
stats={stats}
distributions={data.distributions}
2026-07-30 12:06:41 +08:00
tasks={data.tasks}
onNavigate={navigate}
showResources={currentUser.role !== "user"}
2026-07-30 12:06:41 +08:00
/>
)}
{activeNav === "tasks" && (
<TasksPage
tasks={data.tasks}
distributions={data.distributions}
2026-07-30 12:06:41 +08:00
portalUrl={data.portal_url}
canRelease={isManager}
isManager={isManager}
viewerWecomUserid={currentUser.wecom_userid ?? ""}
working={working}
setToast={setToast}
onTasksChanged={loadData}
onCreate={(projectId) => {
if (projectId) {
setTaskForm((form) => ({ ...form, projectId }));
}
setTaskModalOpen(true);
}}
onEditTask={(task) => {
setSourcePreview(null);
setTaskExampleImage(null);
setTaskForm({
id: task.id,
platform: task.platform,
contentFormat: task.content_format,
name: task.name,
brand: task.brand,
dueAt: task.due_at,
feishuUrl: "",
projectId: task.project_id ?? "",
ownerUserid: task.owner_userid ?? "",
recoveryEnabled: Number(task.recovery_enabled ?? 1) === 1,
recoveryDueAt: task.recovery_due_at || task.due_at,
recoveryInstructions: task.recovery_instructions ?? "",
});
setTaskModalOpen(true);
}}
2026-07-30 12:06:41 +08:00
onCopyShare={copyTaskShareLink}
onPushToGroups={(task) => setGroupPushTask(task)}
onOwnerLinks={(task) => setOwnerLinksTask(task)}
onBindCustomerChat={(task) => setCustomerChatTask(task)}
onCreateRecovery={async (task) => {
await runAction(
{
action: "task_enable_recovery",
taskId: task.id,
recoveryDueAt: task.recovery_due_at || task.due_at,
recoveryInstructions:
task.recovery_instructions || "请提交发布后的笔记截图",
recoveryExampleImageKey: "",
},
"回收已开通",
);
}}
onRelease={async (distribution) => {
const result = await runAction(
{
action: "release_distribution",
distributionId: distribution.id,
},
`“${distribution.content_title}”已释放,可重新领取`,
);
return result !== false;
}}
2026-07-30 12:06:41 +08:00
/>
)}
{activeNav === "resources" && (
<ResourcesPage
accounts={data.accounts}
distributions={data.distributions}
2026-08-06 10:18:23 +08:00
exporting={exportingResources}
onExport={exportResources}
onImported={completeResourceImport}
partnerNameByAccountId={partnerNameByAccountId}
onCreatePartner={createPartnerFromAccount}
creatingPartnerId={creatingPartnerId}
2026-07-30 12:06:41 +08:00
/>
)}
{activeNav === "partners" && isManager && (
<PartnersPage
partners={data.partners}
accounts={data.accounts}
working={working}
onRefresh={loadData}
setToast={setToast}
/>
)}
{activeNav === "users" && isManager && (
<UsersPage currentUser={currentUser} />
)}
2026-07-30 12:06:41 +08:00
{activeNav === "recovery" && (
<RecoveryPage
tasks={data.tasks}
distributions={data.distributions}
2026-07-30 12:06:41 +08:00
working={working}
collectingDistributionIds={collectingDistributionIds}
canUpdatePublishUrl={isManager}
isManager={isManager}
2026-07-30 12:06:41 +08:00
onCollect={collectMetrics}
onUpdatePublishUrl={updateDistributionPublishUrl}
2026-07-30 12:06:41 +08:00
onSaveSchedule={saveCollectionSchedule}
onUpload={uploadScreenshot}
onFallback={openMetricFallback}
onRetryFailed={retryFailedMetrics}
onRemind={remindDueSoon}
onExport={exportRecoveryData}
onExportScreenshots={exportScreenshotResults}
exportingTaskId={exportingTaskId}
2026-07-30 12:06:41 +08:00
/>
)}
</>
)}
</section>
</main>
{taskModalOpen && (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setTaskModalOpen(false)}>
<form className="modal-card" onSubmit={submitTask} onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">
{taskForm.id ? "任务创建后仍可调整基础信息" : "分发与回收一一对应,状态自动闭环"}
</p>
<h2>{taskForm.id ? "编辑任务" : "新建任务"}</h2>
2026-07-30 12:06:41 +08:00
</div>
<button type="button" onClick={() => setTaskModalOpen(false)} aria-label="关闭">×</button>
</div>
{!taskForm.id && (
<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>
)}
{!taskForm.id && (
<label>
<span>飞书内容表链接</span>
<div className="source-url-row">
<input
value={taskForm.feishuUrl}
onChange={(event) => {
setTaskForm({ ...taskForm, feishuUrl: event.target.value });
setSourcePreview(null);
}}
placeholder="粘贴飞书 Wiki / Sheets 链接"
required
/>
<button
type="button"
className="ghost-button source-read-button"
disabled={sourceWorking || !taskForm.feishuUrl.trim()}
onClick={() => void inspectFeishu()}
>
{sourceWorking ? "读取中…" : "读取表格"}
</button>
2026-07-30 12:06:41 +08:00
</div>
<small>
{taskForm.contentFormat === "video"
? "自动读取标题、正文和视频附件;空标题或无视频的行不会进入任务。"
: "自动读取标题、正文/笔记内容、标签和全部配图;空标题行不会进入任务。"}
</small>
</label>
)}
{!taskForm.id && sourcePreview && (
<div className="source-preview">
<div className="source-preview-head">
<div>
<span className="source-check">✓</span>
<div>
<strong>{sourcePreview.sheetName}</strong>
<small>工作表 {sourcePreview.sheetId} · 已匹配 {sourcePreview.columns.length} 个字段</small>
</div>
</div>
<b>
{sourcePreview.rowCount} 条 · {taskForm.contentFormat === "video"
? `${sourcePreview.videoCount} 个视频`
: `${sourcePreview.imageCount} 张图片`}
</b>
2026-07-30 12:06:41 +08:00
</div>
<div className="source-columns">
{sourcePreview.columns.map((column) => <span key={column}>{column}</span>)}
</div>
<div className="source-samples">
{sourcePreview.preview.map((row) => (
<div key={row.sourceRow}>
<span>{row.sourceRow}</span>
<strong>{row.title}</strong>
</div>
))}
</div>
</div>
)}
<div className="recovery-inline-fields">
<label className="recovery-toggle">
<input
type="checkbox"
checked={taskForm.recoveryEnabled}
onChange={(event) =>
setTaskForm({ ...taskForm, recoveryEnabled: event.target.checked })
}
/>
<span>
<b>开启截图回收(第二阶段)</b>
<small>KOC 回填发布链接后需上传笔记截图,回收份数与领取的笔记一一对应</small>
</span>
</label>
{taskForm.recoveryEnabled && (
<>
<label>
<span>回收截止日期</span>
<input
type="date"
value={taskForm.recoveryDueAt || taskForm.dueAt}
onChange={(event) =>
setTaskForm({ ...taskForm, recoveryDueAt: event.target.value })
}
/>
<small>默认与发布截止日期一致</small>
</label>
<label>
<span>回收说明</span>
<textarea
rows={3}
value={taskForm.recoveryInstructions}
onChange={(event) =>
setTaskForm({ ...taskForm, recoveryInstructions: event.target.value })
}
placeholder="说明截图范围和提交要求,默认「请提交发布后的笔记截图」"
/>
</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
: taskForm.id
? "重新上传将替换示例截图(可不选)"
: "点击上传示例截图"}
</strong>
<small>帮助KOC确认截图范围,支持 JPG、PNG,最大8MB</small>
</div>
</label>
</>
)}
</div>
2026-07-30 12:06:41 +08:00
<div className="form-grid two">
<label>
<span>任务名称</span>
<input
value={taskForm.name}
onChange={(event) => setTaskForm({ ...taskForm, name: event.target.value })}
placeholder="例如:夏日轻盈计划"
required
/>
</label>
{!taskForm.id && (
<label>
<span>品牌/项目</span>
<input
value={taskForm.brand}
onChange={(event) => setTaskForm({ ...taskForm, brand: event.target.value })}
placeholder="例如:青柠实验室"
required
/>
</label>
)}
2026-07-30 12:06:41 +08:00
</div>
<label>
<span>发布截止日期</span>
2026-07-30 12:06:41 +08:00
<input
type="date"
value={taskForm.dueAt}
onChange={(event) => setTaskForm({ ...taskForm, dueAt: event.target.value })}
required
/>
</label>
<label>
<span>所属项目(可选)</span>
<select
value={taskForm.projectId}
onChange={(event) => setTaskForm({ ...taskForm, projectId: event.target.value })}
>
<option value="">不挂项目</option>
{taskFormProjects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
<small>挂在项目下的任务可在「任务中心」的项目分组中直接推送到项目总群与任务群。</small>
</label>
<label>
<span>任务负责人(可选)</span>
<div className="field-with-action">
<input
value={taskForm.ownerUserid}
onChange={(event) => setTaskForm({ ...taskForm, ownerUserid: event.target.value })}
placeholder="例如:zhangsan"
/>
<button
type="button"
className="ghost-button"
onClick={() => setTaskOwnerPickerOpen(true)}
disabled={working}
>
从企微选择
</button>
</div>
<small>保存后自动拉进项目总群并预建任务群(负责人+项目负责人)。</small>
</label>
2026-07-30 12:06:41 +08:00
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setTaskModalOpen(false)}>取消</button>
<button
className="primary-button"
disabled={working || (!taskForm.id && !sourcePreview)}
>
{working
? taskForm.id
? "保存中…"
: "创建中…"
: taskForm.id
? "保存修改"
: "创建并开放领取"}
</button>
2026-07-30 12:06:41 +08:00
</div>
</form>
</div>
)}
{taskOwnerPickerOpen && (
<WecomUserPickerModal
mode="single"
selected={taskForm.ownerUserid.trim() ? [taskForm.ownerUserid.trim()] : []}
onClose={() => setTaskOwnerPickerOpen(false)}
onConfirm={(userids) => {
setTaskForm((current) => ({ ...current, ownerUserid: userids[0] ?? "" }));
setTaskOwnerPickerOpen(false);
}}
/>
)}
2026-07-30 12:06:41 +08:00
{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}
/>
)}
{ownerLinksTask && (
<OwnerLinksModal
task={ownerLinksTask}
onClose={() => setOwnerLinksTask(null)}
setToast={setToast}
/>
)}
{customerChatTask && (
<TaskCustomerChatModal
task={{
id: customerChatTask.id,
name: customerChatTask.name,
customerChatId: customerChatTask.customer_chat_id ?? "",
customerChatName: customerChatTask.customer_chat_name ?? "",
}}
onClose={() => setCustomerChatTask(null)}
onBound={() => {
void loadData();
}}
/>
)}
2026-07-30 12:06:41 +08:00
{toast && <div className="toast"><span>✓</span>{toast}</div>}
{working && <div className="working-bar" />}
</div>
);
}
type WecomStatus = {
robotConfigured: boolean;
appConfigured: boolean;
dueDays: number;
};
type WecomContact = {
externalUserId: string;
name: string;
avatar: string;
corpName: string;
ownerUserId: string;
};
type WecomGroupChatRow = {
chat_id: string;
name: string;
owner_user_id: string;
member_count: number;
};
type WecomGroupPushResult = {
sender: string;
msgid: string;
chatCount: number;
failList: string[];
};
type WecomGroupPushRecord = {
id: string;
taskId: string;
msgid: string;
sender: string;
chatIds: string[];
failList: string[];
createdAt: string;
};
function PartnersPage({
partners,
accounts,
working,
onRefresh,
setToast,
}: {
partners: Partner[];
accounts: Account[];
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 [resourcePickerPartner, setResourcePickerPartner] = useState<Partner | null>(null);
const [resourceBindingId, setResourceBindingId] = useState<string | null>(null);
const [resourcePickerSearch, setResourcePickerSearch] = useState("");
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 [userPickerOpen, setUserPickerOpen] = 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 bindResourceAccount = async (partner: Partner, accountId: string) => {
if (resourceBindingId) return;
setResourceBindingId(accountId || partner.id);
try {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "partner_bind_account",
partnerId: partner.id,
accountId,
}),
});
const result = await readApiResponse<{ error?: string }>(response, "绑定 KOC 资源失败");
if (!response.ok) throw new Error(result.error || "绑定 KOC 资源失败");
setResourcePickerPartner(null);
setToast(accountId ? "已绑定 KOC 资源" : "已解绑 KOC 资源");
await onRefresh();
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "绑定 KOC 资源失败");
} finally {
setResourceBindingId(null);
}
};
const partnerNameByAccountId = useMemo(() => {
const map = new Map<string, string>();
for (const partner of partners) {
if (partner.account_id) map.set(partner.account_id, partner.name);
}
return map;
}, [partners]);
const filteredResourceAccounts = useMemo(() => {
const q = resourcePickerSearch.trim().toLowerCase();
if (!q) return accounts;
return accounts.filter((account) =>
`${account.nickname} ${account.public_account_id} ${account.current_contact || ""} ${account.tags || ""}`
.toLowerCase()
.includes(q),
);
}, [accounts, resourcePickerSearch]);
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>KOC 资源 / 联系方式</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 className="partner-resource-cell">
{isGroupOwner ? (
<code title="群主合作方不绑定 KOC 资源">—</code>
) : partner.account_id ? (
<>
<strong title={partner.account_public_id || undefined}>
{partner.account_nickname || "未命名资源"}
</strong>
<em
className={
partner.account_contact
? "partner-resource-contact"
: "partner-resource-contact empty"
}
title={partner.account_contact ? undefined : "资源库中该 KOC 联系方式待补充"}
>
{partner.account_contact || "联系方式待补充"}
</em>
<span className="partner-resource-actions">
<button
type="button"
className="ghost-button"
disabled={busy || working || !!resourceBindingId}
onClick={() => setResourcePickerPartner(partner)}
>
换绑
</button>
<button
type="button"
className="ghost-button danger"
disabled={busy || working || !!resourceBindingId}
onClick={() => void bindResourceAccount(partner, "")}
>
{resourceBindingId === partner.id ? "解绑中…" : "解绑"}
</button>
</span>
</>
) : (
<button
type="button"
className="ghost-button"
disabled={busy || working}
onClick={() => setResourcePickerPartner(partner)}
>
绑定资源
</button>
)}
</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>
<div className="field-with-action">
<input
value={createDraft.wecomUserid}
onChange={(event) => setCreateDraft({ ...createDraft, wecomUserid: event.target.value })}
placeholder="例如:zhangsan"
/>
<button
type="button"
className="ghost-button"
onClick={() => setUserPickerOpen(true)}
disabled={createSaving}
>
从企微选择
</button>
</div>
</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>
)}
{userPickerOpen && (
<WecomUserPickerModal
mode="single"
selected={
createDraft.wecomUserid.trim() ? [createDraft.wecomUserid.trim()] : []
}
onClose={() => setUserPickerOpen(false)}
onConfirm={(userids) => {
setCreateDraft((previous) => ({
...previous,
wecomUserid: userids[0] ?? "",
}));
setUserPickerOpen(false);
}}
/>
)}
{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>
)}
{resourcePickerPartner && (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={() => {
if (resourceBindingId) return;
setResourcePickerPartner(null);
setResourcePickerSearch("");
}}
>
<div className="modal-card wecom-picker-modal" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<h2>选择 {resourcePickerPartner.name} 的 KOC 资源</h2>
<p>绑定后可在合作方列表直接查看该 KOC 的联系方式;每个资源账号只能绑定一个外部KOC合作方。</p>
</div>
<button
type="button"
className="ghost-button"
onClick={() => {
setResourcePickerPartner(null);
setResourcePickerSearch("");
}}
disabled={!!resourceBindingId}
aria-label="关闭"
>
×
</button>
</div>
<div className="import-guide-strip">
<input
type="search"
className="wecom-picker-search"
placeholder="按昵称 / 账号ID / 联系方式 / 标签 搜索"
value={resourcePickerSearch}
onChange={(event) => setResourcePickerSearch(event.target.value)}
disabled={!!resourceBindingId}
/>
</div>
<div className="wecom-picker-list">
{accounts.length === 0 ? (
<div className="wecom-picker-empty">KOC 资源库为空,请先在「KOC 资源」页导入账号</div>
) : filteredResourceAccounts.length === 0 ? (
<div className="wecom-picker-empty">没有匹配的 KOC 资源</div>
) : (
filteredResourceAccounts.map((account) => {
const holderName = partnerNameByAccountId.get(account.id);
const boundByOther = Boolean(holderName) && account.id !== resourcePickerPartner.account_id;
const isCurrent = account.id === resourcePickerPartner.account_id;
return (
<button
key={account.id}
type="button"
className="wecom-picker-row"
onClick={() => void bindResourceAccount(resourcePickerPartner, account.id)}
disabled={!!resourceBindingId || boundByOther}
>
<span className="wecom-picker-avatar placeholder" aria-hidden="true">
{account.nickname.slice(0, 1) || "?"}
</span>
<span className="wecom-picker-meta">
<span className="wecom-picker-name">{account.nickname || "(未命名)"}</span>
<span className="wecom-picker-sub">
{account.public_account_id || "账号ID待识别"} · 联系方式 {account.current_contact || "待补充"}
</span>
{account.tags && <code className="wecom-picker-id">{account.tags}</code>}
</span>
{resourceBindingId === account.id ? (
<span className="wecom-picker-binding">绑定中…</span>
) : boundByOther ? (
<span className="wecom-picker-current">已绑定:{holderName}</span>
) : isCurrent ? (
<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.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 || `群主 ${group.owner_user_id || "—"} · 未命名客户群(${group.member_count} 人)`}
</span>
{group.name ? (
<span className="wecom-picker-sub">群主 {group.owner_user_id || "—"} · {group.member_count} 人</span>
) : (
<span className="wecom-picker-sub">企微端未改过群名,让群主改一次群名后重新同步即可显示</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;
mainChatId: string;
taskCount: number;
};
type ProjectTaskItem = {
id: string;
name: string;
brand: string;
quantity: number;
claimed_quantity: number;
due_at: string;
status: string;
platform: string;
content_format: string;
share_token: string | null;
owner_userid: string;
task_chat_id: string;
recovery_enabled: number;
recovery_due_at: string;
dist_total: number;
recovery_submitted: number;
};
type ProjectDetailData = {
project: ProjectSummary;
tasks: ProjectTaskItem[];
mainChat: { chatId: string; members: 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,
tasksOf,
canManageProject,
detailVersion,
portalUrl,
setToast,
onProjectsChanged,
onEditProject,
onDeleteProject,
onCreateTask,
onOpenTaskDetail,
onEditTask,
onCopyShare,
onPushToGroups,
onOwnerLinks,
onBindCustomerChat,
onCreateRecovery,
}: {
project: ProjectSummary;
tasksOf: (projectId: string) => Task[];
canManageProject: (project: ProjectSummary) => boolean;
detailVersion: number;
portalUrl: string;
setToast: (message: string) => void;
onProjectsChanged: () => Promise<void>;
onEditProject: (project?: ProjectSummary) => void;
onDeleteProject: (project: ProjectSummary) => void;
onCreateTask: (projectId: string) => void;
onOpenTaskDetail: (taskId: string) => void;
onEditTask: (task: Task) => void;
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOwnerLinks: (task: Task) => void;
onBindCustomerChat: (task: Task) => void;
onCreateRecovery: (task: Task) => void;
}) {
const tasks = tasksOf(project.id);
const canManage = canManageProject(project);
const [treeOpen, setTreeOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const [detail, setDetail] = useState<ProjectDetailData | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [ownerPickerTaskId, setOwnerPickerTaskId] = useState<string | null>(null);
const [ownerSaving, setOwnerSaving] = useState(false);
const [mainChatWorking, setMainChatWorking] = useState(false);
const [mainChatOpen, setMainChatOpen] = useState(false);
const [memberPickerOpen, setMemberPickerOpen] = useState(false);
const [memberSaving, setMemberSaving] = useState(false);
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]);
const tasksSignature = tasks
.map(
(task) =>
`${task.id}:${task.dist_total}:${task.dist_submitted}:${task.status}`,
)
.join("|");
useEffect(() => {
if (!expanded) return;
const timer = window.setTimeout(() => {
void loadDetail();
}, 0);
return () => window.clearTimeout(timer);
}, [expanded, detailVersion, loadDetail, tasksSignature]);
const assignOwner = async (taskId: string, ownerUserid: string) => {
if (!ownerUserid) {
setToast("请选择任务负责人");
return;
}
setOwnerSaving(true);
try {
const parsed = await postAction<{
assignment: { chatId: string | null; warning?: string };
}>(
{ action: "task_set_owner", taskId, ownerUserid },
"分配负责人失败",
);
setToast(
parsed.assignment?.warning ??
(parsed.assignment?.chatId
? "负责人已更新,任务群已就绪"
: "负责人已更新;任务群暂未创建(成员不足或企微未配置)"),
);
setOwnerPickerTaskId(null);
await loadDetail();
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "分配负责人失败");
} finally {
setOwnerSaving(false);
}
};
const retryMainChat = async () => {
if (!detail) return;
setMainChatWorking(true);
try {
const parsed = await postAction<{
chatId: string | null;
reason?: string;
warning?: string;
}>(
{ action: "project_ensure_main_chat", projectId: detail.project.id },
"项目总群操作失败",
);
setToast(
parsed.warning ??
(parsed.chatId ? "项目总群已就绪" : "项目总群暂未创建,请检查成员数量与企微配置"),
);
await loadDetail();
await onProjectsChanged();
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "项目总群操作失败");
} finally {
setMainChatWorking(false);
}
};
const saveMembers = async (nextMembers: string[]) => {
if (!detail || memberSaving) return;
setMemberSaving(true);
try {
const parsed = await postAction<{
mainChat?: { chatId: string | null; warning?: string };
}>(
{
action: "project_save",
id: detail.project.id,
name: detail.project.name,
brand: detail.project.brand,
ownerUserid: detail.project.ownerUserid,
mainGroupMemberIds: nextMembers,
note: detail.project.note,
},
"项目总群成员更新失败",
);
setToast(
parsed.mainChat?.warning
? `总群成员已更新;总群同步失败:${parsed.mainChat.warning}`
: "项目总群成员已更新",
);
await loadDetail();
await onProjectsChanged();
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "项目总群成员更新失败");
} finally {
setMemberSaving(false);
}
};
return (
<section className="panel project-panel project-tree-node">
<div className="project-card-head">
<div
className={`project-card-title${tasks.length > 0 ? " clickable" : ""}`}
onClick={tasks.length > 0 ? () => setTreeOpen((previous) => !previous) : undefined}
role={tasks.length > 0 ? "button" : undefined}
tabIndex={tasks.length > 0 ? 0 : undefined}
aria-expanded={tasks.length > 0 ? treeOpen : undefined}
onKeyDown={
tasks.length > 0
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setTreeOpen((previous) => !previous);
}
}
: undefined
}
>
<button
type="button"
className={`project-tree-toggle${treeOpen ? " open" : ""}${tasks.length > 0 ? "" : " leaf"}`}
onClick={(event) => {
event.stopPropagation();
setTreeOpen((previous) => !previous);
}}
disabled={tasks.length === 0}
aria-label={treeOpen ? "收起任务列表" : "展开任务列表"}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path
d="M6 4l4 4-4 4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<div className={`avatar large ${avatarColor(project.name)}`}>
{(project.brand || project.name).slice(0, 1)}
</div>
<div>
<h3>{project.name}</h3>
<small>
{tasks.length} 个任务 · 总群 {project.mainGroupMemberIds.length} 人{project.mainChatId ? "(已建群)" : ""}
</small>
</div>
</div>
<div className="project-card-actions">
{canManage && (
<button className="share-link-button" onClick={() => setExpanded((previous) => !previous)}>
{expanded ? "收起详情" : "查看详情"}
</button>
)}
{canManage && <button className="share-link-button" onClick={() => onEditProject(project)}>编辑</button>}
{canManage && <button className="share-link-button danger" onClick={() => onDeleteProject(project)}>删除</button>}
</div>
</div>
{project.note && <p className="project-note">{project.note}</p>}
{treeOpen && tasks.length === 0 && (
<p className="project-empty-line">项目下还没有任务,点击「查看详情」里的「新建任务」创建。</p>
)}
{treeOpen && tasks.length > 0 && (
<div className="task-list project-task-list">
{tasks.map((task, taskIndex) => (
<TaskRowCard
key={task.id}
task={task}
index={taskIndex + 1}
portalUrl={portalUrl}
canManageTask={canManage}
onDetail={(item) => onOpenTaskDetail(item.id)}
onEdit={onEditTask}
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOwnerLinks={onOwnerLinks}
onBindCustomerChat={onBindCustomerChat}
onCreateRecovery={onCreateRecovery}
/>
))}
</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.tasks.filter((task) => task.task_chat_id).length}/{detail.tasks.length})
</h4>
<button
className="ghost-button"
onClick={() => onCreateTask(project.id)}
>
+ 新建任务
</button>
</div>
<small className="project-chat-hint">
一行 = 一个总任务(分发+回收);新建任务自动挂入「{project.name}」,分配负责人后自动建群,领取人领取后自动进群
</small>
{detail.tasks.length === 0 ? (
<p className="project-empty-line">项目下还没有任务,点击右上「新建任务」创建。</p>
) : (
<table className="data-table project-chat-table">
<thead>
<tr><th>任务</th><th>负责人</th><th>群状态</th><th>领取进度</th><th>回收进度</th><th></th></tr>
</thead>
<tbody>
{detail.tasks.map((task) => (
<tr key={task.id} className={task.task_chat_id ? "" : "project-chat-missing"}>
<td>
{task.name}
{Number(task.recovery_enabled) === 1 ? (
task.recovery_due_at && task.recovery_due_at !== task.due_at ? (
<small className="task-pair-sub">回收截止 {task.recovery_due_at}</small>
) : null
) : (
<span className="tag-weak pair-tag">未开通回收</span>
)}
</td>
<td>
{task.owner_userid ? (
<code>{task.owner_userid}</code>
) : (
<span className="project-empty-line">未分配</span>
)}
</td>
<td>
<span
className={`chat-status ${task.task_chat_id ? "on" : "off"}`}
title={task.task_chat_id ? `群 ID:${task.task_chat_id}` : undefined}
>
{task.task_chat_id ? "已建" : "未建"}
</span>
</td>
<td>
{task.dist_total} / {task.quantity}
</td>
<td>
{Number(task.recovery_enabled) === 1
? `已收 ${task.recovery_submitted} / ${task.dist_total}`
: "—"}
</td>
<td>
<button
className="share-link-button"
onClick={() => setOwnerPickerTaskId(task.id)}
disabled={ownerSaving}
>
{task.owner_userid ? "更换负责人" : "分配负责人"}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="project-detail-block">
<div className="project-detail-block-head">
<h4>项目总群({detail.mainChat.members.length} 人)</h4>
<div className="project-detail-block-actions">
<button
className="ghost-button"
onClick={() => setMainChatOpen((previous) => !previous)}
>
{mainChatOpen ? "收起管理" : "管理成员"}
</button>
</div>
</div>
<div
className="main-chat-summary"
title={detail.mainChat.chatId ? `群 ID:${detail.mainChat.chatId}` : undefined}
>
<span className={`chat-status ${detail.mainChat.chatId ? "on" : "off"}`}>
{detail.mainChat.chatId ? "已建群" : "未建群"}
</span>
<span>
{detail.mainChat.chatId
? "推送任务直接群发进群"
: detail.mainChat.members.length < 3
? `成员不足 3 人(当前 ${detail.mainChat.members.length} 人),编辑项目补充成员后自动创建`
: "展开「管理成员」后点击「重试建群」尝试创建"}
</span>
</div>
{mainChatOpen && (
<div className="main-chat-manage">
<div className="main-chat-manage-actions">
{canManage && (
<button
className="ghost-button"
onClick={() => setMemberPickerOpen(true)}
disabled={memberSaving}
>
+ 成员
</button>
)}
<button
className="ghost-button"
onClick={() => void retryMainChat()}
disabled={mainChatWorking || memberSaving}
>
{detail.mainChat.chatId ? "同步群成员" : "重试建群"}
</button>
</div>
<ul className="project-member-list">
{detail.mainChat.members.map((userid) => (
<li key={userid} title={userid === detail.project.ownerUserid ? "项目负责人,不可移除" : userid}>
<code>{userid}</code>
{canManage && userid !== detail.project.ownerUserid && (
<button
className="project-member-remove"
aria-label={`移除 ${userid}`}
onClick={() => void saveMembers(detail.mainChat.members.filter((m) => m !== userid))}
disabled={memberSaving}
>
×
</button>
)}
</li>
))}
</ul>
</div>
)}
</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>
)}
{ownerPickerTaskId && detail && (
<WecomUserPickerModal
mode="single"
selected={
detail.tasks.find((task) => task.id === ownerPickerTaskId)?.owner_userid.trim()
? [detail.tasks.find((task) => task.id === ownerPickerTaskId)!.owner_userid.trim()]
: []
}
onClose={() => setOwnerPickerTaskId(null)}
onConfirm={(userids) => {
void assignOwner(ownerPickerTaskId, userids[0] ?? "");
}}
/>
)}
{memberPickerOpen && detail && (
<WecomUserPickerModal
mode="multi"
selected={detail.mainChat.members}
onClose={() => setMemberPickerOpen(false)}
onConfirm={(userids) => {
setMemberPickerOpen(false);
void saveMembers(userids);
}}
/>
)}
{pushProject && (
<ProjectPushModal
detail={pushProject}
portalUrl={portalUrl}
onClose={() => setPushProject(null)}
setToast={setToast}
/>
)}
</section>
);
}
function ProjectEditorModal({
draft,
saving,
canEditOwner,
onChange,
onClose,
onSave,
}: {
draft: ProjectEditorDraft;
saving: boolean;
canEditOwner: boolean;
onChange: (draft: ProjectEditorDraft) => void;
onClose: () => void;
onSave: () => void;
}) {
const [picker, setPicker] = useState<null | "owner" | "members">(null);
const memberLines = () =>
draft.members
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
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>
<label>
<span>项目名称 *</span>
<input
value={draft.name}
onChange={(event) => onChange({ ...draft, name: event.target.value })}
placeholder="例如:暑期焕新计划"
required
/>
</label>
{canEditOwner && (
<label>
<span>项目负责人 userid</span>
<div className="field-with-action">
<input
value={draft.owner}
onChange={(event) => onChange({ ...draft, owner: event.target.value })}
placeholder="例如:zhangsan"
/>
<button
type="button"
className="ghost-button"
onClick={() => setPicker("owner")}
disabled={saving}
>
从企微选择
</button>
</div>
<small>任务群创建时自动把负责人拉进群;请确保该成员在自建应用可见范围内。</small>
</label>
)}
{!draft.id ? (
<>
<label>
<span>项目总群成员 userid(一行一个)</span>
<textarea
rows={5}
value={draft.members}
onChange={(event) => onChange({ ...draft, members: event.target.value })}
placeholder={"zhangsan\nlisi\nwangwu"}
/>
<small>推送任务时会逐人发送应用消息;可从企业微信通讯录选择或手动填写。</small>
</label>
<button
type="button"
className="ghost-button"
onClick={() => setPicker("members")}
disabled={saving}
>
从企微选择总群成员
</button>
</>
) : (
<small className="project-chat-hint">
总群成员在项目卡片的「查看详情」面板里管理(企微选人 / 移除 / 同步群成员)。
</small>
)}
<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>
{picker === "owner" && (
<WecomUserPickerModal
mode="single"
selected={draft.owner ? [draft.owner.trim()] : []}
onClose={() => setPicker(null)}
onConfirm={(userids) => {
onChange({ ...draft, owner: userids[0] ?? "" });
setPicker(null);
}}
/>
)}
{picker === "members" && (
<WecomUserPickerModal
mode="multi"
selected={memberLines()}
onClose={() => setPicker(null)}
onConfirm={(userids) => {
const merged = [...memberLines()];
for (const userid of userids) {
if (!merged.includes(userid)) merged.push(userid);
}
onChange({ ...draft, members: merged.join("\n") });
setPicker(null);
}}
/>
)}
</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 formatPushDueLabel(dueAt: string | null | undefined) {
const date = dueAt ? dueAt.slice(0, 10) : "";
if (!date) return "—";
const end = new Date(`${date}T23:59:59+08:00`);
const overdue = !Number.isNaN(end.getTime()) && end.getTime() < Date.now();
return `${date}${overdue ? ",已过期" : ""}`;
}
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.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 pushTasks = detail.tasks;
const [taskId, setTaskId] = useState(pushTasks[0]?.id ?? "");
const [sendMainGroup, setSendMainGroup] = useState(true);
const [sendGroupChats, setSendGroupChats] = useState(true);
const [sending, setSending] = useState(false);
const [outcome, setOutcome] = useState<{
mainChatMode: "group" | "per_member" | "skipped";
appResults: Array<{ userid: string; ok: boolean; error: string }>;
appSent: number;
appFailedCount: number;
taskChat: { sent: boolean; chatId: string; reason?: string };
skippedReasons: string[];
hint?: string;
} | null>(null);
const selectedTask = pushTasks.find((task) => task.id === taskId) ?? pushTasks[0];
const [draft, setDraft] = useState(() =>
pushTasks[0] ? buildProjectPushDraft(pushTasks[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 = pushTasks.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}>
{pushTasks.map((task) => (
<option key={task.id} value={task.id}>
{task.name}(截止 {formatPushDueLabel(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>
发到该项目的总群:已建群直接群发进群;未建群则逐个私信项目成员
</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>发到该任务的内部群(负责人+已领取人,即时送达)</small>
</span>
</label>
</div>
<label className="group-push-text">
<span>推送文案(可编辑,发送前请确认)</span>
<textarea
rows={6}
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.mainChatMode === "group"
? "项目总群:已群发进群。"
: outcome.mainChatMode === "per_member"
? `项目总群:逐人私信,成功 ${outcome.appSent} 人、失败 ${outcome.appFailedCount} 人。`
: "项目总群:未发送。"}
任务群:{outcome.taskChat.sent ? "已送达" : `未发送(${outcome.taskChat.reason ?? "原因未知"})`}。
{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>
)}
</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>
);
}
function ProjectProgressPanel({
tasks,
distributions,
onNavigate,
}: {
tasks: Task[];
distributions: Distribution[];
onNavigate: (key: NavKey) => void;
}) {
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const timer = window.setTimeout(() => {
postAction<{ projects?: ProjectSummary[] }>(
{ action: "project_list" },
"读取项目列表失败",
)
.then((parsed) => {
if (!cancelled) setProjects(parsed.projects ?? []);
})
.catch(() => {
if (!cancelled) setProjects([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
}, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, []);
const rows = projects.map((project) => {
const projectTasks = tasks.filter((task) => task.project_id === project.id);
const taskIds = new Set(projectTasks.map((task) => task.id));
const projectDistributions = distributions.filter((item) =>
taskIds.has(item.task_id),
);
return {
project,
taskCount: projectTasks.length,
activeCount: projectTasks.filter((task) => task.status === "active").length,
completedCount: projectTasks.filter((task) => task.status === "completed")
.length,
claimed: projectDistributions.length,
published: projectDistributions.filter((item) => item.publish_url).length,
submitted: projectDistributions.filter((item) => item.result_submitted_at)
.length,
recovered: projectDistributions.filter(
(item) => latestPublicMetrics(item).likes !== null,
).length,
};
});
if (!loading && rows.length === 0) return null;
return (
<section className="panel project-progress-panel">
<div className="panel-heading">
<div>
<h2>项目进度</h2>
<p>从项目到任务再到每一条领取,逐层看完成情况</p>
</div>
<button className="text-button" onClick={() => onNavigate("tasks")}>
进入任务中心
</button>
</div>
{loading ? (
<div className="table-empty compact">正在读取项目…</div>
) : (
<div className="project-progress-list">
{rows.map((row) => (
<article className="project-progress-row" key={row.project.id}>
<div className="project-progress-title">
<div className={`avatar ${avatarColor(row.project.name)}`}>
{(row.project.brand || row.project.name).slice(0, 1)}
</div>
<div>
<strong>{row.project.name}</strong>
<span>
{row.taskCount} 个任务 · {row.activeCount} 进行中 · {row.completedCount} 已完结
</span>
</div>
</div>
<div className="project-progress-funnel">
<span className="funnel-chip">
已领取 <b>{row.claimed}</b>
</span>
<i>→</i>
<span className="funnel-chip">
已发布 <b>{row.published}</b>
</span>
<span className="funnel-chip">
截图提交 <b>{row.submitted}</b>
</span>
<i>→</i>
<span className="funnel-chip done">
数据回收 <b>{row.recovered}</b>
</span>
</div>
<button
className="text-button"
onClick={() => onNavigate("tasks")}
>
查看任务 →
</button>
</article>
))}
</div>
)}
</section>
);
}
2026-07-30 12:06:41 +08:00
function LoadingState() {
return (
<div className="loading-layout" aria-label="正在加载">
<div className="loading-card wide" />
<div className="loading-card" />
<div className="loading-card" />
<div className="loading-table" />
</div>
);
}
function OverviewPage({
stats,
distributions,
tasks,
onNavigate,
showResources,
2026-07-30 12:06:41 +08:00
}: {
stats: {
resources: number;
activeTasks: number;
published: number;
completed: number;
pendingRecovery: number;
completedTasks: number;
screenshotPending: number;
pairedSubmitted: number;
pairedTotal: number;
2026-07-30 12:06:41 +08:00
};
distributions: Distribution[];
tasks: Task[];
onNavigate: (key: NavKey) => void;
showResources: boolean;
2026-07-30 12:06:41 +08:00
}) {
const cards = [
{
label: "已沉淀账号资源",
value: stats.resources,
hint: "均来自真实发布",
tone: "ink",
},
{
label: "正在分发任务",
value: stats.activeTasks,
hint: `${tasks.reduce((sum, task) => sum + (task.dist_total ?? 0), 0)} 篇已被领取`,
2026-07-30 12:06:41 +08:00
tone: "green",
},
{
label: "已回填发布",
value: stats.published,
hint: "链接自动识别账号",
tone: "blue",
},
{
label: "待完成数据回收",
value: stats.pendingRecovery,
hint:
stats.screenshotPending > 0
? `需要跟进 · 截图待提交 ${stats.screenshotPending}`
: stats.pendingRecovery > 0
? "需要跟进"
: "全部按时完成",
2026-07-30 12:06:41 +08:00
tone: "orange",
},
{
label: "已完结任务",
value: stats.completedTasks,
hint:
stats.pairedTotal > 0
? `截图回收已提交 ${stats.pairedSubmitted}/${stats.pairedTotal}`
: "发布与回收全部完成",
tone: "green",
},
].filter((card) => showResources || card.label !== "已沉淀账号资源");
2026-07-30 12:06:41 +08:00
return (
<>
<div className="stat-grid">
{cards.map((card) => (
<article className="stat-card" key={card.label}>
<div className={`stat-icon ${card.tone}`}><span /></div>
<p>{card.label}</p>
<div className="stat-value">
<strong>{card.value}</strong>
<span>个</span>
</div>
<small>{card.hint}</small>
</article>
))}
</div>
<ProjectProgressPanel
tasks={tasks}
distributions={distributions}
onNavigate={onNavigate}
/>
2026-07-30 12:06:41 +08:00
<div className="overview-grid">
<section className="panel workflow-panel">
<div className="panel-heading">
<div>
<h2>当前闭环</h2>
<p>每篇内容只沿着一条记录向前走</p>
</div>
<button className="text-button" onClick={() => onNavigate("tasks")}>查看全部</button>
2026-07-30 12:06:41 +08:00
</div>
<div className="workflow-track">
<div className="workflow-step done">
<span>01</span>
<div><strong>内容已同步</strong><small>{tasks.reduce((sum, task) => sum + task.quantity, 0)} 篇</small></div>
</div>
<i />
<div className="workflow-step active">
<span>02</span>
<div><strong>KOC领取</strong><small>{tasks.reduce((sum, task) => sum + (task.dist_total ?? 0), 0)} 篇</small></div>
2026-07-30 12:06:41 +08:00
</div>
<i />
<div className="workflow-step">
<span>03</span>
<div><strong>发布回填</strong><small>{stats.published} 篇</small></div>
</div>
<i />
<div className="workflow-step">
<span>04</span>
<div><strong>数据回收</strong><small>{stats.completed} 篇完成</small></div>
</div>
</div>
<div className="focus-callout">
<div className="callout-ring"><strong>{stats.pendingRecovery}</strong><span>待回收</span></div>
<div>
<p className="eyebrow">今天最需要推进</p>
<h3>把已发布笔记的数据收回来</h3>
<p>按任务选择采集日期,系统在当天09:00更新点赞、收藏、评论、转发和总互动。</p>
2026-07-30 12:06:41 +08:00
</div>
<button className="primary-button soft" onClick={() => onNavigate("recovery")}>进入数据回收</button>
</div>
</section>
<section className="panel task-mini-panel">
<div className="panel-heading">
<div><h2>进行中的任务</h2><p>按截止时间排序</p></div>
</div>
<div className="task-mini-list">
{tasks.map((task) => {
const progress = Math.round(((task.dist_total ?? 0) / task.quantity) * 100);
2026-07-30 12:06:41 +08:00
return (
<article key={task.id}>
<div className="task-mini-top">
<div className={`avatar ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
<div>
<strong>{task.name}</strong>
<span className="platform-meta-line">
<span>{task.brand}</span>
<PlatformBadge platform={task.platform} compact />
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
</span>
</div>
2026-07-30 12:06:41 +08:00
<b>{progress}%</b>
</div>
<div className="progress"><span style={{ width: `${progress}%` }} /></div>
<div className="task-mini-meta">
<span>{task.dist_total ?? 0}/{task.quantity} 篇已领取</span>
2026-07-30 12:06:41 +08:00
<span>{formatDate(task.due_at)}截止</span>
</div>
</article>
);
})}
</div>
<button className="full-ghost-button" onClick={() => onNavigate("tasks")}>管理全部任务</button>
</section>
</div>
<section className="panel table-panel">
<div className="panel-heading">
<div><h2>最近分发</h2><p>领取、发布账号与回收状态一眼看清</p></div>
<button className="text-button" onClick={() => onNavigate("tasks")}>查看全部</button>
2026-07-30 12:06:41 +08:00
</div>
<DistributionTable
distributions={distributions.slice(0, 6)}
compact
/>
2026-07-30 12:06:41 +08:00
</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">
该任务未分配负责人,请先在任务中心分配任务负责人。
</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,
index,
projectName,
portalUrl,
canManageTask,
onDetail,
onEdit,
onCopyShare,
onPushToGroups,
onOwnerLinks,
onBindCustomerChat,
onCreateRecovery,
}: {
task: Task;
index?: number;
projectName?: string;
portalUrl: string;
canManageTask: boolean;
onDetail: (task: Task) => void;
onEdit: (task: Task) => void;
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOwnerLinks: (task: Task) => void;
onBindCustomerChat: (task: Task) => void;
onCreateRecovery?: (task: Task) => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const runMenuAction = (action: () => void) => {
setMenuOpen(false);
action();
};
const recoveryEnabled = Number(task.recovery_enabled ?? 1) === 1;
const linkReady = (item: Task) => Boolean(portalUrl && item.share_token);
const panelChat = (item: Task, label: string) => (
<span className={`panel-chat ${item.task_chat_id ? "on" : "off"}`}>
{item.task_chat_id ? `${label}已建` : `${label}未建`}
</span>
);
const progressPct = (claimed: number, total: number) =>
total > 0 ? Math.min(100, Math.round((claimed / total) * 100)) : 0;
const panelMetric = (claimed: number, total: number, label: string, tone: string) => (
<span className="panel-metric">
<b>
{claimed}
<i>/{total}</i>
</b>
<em>{label}</em>
<span className="panel-progress" aria-hidden="true">
<span className={tone} style={{ width: `${progressPct(claimed, total)}%` }} />
</span>
</span>
);
const menuButton = (label: string, action: () => void, item: Task, options?: { disabled?: boolean }) => (
<button
type="button"
disabled={options?.disabled}
onClick={() => runMenuAction(() => action())}
title={options?.disabled && !linkReady(item) ? "任务尚未生成领取链接" : undefined}
>
{label}
</button>
);
return (
<article className={`master-task-card${menuOpen ? " menu-open" : ""}`}>
<div className="master-head">
{index !== undefined && (
<span className="master-index">{String(index).padStart(2, "0")}</span>
)}
<h3>{task.name}</h3>
<div className="master-chips">
{projectName && <span className="master-chip project">{projectName}</span>}
{task.brand && <span className="master-chip">{task.brand}</span>}
<PlatformBadge platform={task.platform} compact />
<span className="master-chip">{task.content_format === "video" ? "视频" : "图文"}</span>
{task.source_sheet_name && <span className="master-chip">飞书 {task.source_sheet_name}</span>}
{task.customer_chat_name && (
<span className="master-chip" title={task.customer_chat_name}>
客户群 · {task.customer_chat_name}
</span>
)}
</div>
<div className="master-meta">
<span className={`due-badge${dueBadgeState(task.due_at)}`}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<circle cx="8" cy="8" r="6.2" />
<path d="M8 4.8V8l2.2 1.4" />
</svg>
截止 {formatDate(task.due_at)}
</span>
<span className={`status-pill ${task.status}`}>{statusLabel(task.status)}</span>
</div>
{portalUrl && task.share_token && (
<a
className="round-arrow"
href={`${portalUrl.replace(/\/$/, "")}/?task=${task.share_token}`}
target="_blank"
rel="noreferrer"
aria-label="打开外部KOC领取页"
>
↗
</a>
)}
<div className="master-menu-wrap">
<button
type="button"
className="master-menu-button"
aria-label="更多操作"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((previous) => !previous)}
>
⋯
</button>
{menuOpen && (
<>
<div className="master-menu-backdrop" onClick={() => setMenuOpen(false)} />
<div className="master-menu">
<div className="master-menu-group">
<b>任务</b>
{canManageTask && menuButton("编辑", () => onEdit(task), task)}
{canManageTask && menuButton("复制领取链接", () => onCopyShare(task), task, { disabled: !linkReady(task) })}
{menuButton("发布到企微群", () => onPushToGroups(task), task)}
{menuButton("群主链接", () => onOwnerLinks(task), task, { disabled: !linkReady(task) })}
{menuButton(
task.customer_chat_name ? "客户群 · 换绑" : "绑定客户群",
() => onBindCustomerChat(task),
task,
)}
</div>
</div>
</>
)}
</div>
</div>
<div className="master-panels">
<section className="master-panel publish">
<div className="panel-head">
<b>发布</b>
<span className="panel-count">{task.quantity} 篇内容</span>
</div>
<div className="panel-body">
{panelMetric(task.dist_total ?? 0, task.quantity, "已领取", "publish")}
{panelChat(task, "任务群")}
</div>
<div className="panel-foot">
<button type="button" className="panel-detail" onClick={() => onDetail(task)}>
查看明细 ›
</button>
</div>
</section>
{recoveryEnabled ? (
<section className="master-panel recovery">
<div className="panel-head">
<b>回收</b>
<span className="panel-count">
{task.recovery_due_at && task.recovery_due_at !== task.due_at
? `截止 ${formatDate(task.recovery_due_at)}`
: `${task.dist_total ?? 0} 份截图`}
</span>
</div>
<div className="panel-body">
{panelMetric(task.dist_submitted ?? 0, task.dist_total ?? 0, "已收取", "recovery")}
</div>
<div className="panel-foot">
<button type="button" className="panel-detail" onClick={() => onDetail(task)}>
查看明细 ›
</button>
</div>
</section>
) : (
<section className="master-panel ghost">
<div className="panel-head">
<b>回收</b>
</div>
<div className="panel-body">
<span className="panel-metric muted">
<b>—</b>
<em>未开通回收</em>
</span>
</div>
<div className="panel-foot">
{canManageTask && onCreateRecovery && (
<button
type="button"
className="panel-detail"
onClick={() => onCreateRecovery(task)}
>
开通回收 ›
</button>
)}
</div>
</section>
)}
</div>
</article>
);
}
2026-07-30 12:06:41 +08:00
function TasksPage({
tasks,
distributions,
2026-07-30 12:06:41 +08:00
portalUrl,
canRelease,
isManager,
viewerWecomUserid,
working,
setToast,
onTasksChanged,
2026-07-30 12:06:41 +08:00
onCreate,
onEditTask,
2026-07-30 12:06:41 +08:00
onCopyShare,
onPushToGroups,
onOwnerLinks,
onBindCustomerChat,
onCreateRecovery,
onRelease,
2026-07-30 12:06:41 +08:00
}: {
tasks: Task[];
distributions: Distribution[];
2026-07-30 12:06:41 +08:00
portalUrl: string;
canRelease: boolean;
isManager: boolean;
viewerWecomUserid: string;
working: boolean;
setToast: (message: string) => void;
onTasksChanged: () => Promise<void>;
onCreate: (projectId?: string) => void;
onEditTask: (task: Task) => void;
2026-07-30 12:06:41 +08:00
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOwnerLinks: (task: Task) => void;
onBindCustomerChat: (task: Task) => void;
onCreateRecovery: (task: Task) => void;
onRelease: (distribution: Distribution) => Promise<boolean>;
2026-07-30 12:06:41 +08:00
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"grouped" | "flat">("grouped");
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 ownedProjectIds = useMemo(
() =>
new Set(
projects
.filter(
(project) =>
project.ownerUserid && project.ownerUserid === viewerWecomUserid,
)
.map((project) => project.id),
),
[projects, viewerWecomUserid],
);
const canCreateTask = isManager || ownedProjectIds.size > 0;
const tasksOf = useCallback(
(projectId: string) => tasks.filter((task) => task.project_id === projectId),
[tasks],
);
const canManageProject = useCallback(
(project: ProjectSummary) =>
isManager || (Boolean(viewerWecomUserid) && project.ownerUserid === viewerWecomUserid),
[isManager, viewerWecomUserid],
);
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(() => {
const timer = window.setTimeout(() => {
void loadProjects();
}, 0);
return () => window.clearTimeout(timer);
}, [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 {
const parsed = await postAction<{
mainChat?: { chatId: string | null; warning?: string };
}>(
{
action: "project_save",
id: editorDraft.id,
name: editorDraft.name,
brand: editorDraft.brand,
ownerUserid: editorDraft.owner,
mainGroupMemberIds: editorDraft.members,
note: editorDraft.note,
},
"保存项目失败",
);
setEditorOpen(false);
setToast(
parsed.mainChat?.warning
? `${editorDraft.id ? "项目已更新" : "项目已创建"};总群同步失败:${parsed.mainChat.warning}`
: 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 = viewMode === "grouped";
2026-07-30 12:06:41 +08:00
return (
<div className="stack">
<section className="task-hero">
<div>
<p className="eyebrow">一任务两阶段 · 项目化分发</p>
<h2>发布笔记,回收截图</h2>
<p>任务从飞书导入笔记,KOC 领取发布后自动进入截图回收阶段,全程一个闭环。</p>
2026-07-30 12:06:41 +08:00
</div>
<div className="project-hero-actions">
{isManager && <button className="ghost-button" onClick={() => openEditor()}>+ 新建项目</button>}
{canCreateTask && (
<button className="primary-button light" onClick={() => onCreate()}>+ 新建任务</button>
)}
2026-07-30 12:06:41 +08:00
</div>
</section>
<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>
{isManager
? "新建项目后,把任务挂到项目下,即可一次推送到项目总群与各任务群。"
: "您名下还没有负责的项目;可切换「全部任务」查看您负责的任务,或联系管理员把您设为项目负责人。"}
</p>
{isManager && <button className="primary-button" onClick={() => openEditor()}>+ 新建项目</button>}
</div>
) : (
projects.map((project) => (
<ProjectSection
key={project.id}
project={project}
tasksOf={tasksOf}
canManageProject={canManageProject}
detailVersion={detailVersion}
portalUrl={portalUrl}
setToast={setToast}
onProjectsChanged={loadProjects}
onEditProject={openEditor}
onDeleteProject={setDeleteTarget}
onCreateTask={onCreate}
onOpenTaskDetail={setSelectedTaskId}
onEditTask={onEditTask}
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOwnerLinks={onOwnerLinks}
onBindCustomerChat={onBindCustomerChat}
onCreateRecovery={onCreateRecovery}
/>
))
)}
<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, taskIndex) => (
<TaskRowCard
key={task.id}
task={task}
index={taskIndex + 1}
portalUrl={portalUrl}
canManageTask={
isManager || ownedProjectIds.has(task.project_id ?? "")
}
onDetail={(item) => setSelectedTaskId(item.id)}
onEdit={onEditTask}
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOwnerLinks={onOwnerLinks}
onBindCustomerChat={onBindCustomerChat}
onCreateRecovery={onCreateRecovery}
/>
))}
</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}
projectName={
task.project_id
? projects.find((project) => project.id === task.project_id)?.name
: undefined
}
portalUrl={portalUrl}
canManageTask={
isManager || ownedProjectIds.has(task.project_id ?? "")
}
onDetail={(item) => setSelectedTaskId(item.id)}
onEdit={onEditTask}
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOwnerLinks={onOwnerLinks}
onBindCustomerChat={onBindCustomerChat}
onCreateRecovery={onCreateRecovery}
/>
))}
</div>
</section>
)}
{editorOpen && (
<ProjectEditorModal
draft={editorDraft}
saving={editorSaving}
canEditOwner={isManager}
onChange={setEditorDraft}
onClose={() => setEditorOpen(false)}
onSave={() => void submitEditor()}
/>
)}
{deleteTarget && (
<ProjectDeleteModal
target={deleteTarget}
working={deleteWorking}
onClose={() => setDeleteTarget(null)}
onConfirm={() => void confirmDelete()}
/>
)}
2026-07-30 12:06:41 +08:00
</div>
);
}
function TaskScopeList({
tasks,
distributions,
onOpen,
}: {
tasks: Task[];
distributions: Distribution[];
onOpen: (taskId: string) => void;
}) {
return (
<section className="task-scope-grid">
{tasks.map((task) => {
2026-07-30 12:06:41 +08:00
const taskDistributions = distributions.filter(
(item) => item.task_id === task.id,
);
const recoveryEnabled = Number(task.recovery_enabled ?? 1) === 1;
2026-07-30 12:06:41 +08:00
const published = taskDistributions.filter((item) => item.publish_url);
const submitted = taskDistributions.filter(
(item) => item.result_submitted_at,
);
2026-07-30 12:06:41 +08:00
const publicDataComplete = published.filter(
(item) => latestPublicMetrics(item).likes !== null,
).length;
const creatorComplete = published.filter(
(item) => hasCreatorMetrics(item),
).length;
return (
<article className="task-scope-card" key={task.id}>
<div className="task-scope-head">
<div className={`avatar large ${avatarColor(task.name)}`}>
{task.brand.slice(0, 1)}
</div>
<div>
<h3>{task.name}</h3>
<div className="task-scope-subline">
<span>{task.brand} · {formatDate(task.due_at)} 截止</span>
<PlatformBadge platform={task.platform} compact />
<span className={`content-format-badge ${task.content_format === "video" ? "video" : "image-text"}`}>
{task.content_format === "video" ? "视频" : "图文"}
</span>
{!recoveryEnabled && <span className="tag-weak">未开通回收</span>}
{recoveryEnabled && task.recovery_due_at && task.recovery_due_at !== task.due_at && (
<span className="tag-weak">回收截止 {formatDate(task.recovery_due_at)}</span>
)}
</div>
2026-07-30 12:06:41 +08:00
</div>
<span className={`status-pill ${task.status}`}>{statusLabel(task.status)}</span>
</div>
<div className="task-source-line">
<span>飞书来源</span>
{task.source_url ? (
2026-07-30 12:06:41 +08:00
<a href={task.source_url} target="_blank" rel="noreferrer">
{task.source_sheet_name || `工作表 ${task.source_sheet_id}`} ↗
</a>
) : (
<strong>历史示例内容表</strong>
)}
</div>
<div className="task-scope-metrics">
<div><span>已发布</span><strong>{published.length}</strong></div>
<div><span>已收截图</span><strong>{recoveryEnabled ? submitted.length : "—"}</strong></div>
<div><span>公开数据</span><strong>{publicDataComplete}</strong></div>
<div><span>创作者数据</span><strong>{creatorComplete}</strong></div>
</div>
2026-07-30 12:06:41 +08:00
<button className="task-scope-open" onClick={() => onOpen(task.id)}>
<span>进入回收队列</span>
2026-07-30 12:06:41 +08:00
<b>→</b>
</button>
</article>
);
})}
</section>
);
}
function TaskDetailHeader({
task,
label,
onBack,
}: {
task: Task;
label: string;
onBack: () => void;
}) {
return (
<section className="task-detail-header">
<button className="detail-back" onClick={onBack}>← 返回任务列表</button>
<div className={`avatar large ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
<div>
<p>{label}</p>
<h2>{task.name}</h2>
<span className="platform-meta-line">
<span>{task.brand}</span>
<PlatformBadge platform={task.platform} compact />
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
<span>{task.quantity} 篇</span>
<span>{formatDate(task.due_at)} 截止</span>
</span>
2026-07-30 12:06:41 +08:00
</div>
{task.source_url && (
2026-07-30 12:06:41 +08:00
<a href={task.source_url} target="_blank" rel="noreferrer">
打开飞书原表 ↗
</a>
)}
</section>
);
}
function TaskDistributionDetail({
task,
2026-07-30 12:06:41 +08:00
distributions,
canRelease,
working,
onRelease,
onBack,
2026-07-30 12:06:41 +08:00
}: {
task: Task;
2026-07-30 12:06:41 +08:00
distributions: Distribution[];
canRelease: boolean;
working: boolean;
onRelease: (distribution: Distribution) => Promise<boolean>;
onBack: () => void;
2026-07-30 12:06:41 +08:00
}) {
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
2026-07-30 12:06:41 +08:00
const taskDistributions = distributions.filter(
(item) => item.task_id === task.id,
2026-07-30 12:06:41 +08:00
);
const recoveryEnabled = Number(task.recovery_enabled ?? 1) === 1;
2026-07-30 12:06:41 +08:00
const published = taskDistributions.filter((item) => item.publish_url).length;
const submitted = taskDistributions.filter((item) => item.result_submitted_at).length;
2026-07-30 12:06:41 +08:00
return (
<div className="stack">
<TaskDetailHeader
task={task}
label="内容分发"
onBack={onBack}
2026-07-30 12:06:41 +08:00
/>
<div className="inline-summary">
<div><span>已领取</span><strong>{taskDistributions.length}</strong></div>
<i />
<div><span>已回填链接</span><strong>{published}</strong></div>
2026-07-30 12:06:41 +08:00
<i />
<div><span>待发布</span><strong>{taskDistributions.length - published}</strong></div>
{recoveryEnabled && (
<>
<i />
<div>
<span>已收截图</span>
<strong>
{submitted}
{task.recovery_due_at && task.recovery_due_at !== task.due_at
? `(回收截止 ${formatDate(task.recovery_due_at)})`
: ""}
</strong>
</div>
</>
)}
<div className="summary-note"><span className="pulse-dot" />当前仅展示“{task.name}”</div>
2026-07-30 12:06:41 +08:00
</div>
<section className="panel table-panel">
<div className="panel-heading">
<div><h2>内容分发表</h2><p>实际发布账号在链接回填后自动补齐</p></div>
2026-07-30 12:06:41 +08:00
</div>
<DistributionTable
distributions={taskDistributions}
onRelease={canRelease ? setReleaseTarget : undefined}
showRecovery={recoveryEnabled}
/>
2026-07-30 12:06:41 +08:00
</section>
{releaseTarget && (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={() => { if (!working) setReleaseTarget(null); }}
>
<div
className="modal-card compact release-distribution-modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="release-distribution-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow warning">释放领取</p>
<h2 id="release-distribution-title">确认释放这篇笔记?</h2>
</div>
<button
type="button"
onClick={() => setReleaseTarget(null)}
aria-label="关闭"
disabled={working}
>
×
</button>
</div>
<div className="release-distribution-summary">
<strong>{releaseTarget.content_title}</strong>
<span>当前领取合作方:{releaseTarget.partner_name}</span>
</div>
<p className="release-distribution-warning">
释放后,这篇笔记会从当前领取记录中移除并回到可领取池,其他 KOC 可以重新领取。此操作不能撤销。
</p>
<div className="modal-actions">
<button
type="button"
className="ghost-button"
onClick={() => setReleaseTarget(null)}
disabled={working}
>
取消
</button>
<button
type="button"
className="release-confirm-button"
onClick={async () => {
const released = await onRelease(releaseTarget);
if (released) setReleaseTarget(null);
}}
disabled={working}
>
{working ? "释放中…" : "确认释放"}
</button>
</div>
</div>
</div>
)}
2026-07-30 12:06:41 +08:00
</div>
);
}
function DistributionTable({
distributions,
compact = false,
onRelease,
showRecovery = false,
2026-07-30 12:06:41 +08:00
}: {
distributions: Distribution[];
compact?: boolean;
onRelease?: (distribution: Distribution) => void;
showRecovery?: boolean;
2026-07-30 12:06:41 +08:00
}) {
// 已发布但还没采到数据的行:有计划时按首个采集日显示,没计划时
// 明说待设置,而不是写死「等待D2」误导运营。
const waitingCollectionLabel = (item: Distribution) => {
let days: number[] = [];
try {
const parsed = JSON.parse(item.task_collection_days || "[]") as unknown;
if (Array.isArray(parsed)) {
days = parsed
.map(Number)
.filter((day) => Number.isInteger(day) && day >= 1 && day <= 7);
}
} catch {
days = [];
}
if (!item.task_collection_start_date || days.length === 0) {
return "待设置采集计划";
}
return `等待D${Math.min(...days)}`;
};
2026-07-30 12:06:41 +08:00
return (
<div className="table-scroll">
<table className="data-table">
<thead>
<tr>
<th>内容</th>
<th>领取合作方</th>
<th>实际发布账号</th>
<th>发布时间</th>
<th>数据状态</th>
{showRecovery && <th>截图回收</th>}
2026-07-30 12:06:41 +08:00
<th>状态</th>
{onRelease && <th>操作</th>}
2026-07-30 12:06:41 +08:00
</tr>
</thead>
<tbody>
{distributions.map((item) => (
<tr key={item.id}>
<td>
<div className="content-cell">
<span className="content-id">{item.content_id.split("-").at(-1)?.toUpperCase()}</span>
<div><strong>{item.content_title}</strong><span>{item.task_name} · {item.task_brand}</span></div>
</div>
</td>
<td><strong className="plain-strong">{item.partner_name}</strong></td>
<td>
{item.account_nickname ? (
<div className="account-cell">
<div className={`avatar small ${avatarColor(item.account_nickname)}`}>{item.account_nickname.slice(0, 1)}</div>
<div><strong>{item.account_nickname}</strong><PlatformBadge platform={item.account_platform} compact /></div>
2026-07-30 12:06:41 +08:00
</div>
) : (
<span className="muted">回填链接后识别</span>
)}
</td>
<td><span className="date-value">{formatDate(item.publish_time, true)}</span></td>
<td>
{item.d7_likes !== null ? (
<span className="metric-ready">D7 已回收</span>
) : item.d2_likes !== null ? (
<span className="metric-waiting">采集中</span>
) : (
<span className="muted">
{item.publish_url
? waitingCollectionLabel(item)
: "尚未发布"}
</span>
2026-07-30 12:06:41 +08:00
)}
</td>
{showRecovery && (
<td>
<div className="collection-verify-cell">
{item.result_submitted_at ? (
<>
<span className="status-pill complete">已提交</span>
<small>{formatDate(item.result_submitted_at, true)}</small>
</>
) : item.publish_url ? (
<span className="status-pill claimed">未提交</span>
) : (
<span className="creator-screenshot-pending">—</span>
)}
</div>
</td>
)}
2026-07-30 12:06:41 +08:00
<td><span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span></td>
{onRelease && (
<td>
{!item.publish_url && !item.result_submitted_at ? (
<button
type="button"
className="release-distribution-button"
onClick={() => onRelease(item)}
>
释放
</button>
) : (
<span className="muted">—</span>
)}
</td>
)}
2026-07-30 12:06:41 +08:00
</tr>
))}
</tbody>
</table>
{!compact && distributions.length === 0 && <div className="table-empty">暂无分发记录</div>}
</div>
);
}
type AdminPreviewImage = { src: string; title: string };
function AdminImageLightbox({
images,
activeIndex,
onIndexChange,
onClose,
}: {
images: AdminPreviewImage[];
activeIndex: number;
onIndexChange?: (index: number) => void;
onClose: () => void;
}) {
const image = images[activeIndex] ?? null;
const canNavigate = images.length > 1 && Boolean(onIndexChange);
const showPrevious = () => {
if (!canNavigate) return;
onIndexChange?.((activeIndex - 1 + images.length) % images.length);
};
const showNext = () => {
if (!canNavigate) return;
onIndexChange?.((activeIndex + 1) % images.length);
};
useEffect(() => {
if (!image) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
if (event.key === "ArrowLeft" && canNavigate) {
onIndexChange?.((activeIndex - 1 + images.length) % images.length);
}
if (event.key === "ArrowRight" && canNavigate) {
onIndexChange?.((activeIndex + 1) % images.length);
}
};
document.addEventListener("keydown", onKeyDown);
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = previousOverflow;
};
}, [activeIndex, canNavigate, image, images.length, onClose, onIndexChange]);
if (!image) return null;
return (
<div
className="admin-image-lightbox"
role="dialog"
aria-modal="true"
aria-label={image.title}
onMouseDown={onClose}
>
<div
className="admin-image-lightbox-card"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="admin-image-lightbox-heading">
<div>
<strong>{image.title}</strong>
{canNavigate && (
<span aria-live="polite">{activeIndex + 1} / {images.length}</span>
)}
</div>
<button type="button" onClick={onClose}>关闭</button>
</div>
<div className="admin-image-lightbox-stage">
{canNavigate && (
<button type="button" className="previous" onClick={showPrevious} aria-label="上一张">
上一张
</button>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.src} alt={image.title} />
{canNavigate && (
<button type="button" className="next" onClick={showNext} aria-label="下一张">
下一张
</button>
)}
</div>
</div>
</div>
);
}
function ScreenshotTaskTable({ distributions }: { distributions: Distribution[] }) {
const [previewGallery, setPreviewGallery] = useState<{
images: AdminPreviewImage[];
activeIndex: number;
} | null>(null);
return (
<>
<div className="table-scroll">
<table className="data-table screenshot-task-table">
<thead>
<tr>
<th>笔记</th>
<th>领取合作方</th>
<th>领取时间</th>
<th>发布时间</th>
<th>提交时间</th>
<th>结果截图</th>
<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.publish_time, true)}</span></td>
<td><span className="date-value">{formatDate(item.result_submitted_at, true)}</span></td>
<td>
{parseResultScreenshotKeys(item.result_screenshot_key).length > 0 ? (
<div className="admin-result-gallery">
{parseResultScreenshotKeys(item.result_screenshot_key).map((_, index, screenshotKeys) => {
return (
<button
type="button"
onClick={() =>
setPreviewGallery({
images: screenshotKeys.map((__, imageIndex) => ({
src: `/api/task-result-image?distribution=${encodeURIComponent(item.id)}&index=${imageIndex + 1}`,
title: `${item.content_title} · 第${imageIndex + 1}张结果截图`,
})),
activeIndex: index,
})
}
key={`${item.id}-${index}`}
title={`查看第${index + 1}张截图`}
>
<img
src={`/api/task-result-image?distribution=${encodeURIComponent(item.id)}&index=${index + 1}`}
alt={`任务结果截图 ${index + 1}`}
loading="lazy"
/>
</button>
)})}
<span>{parseResultScreenshotKeys(item.result_screenshot_key).length}张</span>
</div>
) : (
<span className="muted">待KOC上传</span>
)}
</td>
<td>
<span className={`status-pill ${item.result_submitted_at ? "complete" : "claimed"}`}>
{item.result_submitted_at ? "已提交" : "待提交"}
</span>
</td>
</tr>
))}
</tbody>
</table>
{distributions.length === 0 && <div className="table-empty">暂无领取记录</div>}
</div>
<AdminImageLightbox
images={previewGallery?.images ?? []}
activeIndex={previewGallery?.activeIndex ?? 0}
onIndexChange={(activeIndex) =>
setPreviewGallery((current) => current ? { ...current, activeIndex } : current)
}
onClose={() => setPreviewGallery(null)}
/>
</>
);
}
const RESOURCE_PAGE_SIZE = 15;
function pageList(current: number, total: number): Array<number | "gap"> {
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1);
}
const items: Array<number | "gap"> = [1];
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
if (start > 2) items.push("gap");
for (let page = start; page <= end; page += 1) items.push(page);
if (end < total - 1) items.push("gap");
items.push(total);
return items;
}
2026-07-30 12:06:41 +08:00
function ResourcesPage({
accounts,
distributions,
2026-08-06 10:18:23 +08:00
exporting,
onExport,
onImported,
partnerNameByAccountId,
onCreatePartner,
creatingPartnerId,
2026-07-30 12:06:41 +08:00
}: {
accounts: Account[];
distributions: Distribution[];
2026-08-06 10:18:23 +08:00
exporting: boolean;
onExport: (accountIds: string[]) => void;
onImported: (message: string) => Promise<void>;
partnerNameByAccountId: Map<string, string>;
onCreatePartner: (account: Account) => Promise<void>;
creatingPartnerId: string | null;
2026-07-30 12:06:41 +08:00
}) {
2026-08-06 10:18:23 +08:00
const [query, setQuery] = useState("");
const [platformFilter, setPlatformFilter] = useState("全部平台");
2026-08-06 11:48:10 +08:00
const [ipFilter, setIpFilter] = useState("");
const [sourceFilter, setSourceFilter] = useState("");
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
const [importMode, setImportMode] = useState<"bulk" | "single">("bulk");
const [importFile, setImportFile] = useState<File | null>(null);
const [importPreview, setImportPreview] = useState<ResourceImportPreview | null>(null);
const [importWorking, setImportWorking] = useState(false);
const [importError, setImportError] = useState("");
const [singleForm, setSingleForm] = useState({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
const [singleWorking, setSingleWorking] = useState(false);
const [singleError, setSingleError] = useState("");
const [editAccount, setEditAccount] = useState<Account | null>(null);
const [editAccountId, setEditAccountId] = useState("");
const [editWorking, setEditWorking] = useState(false);
const [editError, setEditError] = useState("");
const [idRefresh, setIdRefresh] = useState<FieldRefreshStatus | null>(null);
const [idRefreshStarting, setIdRefreshStarting] = useState(false);
const idRefreshNotifiedRef = useRef(true);
const [idRefreshError, setIdRefreshError] = useState("");
const [fieldPickOpen, setFieldPickOpen] = useState(false);
const [fieldChecks, setFieldChecks] = useState<Record<string, boolean>>(
() =>
Object.fromEntries(
REFRESH_FIELD_OPTIONS.map((option) => [
option.key,
option.key === "publicAccountId",
]),
),
);
2026-08-06 10:18:23 +08:00
const resourceAccounts = useMemo(() => {
const deliveriesByAccount = new Map<string, Distribution[]>();
distributions.forEach((distribution) => {
if (!distribution.account_id) return;
const current = deliveriesByAccount.get(distribution.account_id) ?? [];
current.push(distribution);
deliveriesByAccount.set(distribution.account_id, current);
});
return accounts.map((account) => {
const deliveries = deliveriesByAccount.get(account.id) ?? [];
return {
account,
sources: [
...new Set(
[
...deliveries
.filter(
(item) =>
!item.claimant_name ||
item.partner_name !== item.claimant_name,
)
.map((item) => item.partner_name),
...(account.cooperation_source || "")
.split(/[、,,;;|]/)
.map((item) => item.trim()),
].filter(Boolean),
2026-08-06 10:18:23 +08:00
),
].sort((left, right) => left.localeCompare(right, "zh-CN")),
partnerManagedOnly:
deliveries.some((item) => item.delegation_bundle_id) &&
deliveries.every((item) => item.delegation_bundle_id),
};
});
}, [accounts, distributions]);
const platformOptions = useMemo(
() => [
"小红书",
"抖音",
...[...new Set(accounts.map((account) => account.platform).filter(Boolean))]
.filter((platform) => platform !== "小红书" && platform !== "抖音")
2026-08-06 10:18:23 +08:00
.sort((left, right) => left.localeCompare(right, "zh-CN")),
],
2026-08-06 10:18:23 +08:00
[accounts],
);
const ipOptions = useMemo(
() =>
[
...new Set(
accounts.map((account) => account.ip_location || "待识别"),
),
].sort((left, right) => {
if (left === "待识别") return 1;
if (right === "待识别") return -1;
return left.localeCompare(right, "zh-CN");
}),
[accounts],
);
const sourceOptions = useMemo(
() =>
[...new Set(resourceAccounts.flatMap((item) => item.sources))].sort(
(left, right) => left.localeCompare(right, "zh-CN"),
),
[resourceAccounts],
);
const filteredAccounts = useMemo(() => {
const keyword = query.trim().toLocaleLowerCase("zh-CN");
2026-08-06 11:48:10 +08:00
const ipKeyword = ipFilter.trim().toLocaleLowerCase("zh-CN");
const sourceKeyword = sourceFilter.trim().toLocaleLowerCase("zh-CN");
2026-08-06 10:18:23 +08:00
return resourceAccounts.filter(({ account, sources }) => {
const searchable = `${account.nickname} ${account.public_account_id} ${account.current_contact || ""} ${account.bio || ""} ${account.tags || ""}`.toLocaleLowerCase(
2026-08-06 10:18:23 +08:00
"zh-CN",
);
2026-08-06 11:48:10 +08:00
const ipLocation = (account.ip_location || "待识别").toLocaleLowerCase(
"zh-CN",
);
2026-08-06 10:18:23 +08:00
return (
(!keyword || searchable.includes(keyword)) &&
(platformFilter === "全部平台" ||
account.platform === platformFilter) &&
2026-08-06 11:48:10 +08:00
(!ipKeyword || ipLocation.includes(ipKeyword)) &&
(!sourceKeyword ||
sources.some((source) =>
source.toLocaleLowerCase("zh-CN").includes(sourceKeyword),
))
2026-08-06 10:18:23 +08:00
);
});
}, [ipFilter, platformFilter, query, resourceAccounts, sourceFilter]);
const hasActiveFilters =
Boolean(query.trim()) ||
platformFilter !== "全部平台" ||
2026-08-06 11:48:10 +08:00
Boolean(ipFilter.trim()) ||
Boolean(sourceFilter.trim());
const totalPages = Math.max(
1,
Math.ceil(filteredAccounts.length / RESOURCE_PAGE_SIZE),
);
const currentPage = Math.min(page, totalPages);
const pagedAccounts = filteredAccounts.slice(
(currentPage - 1) * RESOURCE_PAGE_SIZE,
currentPage * RESOURCE_PAGE_SIZE,
);
2026-08-06 10:18:23 +08:00
const clearFilters = () => {
setQuery("");
setPlatformFilter("全部平台");
2026-08-06 11:48:10 +08:00
setIpFilter("");
setSourceFilter("");
setPage(1);
2026-07-30 12:06:41 +08:00
};
const closeImport = () => {
if (importWorking || singleWorking) return;
setImportOpen(false);
setImportFile(null);
setImportPreview(null);
setImportError("");
setSingleForm({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
setSingleError("");
};
const switchImportMode = (mode: "bulk" | "single") => {
if (importWorking || singleWorking || mode === importMode) return;
setImportMode(mode);
setImportFile(null);
setImportPreview(null);
setImportError("");
setSingleForm({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
setSingleError("");
};
const submitSingle = async () => {
if (!singleForm.profileUrl.trim()) {
setSingleError("请填写账号主页链接");
return;
}
try {
setSingleWorking(true);
setSingleError("");
const response = await fetch("/api/resources-insert", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(singleForm),
});
const result = await readApiResponse<{ action: string; message?: string; error?: string }>(
response,
"保存失败",
);
if (!response.ok) throw new Error(result.error || "保存失败");
setImportOpen(false);
setImportMode("bulk");
setSingleForm({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
setSingleError("");
await onImported(result.message || "已保存账号");
} catch (reason) {
setSingleError(reason instanceof Error ? reason.message : "保存失败");
} finally {
setSingleWorking(false);
}
};
const submitImport = async (mode: "preview" | "commit") => {
if (!importFile) {
setImportError("请先选择需要导入的 Excel 文件");
return;
}
try {
setImportWorking(true);
setImportError("");
const form = new FormData();
form.append("file", importFile);
form.append("mode", mode);
const response = await fetch("/api/resources-import", {
method: "POST",
body: form,
});
const result = await readApiResponse<
ResourceImportPreview & { error?: string; message?: string }
>(response, "导入失败");
if (!response.ok) throw new Error(result.error || "导入失败");
if (mode === "preview") setImportPreview(result);
else {
setImportOpen(false);
setImportFile(null);
setImportPreview(null);
await onImported(result.message || "KOC资源已导入");
}
} catch (reason) {
setImportError(reason instanceof Error ? reason.message : "导入失败");
} finally {
setImportWorking(false);
}
};
const closeAccountEdit = () => {
if (editWorking) return;
setEditAccount(null);
setEditAccountId("");
setEditError("");
};
const submitAccountEdit = async () => {
if (!editAccount) return;
const value = editAccountId.trim();
if (!value) {
setEditError("请填写新的账号ID");
return;
}
if (!/^[\p{L}\p{N}._-]{2,80}$/u.test(value)) {
setEditError("账号ID需为 2-80 位字母、数字或 . _ - 组合");
return;
}
try {
setEditWorking(true);
setEditError("");
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "set_public_account_ids",
items: [{ accountId: editAccount.id, publicAccountId: value }],
}),
});
const result = await readApiResponse<{ error?: string }>(response, "保存失败");
if (!response.ok) throw new Error(result.error || "保存失败");
const label = editAccount.nickname || editAccount.public_account_id || "该账号";
setEditAccount(null);
setEditAccountId("");
await onImported(`已更新 ${label} 的账号ID`);
} catch (reason) {
setEditError(reason instanceof Error ? reason.message : "保存失败");
} finally {
setEditWorking(false);
}
};
const runningIdRefresh = idRefresh?.status === "running";
const fetchIdRefreshStatus = useCallback(async () => {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "account_field_refresh_status" }),
});
const result = await readApiResponse<{ refresh: FieldRefreshStatus | null }>(
response,
"查询失败",
);
if (!response.ok) throw new Error(result.error || "查询失败");
return result.refresh;
}, []);
const applyIdRefreshStatus = useCallback(
(status: FieldRefreshStatus) => {
if (status.status === "done" && !idRefreshNotifiedRef.current) {
idRefreshNotifiedRef.current = true;
const summary = `账号资料更新完成:成功 ${status.updated} 个${
status.skipped ? `,未识别 ${status.skipped} 个` : ""
}${status.failed ? `,失败 ${status.failed} 个` : ""}`;
void onImported(summary);
}
setIdRefresh(status);
},
[onImported],
);
useEffect(() => {
void fetchIdRefreshStatus()
.then((status) => {
if (status?.status === "running") {
idRefreshNotifiedRef.current = false;
applyIdRefreshStatus(status);
}
})
.catch(() => undefined);
}, [applyIdRefreshStatus, fetchIdRefreshStatus]);
useEffect(() => {
if (!runningIdRefresh) return;
const timer = window.setInterval(() => {
void fetchIdRefreshStatus()
.then((status) => {
if (status) applyIdRefreshStatus(status);
})
.catch(() => undefined);
}, 3000);
return () => window.clearInterval(timer);
}, [applyIdRefreshStatus, fetchIdRefreshStatus, runningIdRefresh]);
const closeFieldPick = () => {
if (idRefreshStarting) return;
setFieldPickOpen(false);
};
const startIdRefresh = async () => {
if (runningIdRefresh || idRefreshStarting) return;
const fields = REFRESH_FIELD_OPTIONS.filter(
(option) => fieldChecks[option.key],
).map((option) => option.key);
if (fields.length === 0) return;
try {
setIdRefreshStarting(true);
setIdRefreshError("");
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "refresh_account_fields", fields }),
});
const result = await readApiResponse<{ refresh: FieldRefreshStatus }>(
response,
"启动失败",
);
if (!response.ok) throw new Error(result.error || "启动失败");
idRefreshNotifiedRef.current = false;
setIdRefresh(result.refresh);
setFieldPickOpen(false);
} catch (reason) {
setIdRefreshError(reason instanceof Error ? reason.message : "启动失败");
} finally {
setIdRefreshStarting(false);
}
};
2026-07-30 12:06:41 +08:00
return (
<div className="stack">
<section className="resource-intro">
<div className="resource-figure">
<strong>{accounts.length}</strong>
<span>个真实发布账号</span>
</div>
<div>
<h2>资源从合作与历史资产中持续沉淀</h2>
<p>发布链接会自动建档,已有KOC资源也可以使用标准模板批量导入。</p>
2026-07-30 12:06:41 +08:00
</div>
<div className="resource-rule">
<span>唯一去重规则</span>
<strong>平台 + 账号主页</strong>
</div>
</section>
<section className="panel table-panel">
<div className="panel-heading">
<div><h2>账号资源库</h2><p>统一管理合作沉淀与历史导入账号</p></div>
<div className="resource-heading-actions">
<a className="ghost-button" download href="/KOC资源导入模板.xlsx">
下载导入模板
</a>
<button className="primary-button soft" onClick={() => setImportOpen(true)}>
导入KOC资源
2026-08-06 10:18:23 +08:00
</button>
<div className="filter-chips">
2026-08-06 10:18:23 +08:00
<button
className={platformFilter === "全部平台" ? "active" : ""}
onClick={() => {
setPlatformFilter("全部平台");
setPage(1);
}}
2026-08-06 10:18:23 +08:00
>
全部平台
2026-08-06 10:18:23 +08:00
</button>
{platformOptions.map((platform) => (
<button
className={platformFilter === platform ? "active" : ""}
key={platform}
onClick={() => {
setPlatformFilter(platform);
setPage(1);
}}
>
<PlatformBadge platform={platform} compact />
</button>
))}
</div>
2026-08-06 10:18:23 +08:00
</div>
</div>
<div className="resource-toolbar">
2026-08-06 11:48:10 +08:00
<div className="resource-search">
2026-08-06 10:18:23 +08:00
<span aria-hidden="true">⌕</span>
<input
aria-label="搜索账号名称、账号ID或当前联系人"
onChange={(event) => {
setQuery(event.target.value);
setPage(1);
}}
placeholder="搜索账号名称 / 账号ID / 当前联系人 / 标签"
2026-08-06 10:18:23 +08:00
type="search"
value={query}
/>
2026-08-06 11:48:10 +08:00
</div>
<div className="resource-filter-search">
<span aria-hidden="true">⌕</span>
<input
aria-label="模糊搜索IP地区"
list="resource-ip-options"
onChange={(event) => {
setIpFilter(event.target.value);
setPage(1);
}}
2026-08-06 11:48:10 +08:00
placeholder="搜索IP地区"
type="search"
value={ipFilter}
/>
<datalist id="resource-ip-options">
{ipOptions.map((ip) => <option key={ip} value={ip} />)}
</datalist>
</div>
<div className="resource-filter-search">
<span aria-hidden="true">⌕</span>
<input
aria-label="模糊搜索合作来源"
list="resource-source-options"
onChange={(event) => {
setSourceFilter(event.target.value);
setPage(1);
}}
2026-08-06 11:48:10 +08:00
placeholder="搜索合作来源"
type="search"
value={sourceFilter}
/>
<datalist id="resource-source-options">
{sourceOptions.map((source) => (
<option key={source} value={source} />
))}
</datalist>
</div>
2026-08-06 10:18:23 +08:00
<button
className="resource-clear-button"
disabled={!hasActiveFilters}
onClick={clearFilters}
>
清空筛选
</button>
<div className="resource-toolbar-summary">
<span>共 <b>{filteredAccounts.length}</b> 个结果{totalPages > 1 ? ` · 第 ${currentPage}/${totalPages} 页` : ""}</span>
<button
className="export-data-button"
disabled={runningIdRefresh || idRefreshStarting}
title="爬取全部账号主页,按选择的字段批量更新账号资料"
onClick={() => setFieldPickOpen(true)}
>
{idRefreshStarting
? "正在启动…"
: runningIdRefresh
? `更新中 ${idRefresh?.processed ?? 0}/${idRefresh?.total ?? 0}`
: "批量更新账号资料"}
</button>
2026-08-06 10:18:23 +08:00
<button
className="export-data-button"
disabled={exporting || filteredAccounts.length === 0}
onClick={() =>
onExport(filteredAccounts.map((item) => item.account.id))
}
>
{exporting ? "正在导出…" : "导出筛选结果"}
</button>
</div>
{idRefreshError && <div className="form-error">{idRefreshError}</div>}
2026-07-30 12:06:41 +08:00
</div>
<div className="resource-grid">
{pagedAccounts.map(({ account, sources, partnerManagedOnly }) => {
const profileLink = resourceProfileLink(account);
const accountTags = (account.tags || "")
.split(/[,,、;;|]/)
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 5);
const rawBio = (account.bio || "").trim();
const hasBio = Boolean(
rawBio &&
!/^(未填写简介|还没有简介|暂无简介|未填写|待识别)$/u.test(rawBio),
);
const accountIdLabel =
account.platform === "小红书"
? "小红书号"
: account.platform === "抖音"
? "抖音号"
: "账号";
return (
<article
className={`resource-card ${account.platform === "小红书" ? "platform-xhs" : account.platform === "抖音" ? "platform-douyin" : "platform-other"}`}
key={account.id}
>
2026-07-30 12:06:41 +08:00
<div className="resource-card-head">
<div className={`avatar xlarge resource-profile-avatar ${avatarColor(account.nickname)}`}>{account.nickname.slice(0, 1)}</div>
<div className="resource-profile-main">
<div className="resource-name-line">
<h3>{account.nickname}</h3>
{account.gender === "男" && (
<span className="gender-icon male" aria-label="男性" title="男性">♂</span>
)}
{account.gender === "女" && (
<span className="gender-icon female" aria-label="女性" title="女性">♀</span>
)}
</div>
<div className="resource-account-number">
<span>{accountIdLabel}</span>
<strong>{account.public_account_id || "待识别"}</strong>
<button
type="button"
className="account-id-edit-button"
title="手动修改账号ID"
onClick={() => {
setEditAccount(account);
setEditAccountId(account.public_account_id || "");
setEditError("");
}}
>
修改
</button>
</div>
<div className="resource-platform-line">
<PlatformBadge platform={account.platform} compact />
<span className="resource-location">IP属地 · {account.ip_location || "待识别"}</span>
</div>
<div className="resource-current-contact">
<span>当前联系人</span>
<strong>{account.current_contact || "待补充"}</strong>
</div>
</div>
2026-07-30 12:06:41 +08:00
</div>
<p className={`resource-bio ${hasBio ? "" : "empty"}`} title={hasBio ? rawBio : undefined}>
{hasBio ? rawBio : "暂无简介"}
</p>
<div className={`resource-tags ${accountTags.length === 0 ? "empty" : ""}`}>
<div>
{accountTags.length > 0
? accountTags.map((tag) => <b key={tag}>{tag}</b>)
: <b>待打标</b>}
</div>
2026-07-30 12:06:41 +08:00
</div>
<div className="resource-metrics" aria-label="账号数据">
2026-07-30 12:06:41 +08:00
<div><span>粉丝</span><strong>{formatNumber(account.followers)}</strong></div>
<div><span>合作发布</span><strong>{account.post_count}</strong></div>
<div className="resource-latest"><span>最近合作</span><strong>{formatDate(account.last_seen_at)}</strong></div>
2026-07-30 12:06:41 +08:00
</div>
<div className="resource-card-foot">
<div className="resource-source">
<span>合作来源</span>
<div>
{sources.map((source) => <b key={source}>{source}</b>)}
{partnerManagedOnly && (
<b className="partner-managed">合作社资源 · 不可直联</b>
)}
{sources.length === 0 && !partnerManagedOnly && <b className="empty">暂无</b>}
</div>
</div>
<div className="resource-foot-actions">
{(() => {
const boundPartnerName = partnerNameByAccountId.get(account.id);
if (boundPartnerName) {
return (
<span className="resource-partner-bound" title={`已绑定合作方:${boundPartnerName}`}>
合作方 · {boundPartnerName}
</span>
);
}
return (
<button
type="button"
className="resource-partner-button"
disabled={creatingPartnerId !== null}
title="从该 KOC 资源一键创建外部KOC合作方"
onClick={() => void onCreatePartner(account)}
>
{creatingPartnerId === account.id ? "创建中…" : "设为合作方"}
</button>
);
})()}
{profileLink && <a href={profileLink.href} target="_blank" rel="noreferrer">{profileLink.label}</a>}
</div>
2026-07-30 12:06:41 +08:00
</div>
</article>
);
})}
2026-07-30 12:06:41 +08:00
</div>
2026-08-06 10:18:23 +08:00
{filteredAccounts.length === 0 && (
<div className="resource-empty">
<strong>没有符合条件的KOC</strong>
<span>可以调整搜索内容或清空筛选条件</span>
<button className="ghost-button" onClick={clearFilters}>
清空筛选
</button>
</div>
)}
{totalPages > 1 && (
<div className="resource-pagination">
<button
disabled={currentPage === 1}
onClick={() => setPage(currentPage - 1)}
>
上一页
</button>
{pageList(currentPage, totalPages).map((item, index) =>
item === "gap" ? (
<span className="gap" key={`gap-${index}`}>…</span>
) : (
<button
className={item === currentPage ? "active" : ""}
key={item}
onClick={() => setPage(item)}
>
{item}
</button>
),
)}
<button
disabled={currentPage === totalPages}
onClick={() => setPage(currentPage + 1)}
>
下一页
</button>
</div>
)}
2026-07-30 12:06:41 +08:00
</section>
{fieldPickOpen && (
<div className="modal-backdrop" role="presentation" onMouseDown={closeFieldPick}>
<div
className="modal-card compact"
role="dialog"
aria-modal="true"
aria-labelledby="field-pick-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow">批量更新</p>
<h2 id="field-pick-title">选择要更新的字段</h2>
</div>
<button type="button" onClick={closeFieldPick} aria-label="关闭">×</button>
</div>
<p className="field-pick-note">
将逐个爬取全部账号主页(小红书 + 抖音),只写入选中的字段;
爬取为空的字段自动跳过,不会清空已有数据。账号较多时需要几分钟。
</p>
<div className="field-pick-list">
{REFRESH_FIELD_OPTIONS.map((option) => (
<label className="field-pick-item" key={option.key}>
<input
checked={Boolean(fieldChecks[option.key])}
onChange={(event) =>
setFieldChecks((checks) => ({
...checks,
[option.key]: event.target.checked,
}))
}
type="checkbox"
/>
<span>{option.label}</span>
</label>
))}
</div>
{idRefreshError && <div className="form-error">{idRefreshError}</div>}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={closeFieldPick}>取消</button>
<button
type="button"
className="primary-button"
disabled={
idRefreshStarting ||
runningIdRefresh ||
!REFRESH_FIELD_OPTIONS.some((option) => fieldChecks[option.key])
}
onClick={() => void startIdRefresh()}
>
{idRefreshStarting
? "正在启动…"
: runningIdRefresh
? `更新中 ${idRefresh?.processed ?? 0}/${idRefresh?.total ?? 0}`
: "开始更新"}
</button>
</div>
</div>
</div>
)}
{editAccount && (
<div className="modal-backdrop" role="presentation" onMouseDown={closeAccountEdit}>
<div
className="modal-card compact"
role="dialog"
aria-modal="true"
aria-labelledby="account-id-edit-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow">{editAccount.nickname || "账号"}</p>
<h2 id="account-id-edit-title">修改账号ID</h2>
</div>
<button type="button" onClick={closeAccountEdit} aria-label="关闭">×</button>
</div>
<div className="account-id-edit-form">
<label>
<span>
{editAccount.platform === "小红书"
? "小红书号"
: editAccount.platform === "抖音"
? "抖音号"
: "账号ID"}
</span>
<input
autoFocus
type="text"
value={editAccountId}
placeholder="2-80 位字母、数字或 . _ -"
onChange={(event) => setEditAccountId(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void submitAccountEdit();
}}
/>
<small>只更新账号ID,昵称、粉丝数等其他资料保持不变</small>
</label>
{editError && <div className="form-error">{editError}</div>}
</div>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={closeAccountEdit}>取消</button>
<button
type="button"
className="primary-button"
disabled={editWorking || !editAccountId.trim()}
onClick={() => void submitAccountEdit()}
>
{editWorking ? "保存中…" : "保存"}
</button>
</div>
</div>
</div>
)}
{importOpen && (
<div className="modal-backdrop" role="presentation" onMouseDown={closeImport}>
<div
className="modal-card resource-import-modal"
role="dialog"
aria-modal="true"
aria-labelledby="resource-import-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow">{importMode === "single" ? "单条新增" : "批量导入"}</p>
<h2 id="resource-import-title">导入已有KOC资源</h2>
</div>
<button type="button" onClick={closeImport} aria-label="关闭">×</button>
</div>
<div className="import-mode-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={importMode === "bulk"}
className={importMode === "bulk" ? "active" : ""}
onClick={() => switchImportMode("bulk")}
>
批量导入
</button>
<button
type="button"
role="tab"
aria-selected={importMode === "single"}
className={importMode === "single" ? "active" : ""}
onClick={() => switchImportMode("single")}
>
单条新增
</button>
</div>
{importMode === "bulk" && (
<>
<div className="import-guide-strip">
<span>1</span><p>账号链接必填,其余资料选填</p>
<i />
<span>2</span><p>系统只补全空缺的公开资料</p>
<i />
<span>3</span><p>确认后写入资源库</p>
</div>
<label className="resource-import-dropzone">
<input
accept=".xlsx,.csv"
type="file"
onChange={(event) => {
setImportFile(event.target.files?.[0] ?? null);
setImportPreview(null);
setImportError("");
}}
/>
<strong>{importFile ? importFile.name : "选择 Excel / CSV 文件"}</strong>
<span>单次最多 10,000 个账号,文件不超过 20MB</span>
</label>
{!importPreview && (
<div className="import-template-note">
<div><strong>还没有模板?</strong><span>只有账号链接必填;性别、简介、标签等资料均可选填,多个标签请用逗号分隔且最多 5 个,系统只补全空缺字段。</span></div>
<a className="ghost-button" download href="/KOC资源导入模板.xlsx">下载模板</a>
</div>
)}
{importPreview && (
<div className="resource-import-preview">
<div className="import-summary-grid">
<div><span>数据总数</span><strong>{importPreview.summary.total}</strong></div>
<div className="create"><span>新增账号</span><strong>{importPreview.summary.create}</strong></div>
<div className="update"><span>更新账号</span><strong>{importPreview.summary.update}</strong></div>
<div className={importPreview.summary.error ? "error" : ""}><span>异常数据</span><strong>{importPreview.summary.error}</strong></div>
</div>
<div className="import-preview-table">
<div className="import-preview-row head">
<span>行号</span><span>账号</span><span>账号ID</span><span>IP / 来源</span><span>结果</span>
</div>
{importPreview.rows.slice(0, 8).map((row) => (
<div className="import-preview-row" key={row.rowNumber}>
<span>{row.rowNumber}</span>
<span><strong>{row.nickname || "—"}</strong><small><PlatformBadge platform={row.platform} compact /></small></span>
<span>{row.publicAccountId || "主页识别"}</span>
<span><strong>{row.ipLocation}</strong><small>{row.cooperationSource || "未填写来源"}</small></span>
<span className={`import-result ${row.action}`}>
{row.action === "create" ? "新增" : row.action === "update" ? "更新" : row.errors.join(";")}
</span>
</div>
))}
</div>
{(importPreview.summary.total > 8 || importPreview.truncated) && (
<p className="import-preview-more">这里只预览 8 行,异常数据会优先展示;确认导入时会处理全部有效数据。</p>
)}
{importPreview.summary.error > 0 && (
<p className="import-preview-warning">
{importPreview.summary.error} 条异常数据将自动跳过,不会导入;其余 {importPreview.summary.create + importPreview.summary.update} 个有效账号可正常导入。
</p>
)}
{Boolean(importPreview.deferredEnrichment) && (
<p className="import-preview-more">
其中 {importPreview.deferredEnrichment} 个账号缺少公开资料;确认后会先导入资源库,再在后台逐步补全。
</p>
)}
</div>
)}
{importError && <div className="form-error">{importError}</div>}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={closeImport}>取消</button>
{!importPreview ? (
<button
type="button"
className="primary-button"
disabled={!importFile || importWorking}
onClick={() => void submitImport("preview")}
>
{importWorking ? "校验中…" : "校验并预览"}
</button>
) : (
<button
type="button"
className="primary-button"
disabled={importPreview.summary.create + importPreview.summary.update === 0 || importWorking}
onClick={() => void submitImport("commit")}
>
{importWorking ? "导入中…" : `确认导入 ${importPreview.summary.create + importPreview.summary.update} 个账号`}
</button>
)}
</div>
</>
)}
{importMode === "single" && (
<>
<div className="import-guide-strip">
<span>1</span><p>账号主页链接必填,平台自动识别</p>
<i />
<span>2</span><p>其余资料选填,空缺字段系统会补全</p>
<i />
<span>3</span><p>已存在的账号会更新资料</p>
</div>
<div className="form-grid two">
<label>
<span>账号主页链接 *</span>
<input
type="url"
value={singleForm.profileUrl}
onChange={(event) =>
setSingleForm((form) => ({ ...form, profileUrl: event.target.value }))
}
placeholder="https://www.xiaohongshu.com/user/profile/..."
/>
<small>
{platformFromProfileUrl(singleForm.profileUrl)
? `识别平台:${platformFromProfileUrl(singleForm.profileUrl)}`
: "支持小红书 / 抖音账号主页"}
</small>
</label>
<label>
<span>账号昵称</span>
<input
type="text"
value={singleForm.nickname}
onChange={(event) =>
setSingleForm((form) => ({ ...form, nickname: event.target.value }))
}
placeholder="选填"
/>
</label>
<label>
<span>账号ID</span>
<input
type="text"
value={singleForm.publicAccountId}
onChange={(event) =>
setSingleForm((form) => ({ ...form, publicAccountId: event.target.value }))
}
placeholder="小红书号 / 抖音号"
/>
</label>
<label>
<span>IP属地</span>
<input
type="text"
value={singleForm.ipLocation}
onChange={(event) =>
setSingleForm((form) => ({ ...form, ipLocation: event.target.value }))
}
placeholder="省份 / 地区 / 国家"
/>
</label>
<label>
<span>粉丝数</span>
<input
type="text"
value={singleForm.followers}
onChange={(event) =>
setSingleForm((form) => ({ ...form, followers: event.target.value }))
}
placeholder="如 1.3万 / 8000"
/>
</label>
<label>
<span>性别</span>
<select
value={singleForm.gender}
onChange={(event) =>
setSingleForm((form) => ({ ...form, gender: event.target.value }))
}
>
<option value="">未填写</option>
<option value="男">男</option>
<option value="女">女</option>
</select>
</label>
<label>
<span>合作来源</span>
<input
type="text"
value={singleForm.cooperationSource}
onChange={(event) =>
setSingleForm((form) => ({ ...form, cooperationSource: event.target.value }))
}
placeholder="历史合作来源 / 资源来源"
/>
</label>
<label>
<span>标签</span>
<input
type="text"
value={singleForm.tags}
onChange={(event) =>
setSingleForm((form) => ({ ...form, tags: event.target.value }))
}
placeholder="逗号分隔,最多 5 个"
/>
</label>
</div>
<label>
<span>简介</span>
<textarea
value={singleForm.bio}
onChange={(event) =>
setSingleForm((form) => ({ ...form, bio: event.target.value }))
}
placeholder="选填"
/>
</label>
{singleError && <div className="form-error">{singleError}</div>}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={closeImport}>取消</button>
<button
type="button"
className="primary-button"
disabled={!singleForm.profileUrl.trim() || singleWorking}
onClick={() => void submitSingle()}
>
{singleWorking ? "保存中…" : "保存账号"}
</button>
</div>
</>
)}
</div>
</div>
)}
2026-07-30 12:06:41 +08:00
</div>
);
}
function CreatorScreenshotPreview({
distributionId,
title,
}: {
distributionId: string;
title: string;
}) {
const [open, setOpen] = useState(false);
const imageUrl = `/api/creator-screenshot?distribution=${encodeURIComponent(distributionId)}`;
return (
<>
<button
type="button"
className="creator-screenshot-link"
onClick={() => setOpen(true)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={imageUrl} alt={`${title} 创作者中心截图`} loading="lazy" />
<span>点击查看</span>
</button>
<AdminImageLightbox
images={open ? [{ src: imageUrl, title: `${title} · 创作者中心截图` }] : []}
activeIndex={0}
onClose={() => setOpen(false)}
/>
</>
);
}
2026-07-30 12:06:41 +08:00
function RecoveryPage({
tasks,
distributions,
working,
collectingDistributionIds,
canUpdatePublishUrl,
isManager,
2026-07-30 12:06:41 +08:00
onCollect,
onUpdatePublishUrl,
2026-07-30 12:06:41 +08:00
onSaveSchedule,
onUpload,
onFallback,
onRetryFailed,
onRemind,
onExport,
onExportScreenshots,
exportingTaskId,
2026-07-30 12:06:41 +08:00
}: {
tasks: Task[];
distributions: Distribution[];
working: boolean;
collectingDistributionIds: ReadonlySet<string>;
canUpdatePublishUrl: boolean;
isManager: boolean;
2026-07-30 12:06:41 +08:00
onCollect: (distribution: Distribution) => void;
onUpdatePublishUrl: (distribution: Distribution) => void;
2026-07-30 12:06:41 +08:00
onSaveSchedule: (
taskId: string,
startDate: string,
days: number[],
) => Promise<boolean>;
onUpload: (distribution: Distribution, event: ChangeEvent<HTMLInputElement>) => void;
onFallback: (distribution: Distribution) => void;
onRetryFailed: (taskId: string, failedCount: number) => void;
onRemind: (taskId: string, pendingCount: number) => void;
onExport: (task: Task) => void;
onExportScreenshots: (task: Task) => void;
exportingTaskId: string | null;
2026-07-30 12:06:41 +08:00
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [recoverySort, setRecoverySort] = useState<{
column: RecoverySortKey | null;
direction: SortDirection;
}>({ column: null, direction: "desc" });
2026-07-30 12:06:41 +08:00
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
if (!selectedTask) {
return (
<div className="stack">
<div className="section-intro">
<div><p className="eyebrow">按任务回收</p><h2>选择一个数据回收任务</h2></div>
<p>进入任务后设置自动采集日期,统一查看每篇笔记的最新互动数据。</p>
</div>
<TaskScopeList
tasks={tasks}
distributions={distributions}
onOpen={setSelectedTaskId}
/>
</div>
);
}
const taskDistributions = distributions.filter(
(item) => item.task_id === selectedTask.id,
);
const recoveryEnabled = Number(selectedTask.recovery_enabled ?? 1) === 1;
const recoverySubmitted = taskDistributions.filter(
(item) => item.result_submitted_at,
).length;
const recoveryPending = taskDistributions.filter(
(item) => item.publish_url && !item.result_submitted_at,
).length;
2026-07-30 12:06:41 +08:00
const failedCollectionCount = taskDistributions.filter(
(item) => item.collection_status === "failed",
).length;
const sortedTaskDistributions = recoverySort.column
? sortRecoveryDistributions(
taskDistributions,
recoverySort.column,
recoverySort.direction,
)
: taskDistributions;
const changeRecoverySort = (column: RecoverySortKey) => {
setRecoverySort((current) =>
current.column === column
? {
column,
direction: current.direction === "desc" ? "asc" : "desc",
}
: { column, direction: "desc" },
);
};
2026-07-30 12:06:41 +08:00
return (
<div className="stack">
<TaskDetailHeader
task={selectedTask}
label="数据回收"
onBack={() => setSelectedTaskId(null)}
/>
<CollectionScheduleCard
key={`${selectedTask.id}:${selectedTask.collection_start_date}:${selectedTask.collection_days}`}
task={selectedTask}
working={working}
onSave={onSaveSchedule}
/>
<section className="panel table-panel recovery-panel">
<div className="panel-heading">
<div>
<h2>数据回收队列</h2>
<p>{taskDistributions.length} 篇已发布作品 · 展示最近一次采集结果</p>
2026-07-30 12:06:41 +08:00
</div>
<div className="recovery-panel-actions">
<button
className="export-data-button"
disabled={working || exportingTaskId === selectedTask.id}
onClick={() => onExport(selectedTask)}
>
{exportingTaskId === selectedTask.id
? "正在生成 Excel…"
: "导出全部数据"}
</button>
{isManager && (
<button
className="retry-failed-button"
disabled={working || failedCollectionCount === 0}
onClick={() =>
onRetryFailed(selectedTask.id, failedCollectionCount)
}
>
一键补采异常数据
{failedCollectionCount > 0 && <b>{failedCollectionCount}</b>}
</button>
)}
{(() => {
const publishPending = taskDistributions.filter(
(item) => !item.publish_url && !item.result_submitted_at,
).length;
const pendingCount = publishPending + (recoveryEnabled ? recoveryPending : 0);
return (
<button
className="retry-failed-button"
disabled={working || pendingCount === 0}
onClick={() => onRemind(selectedTask.id, pendingCount)}
>
催办未完成
{pendingCount > 0 && <b>{pendingCount}</b>}
</button>
);
})()}
</div>
2026-07-30 12:06:41 +08:00
</div>
<div className="table-scroll">
<table className="data-table recovery-table">
<thead>
<tr>
<th>内容 / 发布账号</th>
<SortableRecoveryHeader
label="发布时间"
column="publish_time"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="点赞"
column="likes"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="收藏"
column="collects"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
{selectedTask.platform === "抖音" && (
<SortableRecoveryHeader
label="转发"
column="shares"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
)}
<SortableRecoveryHeader
label="评论"
column="comments"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="总互动"
column="total"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="数据更新时间"
column="collection_updated_at"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
2026-07-30 12:06:41 +08:00
<th>采集状态</th>
<th>创作者截图</th>
{recoveryEnabled && <th>收录截图</th>}
2026-07-30 12:06:41 +08:00
<th>操作</th>
</tr>
</thead>
<tbody>
{sortedTaskDistributions.map((item) => {
2026-07-30 12:06:41 +08:00
const metrics = latestPublicMetrics(item);
const noteUrl = publicPublishUrl(item.publish_url);
2026-07-30 12:06:41 +08:00
const hasMetrics = metrics.likes !== null;
const collectionStatus =
item.collection_status ||
(hasMetrics ? "success" : "pending");
return (
<tr key={item.id}>
<td>
<div className="account-cell recovery-content">
<div className={`avatar small ${avatarColor(item.content_title)}`}>{Array.from(item.content_title)[0] || "笔"}</div>
<div>
{noteUrl ? (
<a
className="recovery-title-link"
href={noteUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`打开${selectedTask.platform}作品:${item.content_title}`}
title={`打开${selectedTask.platform}作品`}
2026-07-30 12:06:41 +08:00
>
<strong>{item.content_title}</strong>
</a>
) : (
<strong>{item.content_title}</strong>
)}
<span className="platform-meta-line">
<span>{item.account_nickname || "待识别账号"}</span>
{item.account_platform && <PlatformBadge platform={item.account_platform} compact />}
2026-07-30 12:06:41 +08:00
</span>
</div>
</div>
</td>
<td>
<div className="metric-cell publish-time">
<strong>{formatDate(item.publish_time, true)}</strong>
</div>
</td>
<td><div className="metric-cell"><strong>{formatNumber(metrics.likes)}</strong></div></td>
<td><div className="metric-cell"><strong>{formatNumber(metrics.collects)}</strong></div></td>
{selectedTask.platform === "抖音" && (
<td><div className="metric-cell"><strong>{formatNumber(metrics.shares)}</strong></div></td>
)}
2026-07-30 12:06:41 +08:00
<td><div className="metric-cell"><strong>{formatNumber(metrics.comments)}</strong></div></td>
<td>
<div className="metric-cell total">
<strong>{formatNumber(metrics.total)}</strong>
<span>{selectedTask.platform === "抖音" ? "赞 + 藏 + 转 + 评" : "赞 + 藏 + 评"}</span>
2026-07-30 12:06:41 +08:00
</div>
</td>
<td>
<div className="metric-cell update-time">
<strong>{formatDate(item.collection_updated_at || (hasMetrics ? item.updated_at : null), true)}</strong>
{item.last_collection_day && <span>第{item.last_collection_day}天采集</span>}
</div>
</td>
<td>
<div className="collection-status-cell">
<span className={`collection-status ${collectionStatus}`}>
{collectionStatusLabel(collectionStatus, hasMetrics)}
</span>
<small>
{item.collection_status_description ||
(hasMetrics ? "成功" : "等待采集计划")}
</small>
</div>
</td>
<td>
<div className="creator-screenshot-cell">
{item.screenshot_key ? (
<CreatorScreenshotPreview
distributionId={item.id}
title={item.content_title}
/>
2026-07-30 12:06:41 +08:00
) : (
<span className="creator-screenshot-pending">待KOC上传</span>
)}
{hasCreatorMetrics(item) ? (
<span className="creator-data-summary">
<b>曝光 {formatNumber(item.exposure)}</b>
<b>阅读 {formatNumber(item.views)}</b>
</span>
) : item.screenshot_key ? (
<span className="creator-ocr-failed">
{item.ocr_status === "processing"
? "正在识别数据"
: "识别失败,请手动填写"}
2026-07-30 12:06:41 +08:00
</span>
) : null}
{item.screenshot_key &&
!hasCreatorMetrics(item) && (
<label className="upload-button retry-upload">
<input
type="file"
accept="image/*"
onChange={(event) => onUpload(item, event)}
/>
重新上传
</label>
)}
</div>
</td>
{recoveryEnabled && (
<td>
<div className="collection-verify-cell">
{item.result_submitted_at ? (
<>
<span className="status-pill complete">已提交</span>
<small>{formatDate(item.result_submitted_at, true)}</small>
</>
) : item.publish_url ? (
<span className="status-pill claimed">未提交</span>
) : (
<span className="creator-screenshot-pending">未发布</span>
)}
</div>
</td>
)}
2026-07-30 12:06:41 +08:00
<td>
<div className="recovery-row-actions">
{item.screenshot_key && !hasCreatorMetrics(item) ? (
<button className="text-button" onClick={() => onFallback(item)}>手动填写</button>
) : (
<button
className="collect-button"
disabled={
!noteUrl ||
(working &&
(collectingDistributionIds.size === 0 ||
collectingDistributionIds.has(item.id)))
}
onClick={() => onCollect(item)}
>
{noteUrl ? "立即采集" : "待填链接"}
</button>
)}
{canUpdatePublishUrl && (
<button
className="publish-url-edit-button"
disabled={working}
onClick={() => onUpdatePublishUrl(item)}
>
{noteUrl ? "更新链接" : "填写链接"}
</button>
)}
</div>
2026-07-30 12:06:41 +08:00
</td>
</tr>
);
})}
</tbody>
</table>
{taskDistributions.length === 0 && (
<div className="table-empty compact">当前任务还没有已发布笔记</div>
)}
</div>
</section>
{recoveryEnabled && (
<>
<div className="inline-summary related-summary">
<div><span>已收截图</span><strong>{recoverySubmitted}</strong></div>
<i />
<div><span>已发布待收</span><strong>{recoveryPending}</strong></div>
<div className="summary-note">
<span className="pulse-dot" />
回收截止 {formatDate(selectedTask.recovery_due_at || selectedTask.due_at)}
</div>
</div>
<section className="panel table-panel recovery-panel screenshot-recovery-panel">
<div className="panel-heading">
<div>
<h2>截图回收 · {selectedTask.name}</h2>
<p>KOC 对已发布的笔记上传结果截图,每一项对应上方一篇笔记,用于验证收录。</p>
</div>
<div className="recovery-panel-actions">
<button
className="retry-failed-button"
disabled={working || recoveryPending === 0}
onClick={() => onRemind(selectedTask.id, recoveryPending)}
>
催办未提交
{recoveryPending > 0 && <b>{recoveryPending}</b>}
</button>
<button
className="export-data-button"
disabled={exportingTaskId === selectedTask.id || recoverySubmitted === 0}
onClick={() => onExportScreenshots(selectedTask)}
>
{exportingTaskId === selectedTask.id ? "正在打包…" : "批量下载截图"}
</button>
</div>
</div>
<ScreenshotTaskTable distributions={taskDistributions} />
</section>
</>
)}
2026-07-30 12:06:41 +08:00
</div>
);
}
function CollectionScheduleCard({
task,
working,
onSave,
}: {
task: Task;
working: boolean;
onSave: (
taskId: string,
startDate: string,
days: number[],
) => Promise<boolean>;
}) {
const [startDate, setStartDate] = useState(
task.collection_start_date || todayInputValue(),
);
const [days, setDays] = useState<number[]>(
parseCollectionDays(task.collection_days),
);
const toggleDay = (day: number) => {
setDays((current) =>
current.includes(day)
? current.filter((item) => item !== day)
: [...current, day].sort((a, b) => a - b),
);
};
return (
<section className="panel collection-schedule-panel">
<div className="collection-schedule-heading">
<div>
<p className="eyebrow">自动采集计划</p>
<h2>选择开始日期与采集日</h2>
<span>勾选第1天到第7天,所选日期均在北京时间09:00自动采集。</span>
2026-07-30 12:06:41 +08:00
</div>
<div className="schedule-save-area">
<span>已选择 {days.length} 天</span>
<button
className="primary-button"
disabled={working || !startDate || days.length === 0}
onClick={() => void onSave(task.id, startDate, days)}
>
{working ? "保存中…" : "保存采集计划"}
</button>
</div>
</div>
<div className="schedule-grid">
<label className="schedule-start">
<span>开始采集日期</span>
<input
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/>
</label>
<div className="schedule-days" role="group" aria-label="选择自动采集日">
{Array.from({ length: 7 }, (_, index) => index + 1).map((day) => {
const selected = days.includes(day);
const date = addCalendarDays(startDate, day - 1);
return (
<button
key={day}
type="button"
className={selected ? "selected" : ""}
aria-pressed={selected}
onClick={() => toggleDay(day)}
>
<span>第{day}天</span>
<b aria-hidden="true">{selected ? "✓" : ""}</b>
<small>{date ? date.slice(5).replace("-", "/") : "—"}</small>
</button>
);
})}
</div>
<span className="schedule-mobile-hint">左右滑动查看第1—7天</span>
</div>
{task.collection_schedule_updated_at && (
<p className="schedule-updated">
当前计划更新于 {formatDate(task.collection_schedule_updated_at, true)}
</p>
)}
</section>
);
}