feat: KOC详情弹窗 + 任务中心单入口改版 + 资源池默认合作方/采集兜底
KOC 详情弹窗(合作方/数据回收/工作台三处入口): - 新增 KocDetailModal:基本信息 / 绑定KOC账号画像 / 参与任务记录 / 履约漏斗 - 工作台最近分发、回收队列账号名、截图回收合作方名、合作方页名称、分发详情头部合作方均可点开 - 分发表行点击仍打开分发详情(stopPropagation 隔离) 任务中心单入口改版(对齐"单任务双阶段"模型): - 任务卡发布/回收面板整块可点,全卡仅保留一个「查看明细」,回收面板重复按钮移除 - 详情页自述身份:动态 label(任务详情·分发与回收一体/仅分发)+ 阶段徽章(含回收·截止/未开通回收) - 内容分发表新增阶段筛选 chips(全部/待发布/已发布待数据/待收截图,未开通回收不显示截图项),筛选落空给专用空态文案 - 数据回收页入口文案改为「选择一个任务进入回收队列」,全站不再区分发布/回收两种任务类型 资源池 KOC 默认加入合作方 + 采集兜底: - partners 新增 released_account_id(mysql/0023):显式解绑/被顶替的账号不再被默认合作方同步回填 - 任务未设置采集计划时,默认按发布时间起连续采集 7 天,等待文案显示「等待D1 · 默认7天」
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user