feat: 完善视频任务与 KOC 资源库
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
@@ -38,6 +39,8 @@ type Task = {
|
||||
due_at: string;
|
||||
status: string;
|
||||
task_type: "content_publish" | "screenshot_collect";
|
||||
platform: "小红书" | "抖音";
|
||||
content_format: "image_text" | "video";
|
||||
source_url: string;
|
||||
source_sheet_id: string;
|
||||
source_sheet_name: string;
|
||||
@@ -55,11 +58,16 @@ type Account = {
|
||||
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;
|
||||
};
|
||||
@@ -73,11 +81,16 @@ type ResourceImportPreview = {
|
||||
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 = {
|
||||
@@ -109,6 +122,7 @@ type Distribution = {
|
||||
latest_likes: number | null;
|
||||
latest_comments: number | null;
|
||||
latest_collects: number | null;
|
||||
latest_shares: number | null;
|
||||
collection_status: string;
|
||||
collection_status_description: string | null;
|
||||
collection_updated_at: string | null;
|
||||
@@ -116,11 +130,14 @@ type Distribution = {
|
||||
updated_at: string;
|
||||
content_title: string;
|
||||
partner_name: string;
|
||||
claimant_name: string | null;
|
||||
account_nickname: string | null;
|
||||
account_platform: string | null;
|
||||
task_name: string;
|
||||
task_brand: string;
|
||||
task_type: "content_publish" | "screenshot_collect";
|
||||
task_platform: "小红书" | "抖音";
|
||||
content_format: "image_text" | "video";
|
||||
due_at: string;
|
||||
};
|
||||
|
||||
@@ -128,6 +145,7 @@ type RecoverySortKey =
|
||||
| "publish_time"
|
||||
| "likes"
|
||||
| "collects"
|
||||
| "shares"
|
||||
| "comments"
|
||||
| "total"
|
||||
| "collection_updated_at";
|
||||
@@ -145,6 +163,8 @@ type FeishuPreview = {
|
||||
sheetName: string;
|
||||
syncedAt: string;
|
||||
rowCount: number;
|
||||
imageCount: number;
|
||||
videoCount: number;
|
||||
columns: string[];
|
||||
preview: Array<{
|
||||
sourceRow: number;
|
||||
@@ -200,6 +220,67 @@ function formatNumber(value: number | null | undefined) {
|
||||
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: "待发布",
|
||||
@@ -230,14 +311,16 @@ function latestPublicMetrics(distribution: Distribution) {
|
||||
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),
|
||||
: likes + (comments ?? 0) + (collects ?? 0) + shares,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -248,6 +331,7 @@ function recoverySortValue(
|
||||
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;
|
||||
|
||||
@@ -341,19 +425,21 @@ function addCalendarDays(value: string, days: number) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function xhsPublishUrl(value: string | null) {
|
||||
function publicPublishUrl(value: string | null) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const isXhsHost =
|
||||
const isSupportedHost =
|
||||
hostname === "xiaohongshu.com" ||
|
||||
hostname.endsWith(".xiaohongshu.com") ||
|
||||
hostname === "xhslink.cn" ||
|
||||
hostname.endsWith(".xhslink.cn") ||
|
||||
hostname === "xhslink.com" ||
|
||||
hostname.endsWith(".xhslink.com");
|
||||
return ["http:", "https:"].includes(url.protocol) && isXhsHost
|
||||
hostname.endsWith(".xhslink.com") ||
|
||||
hostname === "douyin.com" ||
|
||||
hostname.endsWith(".douyin.com");
|
||||
return ["http:", "https:"].includes(url.protocol) && isSupportedHost
|
||||
? url.toString()
|
||||
: null;
|
||||
} catch {
|
||||
@@ -415,6 +501,8 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
|
||||
const [taskForm, setTaskForm] = useState({
|
||||
taskType: "content_publish" as "content_publish" | "screenshot_collect",
|
||||
platform: "小红书" as "小红书" | "抖音",
|
||||
contentFormat: "image_text" as "image_text" | "video",
|
||||
name: "",
|
||||
brand: "",
|
||||
dueAt: "2026-08-12",
|
||||
@@ -527,6 +615,14 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
setToast("请先读取飞书表格");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
taskForm.taskType === "content_publish" &&
|
||||
taskForm.contentFormat === "video" &&
|
||||
(sourcePreview?.videoCount ?? 0) === 0
|
||||
) {
|
||||
setToast("当前飞书表格没有识别到视频附件");
|
||||
return;
|
||||
}
|
||||
let exampleImageKey = "";
|
||||
if (taskForm.taskType === "screenshot_collect" && taskExampleImage) {
|
||||
try {
|
||||
@@ -574,6 +670,8 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
setTaskExampleImage(null);
|
||||
setTaskForm({
|
||||
taskType: "content_publish",
|
||||
platform: "小红书",
|
||||
contentFormat: "image_text",
|
||||
name: "",
|
||||
brand: "",
|
||||
dueAt: "2026-08-12",
|
||||
@@ -610,7 +708,9 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
...current,
|
||||
name: current.name || result.sheetName.replace(/500篇$/, "分发任务"),
|
||||
}));
|
||||
setToast(`已读取 ${result.rowCount} 篇有效内容`);
|
||||
setToast(
|
||||
`已读取 ${result.rowCount} 条有效内容,识别到 ${result.videoCount} 个视频`,
|
||||
);
|
||||
} catch (reason) {
|
||||
setSourcePreview(null);
|
||||
setToast(reason instanceof Error ? reason.message : "读取飞书表格失败");
|
||||
@@ -668,6 +768,32 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
);
|
||||
};
|
||||
|
||||
const updateDistributionPublishUrl = async (
|
||||
distribution: Distribution,
|
||||
) => {
|
||||
const publishInput = window.prompt(
|
||||
distribution.publish_url
|
||||
? "更新小红书笔记链接(支持长链、短链或完整分享文案)"
|
||||
: "填写小红书笔记链接(支持长链、短链或完整分享文案)",
|
||||
distribution.publish_url || "",
|
||||
);
|
||||
if (publishInput === null) return;
|
||||
if (!publishInput.trim()) {
|
||||
setToast("请填写笔记链接");
|
||||
return;
|
||||
}
|
||||
await runAction(
|
||||
{
|
||||
action: "update_distribution_publish_url",
|
||||
distributionId: distribution.id,
|
||||
publishUrl: publishInput.trim(),
|
||||
},
|
||||
distribution.publish_url
|
||||
? "笔记链接已更新,旧公开采集数据已清空"
|
||||
: "笔记链接已补充,可开始数据采集",
|
||||
);
|
||||
};
|
||||
|
||||
const retryFailedMetrics = async (taskId: string, failedCount: number) => {
|
||||
if (failedCount === 0) {
|
||||
setToast("当前没有采集异常的笔记");
|
||||
@@ -997,7 +1123,9 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
tasks={data.tasks}
|
||||
distributions={data.distributions}
|
||||
working={working}
|
||||
canUpdatePublishUrl={isManager}
|
||||
onCollect={collectMetrics}
|
||||
onUpdatePublishUrl={updateDistributionPublishUrl}
|
||||
onSaveSchedule={saveCollectionSchedule}
|
||||
onUpload={uploadScreenshot}
|
||||
onFallback={openMetricFallback}
|
||||
@@ -1045,6 +1173,42 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
</div>
|
||||
{taskForm.taskType === "content_publish" ? (
|
||||
<>
|
||||
<div className="form-grid two">
|
||||
<label>
|
||||
<span className="platform-meta-line">
|
||||
<span>发布平台</span>
|
||||
<PlatformBadge platform={taskForm.platform} compact />
|
||||
</span>
|
||||
<select
|
||||
value={taskForm.platform}
|
||||
onChange={(event) =>
|
||||
setTaskForm({
|
||||
...taskForm,
|
||||
platform: event.target.value as "小红书" | "抖音",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="小红书">小红书</option>
|
||||
<option value="抖音">抖音</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>内容类型</span>
|
||||
<select
|
||||
value={taskForm.contentFormat}
|
||||
onChange={(event) => {
|
||||
setTaskForm({
|
||||
...taskForm,
|
||||
contentFormat: event.target.value as "image_text" | "video",
|
||||
});
|
||||
setSourcePreview(null);
|
||||
}}
|
||||
>
|
||||
<option value="image_text">图文</option>
|
||||
<option value="video">视频</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>飞书内容表链接</span>
|
||||
<div className="source-url-row">
|
||||
@@ -1066,7 +1230,11 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
{sourceWorking ? "读取中…" : "读取表格"}
|
||||
</button>
|
||||
</div>
|
||||
<small>自动读取“标题、正文/笔记内容、标签和全部配图”,空标题行不会进入任务。</small>
|
||||
<small>
|
||||
{taskForm.contentFormat === "video"
|
||||
? "自动读取标题、正文和视频附件;空标题或无视频的行不会进入任务。"
|
||||
: "自动读取标题、正文/笔记内容、标签和全部配图;空标题行不会进入任务。"}
|
||||
</small>
|
||||
</label>
|
||||
{sourcePreview && (
|
||||
<div className="source-preview">
|
||||
@@ -1078,7 +1246,11 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
<small>工作表 {sourcePreview.sheetId} · 已匹配 {sourcePreview.columns.length} 个字段</small>
|
||||
</div>
|
||||
</div>
|
||||
<b>{sourcePreview.rowCount} 篇</b>
|
||||
<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>)}
|
||||
@@ -1332,7 +1504,7 @@ function OverviewPage({
|
||||
<div>
|
||||
<p className="eyebrow">今天最需要推进</p>
|
||||
<h3>把已发布笔记的数据收回来</h3>
|
||||
<p>按任务选择采集日期,系统在当天09:00更新点赞、收藏、评论和总互动。</p>
|
||||
<p>按任务选择采集日期,系统在当天09:00更新点赞、收藏、评论、转发和总互动。</p>
|
||||
</div>
|
||||
<button className="primary-button soft" onClick={() => onNavigate("recovery")}>进入数据回收</button>
|
||||
</div>
|
||||
@@ -1349,7 +1521,14 @@ function OverviewPage({
|
||||
<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>{task.brand}</span></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>
|
||||
@@ -1409,12 +1588,16 @@ function TasksPage({
|
||||
<div className={`avatar large ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
|
||||
<div className="task-row-main">
|
||||
<div><h3>{task.name}</h3><span>{task.brand}</span></div>
|
||||
<small>
|
||||
{task.task_type === "screenshot_collect"
|
||||
? "截图回收 · 关键词任务"
|
||||
: task.source_sheet_name
|
||||
? `飞书 · ${task.source_sheet_name}`
|
||||
: "历史示例内容表"}
|
||||
<small className="platform-meta-line">
|
||||
{task.task_type === "screenshot_collect" ? (
|
||||
<span>截图回收 · 关键词任务</span>
|
||||
) : (
|
||||
<>
|
||||
<PlatformBadge platform={task.platform} compact />
|
||||
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
|
||||
<span>{task.source_sheet_name ? `飞书 ${task.source_sheet_name}` : "历史示例内容表"}</span>
|
||||
</>
|
||||
)}
|
||||
</small>
|
||||
<div className="progress"><span style={{ width: `${progress}%` }} /></div>
|
||||
</div>
|
||||
@@ -1489,7 +1672,13 @@ function TaskScopeList({
|
||||
</div>
|
||||
<div>
|
||||
<h3>{task.name}</h3>
|
||||
<span>{task.brand} · {formatDate(task.due_at)} 截止</span>
|
||||
<div className="task-scope-subline">
|
||||
<span>{task.brand} · {formatDate(task.due_at)} 截止</span>
|
||||
<PlatformBadge platform={task.platform} compact />
|
||||
<span className={`content-format-badge ${isScreenshotTask ? "screenshot" : task.content_format === "video" ? "video" : "image-text"}`}>
|
||||
{isScreenshotTask ? "截图回收" : task.content_format === "video" ? "视频" : "图文"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`status-pill ${task.status}`}>{statusLabel(task.status)}</span>
|
||||
</div>
|
||||
@@ -1560,7 +1749,19 @@ function TaskDetailHeader({
|
||||
<div>
|
||||
<p>{label}</p>
|
||||
<h2>{task.name}</h2>
|
||||
<span>{task.brand} · {task.quantity} {task.task_type === "screenshot_collect" ? "份" : "篇"} · {formatDate(task.due_at)} 截止</span>
|
||||
<span className="platform-meta-line">
|
||||
<span>{task.brand}</span>
|
||||
{task.task_type === "screenshot_collect" ? (
|
||||
<span>截图回收</span>
|
||||
) : (
|
||||
<>
|
||||
<PlatformBadge platform={task.platform} compact />
|
||||
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
|
||||
</>
|
||||
)}
|
||||
<span>{task.quantity} {task.task_type === "screenshot_collect" ? "份" : "篇"}</span>
|
||||
<span>{formatDate(task.due_at)} 截止</span>
|
||||
</span>
|
||||
</div>
|
||||
{task.task_type !== "screenshot_collect" && task.source_url && (
|
||||
<a href={task.source_url} target="_blank" rel="noreferrer">
|
||||
@@ -1585,6 +1786,74 @@ function DistributionPage({
|
||||
onRelease: (distribution: Distribution) => Promise<boolean>;
|
||||
}) {
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [taskQuery, setTaskQuery] = useState("");
|
||||
const [brandFilter, setBrandFilter] = useState("");
|
||||
const [contentTypeFilter, setContentTypeFilter] = useState("");
|
||||
const [platformFilter, setPlatformFilter] = useState("");
|
||||
const [openTaskFilter, setOpenTaskFilter] = useState<"brand" | "content" | "platform" | null>(null);
|
||||
const taskFiltersRef = useRef<HTMLDivElement | null>(null);
|
||||
const brandOptions = useMemo(
|
||||
() => Array.from(new Set(tasks.map((task) => task.brand).filter(Boolean))).sort((a, b) => a.localeCompare(b, "zh-CN")),
|
||||
[tasks],
|
||||
);
|
||||
const brandFilterOptions = useMemo(
|
||||
() => [{ value: "", label: "全部品牌/项目" }, ...brandOptions.map((brand) => ({ value: brand, label: brand }))],
|
||||
[brandOptions],
|
||||
);
|
||||
const contentTypeOptions = [
|
||||
{ value: "", label: "全部内容类型" },
|
||||
{ value: "图文", label: "图文" },
|
||||
{ value: "视频", label: "视频" },
|
||||
{ value: "截图回收", label: "截图回收" },
|
||||
];
|
||||
const platformOptions = [
|
||||
{ value: "", label: "全部平台" },
|
||||
{ value: "小红书", label: "小红书" },
|
||||
{ value: "抖音", label: "抖音" },
|
||||
];
|
||||
const taskFilterControls = [
|
||||
{ key: "brand" as const, label: "品牌/项目", value: brandFilter, options: brandFilterOptions },
|
||||
{ key: "content" as const, label: "内容类型", value: contentTypeFilter, options: contentTypeOptions },
|
||||
{ key: "platform" as const, label: "平台", value: platformFilter, options: platformOptions },
|
||||
];
|
||||
useEffect(() => {
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
if (!taskFiltersRef.current?.contains(event.target as Node)) {
|
||||
setOpenTaskFilter(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", closeOnOutsideClick);
|
||||
return () => document.removeEventListener("mousedown", closeOnOutsideClick);
|
||||
}, []);
|
||||
const setTaskFilterValue = (key: "brand" | "content" | "platform", value: string) => {
|
||||
if (key === "brand") setBrandFilter(value);
|
||||
if (key === "content") setContentTypeFilter(value);
|
||||
if (key === "platform") setPlatformFilter(value);
|
||||
};
|
||||
const selectTaskFilter = (key: "brand" | "content" | "platform", value: string) => {
|
||||
setTaskFilterValue(key, value);
|
||||
setOpenTaskFilter(null);
|
||||
};
|
||||
const filteredTasks = useMemo(() => {
|
||||
const query = taskQuery.trim().toLocaleLowerCase("zh-CN");
|
||||
const brandQuery = brandFilter.trim().toLocaleLowerCase("zh-CN");
|
||||
const contentTypeQuery = contentTypeFilter.trim().toLocaleLowerCase("zh-CN");
|
||||
const platformQuery = platformFilter.trim().toLocaleLowerCase("zh-CN");
|
||||
return tasks.filter((task) => {
|
||||
const taskContentType = task.task_type === "screenshot_collect"
|
||||
? "截图回收"
|
||||
: task.content_format === "video" ? "视频" : "图文";
|
||||
return (
|
||||
(!query || task.name.toLocaleLowerCase("zh-CN").includes(query)) &&
|
||||
(!brandQuery || task.brand.toLocaleLowerCase("zh-CN").includes(brandQuery)) &&
|
||||
(!contentTypeQuery || taskContentType.toLocaleLowerCase("zh-CN").includes(contentTypeQuery)) &&
|
||||
(!platformQuery || task.platform.toLocaleLowerCase("zh-CN").includes(platformQuery))
|
||||
);
|
||||
});
|
||||
}, [brandFilter, contentTypeFilter, platformFilter, taskQuery, tasks]);
|
||||
const hasTaskFilters = Boolean(
|
||||
taskQuery.trim() || brandFilter.trim() || contentTypeFilter.trim() || platformFilter.trim(),
|
||||
);
|
||||
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
|
||||
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
|
||||
if (!selectedTask) {
|
||||
@@ -1594,12 +1863,110 @@ function DistributionPage({
|
||||
<div><p className="eyebrow">按任务管理</p><h2>选择一个分发任务</h2></div>
|
||||
<p>每个任务独立查看领取、发布和账号识别进度,不混排不同项目的笔记。</p>
|
||||
</div>
|
||||
<TaskScopeList
|
||||
tasks={tasks}
|
||||
distributions={distributions}
|
||||
mode="distribution"
|
||||
onOpen={setSelectedTaskId}
|
||||
/>
|
||||
<section className="distribution-task-toolbar" aria-label="筛选分发任务">
|
||||
<label className="distribution-task-search">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input
|
||||
type="search"
|
||||
value={taskQuery}
|
||||
onChange={(event) => setTaskQuery(event.target.value)}
|
||||
placeholder="搜索任务名称"
|
||||
aria-label="按任务名称模糊搜索"
|
||||
/>
|
||||
</label>
|
||||
<div className="distribution-task-filter-group" ref={taskFiltersRef}>
|
||||
{taskFilterControls.map((filter) => {
|
||||
const normalizedFilterValue = filter.value.trim().toLocaleLowerCase("zh-CN");
|
||||
const visibleOptions = filter.options.filter((option) => (
|
||||
!normalizedFilterValue ||
|
||||
option.label.toLocaleLowerCase("zh-CN").includes(normalizedFilterValue)
|
||||
));
|
||||
return (
|
||||
<div
|
||||
className={`distribution-task-filter-combobox ${filter.key === "brand" ? "brand" : ""}`}
|
||||
key={filter.key}
|
||||
>
|
||||
<div className="distribution-task-filter-input">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input
|
||||
type="search"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={openTaskFilter === filter.key}
|
||||
aria-controls={`distribution-task-${filter.key}-menu`}
|
||||
value={filter.value}
|
||||
placeholder={`搜索${filter.label}`}
|
||||
aria-label={`模糊搜索${filter.label}`}
|
||||
onFocus={() => setOpenTaskFilter(filter.key)}
|
||||
onChange={(event) => {
|
||||
setTaskFilterValue(filter.key, event.target.value);
|
||||
setOpenTaskFilter(filter.key);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") setOpenTaskFilter(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{openTaskFilter === filter.key ? (
|
||||
<div
|
||||
id={`distribution-task-${filter.key}-menu`}
|
||||
className="distribution-task-filter-menu"
|
||||
role="listbox"
|
||||
aria-label={`选择${filter.label}`}
|
||||
>
|
||||
<div className="distribution-task-filter-menu-options">
|
||||
{visibleOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={filter.value === option.value}
|
||||
className={filter.value === option.value ? "selected" : ""}
|
||||
onClick={() => selectTaskFilter(filter.key, option.value)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{filter.value === option.value ? <small>✓</small> : null}
|
||||
</button>
|
||||
))}
|
||||
{visibleOptions.length === 0 ? (
|
||||
<span className="distribution-task-filter-empty">没有匹配项</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="distribution-task-clear"
|
||||
disabled={!hasTaskFilters}
|
||||
onClick={() => {
|
||||
setTaskQuery("");
|
||||
setBrandFilter("");
|
||||
setContentTypeFilter("");
|
||||
setPlatformFilter("");
|
||||
setOpenTaskFilter(null);
|
||||
}}
|
||||
>
|
||||
清空筛选
|
||||
</button>
|
||||
<span className="distribution-task-count">共 <b>{filteredTasks.length}</b> 个任务</span>
|
||||
</section>
|
||||
{filteredTasks.length > 0 ? (
|
||||
<TaskScopeList
|
||||
tasks={filteredTasks}
|
||||
distributions={distributions}
|
||||
mode="distribution"
|
||||
onOpen={setSelectedTaskId}
|
||||
/>
|
||||
) : (
|
||||
<section className="distribution-task-empty">
|
||||
<strong>没有符合条件的任务</strong>
|
||||
<span>可以调整任务名称或清空筛选条件</span>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1737,7 +2104,7 @@ function DistributionTable({
|
||||
{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><span>{item.account_platform}</span></div>
|
||||
<div><strong>{item.account_nickname}</strong><PlatformBadge platform={item.account_platform} compact /></div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="muted">回填链接后识别</span>
|
||||
@@ -1984,7 +2351,13 @@ function ResourcesPage({
|
||||
sources: [
|
||||
...new Set(
|
||||
[
|
||||
...deliveries.map((item) => item.partner_name),
|
||||
...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()),
|
||||
@@ -1998,9 +2371,13 @@ function ResourcesPage({
|
||||
});
|
||||
}, [accounts, distributions]);
|
||||
const platformOptions = useMemo(
|
||||
() =>
|
||||
[...new Set(accounts.map((account) => account.platform).filter(Boolean))]
|
||||
() => [
|
||||
"小红书",
|
||||
"抖音",
|
||||
...[...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(
|
||||
@@ -2028,7 +2405,7 @@ function ResourcesPage({
|
||||
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}`.toLocaleLowerCase(
|
||||
const searchable = `${account.nickname} ${account.public_account_id} ${account.current_contact || ""} ${account.bio || ""} ${account.tags || ""}`.toLocaleLowerCase(
|
||||
"zh-CN",
|
||||
);
|
||||
const ipLocation = (account.ip_location || "待识别").toLocaleLowerCase(
|
||||
@@ -2135,7 +2512,7 @@ function ResourcesPage({
|
||||
key={platform}
|
||||
onClick={() => setPlatformFilter(platform)}
|
||||
>
|
||||
{platform}
|
||||
<PlatformBadge platform={platform} compact />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -2145,9 +2522,9 @@ function ResourcesPage({
|
||||
<div className="resource-search">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input
|
||||
aria-label="搜索账号名称或账号ID"
|
||||
aria-label="搜索账号名称、账号ID或当前联系人"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索账号名称 / 账号ID"
|
||||
placeholder="搜索账号名称 / 账号ID / 当前联系人 / 标签"
|
||||
type="search"
|
||||
value={query}
|
||||
/>
|
||||
@@ -2203,36 +2580,86 @@ function ResourcesPage({
|
||||
</div>
|
||||
</div>
|
||||
<div className="resource-grid">
|
||||
{filteredAccounts.map(({ account, sources, partnerManagedOnly }) => (
|
||||
<article className="resource-card" key={account.id}>
|
||||
{filteredAccounts.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 ${avatarColor(account.nickname)}`}>{account.nickname.slice(0, 1)}</div>
|
||||
<div><h3>{account.nickname}</h3><span>{account.platform} · {account.ip_location}</span></div>
|
||||
<span className="verified-dot">✓</span>
|
||||
</div>
|
||||
<div className="resource-account-id">
|
||||
<span>{account.platform === "小红书" ? "小红书号" : account.platform === "抖音" ? "抖音号" : "账号"}</span>
|
||||
<strong>{account.public_account_id || "待识别"}</strong>
|
||||
</div>
|
||||
<div className="resource-metrics">
|
||||
<div><span>粉丝</span><strong>{formatNumber(account.followers)}</strong></div>
|
||||
<div><span>合作发布</span><strong>{account.post_count}</strong></div>
|
||||
</div>
|
||||
<div className="resource-source">
|
||||
<span>历史合作来源</span>
|
||||
<div>
|
||||
{sources.map((source) => <b key={source}>{source}</b>)}
|
||||
{partnerManagedOnly && (
|
||||
<b className="partner-managed">合作社资源 · 不可直联</b>
|
||||
)}
|
||||
<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>
|
||||
</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">
|
||||
<span>最近合作 {formatDate(account.last_seen_at)}</span>
|
||||
{account.profile_url && <a href={account.profile_url} target="_blank" rel="noreferrer">查看主页 ↗</a>}
|
||||
<div className="resource-source">
|
||||
<span>合作来源</span>
|
||||
<div>
|
||||
{sources.map((source) => <b key={source}>{source}</b>)}
|
||||
{partnerManagedOnly && (
|
||||
<b className="partner-managed">合作社资源 · 不可直联</b>
|
||||
)}
|
||||
{sources.length === 0 && !partnerManagedOnly && <b className="empty">暂无</b>}
|
||||
</div>
|
||||
</div>
|
||||
{profileLink && <a href={profileLink.href} target="_blank" rel="noreferrer">{profileLink.label}</a>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{filteredAccounts.length === 0 && (
|
||||
<div className="resource-empty">
|
||||
@@ -2278,11 +2705,11 @@ function ResourcesPage({
|
||||
}}
|
||||
/>
|
||||
<strong>{importFile ? importFile.name : "选择 Excel / CSV 文件"}</strong>
|
||||
<span>单次最多 100 个账号,文件不超过 5MB</span>
|
||||
<span>单次最多 10,000 个账号,文件不超过 20MB</span>
|
||||
</label>
|
||||
{!importPreview && (
|
||||
<div className="import-template-note">
|
||||
<div><strong>还没有模板?</strong><span>只有账号链接必填;已有昵称、账号ID、IP属地、粉丝数和合作来源可直接填写,系统只补全空缺字段。</span></div>
|
||||
<div><strong>还没有模板?</strong><span>只有账号链接必填;性别、简介、标签等资料均可选填,多个标签请用逗号分隔且最多 5 个,系统只补全空缺字段。</span></div>
|
||||
<a className="ghost-button" download href="/KOC资源导入模板.xlsx">下载模板</a>
|
||||
</div>
|
||||
)}
|
||||
@@ -2301,7 +2728,7 @@ function ResourcesPage({
|
||||
{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>{row.platform || "未填写平台"}</small></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}`}>
|
||||
@@ -2311,7 +2738,17 @@ function ResourcesPage({
|
||||
))}
|
||||
</div>
|
||||
{(importPreview.summary.total > 8 || importPreview.truncated) && (
|
||||
<p className="import-preview-more">这里只预览前 8 行,确认导入时会处理全部数据。</p>
|
||||
<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>
|
||||
)}
|
||||
@@ -2331,7 +2768,7 @@ function ResourcesPage({
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
disabled={importPreview.summary.error > 0 || importWorking}
|
||||
disabled={importPreview.summary.create + importPreview.summary.update === 0 || importWorking}
|
||||
onClick={() => void submitImport("commit")}
|
||||
>
|
||||
{importWorking ? "导入中…" : `确认导入 ${importPreview.summary.create + importPreview.summary.update} 个账号`}
|
||||
@@ -2378,7 +2815,9 @@ function RecoveryPage({
|
||||
tasks,
|
||||
distributions,
|
||||
working,
|
||||
canUpdatePublishUrl,
|
||||
onCollect,
|
||||
onUpdatePublishUrl,
|
||||
onSaveSchedule,
|
||||
onUpload,
|
||||
onFallback,
|
||||
@@ -2390,7 +2829,9 @@ function RecoveryPage({
|
||||
tasks: Task[];
|
||||
distributions: Distribution[];
|
||||
working: boolean;
|
||||
canUpdatePublishUrl: boolean;
|
||||
onCollect: (distribution: Distribution) => void;
|
||||
onUpdatePublishUrl: (distribution: Distribution) => void;
|
||||
onSaveSchedule: (
|
||||
taskId: string,
|
||||
startDate: string,
|
||||
@@ -2504,7 +2945,7 @@ function RecoveryPage({
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>数据回收队列</h2>
|
||||
<p>{taskDistributions.length} 篇已发布笔记 · 展示最近一次采集结果</p>
|
||||
<p>{taskDistributions.length} 篇已发布作品 · 展示最近一次采集结果</p>
|
||||
</div>
|
||||
<div className="recovery-panel-actions">
|
||||
<button
|
||||
@@ -2554,6 +2995,15 @@ function RecoveryPage({
|
||||
direction={recoverySort.direction}
|
||||
onSort={changeRecoverySort}
|
||||
/>
|
||||
{selectedTask.platform === "抖音" && (
|
||||
<SortableRecoveryHeader
|
||||
label="转发"
|
||||
column="shares"
|
||||
activeColumn={recoverySort.column}
|
||||
direction={recoverySort.direction}
|
||||
onSort={changeRecoverySort}
|
||||
/>
|
||||
)}
|
||||
<SortableRecoveryHeader
|
||||
label="评论"
|
||||
column="comments"
|
||||
@@ -2583,7 +3033,7 @@ function RecoveryPage({
|
||||
<tbody>
|
||||
{sortedTaskDistributions.map((item) => {
|
||||
const metrics = latestPublicMetrics(item);
|
||||
const noteUrl = xhsPublishUrl(item.publish_url);
|
||||
const noteUrl = publicPublishUrl(item.publish_url);
|
||||
const hasMetrics = metrics.likes !== null;
|
||||
const collectionStatus =
|
||||
item.collection_status ||
|
||||
@@ -2600,17 +3050,17 @@ function RecoveryPage({
|
||||
href={noteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`打开小红书笔记:${item.content_title}`}
|
||||
title="打开小红书笔记"
|
||||
aria-label={`打开${selectedTask.platform}作品:${item.content_title}`}
|
||||
title={`打开${selectedTask.platform}作品`}
|
||||
>
|
||||
<strong>{item.content_title}</strong>
|
||||
</a>
|
||||
) : (
|
||||
<strong>{item.content_title}</strong>
|
||||
)}
|
||||
<span>
|
||||
{item.account_nickname || "待识别账号"}
|
||||
{item.account_platform ? ` · ${item.account_platform}` : ""}
|
||||
<span className="platform-meta-line">
|
||||
<span>{item.account_nickname || "待识别账号"}</span>
|
||||
{item.account_platform && <PlatformBadge platform={item.account_platform} compact />}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2622,11 +3072,14 @@ function RecoveryPage({
|
||||
</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>赞 + 藏 + 评</span>
|
||||
<span>{selectedTask.platform === "抖音" ? "赞 + 藏 + 转 + 评" : "赞 + 藏 + 评"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -2680,17 +3133,28 @@ function RecoveryPage({
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{item.screenshot_key && !hasCreatorMetrics(item) ? (
|
||||
<button className="text-button" onClick={() => onFallback(item)}>手动填写</button>
|
||||
) : (
|
||||
<button
|
||||
className="collect-button"
|
||||
disabled={working}
|
||||
onClick={() => onCollect(item)}
|
||||
>
|
||||
立即采集
|
||||
</button>
|
||||
)}
|
||||
<div className="recovery-row-actions">
|
||||
{item.screenshot_key && !hasCreatorMetrics(item) ? (
|
||||
<button className="text-button" onClick={() => onFallback(item)}>手动填写</button>
|
||||
) : (
|
||||
<button
|
||||
className="collect-button"
|
||||
disabled={working || !noteUrl}
|
||||
onClick={() => onCollect(item)}
|
||||
>
|
||||
{noteUrl ? "立即采集" : "待填链接"}
|
||||
</button>
|
||||
)}
|
||||
{canUpdatePublishUrl && (
|
||||
<button
|
||||
className="publish-url-edit-button"
|
||||
disabled={working}
|
||||
onClick={() => onUpdatePublishUrl(item)}
|
||||
>
|
||||
{noteUrl ? "更新链接" : "填写链接"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user