feat: KOC详情弹窗 + 任务中心单入口改版 + 资源池默认合作方/采集兜底

KOC 详情弹窗(合作方/数据回收/工作台三处入口):
- 新增 KocDetailModal:基本信息 / 绑定KOC账号画像 / 参与任务记录 / 履约漏斗
- 工作台最近分发、回收队列账号名、截图回收合作方名、合作方页名称、分发详情头部合作方均可点开
- 分发表行点击仍打开分发详情(stopPropagation 隔离)

任务中心单入口改版(对齐"单任务双阶段"模型):
- 任务卡发布/回收面板整块可点,全卡仅保留一个「查看明细」,回收面板重复按钮移除
- 详情页自述身份:动态 label(任务详情·分发与回收一体/仅分发)+ 阶段徽章(含回收·截止/未开通回收)
- 内容分发表新增阶段筛选 chips(全部/待发布/已发布待数据/待收截图,未开通回收不显示截图项),筛选落空给专用空态文案
- 数据回收页入口文案改为「选择一个任务进入回收队列」,全站不再区分发布/回收两种任务类型

资源池 KOC 默认加入合作方 + 采集兜底:
- partners 新增 released_account_id(mysql/0023):显式解绑/被顶替的账号不再被默认合作方同步回填
- 任务未设置采集计划时,默认按发布时间起连续采集 7 天,等待文案显示「等待D1 · 默认7天」
This commit is contained in:
ABAPPLO
2026-10-12 11:11:31 +08:00
parent c11afd5f99
commit 87f4d4066d
20 changed files with 1472 additions and 195 deletions

View File

@@ -194,6 +194,12 @@ type Distribution = {
task_collection_start_date: string | null;
};
type StageFilterKey =
| "all"
| "pending_publish"
| "awaiting_metrics"
| "awaiting_screenshot";
type RecoverySortKey =
| "publish_time"
| "likes"
@@ -448,6 +454,9 @@ function parseCollectionDays(value: string | null | undefined) {
}
}
// 与服务端 DEFAULT_COLLECTION_DAYS 保持一致:未设置采集日时默认连采 7 天。
const DEFAULT_COLLECTION_DAYS = [1, 2, 3, 4, 5, 6, 7];
function todayInputValue() {
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
@@ -551,6 +560,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
const [customerChatTask, setCustomerChatTask] = useState<Task | null>(null);
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
const [detailDistributionId, setDetailDistributionId] = useState<string | null>(null);
const [detailPartnerId, setDetailPartnerId] = useState<string | null>(null);
const [publishUrlModal, setPublishUrlModal] = useState<{
distribution: Distribution;
value: string;
@@ -1335,6 +1345,8 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
tasks={data.tasks}
onNavigate={navigate}
showResources={currentUser.role !== "user"}
onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)}
onOpenPartner={(partnerId) => setDetailPartnerId(partnerId)}
/>
)}
{activeNav === "tasks" && (
@@ -1401,6 +1413,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
return result !== false;
}}
onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)}
onOpenPartner={(partnerId) => setDetailPartnerId(partnerId)}
/>
)}
{activeNav === "resources" && (
@@ -1422,6 +1435,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
working={working}
onRefresh={loadData}
setToast={setToast}
onOpenPartner={(partner) => setDetailPartnerId(partner.id)}
/>
)}
{activeNav === "users" && isManager && (
@@ -1446,6 +1460,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
onExportScreenshots={exportScreenshotResults}
exportingTaskId={exportingTaskId}
onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)}
onOpenPartner={(partnerId) => setDetailPartnerId(partnerId)}
/>
)}
</>
@@ -1909,6 +1924,10 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
canUpdatePublishUrl={isManager}
canRelease={isManager}
onClose={() => setDetailDistributionId(null)}
onOpenPartner={(partnerId) => {
setDetailDistributionId(null);
setDetailPartnerId(partnerId);
}}
onCollect={collectMetrics}
onFallback={openMetricFallback}
onUpdatePublishUrl={(distribution) =>
@@ -1938,6 +1957,27 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
/>
);
})()}
{(() => {
// KOC 详情按 id 实时取:操作刷新数据后弹窗内容自动更新
const partnerRow = detailPartnerId
? data.partners.find((partner) => partner.id === detailPartnerId)
: null;
if (!partnerRow) return null;
const boundAccount = partnerRow.account_id
? (data.accounts.find((account) => account.id === partnerRow.account_id) ?? null)
: null;
const partnerHistory = data.distributions
.filter((item) => item.partner_id === partnerRow.id)
.sort((a, b) => (a.claimed_at < b.claimed_at ? 1 : -1));
return (
<KocDetailModal
partner={partnerRow}
account={boundAccount}
history={partnerHistory}
onClose={() => setDetailPartnerId(null)}
/>
);
})()}
{toast && <div className="toast"><span>✓</span>{toast}</div>}
{working && <div className="working-bar" />}
</div>
@@ -1988,12 +2028,14 @@ function PartnersPage({
working,
onRefresh,
setToast,
onOpenPartner,
}: {
partners: Partner[];
accounts: Account[];
working: boolean;
onRefresh: () => Promise<void>;
setToast: (message: string) => void;
onOpenPartner: (partner: Partner) => void;
}) {
const [status, setStatus] = useState<WecomStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
@@ -2328,6 +2370,25 @@ function PartnersPage({
return map;
}, [partners]);
// 资源池默认同步生成的空壳合作方:无企微绑定、无领取/完成记录,
// 换绑时服务端会自动顶替,选择器里这些资源账号保持可选。
const defaultShellAccountIds = useMemo(() => {
const ids = new Set<string>();
for (const partner of partners) {
if (
(partner.partner_type || "external_koc") === "external_koc" &&
partner.account_id &&
!(partner.wecom_external_user_id ?? "").trim() &&
!(partner.wecom_userid ?? "").trim() &&
Number(partner.claimed_total ?? 0) === 0 &&
Number(partner.completed_total ?? 0) === 0
) {
ids.add(partner.account_id);
}
}
return ids;
}, [partners]);
const filteredResourceAccounts = useMemo(() => {
const q = resourcePickerSearch.trim().toLowerCase();
if (!q) return accounts;
@@ -2456,7 +2517,7 @@ function PartnersPage({
<div className="panel-heading">
<div>
<h2>合作方</h2>
<p>群主型合作方由任务负责人自动生成(负责任务群分发);外部KOC由领取流程自动生成,绑定外部联系人后接收催办</p>
<p>群主型合作方由任务负责人自动生成(负责任务群分发);资源池 KOC 默认加入外部KOC合作方,绑定外部联系人或领取任务后接收催办</p>
</div>
<button
type="button"
@@ -2495,7 +2556,7 @@ function PartnersPage({
{filteredPartners.length === 0 && (
<div className="partner-row empty">
{partners.length === 0
? "暂无合作方:点击右上角新建群主合作方,或等待 KOC 领取任务后自动生成外部KOC记录"
? "暂无合作方:新建群主合作方、在「KOC 资源」导入账号,或等待 KOC 领取任务后自动生成外部KOC记录"
: "当前筛选下没有合作方"}
</div>
)}
@@ -2505,7 +2566,11 @@ function PartnersPage({
const isGroupOwner = partner.partner_type === "group_owner";
return (
<div className="partner-row" key={partner.id}>
<span>
<span
className="partner-name-cell"
title="点击查看 KOC 详情"
onClick={() => onOpenPartner(partner)}
>
<strong>{partner.name}</strong>
<em className={`partner-type-badge ${isGroupOwner ? "group-owner" : "external-koc"}`}>
{isGroupOwner ? "群主" : "外部KOC"}
@@ -2803,7 +2868,7 @@ function PartnersPage({
<div className="modal-heading">
<div>
<h2>选择 {resourcePickerPartner.name} 的 KOC 资源</h2>
<p>绑定后可在合作方列表直接查看该 KOC 的联系方式;每个资源账号只能绑定一个外部KOC合作方。</p>
<p>绑定后可在合作方列表直接查看该 KOC 的联系方式;每个资源账号只能绑定一个外部KOC合作方,换绑时会自动顶替资源池默认生成的合作方。</p>
</div>
<button
type="button"
@@ -2836,7 +2901,11 @@ function PartnersPage({
) : (
filteredResourceAccounts.map((account) => {
const holderName = partnerNameByAccountId.get(account.id);
const boundByOther = Boolean(holderName) && account.id !== resourcePickerPartner.account_id;
const heldByDefaultShell = defaultShellAccountIds.has(account.id);
const boundByOther =
Boolean(holderName) &&
!heldByDefaultShell &&
account.id !== resourcePickerPartner.account_id;
const isCurrent = account.id === resourcePickerPartner.account_id;
return (
<button
@@ -2862,6 +2931,8 @@ function PartnersPage({
<span className="wecom-picker-current">已绑定:{holderName}</span>
) : isCurrent ? (
<span className="wecom-picker-current">当前</span>
) : heldByDefaultShell && holderName ? (
<span className="wecom-picker-current">默认合作方:{holderName}</span>
) : null}
</button>
);
@@ -4123,6 +4194,8 @@ function OverviewPage({
tasks,
onNavigate,
showResources,
onOpenDetail,
onOpenPartner,
}: {
stats: {
resources: number;
@@ -4139,6 +4212,8 @@ function OverviewPage({
tasks: Task[];
onNavigate: (key: NavKey) => void;
showResources: boolean;
onOpenDetail: (distribution: Distribution) => void;
onOpenPartner: (partnerId: string) => void;
}) {
const cards = [
{
@@ -4285,6 +4360,8 @@ function OverviewPage({
<DistributionTable
distributions={distributions.slice(0, 6)}
compact
onOpenDetail={onOpenDetail}
onOpenPartner={onOpenPartner}
/>
</section>
</>
@@ -4607,7 +4684,11 @@ function TaskRowCard({
</div>
</div>
<div className="master-panels">
<section className="master-panel publish">
<section
className="master-panel publish clickable"
title="查看任务明细(分发与回收一体)"
onClick={() => onDetail(task)}
>
<div className="panel-head">
<b>发布</b>
<span className="panel-count">{task.quantity} 篇内容</span>
@@ -4617,13 +4698,15 @@ function TaskRowCard({
{panelChat(task, "任务群")}
</div>
<div className="panel-foot">
<button type="button" className="panel-detail" onClick={() => onDetail(task)}>
查看明细 ›
</button>
<span className="panel-detail">查看明细 ›</span>
</div>
</section>
{recoveryEnabled ? (
<section className="master-panel recovery">
<section
className="master-panel recovery clickable"
title="查看任务明细(分发与回收一体)"
onClick={() => onDetail(task)}
>
<div className="panel-head">
<b>回收</b>
<span className="panel-count">
@@ -4635,11 +4718,6 @@ function TaskRowCard({
<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">
@@ -4689,6 +4767,7 @@ function TasksPage({
onCreateRecovery,
onRelease,
onOpenDetail,
onOpenPartner,
}: {
tasks: Task[];
distributions: Distribution[];
@@ -4708,6 +4787,7 @@ function TasksPage({
onCreateRecovery: (task: Task) => void;
onRelease: (distribution: Distribution) => Promise<boolean>;
onOpenDetail: (distribution: Distribution) => void;
onOpenPartner: (partnerId: string) => void;
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"grouped" | "flat">("grouped");
@@ -4845,6 +4925,7 @@ function TasksPage({
onRelease={onRelease}
onBack={() => setSelectedTaskId(null)}
onOpenDetail={onOpenDetail}
onOpenPartner={onOpenPartner}
/>
);
}
@@ -5087,6 +5168,13 @@ function TaskDetailHeader({
label: string;
onBack: () => void;
}) {
// 页面自述:一个任务 = 分发 + 回收两阶段,回收是否开通直接说清,不产生两种任务的歧义
const recoveryEnabled = Number(task.recovery_enabled ?? 1) === 1;
const pairTag = !recoveryEnabled
? "未开通回收"
: task.recovery_due_at && task.recovery_due_at !== task.due_at
? `含回收 · 回收截止 ${formatDate(task.recovery_due_at)}`
: "含回收 · 与发布同日截止";
return (
<section className="task-detail-header">
<button className="detail-back" onClick={onBack}>← 返回任务列表</button>
@@ -5100,6 +5188,7 @@ function TaskDetailHeader({
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
<span>{task.quantity} 篇</span>
<span>{formatDate(task.due_at)} 截止</span>
<span className="tag-weak pair-tag">{pairTag}</span>
</span>
</div>
{task.source_url && (
@@ -5119,6 +5208,7 @@ function TaskDistributionDetail({
onRelease,
onBack,
onOpenDetail,
onOpenPartner,
}: {
task: Task;
distributions: Distribution[];
@@ -5127,19 +5217,52 @@ function TaskDistributionDetail({
onRelease: (distribution: Distribution) => Promise<boolean>;
onBack: () => void;
onOpenDetail: (distribution: Distribution) => void;
onOpenPartner: (partnerId: string) => void;
}) {
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
const [stageFilter, setStageFilter] = useState<StageFilterKey>("all");
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;
// 阶段筛选:单一列表按内容生命周期过滤,不再拆分发/回收两套列表
const awaitingMetrics = (item: Distribution) =>
Boolean(item.publish_url) && !(item.exposure !== null && item.views !== null);
const awaitingScreenshot = (item: Distribution) =>
Boolean(item.publish_url) && !item.result_submitted_at;
const stageOptions: Array<{ key: StageFilterKey; label: string; count: number }> = [
{ key: "all", label: "全部", count: taskDistributions.length },
{
key: "pending_publish",
label: "待发布",
count: taskDistributions.filter((item) => !item.publish_url).length,
},
{
key: "awaiting_metrics",
label: "已发布待数据",
count: taskDistributions.filter(awaitingMetrics).length,
},
...(recoveryEnabled
? [{
key: "awaiting_screenshot" as const,
label: "待收截图",
count: taskDistributions.filter(awaitingScreenshot).length,
}]
: []),
];
const visibleDistributions = taskDistributions.filter((item) => {
if (stageFilter === "pending_publish") return !item.publish_url;
if (stageFilter === "awaiting_metrics") return awaitingMetrics(item);
if (stageFilter === "awaiting_screenshot") return awaitingScreenshot(item);
return true;
});
return (
<div className="stack">
<TaskDetailHeader
task={task}
label="内容分发"
label={recoveryEnabled ? "任务详情 · 分发与回收一体" : "任务详情 · 仅分发"}
onBack={onBack}
/>
<div className="inline-summary">
@@ -5166,13 +5289,31 @@ function TaskDistributionDetail({
</div>
<section className="panel table-panel">
<div className="panel-heading">
<div><h2>内容分发表</h2><p>实际发布账号在链接回填后自动补齐</p></div>
<div><h2>内容分发表</h2><p>每行是一篇内容从领取到回收的完整状态,实际发布账号在链接回填后自动补齐</p></div>
</div>
<div className="stage-filter-tabs" role="group" aria-label="按阶段筛选分发明细">
{stageOptions.map((option) => (
<button
key={option.key}
type="button"
className={stageFilter === option.key ? "active" : ""}
onClick={() => setStageFilter(option.key)}
>
{option.label}({option.count})
</button>
))}
</div>
<DistributionTable
distributions={taskDistributions}
distributions={visibleDistributions}
emptyHint={
taskDistributions.length > 0 && visibleDistributions.length === 0
? "当前筛选下没有分发记录"
: undefined
}
onRelease={canRelease ? setReleaseTarget : undefined}
showRecovery={recoveryEnabled}
onOpenDetail={onOpenDetail}
onOpenPartner={onOpenPartner}
/>
</section>
{releaseTarget && (
@@ -5243,15 +5384,19 @@ function DistributionTable({
onRelease,
showRecovery = false,
onOpenDetail,
onOpenPartner,
emptyHint,
}: {
distributions: Distribution[];
compact?: boolean;
onRelease?: (distribution: Distribution) => void;
showRecovery?: boolean;
onOpenDetail?: (distribution: Distribution) => void;
onOpenPartner?: (partnerId: string) => void;
emptyHint?: string;
}) {
// 已发布但还没采到数据的行:有计划时按首个采集日显示,没计划时
// 明说待设置,而不是写死「等待D2」误导运营。
// 已发布但还没采到数据的行:有计划时按首个采集日显示;没计划时
// 走默认 7 天兜底(自发布日起),而不是写死「等待D2」误导运营。
const waitingCollectionLabel = (item: Distribution) => {
let days: number[] = [];
try {
@@ -5265,7 +5410,7 @@ function DistributionTable({
days = [];
}
if (!item.task_collection_start_date || days.length === 0) {
return "待设置采集计划";
return "等待D1 · 默认7天";
}
return `等待D${Math.min(...days)}`;
};
@@ -5298,7 +5443,23 @@ function DistributionTable({
<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>
{onOpenPartner ? (
<button
type="button"
className="koc-link"
title="点击查看 KOC 详情"
onClick={(event) => {
event.stopPropagation();
onOpenPartner(item.partner_id);
}}
>
{item.partner_name}
</button>
) : (
<strong className="plain-strong">{item.partner_name}</strong>
)}
</td>
<td>
{item.account_nickname ? (
<div className="account-cell">
@@ -5359,7 +5520,9 @@ function DistributionTable({
))}
</tbody>
</table>
{!compact && distributions.length === 0 && <div className="table-empty">暂无分发记录</div>}
{!compact && distributions.length === 0 && (
<div className="table-empty">{emptyHint ?? "暂无分发记录"}</div>
)}
</div>
);
}
@@ -5447,7 +5610,13 @@ function AdminImageLightbox({
);
}
function ScreenshotTaskTable({ distributions }: { distributions: Distribution[] }) {
function ScreenshotTaskTable({
distributions,
onOpenPartner,
}: {
distributions: Distribution[];
onOpenPartner?: (partnerId: string) => void;
}) {
const [previewGallery, setPreviewGallery] = useState<{
images: AdminPreviewImage[];
activeIndex: number;
@@ -5476,7 +5645,23 @@ function ScreenshotTaskTable({ distributions }: { distributions: Distribution[]
<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>
{onOpenPartner ? (
<button
type="button"
className="koc-link"
title="点击查看 KOC 详情"
onClick={(event) => {
event.stopPropagation();
onOpenPartner(item.partner_id);
}}
>
{item.partner_name}
</button>
) : (
<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>
@@ -5958,7 +6143,7 @@ function ResourcesPage({
</div>
<div>
<h2>资源从合作与历史资产中持续沉淀</h2>
<p>发布链接会自动建档,已有KOC资源也可以使用标准模板批量导入。</p>
<p>发布链接会自动建档,已有KOC资源也可以使用标准模板批量导入。资源池中的 KOC 会默认加入合作方列表。</p>
</div>
<div className="resource-rule">
<span>唯一去重规则</span>
@@ -6692,6 +6877,7 @@ function DistributionDetailModal({
onFallback,
onUpdatePublishUrl,
onRelease,
onOpenPartner,
}: {
item: Distribution;
recoveryEnabled: boolean;
@@ -6704,6 +6890,7 @@ function DistributionDetailModal({
onFallback: (distribution: Distribution) => void;
onUpdatePublishUrl: (distribution: Distribution) => void;
onRelease: (distribution: Distribution) => void;
onOpenPartner?: (partnerId: string) => void;
}) {
const [previewGallery, setPreviewGallery] = useState<{
images: AdminPreviewImage[];
@@ -6750,7 +6937,21 @@ function DistributionDetailModal({
<div className="detail-meta">
<span>{item.task_name}</span>
<span>· {item.task_brand}</span>
<span>· 合作方 {item.partner_name}</span>
{onOpenPartner ? (
<span className="detail-meta-koc">
· 合作方{" "}
<button
type="button"
className="koc-link"
title="点击查看 KOC 详情"
onClick={() => onOpenPartner(item.partner_id)}
>
{item.partner_name}
</button>
</span>
) : (
<span>· 合作方 {item.partner_name}</span>
)}
{item.claimant_name && <span>· 领取人 {item.claimant_name}</span>}
</div>
</div>
@@ -6857,7 +7058,7 @@ function DistributionDetailModal({
<strong>
{item.task_collection_start_date && collectionDays.length > 0
? `${item.task_collection_start_date} 起 D${collectionDays.join("/D")}`
: "待设置"}
: "默认发布后7天"}
</strong>
</div>
</div>
@@ -7038,6 +7239,189 @@ function DistributionDetailModal({
);
}
function KocDetailModal({
partner,
account,
history,
onClose,
}: {
partner: Partner;
account: Account | null;
history: Distribution[];
onClose: () => void;
}) {
const isGroupOwner = partner.partner_type === "group_owner";
const publishedCount = history.filter((item) => item.publish_url).length;
const completedCount = history.filter(
(item) => item.exposure !== null && item.views !== null,
).length;
const taskCount = new Set(history.map((item) => item.task_id)).size;
return (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={onClose}
>
<div
className="modal-card distribution-detail-modal"
role="dialog"
aria-modal="true"
aria-label={`KOC详情:${partner.name}`}
onMouseDown={(event) => event.stopPropagation()}
>
{/* 头部:KOC 名称 + 类型徽章 + 概览 */}
<div className="distribution-detail-header">
<div className="modal-heading" style={{ marginBottom: 0 }}>
<div>
<p className="eyebrow">KOC 详情</p>
<h2>{partner.name}</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
<div className="detail-badges">
<span className="master-chip">{isGroupOwner ? "群主合作方" : "外部KOC"}</span>
<span className="status-pill published">累计领取 {partner.claimed_total}</span>
<span className="status-pill complete">累计完成 {partner.completed_total}</span>
</div>
<div className="detail-meta">
<span>{isGroupOwner ? partner.wecom_userid || "未填 userid" : partner.wecom_name || "未留企微名"}</span>
<span>· 负责人 {partner.owner || "—"}</span>
{account && <span>· 绑定账号 {account.nickname || partner.account_nickname}</span>}
</div>
</div>
<div className="detail-body">
<section className="detail-section">
<div className="detail-section-head">
<b>① 基本信息</b>
<span className="master-chip">{isGroupOwner ? "群主" : "外部KOC"}</span>
</div>
<div className="detail-section-body">
<div className="detail-kv-grid">
<div className="detail-kv"><span>合作方名称</span><strong>{partner.name}</strong></div>
<div className="detail-kv"><span>合作方类型</span><strong>{isGroupOwner ? "群主" : "外部KOC"}</strong></div>
<div className="detail-kv"><span>企业微信名</span><strong>{partner.wecom_name || "—"}</strong></div>
<div className="detail-kv"><span>企微 userid</span><strong>{partner.wecom_userid || "—"}</strong></div>
<div className="detail-kv"><span>负责人</span><strong>{partner.owner || "—"}</strong></div>
<div className="detail-kv"><span>外部联系人 ID</span><strong>{partner.wecom_external_user_id || "—"}</strong></div>
<div className="detail-kv"><span>累计领取</span><strong>{partner.claimed_total} 篇</strong></div>
<div className="detail-kv"><span>累计完成</span><strong>{partner.completed_total} 篇</strong></div>
</div>
</div>
</section>
<section className="detail-section">
<div className="detail-section-head">
<b>② 绑定 KOC 账号(资源)</b>
{account ? (
<span className="status-pill complete">已绑定</span>
) : (
<span className="status-pill claimed">未绑定</span>
)}
</div>
<div className="detail-section-body">
{account ? (
<div className="detail-kv-grid">
<div className="detail-kv"><span>账号昵称</span><strong>{account.nickname || "—"}</strong></div>
<div className="detail-kv"><span>平台</span><strong>{account.platform}</strong></div>
<div className="detail-kv"><span>账号ID</span><strong>{account.public_account_id || "—"}</strong></div>
<div className="detail-kv"><span>粉丝数</span><strong>{account.followers ? formatNumber(account.followers) : "—"}</strong></div>
<div className="detail-kv"><span>IP属地</span><strong>{account.ip_location || "—"}</strong></div>
<div className="detail-kv"><span>性别</span><strong>{account.gender || "—"}</strong></div>
<div className="detail-kv"><span>笔记数</span><strong>{account.post_count || "—"}</strong></div>
<div className="detail-kv"><span>平均观看</span><strong>{account.avg_views ? formatNumber(account.avg_views) : "—"}</strong></div>
<div className="detail-kv"><span>合作来源</span><strong>{account.cooperation_source || "—"}</strong></div>
<div className="detail-kv"><span>联系方式</span><strong>{account.current_contact || "—"}</strong></div>
<div className="detail-kv"><span>首次发现</span><strong>{account.first_seen_at ? formatDate(account.first_seen_at, true) : "—"}</strong></div>
<div className="detail-kv"><span>最近发现</span><strong>{account.last_seen_at ? formatDate(account.last_seen_at, true) : "—"}</strong></div>
{account.profile_url && (
<div className="detail-kv"><span>主页链接</span><a href={account.profile_url} target="_blank" rel="noopener noreferrer">打开主页 ↗</a></div>
)}
{account.tags && (
<div className="detail-kv detail-kv-wide"><span>标签</span><strong>{account.tags}</strong></div>
)}
{account.bio && (
<div className="detail-kv detail-kv-wide"><span>简介</span><strong>{account.bio}</strong></div>
)}
</div>
) : (
<span className="muted">
{isGroupOwner
? "群主合作方无需绑定 KOC 资源账号"
: "尚未绑定 KOC 资源账号,可在「合作方」页绑定后查看账号画像"}
</span>
)}
</div>
</section>
<section className="detail-section">
<div className="detail-section-head">
<b>③ 参与任务记录</b>
<span className="master-chip">
{history.length} 次领取 · {taskCount} 个任务 · 发布 {publishedCount}
</span>
</div>
<div className="detail-section-body">
{history.length === 0 ? (
<span className="muted">暂无领取记录</span>
) : (
<div className="koc-history-list">
{history.map((item) => {
const itemMetrics = latestPublicMetrics(item);
const itemHasMetrics = itemMetrics.likes !== null;
const itemCollectionStatus =
item.collection_status || (itemHasMetrics ? "success" : "pending");
return (
<div className="koc-history-row" key={item.id}>
<div className="koc-history-main">
<strong>{item.content_title}</strong>
<span>
{item.task_name} · {formatDate(item.claimed_at, true)}领取
{item.publish_url
? ` · ${formatDate(item.publish_time, true)}发布`
: " · 待发布"}
</span>
</div>
<div className="koc-history-side">
<span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span>
<span className={`collection-status ${itemCollectionStatus}`}>
{collectionStatusLabel(itemCollectionStatus, itemHasMetrics)}
</span>
{itemHasMetrics && (
<span className="koc-history-metrics">
赞 {formatNumber(itemMetrics.likes)} · 藏 {formatNumber(itemMetrics.collects)} · 评 {formatNumber(itemMetrics.comments)}
</span>
)}
{item.exposure !== null && item.views !== null && (
<span className="koc-history-metrics">
曝光 {formatNumber(item.exposure)} · 阅读 {formatNumber(item.views)}
</span>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</section>
{/* 漏斗概览:快速判断这个 KOC 的履约质量 */}
{history.length > 0 && (
<div className="koc-summary-strip">
<span>领取 <b>{history.length}</b></span>
<i>→</i>
<span>发布 <b>{publishedCount}</b></span>
<i>→</i>
<span>回收完成 <b>{completedCount}</b></span>
</div>
)}
</div>
</div>
</div>
);
}
function RecoveryPage({
tasks,
distributions,
@@ -7056,6 +7440,7 @@ function RecoveryPage({
onExportScreenshots,
exportingTaskId,
onOpenDetail,
onOpenPartner,
}: {
tasks: Task[];
distributions: Distribution[];
@@ -7078,6 +7463,7 @@ function RecoveryPage({
onExportScreenshots: (task: Task) => void;
exportingTaskId: string | null;
onOpenDetail: (distribution: Distribution) => void;
onOpenPartner: (partnerId: string) => void;
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [recoverySort, setRecoverySort] = useState<{
@@ -7089,8 +7475,8 @@ function RecoveryPage({
return (
<div className="stack">
<div className="section-intro">
<div><p className="eyebrow">按任务回收</p><h2>选择一个数据回收任务</h2></div>
<p>进入任务后设置自动采集日期,统一查看每篇笔记的最新互动数据。</p>
<div><p className="eyebrow">按任务回收</p><h2>选择一个任务进入回收队列</h2></div>
<p>进入任务后可设置自动采集日期;未设置时默认自每篇发布时间起连续采集 7 天。</p>
</div>
<TaskScopeList
tasks={tasks}
@@ -7293,7 +7679,21 @@ function RecoveryPage({
<strong>{item.content_title}</strong>
)}
<span className="platform-meta-line">
<span>{item.account_nickname || "待识别账号"}</span>
{item.partner_id ? (
<button
type="button"
className="koc-link"
title="点击查看 KOC 详情"
onClick={(event) => {
event.stopPropagation();
onOpenPartner(item.partner_id);
}}
>
{item.account_nickname || "待识别账号"}
</button>
) : (
<span>{item.account_nickname || "待识别账号"}</span>
)}
{item.account_platform && <PlatformBadge platform={item.account_platform} compact />}
</span>
</div>
@@ -7471,7 +7871,10 @@ function RecoveryPage({
</button>
</div>
</div>
<ScreenshotTaskTable distributions={taskDistributions} />
<ScreenshotTaskTable
distributions={taskDistributions}
onOpenPartner={onOpenPartner}
/>
</section>
</>
)}
@@ -7495,9 +7898,11 @@ function CollectionScheduleCard({
const [startDate, setStartDate] = useState(
task.collection_start_date || todayInputValue(),
);
const [days, setDays] = useState<number[]>(
parseCollectionDays(task.collection_days),
);
const [days, setDays] = useState<number[]>(() => {
const parsed = parseCollectionDays(task.collection_days);
// 未保存过计划时默认勾选 7 天,与服务端兜底逻辑保持一致。
return parsed.length > 0 ? parsed : [...DEFAULT_COLLECTION_DAYS];
});
const toggleDay = (day: number) => {
setDays((current) =>
@@ -7513,7 +7918,7 @@ function CollectionScheduleCard({
<div>
<p className="eyebrow">自动采集计划</p>
<h2>选择开始日期与采集日</h2>
<span>勾选第1天到第7天,所选日期均在北京时间09:00自动采集。</span>
<span>勾选第1天到第7天,所选日期均在北京时间09:00自动采集;未保存计划时默认连采 7 天。</span>
</div>
<div className="schedule-save-area">
<span>已选择 {days.length} 天</span>