Files
koc-loop/app/admin-app.tsx
ABAPPLO c11afd5f99 feat: 采集失败KOC补交截图闭环 + 任务中心分发详情弹窗
公开数据补交闭环(F1):
- distributions 新增 public_metrics_screenshot_key(mysql/0022 迁移)
- KOC 门户:链接已回填但采集失败时展示补交数据截图入口(kind=public-metrics)
- 运营端:回收队列"核对补录"支持公开互动数据(赞/藏/评),按补交截图补录后采集状态置成功
- 企微催办:采集失败纳入待办(公开数据待补交)
- task-result-image 支持 kind=publish 输出发布截图

门户文案(F2):
- 自动采集数据明确"由系统每天自动采集,无需你填写",仅识别失败时才手动填写曝光/阅读

任务中心分发详情:
- 任务中心/数据回收的分发表行可点击,弹窗查看详情
- 详情按任务类型适配:发布阶段/数据回收(自动采集)/创作者数据(第7天)/截图回收四区块三段式布局
2026-10-09 17:02:46 +08:00

7567 lines
279 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
ChangeEvent,
FormEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
formatShanghaiDate as formatDate,
formatShanghaiToday,
} from "../lib/date-utils";
import type { AuthUser } from "../lib/user-auth";
import { parseResultScreenshotKeys } from "../lib/result-screenshots";
import { platformFromProfileUrl } from "../lib/resource-import";
import {
sortWithNullsLast,
type SortDirection,
} from "../lib/sort-utils";
import UsersPage from "./users-page";
import {
readApiResponse,
WecomUserPickerModal,
} from "./wecom-user-picker";
import { TaskCustomerChatModal } from "./task-customer-chat-modal";
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;
};
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;
source_url: string;
source_sheet_id: string;
source_sheet_name: string;
source_synced_at: string | null;
share_token: string;
collection_start_date: string | null;
collection_days: string;
collection_schedule_updated_at: string | null;
};
type Account = {
id: string;
platform: string;
platform_uid: string;
public_account_id: string;
nickname: string;
profile_url: string;
latest_publish_url: string;
ip_location: string;
followers: number;
gender: string;
bio: string;
tags: string;
post_count: number;
avg_views: number;
cooperation_source: string;
current_contact: string;
first_seen_at: string;
last_seen_at: string;
};
type FieldRefreshStatus = {
status: "running" | "done";
fields: string[];
total: number;
processed: number;
updated: number;
failed: number;
skipped: number;
startedAt: string;
finishedAt: string | null;
};
const REFRESH_FIELD_OPTIONS = [
{ key: "publicAccountId", label: "账号ID(小红书号/抖音号)" },
{ key: "nickname", label: "昵称" },
{ key: "followers", label: "粉丝数" },
{ key: "ipLocation", label: "IP属地" },
{ key: "gender", label: "性别" },
{ key: "bio", label: "简介" },
{ key: "tags", label: "标签(AI 重新打标,覆盖已有标签)" },
] as const;
type ResourceImportPreview = {
summary: { total: number; create: number; update: number; error: number };
rows: Array<{
rowNumber: number;
platform: string;
nickname: string;
publicAccountId: string;
ipLocation: string;
followers: number;
gender: string;
bio: string;
tags: string[];
cooperationSource: string;
action: "create" | "update" | "error";
errors: string[];
}>;
truncated: boolean;
deferredEnrichment?: number;
maxRows?: number;
};
type Distribution = {
id: string;
task_id: string;
content_id: string;
partner_id: string;
delegation_bundle_id: string | null;
account_id: string | null;
publish_url: string | null;
publish_time: string | null;
publish_screenshot_key: string | null;
result_screenshot_key: string | null;
result_submitted_at: string | null;
status: string;
claimed_at: string;
screenshot_key: string | null;
ocr_status: string;
exposure: number | null;
views: number | null;
d2_likes: number | null;
d2_comments: number | null;
d2_collects: number | null;
d5_likes: number | null;
d5_comments: number | null;
d5_collects: number | null;
d7_likes: number | null;
d7_comments: number | null;
d7_collects: number | null;
latest_likes: number | null;
latest_comments: number | null;
latest_collects: number | null;
latest_shares: number | null;
public_metrics_screenshot_key: string | null;
collection_status: string;
collection_status_description: string | null;
collection_updated_at: string | null;
last_collection_day: number | null;
updated_at: string;
content_title: string;
partner_name: string;
claimant_name: string | null;
account_nickname: string | null;
account_platform: string | null;
task_name: string;
task_brand: string;
task_platform: "小红书" | "抖音";
content_format: "image_text" | "video";
due_at: string;
task_collection_days: string | null;
task_collection_start_date: string | null;
};
type RecoverySortKey =
| "publish_time"
| "likes"
| "collects"
| "shares"
| "comments"
| "total"
| "collection_updated_at";
type DashboardData = {
partners: Partner[];
tasks: Task[];
accounts: Account[];
distributions: Distribution[];
portal_url: string;
};
type FeishuPreview = {
sheetId: string;
sheetName: string;
syncedAt: string;
rowCount: number;
imageCount: number;
videoCount: number;
columns: string[];
preview: Array<{
sourceRow: number;
title: string;
body: string;
}>;
};
type NavKey =
| "overview"
| "tasks"
| "resources"
| "partners"
| "recovery"
| "users";
const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
{ key: "overview", label: "工作台", mark: "⌂" },
{ key: "tasks", label: "任务中心", mark: "▤" },
{ key: "resources", label: "KOC资源", mark: "◎" },
{ key: "partners", label: "合作方", mark: "Ⓒ" },
{ key: "recovery", label: "数据回收", mark: "◫" },
{ key: "users", label: "用户管理", mark: "♙" },
];
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>
);
}
function statusLabel(status: string) {
const labels: Record<string, string> = {
claimed: "待发布",
published: "已发布",
collecting: "采集中",
complete: "已完成",
active: "分发中",
completed: "已完结",
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" : "";
}
function latestPublicMetrics(distribution: Distribution) {
const legacyDay =
distribution.d7_likes !== null
? 7
: distribution.d5_likes !== null
? 5
: distribution.d2_likes !== null
? 2
: null;
const likes =
distribution.latest_likes ??
(legacyDay ? distribution[`d${legacyDay}_likes`] : null);
const comments =
distribution.latest_comments ??
(legacyDay ? distribution[`d${legacyDay}_comments`] : null);
const collects =
distribution.latest_collects ??
(legacyDay ? distribution[`d${legacyDay}_collects`] : null);
const shares = distribution.latest_shares ?? 0;
return {
likes,
comments,
collects,
shares,
total:
likes === null
? null
: likes + (comments ?? 0) + (collects ?? 0) + shares,
};
}
function recoverySortValue(
distribution: Distribution,
key: RecoverySortKey,
): number | null {
const metrics = latestPublicMetrics(distribution);
if (key === "likes") return metrics.likes;
if (key === "collects") return metrics.collects;
if (key === "shares") return metrics.shares;
if (key === "comments") return metrics.comments;
if (key === "total") return metrics.total;
const value =
key === "publish_time"
? distribution.publish_time
: distribution.collection_updated_at ||
(metrics.likes !== null ? distribution.updated_at : null);
if (!value) return null;
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? null : timestamp;
}
function sortRecoveryDistributions(
distributions: Distribution[],
key: RecoverySortKey,
direction: SortDirection,
) {
return sortWithNullsLast(
distributions,
(distribution) => recoverySortValue(distribution, key),
direction,
);
}
function SortableRecoveryHeader({
label,
column,
activeColumn,
direction,
onSort,
}: {
label: string;
column: RecoverySortKey;
activeColumn: RecoverySortKey | null;
direction: SortDirection;
onSort: (column: RecoverySortKey) => void;
}) {
const active = activeColumn === column;
const arrow = active ? (direction === "asc" ? "↑" : "↓") : "↕";
return (
<th aria-sort={active ? (direction === "asc" ? "ascending" : "descending") : "none"}>
<button
type="button"
className={`recovery-sort-button${active ? " active" : ""}`}
onClick={() => onSort(column)}
title={`${label}按${active && direction === "desc" ? "正序" : "倒序"}排列`}
>
<span>{label}</span>
<b aria-hidden="true">{arrow}</b>
</button>
</th>
);
}
function hasCreatorMetrics(distribution: Distribution) {
return distribution.exposure !== null && distribution.views !== null;
}
function parseCollectionDays(value: string | null | undefined) {
try {
const parsed = JSON.parse(value || "[]") as unknown;
if (!Array.isArray(parsed)) return [];
return [...new Set(parsed.map(Number))]
.filter((day) => Number.isInteger(day) && day >= 1 && day <= 7)
.sort((a, b) => a - b);
} catch {
return [];
}
}
function todayInputValue() {
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const value = Object.fromEntries(
parts.map((part) => [part.type, part.value]),
);
return `${value.year}-${value.month}-${value.day}`;
}
function addCalendarDays(value: string, days: number) {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return "";
const date = new Date(
Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + days),
);
return date.toISOString().slice(0, 10);
}
function publicPublishUrl(value: string | null) {
if (!value) return null;
try {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
const isSupportedHost =
hostname === "xiaohongshu.com" ||
hostname.endsWith(".xiaohongshu.com") ||
hostname === "xhslink.cn" ||
hostname.endsWith(".xhslink.cn") ||
hostname === "xhslink.com" ||
hostname.endsWith(".xhslink.com") ||
hostname === "douyin.com" ||
hostname.endsWith(".douyin.com");
return ["http:", "https:"].includes(url.protocol) && isSupportedHost
? url.toString()
: null;
} catch {
return null;
}
}
function collectionStatusLabel(status: string, hasMetrics: boolean) {
if (hasMetrics && (!status || status === "pending")) return "采集成功";
const labels: Record<string, string> = {
pending: "待设置",
scheduled: "已计划",
collecting: "采集中",
success: "采集成功",
failed: "采集失败",
};
return labels[status] || status || "待设置";
}
function avatarColor(value: string) {
const colors = ["coral", "cyan", "violet", "amber", "mint"];
const index = [...value].reduce((sum, char) => sum + char.charCodeAt(0), 0);
return colors[index % colors.length];
}
async function compressScreenshot(file: File) {
if (file.size < 850_000 || !file.type.startsWith("image/")) return file;
const bitmap = await createImageBitmap(file);
const maxEdge = 1600;
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const context = canvas.getContext("2d");
if (!context) {
bitmap.close();
return file;
}
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/jpeg", 0.82),
);
if (!blob) return file;
return new File([blob], `${file.name.replace(/\.[^.]+$/, "")}.jpg`, {
type: "image/jpeg",
});
}
export default function Home({ currentUser }: { currentUser: AuthUser }) {
const [data, setData] = useState<DashboardData>(EMPTY_DATA);
const [activeNav, setActiveNav] = useState<NavKey>("overview");
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false);
const [collectingDistributionIds, setCollectingDistributionIds] = useState<Set<string>>(
new Set(),
);
const [error, setError] = useState("");
const [toast, setToast] = useState("");
const [menuOpen, setMenuOpen] = useState(false);
const [taskModalOpen, setTaskModalOpen] = useState(false);
const [groupPushTask, setGroupPushTask] = useState<Task | null>(null);
const [ownerLinksTask, setOwnerLinksTask] = useState<Task | null>(null);
const [customerChatTask, setCustomerChatTask] = useState<Task | null>(null);
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
const [detailDistributionId, setDetailDistributionId] = useState<string | null>(null);
const [publishUrlModal, setPublishUrlModal] = useState<{
distribution: Distribution;
value: string;
} | null>(null);
const [claimLinksOpen, setClaimLinksOpen] = useState(false);
const [taskForm, setTaskForm] = useState({
id: "",
platform: "小红书" as "小红书" | "抖音",
contentFormat: "image_text" as "image_text" | "video",
name: "",
brand: "",
dueAt: "2026-08-12",
feishuUrl: "",
projectId: "",
ownerUserid: "",
recoveryEnabled: true,
recoveryDueAt: "",
recoveryInstructions: "",
});
const [taskOwnerPickerOpen, setTaskOwnerPickerOpen] = useState(false);
const [taskExampleImage, setTaskExampleImage] = useState<File | null>(null);
const [taskFormProjects, setTaskFormProjects] = useState<Array<{ id: string; name: string }>>([]);
const [sourcePreview, setSourcePreview] = useState<FeishuPreview | null>(null);
const [sourceWorking, setSourceWorking] = useState(false);
const [metricForm, setMetricForm] = useState({
exposure: "",
views: "",
likes: "",
collects: "",
comments: "",
});
const [exportingTaskId, setExportingTaskId] = useState<string | null>(null);
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;
});
const loadData = useCallback(async () => {
try {
setLoading(true);
const response = await fetch("/api/bootstrap", { cache: "no-store" });
const result = await readApiResponse<DashboardData>(response, "加载失败");
if (!response.ok) throw new Error(result.error || "加载失败");
setData(result);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadData();
}, 0);
return () => window.clearTimeout(timer);
}, [loadData]);
useEffect(() => {
if (!toast) return;
const timer = window.setTimeout(() => setToast(""), 2600);
return () => window.clearTimeout(timer);
}, [toast]);
useEffect(() => {
if (!taskModalOpen) return;
const timer = window.setTimeout(() => {
void (async () => {
try {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "project_list" }),
});
const result = await readApiResponse<
{ projects?: Array<{ id: string; name: string }> } & { error?: string }
>(response, "读取项目列表失败");
if (!response.ok) throw new Error(result.error || "读取项目列表失败");
setTaskFormProjects(result.projects ?? []);
} catch {
setTaskFormProjects([]);
}
})();
}, 0);
return () => window.clearTimeout(timer);
}, [taskModalOpen]);
const runAction = async (
payload: Record<string, unknown>,
successMessage: string,
) => {
try {
setWorking(true);
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await readApiResponse<
DashboardData & { ownerWarning?: string }
>(response, "操作失败");
if (!response.ok) throw new Error(result.error || "操作失败");
setData(result);
setToast(
result.ownerWarning ? `${successMessage};${result.ownerWarning}` : successMessage,
);
return result;
} 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;
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,
};
}, [data]);
const navigate = (key: NavKey) => {
if (
currentUser.role === "user" &&
(key === "resources" || key === "partners")
) {
return;
}
if (key === "users" && !isManager) return;
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) {
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} 篇内容进入可领取池`,
);
if (success) {
setTaskModalOpen(false);
setSourcePreview(null);
setTaskExampleImage(null);
setTaskForm({
id: "",
platform: "小红书",
contentFormat: "image_text",
name: "",
brand: "",
dueAt: "2026-08-12",
feishuUrl: "",
projectId: "",
ownerUserid: "",
recoveryEnabled: true,
recoveryDueAt: "",
recoveryInstructions: "",
});
}
};
const inspectFeishu = async () => {
if (!taskForm.feishuUrl.trim()) {
setToast("请先粘贴飞书表格链接");
return;
}
try {
setSourceWorking(true);
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "inspect_feishu",
feishuUrl: taskForm.feishuUrl,
}),
});
const result = await readApiResponse<FeishuPreview>(
response,
"读取飞书表格失败",
);
if (!response.ok) throw new Error(result.error || "读取飞书表格失败");
setSourcePreview(result);
setTaskForm((current) => ({
...current,
name: current.name || result.sheetName.replace(/500篇$/, "分发任务"),
}));
setToast(
`已读取 ${result.rowCount} 条有效内容,识别到 ${result.videoCount} 个视频`,
);
} catch (reason) {
setSourcePreview(null);
setToast(reason instanceof Error ? reason.message : "读取飞书表格失败");
} finally {
setSourceWorking(false);
}
};
const copyTaskShareLink = async (task: Task) => {
if (!data.portal_url || !task.share_token) {
setToast("KOC外部站点尚未发布");
return;
}
const url = `${data.portal_url.replace(/\/$/, "")}/?task=${task.share_token}`;
try {
await navigator.clipboard.writeText(url);
setToast("KOC领取链接已复制");
} catch {
window.prompt("复制KOC领取链接", url);
}
};
const exportScreenshotResults = async (task: Task) => {
try {
setExportingTaskId(task.id);
const response = await fetch(
`/api/screenshot-task-export?task=${encodeURIComponent(task.id)}`,
);
if (!response.ok) {
const result = await readApiResponse<Record<string, never>>(
response,
"截图打包失败",
);
throw new Error(result.error || "截图打包失败");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${task.name}-截图回收.zip`;
anchor.click();
URL.revokeObjectURL(url);
setToast("任务截图和回收清单已打包下载");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "截图打包失败");
} finally {
setExportingTaskId(null);
}
};
const collectMetrics = async (distribution: Distribution) => {
setCollectingDistributionIds((current) => {
const next = new Set(current);
next.add(distribution.id);
return next;
});
try {
await runAction(
{ action: "collect_now", distributionId: distribution.id },
"公开数据已更新",
);
} finally {
setCollectingDistributionIds((current) => {
const next = new Set(current);
next.delete(distribution.id);
return next;
});
}
};
const updateDistributionPublishUrl = async (
distribution: Distribution,
) => {
setPublishUrlModal({ distribution, value: distribution.publish_url || "" });
};
const submitPublishUrl = async (event: FormEvent) => {
event.preventDefault();
if (!publishUrlModal) return;
const publishInput = publishUrlModal.value.trim();
if (!publishInput) {
setToast("请填写笔记链接");
return;
}
const distribution = publishUrlModal.distribution;
const updated = await runAction(
{
action: "update_distribution_publish_url",
distributionId: distribution.id,
publishUrl: publishInput,
},
distribution.publish_url
? "笔记链接已更新,旧公开采集数据已清空"
: "笔记链接已补充,可开始数据采集",
);
if (updated) setPublishUrlModal(null);
};
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);
}
};
const saveCollectionSchedule = async (
taskId: string,
startDate: string,
days: number[],
): Promise<boolean> => {
const result = await runAction(
{
action: "save_collection_schedule",
taskId,
startDate,
days,
},
`采集任务已创建,将在所选日期09:00自动执行`,
);
return result !== false;
};
const exportRecoveryData = async (task: Task) => {
try {
setExportingTaskId(task.id);
const response = await fetch(
`/api/recovery-export?task=${encodeURIComponent(task.id)}`,
{ cache: "no-store" },
);
if (!response.ok) {
const result = await readApiResponse<{ error?: string }>(
response,
"导出失败",
);
throw new Error(result.error || "导出失败");
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `${task.name}-数据回收.xlsx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
setToast("Excel 已导出,图片和截图已嵌入表格");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "导出失败");
} finally {
setExportingTaskId(null);
}
};
const exportResources = async (accountIds: string[]) => {
if (accountIds.length === 0) {
setToast("当前筛选结果为空");
return;
}
try {
setExportingResources(true);
const response = await fetch("/api/resources-export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accountIds }),
});
if (!response.ok) {
const result = await readApiResponse<{ error?: string }>(
response,
"导出失败",
);
throw new Error(result.error || "导出失败");
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `KOC资源库-${todayInputValue().replaceAll("-", "")}.xlsx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
setToast(`已导出 ${accountIds.length} 个KOC账号`);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "导出失败");
} finally {
setExportingResources(false);
}
};
const completeResourceImport = async (message: string) => {
await loadData();
setToast(message);
};
const 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);
}
};
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 hasCreatorInput = Boolean(metricForm.exposure.trim() && metricForm.views.trim());
const hasPublicInput = Boolean(
metricForm.likes.trim() ||
metricForm.collects.trim() ||
metricForm.comments.trim(),
);
if (!hasCreatorInput && !hasPublicInput) {
setToast("请至少填写创作者数据或公开互动数据中的一项");
return;
}
const success = await runAction(
{
action: "manual_metrics",
distributionId: metricModal.id,
...(hasCreatorInput
? { exposure: metricForm.exposure, views: metricForm.views }
: {}),
...(metricForm.likes.trim()
? { publicLikes: metricForm.likes }
: {}),
...(metricForm.collects.trim()
? { publicCollects: metricForm.collects }
: {}),
...(metricForm.comments.trim()
? { publicComments: metricForm.comments }
: {}),
},
hasPublicInput && !hasCreatorInput
? "公开互动数据已按补交截图补录"
: "创作者中心数据已确认",
);
if (success) {
setMetricModal(null);
setMetricForm({
exposure: "",
views: "",
likes: "",
collects: "",
comments: "",
});
}
};
const openMetricFallback = (distribution: Distribution) => {
setMetricModal(distribution);
setMetricForm({
exposure: distribution.exposure?.toString() || "",
views: distribution.views?.toString() || "",
likes: distribution.latest_likes?.toString() || "",
collects: distribution.latest_collects?.toString() || "",
comments: distribution.latest_comments?.toString() || "",
});
};
const pageTitle: Record<NavKey, { title: string; subtitle: string }> = {
overview: {
title: "分发工作台",
subtitle: "用最少动作,跑完内容领取、发布与数据回收。",
},
tasks: {
title: "任务中心",
subtitle: "按项目组织任务群与总群成员,从飞书同步内容、开放领取并跟进分发进度。",
},
resources: {
title: "KOC资源",
subtitle: "只沉淀真实发布过的账号,不要求提前登记。",
},
partners: {
title: "合作方",
subtitle: "绑定企业微信外部联系人,临期催办自动推送到人。",
},
recovery: {
title: "数据回收",
subtitle: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。",
},
users: {
title: "用户管理",
subtitle: "由后台统一创建账号和重置密码,不开放自助注册与个人改密。",
},
};
const logout = async () => {
await fetch("/api/auth/logout", { method: "POST" }).catch(() => null);
window.location.assign("/login");
};
return (
<div className="app-shell">
<aside className={`sidebar ${menuOpen ? "open" : ""}`}>
<div className="brand-lockup">
<div className="brand-symbol"><span>K</span></div>
<div>
<strong>KOC LOOP</strong>
<span>内容分发闭环</span>
</div>
</div>
<nav className="main-nav" aria-label="主导航">
<p className="nav-caption">运营中心</p>
{visibleNavItems.map((item) => (
<button
key={item.key}
className={activeNav === item.key ? "active" : ""}
onClick={() => navigate(item.key)}
>
<span className="nav-mark">{item.mark}</span>
{item.label}
{item.key === "recovery" && stats.pendingRecovery > 0 && (
<span className="nav-badge">{stats.pendingRecovery}</span>
)}
</button>
))}
</nav>
<div className="sidebar-summary">
<div className="pulse-dot" />
<div>
<strong>闭环运行中</strong>
<span>{stats.activeTasks} 个任务正在分发</span>
</div>
</div>
<button className="sidebar-user" onClick={() => void logout()} title="退出登录">
<div className="avatar mint">{currentUser.username.slice(0, 1).toUpperCase()}</div>
<div>
<strong>{currentUser.username}</strong>
<span>{currentUser.role === "super_admin" ? "超级管理员" : currentUser.role === "admin" ? "管理员" : "普通用户"}</span>
</div>
<span className="chevron">退出</span>
</button>
</aside>
{menuOpen && <button className="sidebar-scrim" onClick={() => setMenuOpen(false)} aria-label="关闭菜单" />}
<main className="main-area">
<header className="topbar">
<button className="menu-button" onClick={() => setMenuOpen(true)} aria-label="打开菜单">
☰
</button>
<div className="breadcrumb">
<span>KOC LOOP</span>
<i>/</i>
<strong>{pageTitle[activeNav].title}</strong>
</div>
<div className="top-actions">
<span className="sync-state"><i />飞书内容表已接入</span>
<button className="ghost-button" onClick={() => setClaimLinksOpen(true)}>
获取KOC领取链接
</button>
{isManager && (
<button className="primary-button" onClick={() => setTaskModalOpen(true)}>
<span>+</span> 新建任务
</button>
)}
</div>
</header>
<section className="content-frame">
<div className="page-heading">
<div>
<p className="eyebrow">MVP · 核心闭环</p>
<h1>{pageTitle[activeNav].title}</h1>
<p>{pageTitle[activeNav].subtitle}</p>
</div>
{activeNav === "overview" && (
<div className="date-chip">
<span>今天</span>
<strong>{formatShanghaiToday()}</strong>
</div>
)}
</div>
{loading ? (
<LoadingState />
) : error ? (
<div className="empty-state">
<div className="empty-icon">!</div>
<h3>暂时无法打开工作台</h3>
<p>{error}</p>
<button className="primary-button" onClick={() => void loadData()}>
重新加载
</button>
</div>
) : (
<>
{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>
)}
{activeNav === "overview" && (
<OverviewPage
stats={stats}
distributions={data.distributions}
tasks={data.tasks}
onNavigate={navigate}
showResources={currentUser.role !== "user"}
/>
)}
{activeNav === "tasks" && (
<TasksPage
tasks={data.tasks}
distributions={data.distributions}
portalUrl={data.portal_url}
canRelease={isManager}
isManager={isManager}
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);
}}
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;
}}
onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)}
/>
)}
{activeNav === "resources" && (
<ResourcesPage
accounts={data.accounts}
distributions={data.distributions}
exporting={exportingResources}
onExport={exportResources}
onImported={completeResourceImport}
partnerNameByAccountId={partnerNameByAccountId}
onCreatePartner={createPartnerFromAccount}
creatingPartnerId={creatingPartnerId}
/>
)}
{activeNav === "partners" && isManager && (
<PartnersPage
partners={data.partners}
accounts={data.accounts}
working={working}
onRefresh={loadData}
setToast={setToast}
/>
)}
{activeNav === "users" && isManager && (
<UsersPage currentUser={currentUser} />
)}
{activeNav === "recovery" && (
<RecoveryPage
tasks={data.tasks}
distributions={data.distributions}
working={working}
collectingDistributionIds={collectingDistributionIds}
canUpdatePublishUrl={isManager}
isManager={isManager}
onCollect={collectMetrics}
onUpdatePublishUrl={updateDistributionPublishUrl}
onSaveSchedule={saveCollectionSchedule}
onUpload={uploadScreenshot}
onFallback={openMetricFallback}
onRetryFailed={retryFailedMetrics}
onRemind={remindDueSoon}
onExport={exportRecoveryData}
onExportScreenshots={exportScreenshotResults}
exportingTaskId={exportingTaskId}
onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)}
/>
)}
</>
)}
</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>
</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>
</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>
</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>
<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>
)}
</div>
<label>
<span>发布截止日期</span>
<input
type="date"
value={taskForm.dueAt}
onChange={(event) => setTaskForm({ ...taskForm, dueAt: event.target.value })}
required
/>
</label>
<label>
<span>所属项目(可选)</span>
<select
value={taskForm.projectId}
onChange={(event) => setTaskForm({ ...taskForm, projectId: event.target.value })}
>
<option value="">不挂项目</option>
{taskFormProjects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
<small>挂在项目下的任务可在「任务中心」的项目分组中直接推送到项目总群与任务群。</small>
</label>
<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>
<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>
</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);
}}
/>
)}
{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">
{metricModal.collection_status === "failed"
? "采集失败兜底 · 数据核对补录"
: "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>
<label>
<span>创作者数据(曝光 / 阅读,第7天截图数据)</span>
<div className="form-grid two" style={{ marginBottom: 0 }}>
<input type="number" min="0" placeholder="曝光量" value={metricForm.exposure} onChange={(event) => setMetricForm({ ...metricForm, exposure: event.target.value })} />
<input type="number" min="0" placeholder="阅读量" value={metricForm.views} onChange={(event) => setMetricForm({ ...metricForm, views: event.target.value })} />
</div>
<small>曝光 + 阅读齐了才算「回收完成」;本次不改可留空。</small>
</label>
<label>
<span>公开互动数据(按 KOC 补交的数据截图核对)</span>
<div className="form-grid two" style={{ marginBottom: 0 }}>
<input type="number" min="0" placeholder="点赞" value={metricForm.likes} onChange={(event) => setMetricForm({ ...metricForm, likes: event.target.value })} />
<input type="number" min="0" placeholder="收藏" value={metricForm.collects} onChange={(event) => setMetricForm({ ...metricForm, collects: event.target.value })} />
<input type="number" min="0" placeholder="评论" value={metricForm.comments} onChange={(event) => setMetricForm({ ...metricForm, comments: event.target.value })} />
</div>
<small>填写后采集状态会更新为「采集成功(运营已核对)」;不需要补录可留空。</small>
</label>
{metricModal.public_metrics_screenshot_key && (
<a
className="share-link-button"
href={`/api/creator-screenshot?distribution=${encodeURIComponent(metricModal.id)}&kind=public-metrics`}
target="_blank"
rel="noreferrer"
>
查看 KOC 补交的数据截图 ↗
</a>
)}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setMetricModal(null)}>取消</button>
<button className="primary-button" disabled={working}>确认补录</button>
</div>
</form>
</div>
)}
{publishUrlModal && (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setPublishUrlModal(null)}>
<form className="modal-card compact" onSubmit={submitPublishUrl} onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">发布链接</p>
<h2>{publishUrlModal.distribution.publish_url ? "更新笔记链接" : "填写笔记链接"}</h2>
</div>
<button type="button" onClick={() => setPublishUrlModal(null)} aria-label="关闭">×</button>
</div>
<div className="context-strip">
<strong>{publishUrlModal.distribution.account_nickname || "待识别账号"}</strong>
<span>{publishUrlModal.distribution.content_title}</span>
</div>
<div className="form-grid">
<label>
<span>笔记链接</span>
<textarea
rows={3}
value={publishUrlModal.value}
onChange={(event) =>
setPublishUrlModal({
...publishUrlModal,
value: event.target.value,
})
}
placeholder="支持长链、短链或完整分享文案"
required
/>
</label>
</div>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setPublishUrlModal(null)}>取消</button>
<button className="primary-button" disabled={working}>
{publishUrlModal.distribution.publish_url ? "确认更新" : "确认填写"}
</button>
</div>
</form>
</div>
)}
{claimLinksOpen && (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setClaimLinksOpen(false)}>
<div
className="modal-card compact claim-links-modal"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow">任务领取链接</p>
<h2>获取KOC领取链接</h2>
</div>
<button type="button" onClick={() => setClaimLinksOpen(false)} aria-label="关闭">×</button>
</div>
<div className="claim-links-list">
{data.tasks.map((task) => {
const url = task.share_token
? `${data.portal_url.replace(/\/$/, "")}/?task=${task.share_token}`
: "";
return (
<article key={task.id} className={url ? "" : "empty"}>
<div>
<strong>{task.name}</strong>
<span>
{task.brand}
{url ? ` · 已领取 ${task.dist_total ?? 0}/${task.quantity}` : " · 尚未生成领取链接"}
</span>
</div>
{url ? (
<button
type="button"
className="ghost-button"
onClick={() => {
void navigator.clipboard
.writeText(url)
.then(() => setToast("KOC领取链接已复制"))
.catch(() => setToast("复制失败,请稍后重试"));
}}
>
复制链接
</button>
) : (
<span className="muted">未生成</span>
)}
</article>
);
})}
</div>
</div>
</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();
}}
/>
)}
{(() => {
// 详情按 id 实时取行:任何操作刷新数据后弹窗内容自动更新
const detailRow = detailDistributionId
? data.distributions.find((item) => item.id === detailDistributionId)
: null;
if (!detailRow) return null;
const detailTask = data.tasks.find((task) => task.id === detailRow.task_id);
return (
<DistributionDetailModal
item={detailRow}
recoveryEnabled={Number(detailTask?.recovery_enabled ?? 1) === 1}
working={working}
collectingDistributionIds={collectingDistributionIds}
canUpdatePublishUrl={isManager}
canRelease={isManager}
onClose={() => setDetailDistributionId(null)}
onCollect={collectMetrics}
onFallback={openMetricFallback}
onUpdatePublishUrl={(distribution) =>
setPublishUrlModal({
distribution,
value: distribution.publish_url || "",
})
}
onRelease={(distribution) => {
void (async () => {
if (
!window.confirm(
`确认释放“${distribution.content_title}”?释放后回到可领取池,不可撤销。`,
)
)
return;
const result = await runAction(
{
action: "release_distribution",
distributionId: distribution.id,
},
`“${distribution.content_title}”已释放,可重新领取`,
);
if (result !== false) setDetailDistributionId(null);
})();
}}
/>
);
})()}
{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>
);
}
function LoadingState() {
return (
<div className="loading-layout" aria-label="正在加载">
<div className="loading-card wide" />
<div className="loading-card" />
<div className="loading-card" />
<div className="loading-table" />
</div>
);
}
function OverviewPage({
stats,
distributions,
tasks,
onNavigate,
showResources,
}: {
stats: {
resources: number;
activeTasks: number;
published: number;
completed: number;
pendingRecovery: number;
completedTasks: number;
screenshotPending: number;
pairedSubmitted: number;
pairedTotal: number;
};
distributions: Distribution[];
tasks: Task[];
onNavigate: (key: NavKey) => void;
showResources: boolean;
}) {
const cards = [
{
label: "已沉淀账号资源",
value: stats.resources,
hint: "均来自真实发布",
tone: "ink",
},
{
label: "正在分发任务",
value: stats.activeTasks,
hint: `${tasks.reduce((sum, task) => sum + (task.dist_total ?? 0), 0)} 篇已被领取`,
tone: "green",
},
{
label: "已回填发布",
value: stats.published,
hint: "链接自动识别账号",
tone: "blue",
},
{
label: "待完成数据回收",
value: stats.pendingRecovery,
hint:
stats.screenshotPending > 0
? `需要跟进 · 截图待提交 ${stats.screenshotPending}`
: stats.pendingRecovery > 0
? "需要跟进"
: "全部按时完成",
tone: "orange",
},
{
label: "已完结任务",
value: stats.completedTasks,
hint:
stats.pairedTotal > 0
? `截图回收已提交 ${stats.pairedSubmitted}/${stats.pairedTotal}`
: "发布与回收全部完成",
tone: "green",
},
].filter((card) => showResources || card.label !== "已沉淀账号资源");
return (
<>
<div className="stat-grid">
{cards.map((card) => (
<article className="stat-card" key={card.label}>
<div className={`stat-icon ${card.tone}`}><span /></div>
<p>{card.label}</p>
<div className="stat-value">
<strong>{card.value}</strong>
<span>个</span>
</div>
<small>{card.hint}</small>
</article>
))}
</div>
<ProjectProgressPanel
tasks={tasks}
distributions={distributions}
onNavigate={onNavigate}
/>
<div className="overview-grid">
<section className="panel workflow-panel">
<div className="panel-heading">
<div>
<h2>当前闭环</h2>
<p>每篇内容只沿着一条记录向前走</p>
</div>
<button className="text-button" onClick={() => onNavigate("tasks")}>查看全部</button>
</div>
<div className="workflow-track">
<div className="workflow-step done">
<span>01</span>
<div><strong>内容已同步</strong><small>{tasks.reduce((sum, task) => sum + task.quantity, 0)} 篇</small></div>
</div>
<i />
<div className="workflow-step active">
<span>02</span>
<div><strong>KOC领取</strong><small>{tasks.reduce((sum, task) => sum + (task.dist_total ?? 0), 0)} 篇</small></div>
</div>
<i />
<div className="workflow-step">
<span>03</span>
<div><strong>发布回填</strong><small>{stats.published} 篇</small></div>
</div>
<i />
<div className="workflow-step">
<span>04</span>
<div><strong>数据回收</strong><small>{stats.completed} 篇完成</small></div>
</div>
</div>
<div className="focus-callout">
<div className="callout-ring"><strong>{stats.pendingRecovery}</strong><span>待回收</span></div>
<div>
<p className="eyebrow">今天最需要推进</p>
<h3>把已发布笔记的数据收回来</h3>
<p>按任务选择采集日期,系统在当天09:00更新点赞、收藏、评论、转发和总互动。</p>
</div>
<button className="primary-button soft" onClick={() => onNavigate("recovery")}>进入数据回收</button>
</div>
</section>
<section className="panel task-mini-panel">
<div className="panel-heading">
<div><h2>进行中的任务</h2><p>按截止时间排序</p></div>
</div>
<div className="task-mini-list">
{tasks.map((task) => {
const progress = Math.round(((task.dist_total ?? 0) / task.quantity) * 100);
return (
<article key={task.id}>
<div className="task-mini-top">
<div className={`avatar ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
<div>
<strong>{task.name}</strong>
<span className="platform-meta-line">
<span>{task.brand}</span>
<PlatformBadge platform={task.platform} compact />
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
</span>
</div>
<b>{progress}%</b>
</div>
<div className="progress"><span style={{ width: `${progress}%` }} /></div>
<div className="task-mini-meta">
<span>{task.dist_total ?? 0}/{task.quantity} 篇已领取</span>
<span>{formatDate(task.due_at)}截止</span>
</div>
</article>
);
})}
</div>
<button className="full-ghost-button" onClick={() => onNavigate("tasks")}>管理全部任务</button>
</section>
</div>
<section className="panel table-panel">
<div className="panel-heading">
<div><h2>最近分发</h2><p>领取、发布账号与回收状态一眼看清</p></div>
<button className="text-button" onClick={() => onNavigate("tasks")}>查看全部</button>
</div>
<DistributionTable
distributions={distributions.slice(0, 6)}
compact
/>
</section>
</>
);
}
type OwnerLinkRow = {
partnerId: string;
name: string;
wecomUserid: string;
isInternal: boolean;
link: string;
};
type TaskGroupLinksView = {
taskName: string;
taskChatId: string;
claimUrl: string;
owners: OwnerLinkRow[];
};
function OwnerLinksModal({
task,
onClose,
setToast,
}: {
task: Task;
onClose: () => void;
setToast: (message: string) => void;
}) {
const [view, setView] = useState<TaskGroupLinksView | null>(null);
const [loading, setLoading] = useState(true);
const [pushing, setPushing] = useState(false);
const [pushResult, setPushResult] = useState<{
sent: number;
failed: number;
failList: string[];
} | null>(null);
const postAction = useCallback(
async <T,>(payload: Record<string, unknown>, fallback: string) => {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const parsed = await readApiResponse<T & { error?: string }>(
response,
fallback,
);
if (!response.ok) throw new Error(parsed.error || fallback);
return parsed;
},
[],
);
useEffect(() => {
const timer = window.setTimeout(() => {
void (async () => {
try {
const parsed = await postAction<TaskGroupLinksView>(
{ action: "task_group_links", taskId: task.id },
"读取群主链接失败",
);
setView(parsed);
} catch (reason) {
setToast(
reason instanceof Error ? reason.message : "读取群主链接失败",
);
} finally {
setLoading(false);
}
})();
}, 0);
return () => window.clearTimeout(timer);
}, [postAction, task.id, setToast]);
const copyLink = async (label: string, link: string) => {
try {
await navigator.clipboard.writeText(link);
setToast(`${label}已复制`);
} catch {
window.prompt(`复制${label}`, link);
}
};
const pushLinks = async () => {
setPushing(true);
try {
const parsed = await postAction<{
sent: number;
failed: number;
failList: string[];
}>({ action: "task_group_push", taskId: task.id }, "推送群主链接失败");
setPushResult(parsed);
setToast(
parsed.failList.length > 0
? `已送达 ${parsed.sent} 位群主;失败:${parsed.failList.join("、")}`
: `已送达 ${parsed.sent} 位群主`,
);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "推送群主链接失败");
} finally {
setPushing(false);
}
};
const internalCount = view?.owners.filter((owner) => owner.isInternal).length ?? 0;
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-card" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">任务中心 · 群主领取链接</p>
<h2>{task.name}</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
{loading ? (
<div className="wecom-picker-empty">正在读取群主列表…</div>
) : !view || view.owners.length === 0 ? (
<div className="wecom-picker-empty">
该任务未分配负责人,请先在任务中心分配任务负责人。
</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>
);
}
function TasksPage({
tasks,
distributions,
portalUrl,
canRelease,
isManager,
viewerWecomUserid,
working,
setToast,
onTasksChanged,
onCreate,
onEditTask,
onCopyShare,
onPushToGroups,
onOwnerLinks,
onBindCustomerChat,
onCreateRecovery,
onRelease,
onOpenDetail,
}: {
tasks: Task[];
distributions: Distribution[];
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;
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>;
onOpenDetail: (distribution: Distribution) => void;
}) {
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)}
onOpenDetail={onOpenDetail}
/>
);
}
const ungroupedTasks = tasks.filter((task) => !task.project_id);
const showGrouped = viewMode === "grouped";
return (
<div className="stack">
<section className="task-hero">
<div>
<p className="eyebrow">一任务两阶段 · 项目化分发</p>
<h2>发布笔记,回收截图</h2>
<p>任务从飞书导入笔记,KOC 领取发布后自动进入截图回收阶段,全程一个闭环。</p>
</div>
<div className="project-hero-actions">
{isManager && <button className="ghost-button" onClick={() => openEditor()}>+ 新建项目</button>}
{canCreateTask && (
<button className="primary-button light" onClick={() => onCreate()}>+ 新建任务</button>
)}
</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()}
/>
)}
</div>
);
}
function TaskScopeList({
tasks,
distributions,
onOpen,
}: {
tasks: Task[];
distributions: Distribution[];
onOpen: (taskId: string) => void;
}) {
return (
<section className="task-scope-grid">
{tasks.map((task) => {
const taskDistributions = distributions.filter(
(item) => item.task_id === task.id,
);
const recoveryEnabled = Number(task.recovery_enabled ?? 1) === 1;
const published = taskDistributions.filter((item) => item.publish_url);
const submitted = taskDistributions.filter(
(item) => item.result_submitted_at,
);
const publicDataComplete = published.filter(
(item) => latestPublicMetrics(item).likes !== null,
).length;
const creatorComplete = published.filter(
(item) => hasCreatorMetrics(item),
).length;
return (
<article className="task-scope-card" key={task.id}>
<div className="task-scope-head">
<div className={`avatar large ${avatarColor(task.name)}`}>
{task.brand.slice(0, 1)}
</div>
<div>
<h3>{task.name}</h3>
<div className="task-scope-subline">
<span>{task.brand} · {formatDate(task.due_at)} 截止</span>
<PlatformBadge platform={task.platform} compact />
<span className={`content-format-badge ${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>
</div>
<span className={`status-pill ${task.status}`}>{statusLabel(task.status)}</span>
</div>
<div className="task-source-line">
<span>飞书来源</span>
{task.source_url ? (
<a href={task.source_url} target="_blank" rel="noreferrer">
{task.source_sheet_name || `工作表 ${task.source_sheet_id}`} ↗
</a>
) : (
<strong>历史示例内容表</strong>
)}
</div>
<div className="task-scope-metrics">
<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>
<button className="task-scope-open" onClick={() => onOpen(task.id)}>
<span>进入回收队列</span>
<b>→</b>
</button>
</article>
);
})}
</section>
);
}
function TaskDetailHeader({
task,
label,
onBack,
}: {
task: Task;
label: string;
onBack: () => void;
}) {
return (
<section className="task-detail-header">
<button className="detail-back" onClick={onBack}>← 返回任务列表</button>
<div className={`avatar large ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
<div>
<p>{label}</p>
<h2>{task.name}</h2>
<span className="platform-meta-line">
<span>{task.brand}</span>
<PlatformBadge platform={task.platform} compact />
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
<span>{task.quantity} 篇</span>
<span>{formatDate(task.due_at)} 截止</span>
</span>
</div>
{task.source_url && (
<a href={task.source_url} target="_blank" rel="noreferrer">
打开飞书原表 ↗
</a>
)}
</section>
);
}
function TaskDistributionDetail({
task,
distributions,
canRelease,
working,
onRelease,
onBack,
onOpenDetail,
}: {
task: Task;
distributions: Distribution[];
canRelease: boolean;
working: boolean;
onRelease: (distribution: Distribution) => Promise<boolean>;
onBack: () => void;
onOpenDetail: (distribution: Distribution) => void;
}) {
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
const taskDistributions = distributions.filter(
(item) => item.task_id === task.id,
);
const recoveryEnabled = Number(task.recovery_enabled ?? 1) === 1;
const published = taskDistributions.filter((item) => item.publish_url).length;
const submitted = taskDistributions.filter((item) => item.result_submitted_at).length;
return (
<div className="stack">
<TaskDetailHeader
task={task}
label="内容分发"
onBack={onBack}
/>
<div className="inline-summary">
<div><span>已领取</span><strong>{taskDistributions.length}</strong></div>
<i />
<div><span>已回填链接</span><strong>{published}</strong></div>
<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>
</div>
<section className="panel table-panel">
<div className="panel-heading">
<div><h2>内容分发表</h2><p>实际发布账号在链接回填后自动补齐</p></div>
</div>
<DistributionTable
distributions={taskDistributions}
onRelease={canRelease ? setReleaseTarget : undefined}
showRecovery={recoveryEnabled}
onOpenDetail={onOpenDetail}
/>
</section>
{releaseTarget && (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={() => { if (!working) setReleaseTarget(null); }}
>
<div
className="modal-card compact release-distribution-modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="release-distribution-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow warning">释放领取</p>
<h2 id="release-distribution-title">确认释放这篇笔记?</h2>
</div>
<button
type="button"
onClick={() => setReleaseTarget(null)}
aria-label="关闭"
disabled={working}
>
×
</button>
</div>
<div className="release-distribution-summary">
<strong>{releaseTarget.content_title}</strong>
<span>当前领取合作方:{releaseTarget.partner_name}</span>
</div>
<p className="release-distribution-warning">
释放后,这篇笔记会从当前领取记录中移除并回到可领取池,其他 KOC 可以重新领取。此操作不能撤销。
</p>
<div className="modal-actions">
<button
type="button"
className="ghost-button"
onClick={() => setReleaseTarget(null)}
disabled={working}
>
取消
</button>
<button
type="button"
className="release-confirm-button"
onClick={async () => {
const released = await onRelease(releaseTarget);
if (released) setReleaseTarget(null);
}}
disabled={working}
>
{working ? "释放中…" : "确认释放"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
function DistributionTable({
distributions,
compact = false,
onRelease,
showRecovery = false,
onOpenDetail,
}: {
distributions: Distribution[];
compact?: boolean;
onRelease?: (distribution: Distribution) => void;
showRecovery?: boolean;
onOpenDetail?: (distribution: Distribution) => void;
}) {
// 已发布但还没采到数据的行:有计划时按首个采集日显示,没计划时
// 明说待设置,而不是写死「等待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)}`;
};
return (
<div className="table-scroll">
<table className="data-table">
<thead>
<tr>
<th>内容</th>
<th>领取合作方</th>
<th>实际发布账号</th>
<th>发布时间</th>
<th>数据状态</th>
{showRecovery && <th>截图回收</th>}
<th>状态</th>
{onRelease && <th>操作</th>}
</tr>
</thead>
<tbody>
{distributions.map((item) => (
<tr
key={item.id}
className={onOpenDetail ? "clickable" : ""}
onClick={onOpenDetail ? () => onOpenDetail(item) : undefined}
title={onOpenDetail ? "点击查看分发详情" : undefined}
>
<td>
<div className="content-cell">
<span className="content-id">{item.content_id.split("-").at(-1)?.toUpperCase()}</span>
<div><strong>{item.content_title}</strong><span>{item.task_name} · {item.task_brand}</span></div>
</div>
</td>
<td><strong className="plain-strong">{item.partner_name}</strong></td>
<td>
{item.account_nickname ? (
<div className="account-cell">
<div className={`avatar small ${avatarColor(item.account_nickname)}`}>{item.account_nickname.slice(0, 1)}</div>
<div><strong>{item.account_nickname}</strong><PlatformBadge platform={item.account_platform} compact /></div>
</div>
) : (
<span className="muted">回填链接后识别</span>
)}
</td>
<td><span className="date-value">{formatDate(item.publish_time, true)}</span></td>
<td>
{item.d7_likes !== null ? (
<span className="metric-ready">D7 已回收</span>
) : item.d2_likes !== null ? (
<span className="metric-waiting">采集中</span>
) : (
<span className="muted">
{item.publish_url
? waitingCollectionLabel(item)
: "尚未发布"}
</span>
)}
</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>
)}
<td><span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span></td>
{onRelease && (
<td onClick={(event) => event.stopPropagation()}>
{!item.publish_url && !item.result_submitted_at ? (
<button
type="button"
className="release-distribution-button"
onClick={() => onRelease(item)}
>
释放
</button>
) : (
<span className="muted">—</span>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
{!compact && distributions.length === 0 && <div className="table-empty">暂无分发记录</div>}
</div>
);
}
type AdminPreviewImage = { src: string; title: string };
function AdminImageLightbox({
images,
activeIndex,
onIndexChange,
onClose,
}: {
images: AdminPreviewImage[];
activeIndex: number;
onIndexChange?: (index: number) => void;
onClose: () => void;
}) {
const image = images[activeIndex] ?? null;
const canNavigate = images.length > 1 && Boolean(onIndexChange);
const showPrevious = () => {
if (!canNavigate) return;
onIndexChange?.((activeIndex - 1 + images.length) % images.length);
};
const showNext = () => {
if (!canNavigate) return;
onIndexChange?.((activeIndex + 1) % images.length);
};
useEffect(() => {
if (!image) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
if (event.key === "ArrowLeft" && canNavigate) {
onIndexChange?.((activeIndex - 1 + images.length) % images.length);
}
if (event.key === "ArrowRight" && canNavigate) {
onIndexChange?.((activeIndex + 1) % images.length);
}
};
document.addEventListener("keydown", onKeyDown);
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = previousOverflow;
};
}, [activeIndex, canNavigate, image, images.length, onClose, onIndexChange]);
if (!image) return null;
return (
<div
className="admin-image-lightbox"
role="dialog"
aria-modal="true"
aria-label={image.title}
onMouseDown={onClose}
>
<div
className="admin-image-lightbox-card"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="admin-image-lightbox-heading">
<div>
<strong>{image.title}</strong>
{canNavigate && (
<span aria-live="polite">{activeIndex + 1} / {images.length}</span>
)}
</div>
<button type="button" onClick={onClose}>关闭</button>
</div>
<div className="admin-image-lightbox-stage">
{canNavigate && (
<button type="button" className="previous" onClick={showPrevious} aria-label="上一张">
上一张
</button>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.src} alt={image.title} />
{canNavigate && (
<button type="button" className="next" onClick={showNext} aria-label="下一张">
下一张
</button>
)}
</div>
</div>
</div>
);
}
function ScreenshotTaskTable({ distributions }: { distributions: Distribution[] }) {
const [previewGallery, setPreviewGallery] = useState<{
images: AdminPreviewImage[];
activeIndex: number;
} | null>(null);
return (
<>
<div className="table-scroll">
<table className="data-table screenshot-task-table">
<thead>
<tr>
<th>笔记</th>
<th>领取合作方</th>
<th>领取时间</th>
<th>发布时间</th>
<th>提交时间</th>
<th>结果截图</th>
<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;
}
function ResourcesPage({
accounts,
distributions,
exporting,
onExport,
onImported,
partnerNameByAccountId,
onCreatePartner,
creatingPartnerId,
}: {
accounts: Account[];
distributions: Distribution[];
exporting: boolean;
onExport: (accountIds: string[]) => void;
onImported: (message: string) => Promise<void>;
partnerNameByAccountId: Map<string, string>;
onCreatePartner: (account: Account) => Promise<void>;
creatingPartnerId: string | null;
}) {
const [query, setQuery] = useState("");
const [platformFilter, setPlatformFilter] = useState("全部平台");
const [ipFilter, setIpFilter] = useState("");
const [sourceFilter, setSourceFilter] = useState("");
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
const [importMode, setImportMode] = useState<"bulk" | "single">("bulk");
const [importFile, setImportFile] = useState<File | null>(null);
const [importPreview, setImportPreview] = useState<ResourceImportPreview | null>(null);
const [importWorking, setImportWorking] = useState(false);
const [importError, setImportError] = useState("");
const [singleForm, setSingleForm] = useState({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
const [singleWorking, setSingleWorking] = useState(false);
const [singleError, setSingleError] = useState("");
const [editAccount, setEditAccount] = useState<Account | null>(null);
const [editAccountId, setEditAccountId] = useState("");
const [editWorking, setEditWorking] = useState(false);
const [editError, setEditError] = useState("");
const [idRefresh, setIdRefresh] = useState<FieldRefreshStatus | null>(null);
const [idRefreshStarting, setIdRefreshStarting] = useState(false);
const idRefreshNotifiedRef = useRef(true);
const [idRefreshError, setIdRefreshError] = useState("");
const [fieldPickOpen, setFieldPickOpen] = useState(false);
const [fieldChecks, setFieldChecks] = useState<Record<string, boolean>>(
() =>
Object.fromEntries(
REFRESH_FIELD_OPTIONS.map((option) => [
option.key,
option.key === "publicAccountId",
]),
),
);
const resourceAccounts = useMemo(() => {
const deliveriesByAccount = new Map<string, Distribution[]>();
distributions.forEach((distribution) => {
if (!distribution.account_id) return;
const current = deliveriesByAccount.get(distribution.account_id) ?? [];
current.push(distribution);
deliveriesByAccount.set(distribution.account_id, current);
});
return accounts.map((account) => {
const deliveries = deliveriesByAccount.get(account.id) ?? [];
return {
account,
sources: [
...new Set(
[
...deliveries
.filter(
(item) =>
!item.claimant_name ||
item.partner_name !== item.claimant_name,
)
.map((item) => item.partner_name),
...(account.cooperation_source || "")
.split(/[、,,;;|]/)
.map((item) => item.trim()),
].filter(Boolean),
),
].sort((left, right) => left.localeCompare(right, "zh-CN")),
partnerManagedOnly:
deliveries.some((item) => item.delegation_bundle_id) &&
deliveries.every((item) => item.delegation_bundle_id),
};
});
}, [accounts, distributions]);
const platformOptions = useMemo(
() => [
"小红书",
"抖音",
...[...new Set(accounts.map((account) => account.platform).filter(Boolean))]
.filter((platform) => platform !== "小红书" && platform !== "抖音")
.sort((left, right) => left.localeCompare(right, "zh-CN")),
],
[accounts],
);
const ipOptions = useMemo(
() =>
[
...new Set(
accounts.map((account) => account.ip_location || "待识别"),
),
].sort((left, right) => {
if (left === "待识别") return 1;
if (right === "待识别") return -1;
return left.localeCompare(right, "zh-CN");
}),
[accounts],
);
const sourceOptions = useMemo(
() =>
[...new Set(resourceAccounts.flatMap((item) => item.sources))].sort(
(left, right) => left.localeCompare(right, "zh-CN"),
),
[resourceAccounts],
);
const filteredAccounts = useMemo(() => {
const keyword = query.trim().toLocaleLowerCase("zh-CN");
const ipKeyword = ipFilter.trim().toLocaleLowerCase("zh-CN");
const sourceKeyword = sourceFilter.trim().toLocaleLowerCase("zh-CN");
return resourceAccounts.filter(({ account, sources }) => {
const searchable = `${account.nickname} ${account.public_account_id} ${account.current_contact || ""} ${account.bio || ""} ${account.tags || ""}`.toLocaleLowerCase(
"zh-CN",
);
const ipLocation = (account.ip_location || "待识别").toLocaleLowerCase(
"zh-CN",
);
return (
(!keyword || searchable.includes(keyword)) &&
(platformFilter === "全部平台" ||
account.platform === platformFilter) &&
(!ipKeyword || ipLocation.includes(ipKeyword)) &&
(!sourceKeyword ||
sources.some((source) =>
source.toLocaleLowerCase("zh-CN").includes(sourceKeyword),
))
);
});
}, [ipFilter, platformFilter, query, resourceAccounts, sourceFilter]);
const hasActiveFilters =
Boolean(query.trim()) ||
platformFilter !== "全部平台" ||
Boolean(ipFilter.trim()) ||
Boolean(sourceFilter.trim());
const totalPages = Math.max(
1,
Math.ceil(filteredAccounts.length / RESOURCE_PAGE_SIZE),
);
const currentPage = Math.min(page, totalPages);
const pagedAccounts = filteredAccounts.slice(
(currentPage - 1) * RESOURCE_PAGE_SIZE,
currentPage * RESOURCE_PAGE_SIZE,
);
const clearFilters = () => {
setQuery("");
setPlatformFilter("全部平台");
setIpFilter("");
setSourceFilter("");
setPage(1);
};
const closeImport = () => {
if (importWorking || singleWorking) return;
setImportOpen(false);
setImportFile(null);
setImportPreview(null);
setImportError("");
setSingleForm({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
setSingleError("");
};
const switchImportMode = (mode: "bulk" | "single") => {
if (importWorking || singleWorking || mode === importMode) return;
setImportMode(mode);
setImportFile(null);
setImportPreview(null);
setImportError("");
setSingleForm({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
setSingleError("");
};
const submitSingle = async () => {
if (!singleForm.profileUrl.trim()) {
setSingleError("请填写账号主页链接");
return;
}
try {
setSingleWorking(true);
setSingleError("");
const response = await fetch("/api/resources-insert", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(singleForm),
});
const result = await readApiResponse<{ action: string; message?: string; error?: string }>(
response,
"保存失败",
);
if (!response.ok) throw new Error(result.error || "保存失败");
setImportOpen(false);
setImportMode("bulk");
setSingleForm({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
setSingleError("");
await onImported(result.message || "已保存账号");
} catch (reason) {
setSingleError(reason instanceof Error ? reason.message : "保存失败");
} finally {
setSingleWorking(false);
}
};
const submitImport = async (mode: "preview" | "commit") => {
if (!importFile) {
setImportError("请先选择需要导入的 Excel 文件");
return;
}
try {
setImportWorking(true);
setImportError("");
const form = new FormData();
form.append("file", importFile);
form.append("mode", mode);
const response = await fetch("/api/resources-import", {
method: "POST",
body: form,
});
const result = await readApiResponse<
ResourceImportPreview & { error?: string; message?: string }
>(response, "导入失败");
if (!response.ok) throw new Error(result.error || "导入失败");
if (mode === "preview") setImportPreview(result);
else {
setImportOpen(false);
setImportFile(null);
setImportPreview(null);
await onImported(result.message || "KOC资源已导入");
}
} catch (reason) {
setImportError(reason instanceof Error ? reason.message : "导入失败");
} finally {
setImportWorking(false);
}
};
const closeAccountEdit = () => {
if (editWorking) return;
setEditAccount(null);
setEditAccountId("");
setEditError("");
};
const submitAccountEdit = async () => {
if (!editAccount) return;
const value = editAccountId.trim();
if (!value) {
setEditError("请填写新的账号ID");
return;
}
if (!/^[\p{L}\p{N}._-]{2,80}$/u.test(value)) {
setEditError("账号ID需为 2-80 位字母、数字或 . _ - 组合");
return;
}
try {
setEditWorking(true);
setEditError("");
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "set_public_account_ids",
items: [{ accountId: editAccount.id, publicAccountId: value }],
}),
});
const result = await readApiResponse<{ error?: string }>(response, "保存失败");
if (!response.ok) throw new Error(result.error || "保存失败");
const label = editAccount.nickname || editAccount.public_account_id || "该账号";
setEditAccount(null);
setEditAccountId("");
await onImported(`已更新 ${label} 的账号ID`);
} catch (reason) {
setEditError(reason instanceof Error ? reason.message : "保存失败");
} finally {
setEditWorking(false);
}
};
const runningIdRefresh = idRefresh?.status === "running";
const fetchIdRefreshStatus = useCallback(async () => {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "account_field_refresh_status" }),
});
const result = await readApiResponse<{ refresh: FieldRefreshStatus | null }>(
response,
"查询失败",
);
if (!response.ok) throw new Error(result.error || "查询失败");
return result.refresh;
}, []);
const applyIdRefreshStatus = useCallback(
(status: FieldRefreshStatus) => {
if (status.status === "done" && !idRefreshNotifiedRef.current) {
idRefreshNotifiedRef.current = true;
const summary = `账号资料更新完成:成功 ${status.updated} 个${
status.skipped ? `,未识别 ${status.skipped} 个` : ""
}${status.failed ? `,失败 ${status.failed} 个` : ""}`;
void onImported(summary);
}
setIdRefresh(status);
},
[onImported],
);
useEffect(() => {
void fetchIdRefreshStatus()
.then((status) => {
if (status?.status === "running") {
idRefreshNotifiedRef.current = false;
applyIdRefreshStatus(status);
}
})
.catch(() => undefined);
}, [applyIdRefreshStatus, fetchIdRefreshStatus]);
useEffect(() => {
if (!runningIdRefresh) return;
const timer = window.setInterval(() => {
void fetchIdRefreshStatus()
.then((status) => {
if (status) applyIdRefreshStatus(status);
})
.catch(() => undefined);
}, 3000);
return () => window.clearInterval(timer);
}, [applyIdRefreshStatus, fetchIdRefreshStatus, runningIdRefresh]);
const closeFieldPick = () => {
if (idRefreshStarting) return;
setFieldPickOpen(false);
};
const startIdRefresh = async () => {
if (runningIdRefresh || idRefreshStarting) return;
const fields = REFRESH_FIELD_OPTIONS.filter(
(option) => fieldChecks[option.key],
).map((option) => option.key);
if (fields.length === 0) return;
try {
setIdRefreshStarting(true);
setIdRefreshError("");
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "refresh_account_fields", fields }),
});
const result = await readApiResponse<{ refresh: FieldRefreshStatus }>(
response,
"启动失败",
);
if (!response.ok) throw new Error(result.error || "启动失败");
idRefreshNotifiedRef.current = false;
setIdRefresh(result.refresh);
setFieldPickOpen(false);
} catch (reason) {
setIdRefreshError(reason instanceof Error ? reason.message : "启动失败");
} finally {
setIdRefreshStarting(false);
}
};
return (
<div className="stack">
<section className="resource-intro">
<div className="resource-figure">
<strong>{accounts.length}</strong>
<span>个真实发布账号</span>
</div>
<div>
<h2>资源从合作与历史资产中持续沉淀</h2>
<p>发布链接会自动建档,已有KOC资源也可以使用标准模板批量导入。</p>
</div>
<div className="resource-rule">
<span>唯一去重规则</span>
<strong>平台 + 账号主页</strong>
</div>
</section>
<section className="panel table-panel">
<div className="panel-heading">
<div><h2>账号资源库</h2><p>统一管理合作沉淀与历史导入账号</p></div>
<div className="resource-heading-actions">
<a className="ghost-button" download href="/KOC资源导入模板.xlsx">
下载导入模板
</a>
<button className="primary-button soft" onClick={() => setImportOpen(true)}>
导入KOC资源
</button>
<div className="filter-chips">
<button
className={platformFilter === "全部平台" ? "active" : ""}
onClick={() => {
setPlatformFilter("全部平台");
setPage(1);
}}
>
全部平台
</button>
{platformOptions.map((platform) => (
<button
className={platformFilter === platform ? "active" : ""}
key={platform}
onClick={() => {
setPlatformFilter(platform);
setPage(1);
}}
>
<PlatformBadge platform={platform} compact />
</button>
))}
</div>
</div>
</div>
<div className="resource-toolbar">
<div className="resource-search">
<span aria-hidden="true">⌕</span>
<input
aria-label="搜索账号名称、账号ID或当前联系人"
onChange={(event) => {
setQuery(event.target.value);
setPage(1);
}}
placeholder="搜索账号名称 / 账号ID / 当前联系人 / 标签"
type="search"
value={query}
/>
</div>
<div className="resource-filter-search">
<span aria-hidden="true">⌕</span>
<input
aria-label="模糊搜索IP地区"
list="resource-ip-options"
onChange={(event) => {
setIpFilter(event.target.value);
setPage(1);
}}
placeholder="搜索IP地区"
type="search"
value={ipFilter}
/>
<datalist id="resource-ip-options">
{ipOptions.map((ip) => <option key={ip} value={ip} />)}
</datalist>
</div>
<div className="resource-filter-search">
<span aria-hidden="true">⌕</span>
<input
aria-label="模糊搜索合作来源"
list="resource-source-options"
onChange={(event) => {
setSourceFilter(event.target.value);
setPage(1);
}}
placeholder="搜索合作来源"
type="search"
value={sourceFilter}
/>
<datalist id="resource-source-options">
{sourceOptions.map((source) => (
<option key={source} value={source} />
))}
</datalist>
</div>
<button
className="resource-clear-button"
disabled={!hasActiveFilters}
onClick={clearFilters}
>
清空筛选
</button>
<div className="resource-toolbar-summary">
<span>共 <b>{filteredAccounts.length}</b> 个结果{totalPages > 1 ? ` · 第 ${currentPage}/${totalPages} 页` : ""}</span>
<button
className="export-data-button"
disabled={runningIdRefresh || idRefreshStarting}
title="爬取全部账号主页,按选择的字段批量更新账号资料"
onClick={() => setFieldPickOpen(true)}
>
{idRefreshStarting
? "正在启动…"
: runningIdRefresh
? `更新中 ${idRefresh?.processed ?? 0}/${idRefresh?.total ?? 0}`
: "批量更新账号资料"}
</button>
<button
className="export-data-button"
disabled={exporting || filteredAccounts.length === 0}
onClick={() =>
onExport(filteredAccounts.map((item) => item.account.id))
}
>
{exporting ? "正在导出…" : "导出筛选结果"}
</button>
</div>
{idRefreshError && <div className="form-error">{idRefreshError}</div>}
</div>
<div className="resource-grid">
{pagedAccounts.map(({ account, sources, partnerManagedOnly }) => {
const profileLink = resourceProfileLink(account);
const accountTags = (account.tags || "")
.split(/[,,、;;|]/)
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 5);
const rawBio = (account.bio || "").trim();
const hasBio = Boolean(
rawBio &&
!/^(未填写简介|还没有简介|暂无简介|未填写|待识别)$/u.test(rawBio),
);
const accountIdLabel =
account.platform === "小红书"
? "小红书号"
: account.platform === "抖音"
? "抖音号"
: "账号";
return (
<article
className={`resource-card ${account.platform === "小红书" ? "platform-xhs" : account.platform === "抖音" ? "platform-douyin" : "platform-other"}`}
key={account.id}
>
<div className="resource-card-head">
<div className={`avatar xlarge resource-profile-avatar ${avatarColor(account.nickname)}`}>{account.nickname.slice(0, 1)}</div>
<div className="resource-profile-main">
<div className="resource-name-line">
<h3>{account.nickname}</h3>
{account.gender === "男" && (
<span className="gender-icon male" aria-label="男性" title="男性">♂</span>
)}
{account.gender === "女" && (
<span className="gender-icon female" aria-label="女性" title="女性">♀</span>
)}
</div>
<div className="resource-account-number">
<span>{accountIdLabel}</span>
<strong>{account.public_account_id || "待识别"}</strong>
<button
type="button"
className="account-id-edit-button"
title="手动修改账号ID"
onClick={() => {
setEditAccount(account);
setEditAccountId(account.public_account_id || "");
setEditError("");
}}
>
修改
</button>
</div>
<div className="resource-platform-line">
<PlatformBadge platform={account.platform} compact />
<span className="resource-location">IP属地 · {account.ip_location || "待识别"}</span>
</div>
<div className="resource-current-contact">
<span>当前联系人</span>
<strong>{account.current_contact || "待补充"}</strong>
</div>
</div>
</div>
<p className={`resource-bio ${hasBio ? "" : "empty"}`} title={hasBio ? rawBio : undefined}>
{hasBio ? rawBio : "暂无简介"}
</p>
<div className={`resource-tags ${accountTags.length === 0 ? "empty" : ""}`}>
<div>
{accountTags.length > 0
? accountTags.map((tag) => <b key={tag}>{tag}</b>)
: <b>待打标</b>}
</div>
</div>
<div className="resource-metrics" aria-label="账号数据">
<div><span>粉丝</span><strong>{formatNumber(account.followers)}</strong></div>
<div><span>合作发布</span><strong>{account.post_count}</strong></div>
<div className="resource-latest"><span>最近合作</span><strong>{formatDate(account.last_seen_at)}</strong></div>
</div>
<div className="resource-card-foot">
<div className="resource-source">
<span>合作来源</span>
<div>
{sources.map((source) => <b key={source}>{source}</b>)}
{partnerManagedOnly && (
<b className="partner-managed">合作社资源 · 不可直联</b>
)}
{sources.length === 0 && !partnerManagedOnly && <b className="empty">暂无</b>}
</div>
</div>
<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>
</div>
</article>
);
})}
</div>
{filteredAccounts.length === 0 && (
<div className="resource-empty">
<strong>没有符合条件的KOC</strong>
<span>可以调整搜索内容或清空筛选条件</span>
<button className="ghost-button" onClick={clearFilters}>
清空筛选
</button>
</div>
)}
{totalPages > 1 && (
<div className="resource-pagination">
<button
disabled={currentPage === 1}
onClick={() => setPage(currentPage - 1)}
>
上一页
</button>
{pageList(currentPage, totalPages).map((item, index) =>
item === "gap" ? (
<span className="gap" key={`gap-${index}`}>…</span>
) : (
<button
className={item === currentPage ? "active" : ""}
key={item}
onClick={() => setPage(item)}
>
{item}
</button>
),
)}
<button
disabled={currentPage === totalPages}
onClick={() => setPage(currentPage + 1)}
>
下一页
</button>
</div>
)}
</section>
{fieldPickOpen && (
<div className="modal-backdrop" role="presentation" onMouseDown={closeFieldPick}>
<div
className="modal-card compact"
role="dialog"
aria-modal="true"
aria-labelledby="field-pick-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow">批量更新</p>
<h2 id="field-pick-title">选择要更新的字段</h2>
</div>
<button type="button" onClick={closeFieldPick} aria-label="关闭">×</button>
</div>
<p className="field-pick-note">
将逐个爬取全部账号主页(小红书 + 抖音),只写入选中的字段;
爬取为空的字段自动跳过,不会清空已有数据。账号较多时需要几分钟。
</p>
<div className="field-pick-list">
{REFRESH_FIELD_OPTIONS.map((option) => (
<label className="field-pick-item" key={option.key}>
<input
checked={Boolean(fieldChecks[option.key])}
onChange={(event) =>
setFieldChecks((checks) => ({
...checks,
[option.key]: event.target.checked,
}))
}
type="checkbox"
/>
<span>{option.label}</span>
</label>
))}
</div>
{idRefreshError && <div className="form-error">{idRefreshError}</div>}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={closeFieldPick}>取消</button>
<button
type="button"
className="primary-button"
disabled={
idRefreshStarting ||
runningIdRefresh ||
!REFRESH_FIELD_OPTIONS.some((option) => fieldChecks[option.key])
}
onClick={() => void startIdRefresh()}
>
{idRefreshStarting
? "正在启动…"
: runningIdRefresh
? `更新中 ${idRefresh?.processed ?? 0}/${idRefresh?.total ?? 0}`
: "开始更新"}
</button>
</div>
</div>
</div>
)}
{editAccount && (
<div className="modal-backdrop" role="presentation" onMouseDown={closeAccountEdit}>
<div
className="modal-card compact"
role="dialog"
aria-modal="true"
aria-labelledby="account-id-edit-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow">{editAccount.nickname || "账号"}</p>
<h2 id="account-id-edit-title">修改账号ID</h2>
</div>
<button type="button" onClick={closeAccountEdit} aria-label="关闭">×</button>
</div>
<div className="account-id-edit-form">
<label>
<span>
{editAccount.platform === "小红书"
? "小红书号"
: editAccount.platform === "抖音"
? "抖音号"
: "账号ID"}
</span>
<input
autoFocus
type="text"
value={editAccountId}
placeholder="2-80 位字母、数字或 . _ -"
onChange={(event) => setEditAccountId(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void submitAccountEdit();
}}
/>
<small>只更新账号ID,昵称、粉丝数等其他资料保持不变</small>
</label>
{editError && <div className="form-error">{editError}</div>}
</div>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={closeAccountEdit}>取消</button>
<button
type="button"
className="primary-button"
disabled={editWorking || !editAccountId.trim()}
onClick={() => void submitAccountEdit()}
>
{editWorking ? "保存中…" : "保存"}
</button>
</div>
</div>
</div>
)}
{importOpen && (
<div className="modal-backdrop" role="presentation" onMouseDown={closeImport}>
<div
className="modal-card resource-import-modal"
role="dialog"
aria-modal="true"
aria-labelledby="resource-import-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow">{importMode === "single" ? "单条新增" : "批量导入"}</p>
<h2 id="resource-import-title">导入已有KOC资源</h2>
</div>
<button type="button" onClick={closeImport} aria-label="关闭">×</button>
</div>
<div className="import-mode-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={importMode === "bulk"}
className={importMode === "bulk" ? "active" : ""}
onClick={() => switchImportMode("bulk")}
>
批量导入
</button>
<button
type="button"
role="tab"
aria-selected={importMode === "single"}
className={importMode === "single" ? "active" : ""}
onClick={() => switchImportMode("single")}
>
单条新增
</button>
</div>
{importMode === "bulk" && (
<>
<div className="import-guide-strip">
<span>1</span><p>账号链接必填,其余资料选填</p>
<i />
<span>2</span><p>系统只补全空缺的公开资料</p>
<i />
<span>3</span><p>确认后写入资源库</p>
</div>
<label className="resource-import-dropzone">
<input
accept=".xlsx,.csv"
type="file"
onChange={(event) => {
setImportFile(event.target.files?.[0] ?? null);
setImportPreview(null);
setImportError("");
}}
/>
<strong>{importFile ? importFile.name : "选择 Excel / CSV 文件"}</strong>
<span>单次最多 10,000 个账号,文件不超过 20MB</span>
</label>
{!importPreview && (
<div className="import-template-note">
<div><strong>还没有模板?</strong><span>只有账号链接必填;性别、简介、标签等资料均可选填,多个标签请用逗号分隔且最多 5 个,系统只补全空缺字段。</span></div>
<a className="ghost-button" download href="/KOC资源导入模板.xlsx">下载模板</a>
</div>
)}
{importPreview && (
<div className="resource-import-preview">
<div className="import-summary-grid">
<div><span>数据总数</span><strong>{importPreview.summary.total}</strong></div>
<div className="create"><span>新增账号</span><strong>{importPreview.summary.create}</strong></div>
<div className="update"><span>更新账号</span><strong>{importPreview.summary.update}</strong></div>
<div className={importPreview.summary.error ? "error" : ""}><span>异常数据</span><strong>{importPreview.summary.error}</strong></div>
</div>
<div className="import-preview-table">
<div className="import-preview-row head">
<span>行号</span><span>账号</span><span>账号ID</span><span>IP / 来源</span><span>结果</span>
</div>
{importPreview.rows.slice(0, 8).map((row) => (
<div className="import-preview-row" key={row.rowNumber}>
<span>{row.rowNumber}</span>
<span><strong>{row.nickname || "—"}</strong><small><PlatformBadge platform={row.platform} compact /></small></span>
<span>{row.publicAccountId || "主页识别"}</span>
<span><strong>{row.ipLocation}</strong><small>{row.cooperationSource || "未填写来源"}</small></span>
<span className={`import-result ${row.action}`}>
{row.action === "create" ? "新增" : row.action === "update" ? "更新" : row.errors.join(";")}
</span>
</div>
))}
</div>
{(importPreview.summary.total > 8 || importPreview.truncated) && (
<p className="import-preview-more">这里只预览 8 行,异常数据会优先展示;确认导入时会处理全部有效数据。</p>
)}
{importPreview.summary.error > 0 && (
<p className="import-preview-warning">
{importPreview.summary.error} 条异常数据将自动跳过,不会导入;其余 {importPreview.summary.create + importPreview.summary.update} 个有效账号可正常导入。
</p>
)}
{Boolean(importPreview.deferredEnrichment) && (
<p className="import-preview-more">
其中 {importPreview.deferredEnrichment} 个账号缺少公开资料;确认后会先导入资源库,再在后台逐步补全。
</p>
)}
</div>
)}
{importError && <div className="form-error">{importError}</div>}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={closeImport}>取消</button>
{!importPreview ? (
<button
type="button"
className="primary-button"
disabled={!importFile || importWorking}
onClick={() => void submitImport("preview")}
>
{importWorking ? "校验中…" : "校验并预览"}
</button>
) : (
<button
type="button"
className="primary-button"
disabled={importPreview.summary.create + importPreview.summary.update === 0 || importWorking}
onClick={() => void submitImport("commit")}
>
{importWorking ? "导入中…" : `确认导入 ${importPreview.summary.create + importPreview.summary.update} 个账号`}
</button>
)}
</div>
</>
)}
{importMode === "single" && (
<>
<div className="import-guide-strip">
<span>1</span><p>账号主页链接必填,平台自动识别</p>
<i />
<span>2</span><p>其余资料选填,空缺字段系统会补全</p>
<i />
<span>3</span><p>已存在的账号会更新资料</p>
</div>
<div className="form-grid two">
<label>
<span>账号主页链接 *</span>
<input
type="url"
value={singleForm.profileUrl}
onChange={(event) =>
setSingleForm((form) => ({ ...form, profileUrl: event.target.value }))
}
placeholder="https://www.xiaohongshu.com/user/profile/..."
/>
<small>
{platformFromProfileUrl(singleForm.profileUrl)
? `识别平台:${platformFromProfileUrl(singleForm.profileUrl)}`
: "支持小红书 / 抖音账号主页"}
</small>
</label>
<label>
<span>账号昵称</span>
<input
type="text"
value={singleForm.nickname}
onChange={(event) =>
setSingleForm((form) => ({ ...form, nickname: event.target.value }))
}
placeholder="选填"
/>
</label>
<label>
<span>账号ID</span>
<input
type="text"
value={singleForm.publicAccountId}
onChange={(event) =>
setSingleForm((form) => ({ ...form, publicAccountId: event.target.value }))
}
placeholder="小红书号 / 抖音号"
/>
</label>
<label>
<span>IP属地</span>
<input
type="text"
value={singleForm.ipLocation}
onChange={(event) =>
setSingleForm((form) => ({ ...form, ipLocation: event.target.value }))
}
placeholder="省份 / 地区 / 国家"
/>
</label>
<label>
<span>粉丝数</span>
<input
type="text"
value={singleForm.followers}
onChange={(event) =>
setSingleForm((form) => ({ ...form, followers: event.target.value }))
}
placeholder="如 1.3万 / 8000"
/>
</label>
<label>
<span>性别</span>
<select
value={singleForm.gender}
onChange={(event) =>
setSingleForm((form) => ({ ...form, gender: event.target.value }))
}
>
<option value="">未填写</option>
<option value="男">男</option>
<option value="女">女</option>
</select>
</label>
<label>
<span>合作来源</span>
<input
type="text"
value={singleForm.cooperationSource}
onChange={(event) =>
setSingleForm((form) => ({ ...form, cooperationSource: event.target.value }))
}
placeholder="历史合作来源 / 资源来源"
/>
</label>
<label>
<span>标签</span>
<input
type="text"
value={singleForm.tags}
onChange={(event) =>
setSingleForm((form) => ({ ...form, tags: event.target.value }))
}
placeholder="逗号分隔,最多 5 个"
/>
</label>
</div>
<label>
<span>简介</span>
<textarea
value={singleForm.bio}
onChange={(event) =>
setSingleForm((form) => ({ ...form, bio: event.target.value }))
}
placeholder="选填"
/>
</label>
{singleError && <div className="form-error">{singleError}</div>}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={closeImport}>取消</button>
<button
type="button"
className="primary-button"
disabled={!singleForm.profileUrl.trim() || singleWorking}
onClick={() => void submitSingle()}
>
{singleWorking ? "保存中…" : "保存账号"}
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
);
}
function CreatorScreenshotPreview({
distributionId,
title,
}: {
distributionId: string;
title: string;
}) {
const [open, setOpen] = useState(false);
const imageUrl = `/api/creator-screenshot?distribution=${encodeURIComponent(distributionId)}`;
return (
<>
<button
type="button"
className="creator-screenshot-link"
onClick={() => setOpen(true)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={imageUrl} alt={`${title} 创作者中心截图`} loading="lazy" />
<span>点击查看</span>
</button>
<AdminImageLightbox
images={open ? [{ src: imageUrl, title: `${title} · 创作者中心截图` }] : []}
activeIndex={0}
onClose={() => setOpen(false)}
/>
</>
);
}
/* 发布截图预览(管理端核对发布凭证用) */
function PublishEvidencePreview({
distributionId,
title,
}: {
distributionId: string;
title: string;
}) {
const [open, setOpen] = useState(false);
const imageUrl = `/api/task-result-image?distribution=${encodeURIComponent(distributionId)}&kind=publish`;
return (
<>
<button
type="button"
className="detail-shot"
onClick={() => setOpen(true)}
title="查看发布截图大图"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={imageUrl} alt={`${title} 发布截图`} loading="lazy" />
<span>发布截图 · 点击查看</span>
</button>
<AdminImageLightbox
images={open ? [{ src: imageUrl, title: `${title} · 发布截图` }] : []}
activeIndex={0}
onClose={() => setOpen(false)}
/>
</>
);
}
/* ------------------------------------------------------------
分发详情弹窗:单条分发全量数据,按发布/回收双阶段自适应展示
------------------------------------------------------------ */
function DistributionDetailModal({
item,
recoveryEnabled,
working,
collectingDistributionIds,
canUpdatePublishUrl,
canRelease,
onClose,
onCollect,
onFallback,
onUpdatePublishUrl,
onRelease,
}: {
item: Distribution;
recoveryEnabled: boolean;
working: boolean;
collectingDistributionIds: ReadonlySet<string>;
canUpdatePublishUrl: boolean;
canRelease: boolean;
onClose: () => void;
onCollect: (distribution: Distribution) => void;
onFallback: (distribution: Distribution) => void;
onUpdatePublishUrl: (distribution: Distribution) => void;
onRelease: (distribution: Distribution) => void;
}) {
const [previewGallery, setPreviewGallery] = useState<{
images: AdminPreviewImage[];
activeIndex: number;
} | null>(null);
const metrics = latestPublicMetrics(item);
const noteUrl = publicPublishUrl(item.publish_url);
const collectionDays = parseCollectionDays(item.task_collection_days);
const resultKeys = parseResultScreenshotKeys(item.result_screenshot_key);
const collecting =
collectingDistributionIds.size > 0 && collectingDistributionIds.has(item.id);
const resolvedCollectionStatus =
item.collection_status || (metrics.likes !== null ? "success" : "pending");
return (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={onClose}
>
<div
className="modal-card distribution-detail-modal"
role="dialog"
aria-modal="true"
aria-label={`分发详情:${item.content_title}`}
onMouseDown={(event) => event.stopPropagation()}
>
{/* 头部:标题 + 阶段徽章 + 任务元信息 */}
<div className="distribution-detail-header">
<div className="modal-heading" style={{ marginBottom: 0 }}>
<div>
<p className="eyebrow">分发详情</p>
<h2>{item.content_title}</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
<div className="detail-badges">
<span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span>
<PlatformBadge platform={item.task_platform} compact />
<span className="master-chip">{item.content_format === "video" ? "视频" : "图文"}</span>
<span className={`due-badge${dueBadgeState(item.due_at)}`}>
截止 {formatDate(item.due_at)}
</span>
</div>
<div className="detail-meta">
<span>{item.task_name}</span>
<span>· {item.task_brand}</span>
<span>· 合作方 {item.partner_name}</span>
{item.claimant_name && <span>· 领取人 {item.claimant_name}</span>}
</div>
</div>
{/* 内容:四个阶段区块(未开通回收的任务不展示截图回收) */}
<div className="detail-body">
<section className="detail-section">
<div className="detail-section-head">
<b>① 发布阶段</b>
<span className={`status-pill ${item.publish_url ? "published" : "claimed"}`}>
{item.publish_url ? "已发布" : "待发布"}
</span>
</div>
<div className="detail-section-body">
<div className="detail-kv-grid">
<div className="detail-kv">
<span>实际发布账号</span>
{item.account_nickname ? (
<strong>{item.account_nickname}</strong>
) : (
<span className="muted">回填链接后识别</span>
)}
</div>
<div className="detail-kv">
<span>发布时间</span>
<strong>{formatDate(item.publish_time, true) || "—"}</strong>
</div>
<div className="detail-kv">
<span>发布链接</span>
{noteUrl ? (
<a href={noteUrl} target="_blank" rel="noopener noreferrer">
打开作品 ↗
</a>
) : (
<span className="muted">待回填</span>
)}
</div>
<div className="detail-kv">
<span>领取时间</span>
<strong>{formatDate(item.claimed_at, true)}</strong>
</div>
</div>
<div className="detail-evidence">
<span className="detail-evidence-label">发布截图</span>
{item.publish_screenshot_key ? (
<PublishEvidencePreview
distributionId={item.id}
title={item.content_title}
/>
) : (
<span className="muted" style={{ fontSize: 12 }}>
待KOC上传
</span>
)}
</div>
</div>
</section>
<section className="detail-section">
<div className="detail-section-head">
<b>② 数据回收(自动采集)</b>
<span className={`collection-status ${resolvedCollectionStatus}`}>
{collectionStatusLabel(resolvedCollectionStatus, metrics.likes !== null)}
</span>
</div>
<div className="detail-section-body">
<div className="detail-metric-row">
<div className="detail-metric">
<span>点赞</span>
<strong>{formatNumber(metrics.likes)}</strong>
</div>
<div className="detail-metric">
<span>收藏</span>
<strong>{formatNumber(metrics.collects)}</strong>
</div>
{item.task_platform === "抖音" && (
<div className="detail-metric">
<span>转发</span>
<strong>{formatNumber(metrics.shares)}</strong>
</div>
)}
<div className="detail-metric">
<span>评论</span>
<strong>{formatNumber(metrics.comments)}</strong>
</div>
<div className="detail-metric">
<span>总互动</span>
<strong>{formatNumber(metrics.total)}</strong>
</div>
</div>
<div className="detail-kv-grid" style={{ marginTop: 11 }}>
<div className="detail-kv">
<span>数据更新时间</span>
<strong>
{formatDate(
item.collection_updated_at ||
(metrics.likes !== null ? item.updated_at : null),
true,
) || "—"}
</strong>
</div>
<div className="detail-kv">
<span>采集计划</span>
<strong>
{item.task_collection_start_date && collectionDays.length > 0
? `${item.task_collection_start_date} 起 D${collectionDays.join("/D")}`
: "待设置"}
</strong>
</div>
</div>
{(item.collection_status_description || item.public_metrics_screenshot_key) && (
<div className="detail-evidence">
{item.collection_status_description && (
<span
className={`detail-evidence-label${
resolvedCollectionStatus === "failed" ? " warn" : ""
}`}
>
{resolvedCollectionStatus === "failed" ? "⚠ " : ""}
{item.collection_status_description}
</span>
)}
{item.public_metrics_screenshot_key && (
<a
className="text-button"
href={`/api/creator-screenshot?distribution=${encodeURIComponent(item.id)}&kind=public-metrics`}
target="_blank"
rel="noreferrer"
>
KOC补交截图 ↗
</a>
)}
</div>
)}
</div>
</section>
<section className="detail-section">
<div className="detail-section-head">
<b>③ 创作者数据(第7天)</b>
<span className={`status-pill ${hasCreatorMetrics(item) ? "complete" : "claimed"}`}>
{hasCreatorMetrics(item) ? "已提交" : "待提交"}
</span>
</div>
<div className="detail-section-body">
<div className="detail-evidence" style={{ marginTop: 0 }}>
<span className="detail-evidence-label">创作者截图</span>
{item.screenshot_key ? (
<CreatorScreenshotPreview
distributionId={item.id}
title={item.content_title}
/>
) : (
<span className="muted" style={{ fontSize: 12 }}>
待KOC上传
</span>
)}
{hasCreatorMetrics(item) ? (
<span className="creator-data-summary">
<b>曝光 {formatNumber(item.exposure)}</b>
<b>阅读 {formatNumber(item.views)}</b>
</span>
) : item.screenshot_key ? (
<span className="creator-ocr-failed">
{item.ocr_status === "processing" ? "正在识别数据" : "识别失败,可手动填写"}
</span>
) : null}
</div>
</div>
</section>
{recoveryEnabled && (
<section className="detail-section">
<div className="detail-section-head">
<b>④ 截图回收</b>
<span
className={`status-pill ${item.result_submitted_at ? "complete" : "claimed"}`}
>
{item.result_submitted_at ? "已提交" : "待提交"}
</span>
</div>
<div className="detail-section-body">
<div className="detail-kv-grid">
<div className="detail-kv">
<span>提交时间</span>
<strong>
{item.result_submitted_at
? formatDate(item.result_submitted_at, true)
: "—"}
</strong>
</div>
<div className="detail-kv">
<span>结果截图</span>
{resultKeys.length > 0 ? (
<strong>{resultKeys.length} 张</strong>
) : (
<span className="muted">待KOC上传</span>
)}
</div>
</div>
{resultKeys.length > 0 && (
<div className="detail-evidence">
<div className="admin-result-gallery">
{resultKeys.map((_, index) => (
<button
type="button"
key={`${item.id}-result-${index}`}
title={`查看第${index + 1}张结果截图`}
onClick={() =>
setPreviewGallery({
images: resultKeys.map((__, imageIndex) => ({
src: `/api/task-result-image?distribution=${encodeURIComponent(item.id)}&index=${imageIndex + 1}`,
title: `${item.content_title} · 第${imageIndex + 1}张结果截图`,
})),
activeIndex: index,
})
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/task-result-image?distribution=${encodeURIComponent(item.id)}&index=${index + 1}`}
alt={`结果截图 ${index + 1}`}
loading="lazy"
/>
</button>
))}
</div>
</div>
)}
</div>
</section>
)}
</div>
{/* 底部操作条 */}
<div className="detail-actions">
{canUpdatePublishUrl && (
<button
className="publish-url-edit-button"
disabled={working}
onClick={() => onUpdatePublishUrl(item)}
>
{noteUrl ? "更新链接" : "填写链接"}
</button>
)}
{item.screenshot_key && !hasCreatorMetrics(item) ? (
<button className="text-button" onClick={() => onFallback(item)}>
手动填写
</button>
) : item.collection_status === "failed" ? (
<button className="text-button" onClick={() => onFallback(item)}>
核对补录
</button>
) : (
<button
className="collect-button"
disabled={!noteUrl || working || collecting}
onClick={() => onCollect(item)}
>
{noteUrl ? "立即采集" : "待填链接"}
</button>
)}
{canRelease && !item.publish_url && !item.result_submitted_at && (
<button
className="release-distribution-button"
disabled={working}
onClick={() => onRelease(item)}
>
释放
</button>
)}
</div>
</div>
<AdminImageLightbox
images={previewGallery?.images ?? []}
activeIndex={previewGallery?.activeIndex ?? 0}
onIndexChange={(activeIndex) =>
setPreviewGallery((current) =>
current ? { ...current, activeIndex } : current,
)
}
onClose={() => setPreviewGallery(null)}
/>
</div>
);
}
function RecoveryPage({
tasks,
distributions,
working,
collectingDistributionIds,
canUpdatePublishUrl,
isManager,
onCollect,
onUpdatePublishUrl,
onSaveSchedule,
onUpload,
onFallback,
onRetryFailed,
onRemind,
onExport,
onExportScreenshots,
exportingTaskId,
onOpenDetail,
}: {
tasks: Task[];
distributions: Distribution[];
working: boolean;
collectingDistributionIds: ReadonlySet<string>;
canUpdatePublishUrl: boolean;
isManager: boolean;
onCollect: (distribution: Distribution) => void;
onUpdatePublishUrl: (distribution: Distribution) => void;
onSaveSchedule: (
taskId: string,
startDate: string,
days: number[],
) => Promise<boolean>;
onUpload: (distribution: Distribution, event: ChangeEvent<HTMLInputElement>) => void;
onFallback: (distribution: Distribution) => void;
onRetryFailed: (taskId: string, failedCount: number) => void;
onRemind: (taskId: string, pendingCount: number) => void;
onExport: (task: Task) => void;
onExportScreenshots: (task: Task) => void;
exportingTaskId: string | null;
onOpenDetail: (distribution: Distribution) => void;
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [recoverySort, setRecoverySort] = useState<{
column: RecoverySortKey | null;
direction: SortDirection;
}>({ column: null, direction: "desc" });
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
if (!selectedTask) {
return (
<div className="stack">
<div className="section-intro">
<div><p className="eyebrow">按任务回收</p><h2>选择一个数据回收任务</h2></div>
<p>进入任务后设置自动采集日期,统一查看每篇笔记的最新互动数据。</p>
</div>
<TaskScopeList
tasks={tasks}
distributions={distributions}
onOpen={setSelectedTaskId}
/>
</div>
);
}
const taskDistributions = distributions.filter(
(item) => item.task_id === selectedTask.id,
);
const publishedCount = taskDistributions.filter(
(item) => item.publish_url,
).length;
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;
const failedCollectionCount = taskDistributions.filter(
(item) => item.collection_status === "failed",
).length;
const sortedTaskDistributions = recoverySort.column
? sortRecoveryDistributions(
taskDistributions,
recoverySort.column,
recoverySort.direction,
)
: taskDistributions;
const changeRecoverySort = (column: RecoverySortKey) => {
setRecoverySort((current) =>
current.column === column
? {
column,
direction: current.direction === "desc" ? "asc" : "desc",
}
: { column, direction: "desc" },
);
};
return (
<div className="stack">
<TaskDetailHeader
task={selectedTask}
label="数据回收"
onBack={() => setSelectedTaskId(null)}
/>
<CollectionScheduleCard
key={`${selectedTask.id}:${selectedTask.collection_start_date}:${selectedTask.collection_days}`}
task={selectedTask}
working={working}
onSave={onSaveSchedule}
/>
<section className="panel table-panel recovery-panel">
<div className="panel-heading">
<div>
<h2>数据回收队列</h2>
<p>已发布 {publishedCount} / {taskDistributions.length} 篇 · 展示最近一次采集结果</p>
</div>
<div className="recovery-panel-actions">
<button
className="export-data-button"
disabled={working || exportingTaskId === selectedTask.id}
onClick={() => onExport(selectedTask)}
>
{exportingTaskId === selectedTask.id
? "正在生成 Excel…"
: "导出全部数据"}
</button>
{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;
// 与催办口径一致:待发布 + 截图待交 + 公开数据采集失败(催 KOC 补交数据截图)
const pendingCount =
publishPending +
(recoveryEnabled ? recoveryPending : 0) +
failedCollectionCount;
return (
<button
className="retry-failed-button"
disabled={working || pendingCount === 0}
onClick={() => onRemind(selectedTask.id, pendingCount)}
>
催办未完成
{pendingCount > 0 && <b>{pendingCount}</b>}
</button>
);
})()}
</div>
</div>
<div className="table-scroll">
<table className="data-table recovery-table">
<thead>
<tr>
<th>内容 / 发布账号</th>
<SortableRecoveryHeader
label="发布时间"
column="publish_time"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="点赞"
column="likes"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="收藏"
column="collects"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
{selectedTask.platform === "抖音" && (
<SortableRecoveryHeader
label="转发"
column="shares"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
)}
<SortableRecoveryHeader
label="评论"
column="comments"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="总互动"
column="total"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="数据更新时间"
column="collection_updated_at"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<th>采集状态</th>
<th>创作者截图</th>
{recoveryEnabled && <th>收录截图</th>}
<th>操作</th>
</tr>
</thead>
<tbody>
{sortedTaskDistributions.map((item) => {
const metrics = latestPublicMetrics(item);
const noteUrl = publicPublishUrl(item.publish_url);
const hasMetrics = metrics.likes !== null;
const collectionStatus =
item.collection_status ||
(hasMetrics ? "success" : "pending");
return (
<tr
key={item.id}
className="clickable"
onClick={() => onOpenDetail(item)}
title="点击查看分发详情"
>
<td>
<div className="account-cell recovery-content">
<div className={`avatar small ${avatarColor(item.content_title)}`}>{Array.from(item.content_title)[0] || "笔"}</div>
<div>
{noteUrl ? (
<a
className="recovery-title-link"
href={noteUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`打开${selectedTask.platform}作品:${item.content_title}`}
title={`打开${selectedTask.platform}作品`}
>
<strong>{item.content_title}</strong>
</a>
) : (
<strong>{item.content_title}</strong>
)}
<span className="platform-meta-line">
<span>{item.account_nickname || "待识别账号"}</span>
{item.account_platform && <PlatformBadge platform={item.account_platform} compact />}
</span>
</div>
</div>
</td>
<td>
<div className="metric-cell publish-time">
<strong>{formatDate(item.publish_time, true)}</strong>
</div>
</td>
<td><div className="metric-cell"><strong>{formatNumber(metrics.likes)}</strong></div></td>
<td><div className="metric-cell"><strong>{formatNumber(metrics.collects)}</strong></div></td>
{selectedTask.platform === "抖音" && (
<td><div className="metric-cell"><strong>{formatNumber(metrics.shares)}</strong></div></td>
)}
<td><div className="metric-cell"><strong>{formatNumber(metrics.comments)}</strong></div></td>
<td>
<div className="metric-cell total">
<strong>{formatNumber(metrics.total)}</strong>
<span>{selectedTask.platform === "抖音" ? "赞 + 藏 + 转 + 评" : "赞 + 藏 + 评"}</span>
</div>
</td>
<td>
<div className="metric-cell update-time">
<strong>{formatDate(item.collection_updated_at || (hasMetrics ? item.updated_at : null), true)}</strong>
{item.last_collection_day && <span>第{item.last_collection_day}天采集</span>}
</div>
</td>
<td onClick={(event) => event.stopPropagation()}>
<div className="collection-status-cell">
<span className={`collection-status ${collectionStatus}`}>
{collectionStatusLabel(collectionStatus, hasMetrics)}
</span>
<small>
{item.collection_status_description ||
(hasMetrics ? "成功" : "等待采集计划")}
</small>
{item.public_metrics_screenshot_key && (
<a
className="text-button"
href={`/api/creator-screenshot?distribution=${encodeURIComponent(item.id)}&kind=public-metrics`}
target="_blank"
rel="noreferrer"
title="查看 KOC 补交的公开数据截图"
>
KOC补交截图 ↗
</a>
)}
</div>
</td>
<td onClick={(event) => event.stopPropagation()}>
<div className="creator-screenshot-cell">
{item.screenshot_key ? (
<CreatorScreenshotPreview
distributionId={item.id}
title={item.content_title}
/>
) : (
<span className="creator-screenshot-pending">待KOC上传</span>
)}
{hasCreatorMetrics(item) ? (
<span className="creator-data-summary">
<b>曝光 {formatNumber(item.exposure)}</b>
<b>阅读 {formatNumber(item.views)}</b>
</span>
) : item.screenshot_key ? (
<span className="creator-ocr-failed">
{item.ocr_status === "processing"
? "正在识别数据"
: "识别失败,请手动填写"}
</span>
) : null}
{item.screenshot_key &&
!hasCreatorMetrics(item) && (
<label className="upload-button retry-upload">
<input
type="file"
accept="image/*"
onChange={(event) => onUpload(item, event)}
/>
重新上传
</label>
)}
</div>
</td>
{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>
)}
<td onClick={(event) => event.stopPropagation()}>
<div className="recovery-row-actions">
{item.screenshot_key && !hasCreatorMetrics(item) ? (
<button className="text-button" onClick={() => onFallback(item)}>手动填写</button>
) : item.collection_status === "failed" ? (
<button className="text-button" onClick={() => onFallback(item)}>核对补录</button>
) : (
<button
className="collect-button"
disabled={
!noteUrl ||
(working &&
(collectingDistributionIds.size === 0 ||
collectingDistributionIds.has(item.id)))
}
onClick={() => onCollect(item)}
>
{noteUrl ? "立即采集" : "待填链接"}
</button>
)}
{canUpdatePublishUrl && (
<button
className="publish-url-edit-button"
disabled={working}
onClick={() => onUpdatePublishUrl(item)}
>
{noteUrl ? "更新链接" : "填写链接"}
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{taskDistributions.length === 0 && (
<div className="table-empty compact">当前任务还没有已发布笔记</div>
)}
</div>
</section>
{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>
</>
)}
</div>
);
}
function CollectionScheduleCard({
task,
working,
onSave,
}: {
task: Task;
working: boolean;
onSave: (
taskId: string,
startDate: string,
days: number[],
) => Promise<boolean>;
}) {
const [startDate, setStartDate] = useState(
task.collection_start_date || todayInputValue(),
);
const [days, setDays] = useState<number[]>(
parseCollectionDays(task.collection_days),
);
const toggleDay = (day: number) => {
setDays((current) =>
current.includes(day)
? current.filter((item) => item !== day)
: [...current, day].sort((a, b) => a - b),
);
};
return (
<section className="panel collection-schedule-panel">
<div className="collection-schedule-heading">
<div>
<p className="eyebrow">自动采集计划</p>
<h2>选择开始日期与采集日</h2>
<span>勾选第1天到第7天,所选日期均在北京时间09:00自动采集。</span>
</div>
<div className="schedule-save-area">
<span>已选择 {days.length} 天</span>
<button
className="primary-button"
disabled={working || !startDate || days.length === 0}
onClick={() => void onSave(task.id, startDate, days)}
>
{working ? "保存中…" : "保存采集计划"}
</button>
</div>
</div>
<div className="schedule-grid">
<label className="schedule-start">
<span>开始采集日期</span>
<input
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/>
</label>
<div className="schedule-days" role="group" aria-label="选择自动采集日">
{Array.from({ length: 7 }, (_, index) => index + 1).map((day) => {
const selected = days.includes(day);
const date = addCalendarDays(startDate, day - 1);
return (
<button
key={day}
type="button"
className={selected ? "selected" : ""}
aria-pressed={selected}
onClick={() => toggleDay(day)}
>
<span>第{day}天</span>
<b aria-hidden="true">{selected ? "✓" : ""}</b>
<small>{date ? date.slice(5).replace("-", "/") : "—"}</small>
</button>
);
})}
</div>
<span className="schedule-mobile-hint">左右滑动查看第1—7天</span>
</div>
{task.collection_schedule_updated_at && (
<p className="schedule-updated">
当前计划更新于 {formatDate(task.collection_schedule_updated_at, true)}
</p>
)}
</section>
);
}