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>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,10 @@ import { getRuntimeEnv } from "../../../lib/runtime-env";
|
||||
import { runInBackground } from "../../../lib/background";
|
||||
|
||||
const env = getRuntimeEnv();
|
||||
import { backfillAccountProfiles } from "../../../lib/account-enrichment-service";
|
||||
import {
|
||||
backfillAccountProfiles,
|
||||
enrichDistributionAccount,
|
||||
} from "../../../lib/account-enrichment-service";
|
||||
import {
|
||||
ensureSchema,
|
||||
getDashboardData,
|
||||
@@ -35,6 +38,7 @@ import {
|
||||
releaseUnfinishedDistribution,
|
||||
} from "../../../lib/distribution-release-service";
|
||||
import { isManagerRequest } from "../../../lib/user-auth";
|
||||
import { extractPublishUrl } from "../../../lib/publish-url";
|
||||
|
||||
type ActionBody = {
|
||||
action?: string;
|
||||
@@ -63,6 +67,14 @@ export async function POST(request: Request) {
|
||||
sheetName: source.sheetName,
|
||||
syncedAt: source.syncedAt,
|
||||
rowCount: source.rows.length,
|
||||
imageCount: source.rows.reduce(
|
||||
(total, row) => total + row.images.length,
|
||||
0,
|
||||
),
|
||||
videoCount: source.rows.reduce(
|
||||
(total, row) => total + row.videos.length,
|
||||
0,
|
||||
),
|
||||
columns: source.columns,
|
||||
preview: source.rows.slice(0, 3),
|
||||
});
|
||||
@@ -72,6 +84,8 @@ export async function POST(request: Request) {
|
||||
const name = String(body.name ?? "").trim();
|
||||
const brand = String(body.brand ?? "").trim();
|
||||
const dueAt = String(body.dueAt ?? "").trim();
|
||||
const platform = body.platform === "抖音" ? "抖音" : "小红书";
|
||||
const contentFormat = body.contentFormat === "video" ? "video" : "image_text";
|
||||
if (!name || !brand || !dueAt) {
|
||||
return Response.json(
|
||||
{ error: "请补全任务名称、品牌和截止日期" },
|
||||
@@ -84,6 +98,8 @@ export async function POST(request: Request) {
|
||||
name,
|
||||
brand,
|
||||
dueAt,
|
||||
platform,
|
||||
contentFormat,
|
||||
},
|
||||
env as unknown as FeishuBindings,
|
||||
);
|
||||
@@ -146,6 +162,159 @@ export async function POST(request: Request) {
|
||||
db,
|
||||
String(body.distributionId ?? "").trim(),
|
||||
);
|
||||
} else if (body.action === "update_distribution_publish_url") {
|
||||
if (!(await isManagerRequest(request))) return adminForbidden();
|
||||
const distributionId = String(body.distributionId ?? "").trim();
|
||||
if (!distributionId) {
|
||||
return Response.json(
|
||||
{ error: "作品记录不存在" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const current = await db
|
||||
.prepare(
|
||||
`SELECT d.id, d.task_id, d.partner_id, d.publish_url,
|
||||
t.task_type, t.platform, t.collection_start_date, t.collection_days,
|
||||
COALESCE(a.nickname, '待识别账号') AS account_nickname
|
||||
FROM distributions d
|
||||
JOIN tasks t ON t.id = d.task_id
|
||||
LEFT JOIN accounts a ON a.id = d.account_id
|
||||
WHERE d.id = ?`,
|
||||
)
|
||||
.bind(distributionId)
|
||||
.first<{
|
||||
id: string;
|
||||
task_id: string;
|
||||
partner_id: string;
|
||||
publish_url: string | null;
|
||||
task_type?: string | null;
|
||||
platform: string;
|
||||
collection_start_date: string | null;
|
||||
collection_days: string;
|
||||
account_nickname: string;
|
||||
}>();
|
||||
if (!current) {
|
||||
return Response.json({ error: "作品记录不存在" }, { status: 404 });
|
||||
}
|
||||
if (current.task_type === "screenshot_collect") {
|
||||
return Response.json(
|
||||
{ error: "截图回收任务不需要填写发布链接" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const platform = current.platform === "抖音" ? "抖音" : "小红书";
|
||||
const publishUrl = extractPublishUrl(
|
||||
String(body.publishUrl ?? "").trim(),
|
||||
platform,
|
||||
);
|
||||
if (!publishUrl) {
|
||||
return Response.json(
|
||||
{ error: `请填写包含${platform}作品链接的发布内容` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (current.publish_url === publishUrl) {
|
||||
return Response.json(await getDashboardData());
|
||||
}
|
||||
let collectionDays: number[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(current.collection_days || "[]");
|
||||
if (Array.isArray(parsed)) {
|
||||
collectionDays = [...new Set(parsed.map(Number))]
|
||||
.filter(
|
||||
(day) =>
|
||||
Number.isInteger(day) && day >= 1 && day <= 7,
|
||||
)
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
} catch {
|
||||
collectionDays = [];
|
||||
}
|
||||
const isScheduled = Boolean(
|
||||
current.collection_start_date && collectionDays.length > 0,
|
||||
);
|
||||
const statements = [
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE distributions SET
|
||||
publish_url = ?,
|
||||
publish_time = CURRENT_TIMESTAMP,
|
||||
status = 'published',
|
||||
d2_likes = NULL,
|
||||
d2_comments = NULL,
|
||||
d2_collects = NULL,
|
||||
d5_likes = NULL,
|
||||
d5_comments = NULL,
|
||||
d5_collects = NULL,
|
||||
d7_likes = NULL,
|
||||
d7_comments = NULL,
|
||||
d7_collects = NULL,
|
||||
latest_likes = NULL,
|
||||
latest_comments = NULL,
|
||||
latest_collects = NULL,
|
||||
latest_shares = NULL,
|
||||
collection_status = ?,
|
||||
collection_status_description = ?,
|
||||
collection_updated_at = NULL,
|
||||
last_collection_day = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(
|
||||
publishUrl,
|
||||
isScheduled ? "scheduled" : "pending",
|
||||
isScheduled
|
||||
? `管理员已更新链接,等待${collectionDays.length}个采集日`
|
||||
: "管理员已更新链接,等待设置采集计划",
|
||||
distributionId,
|
||||
),
|
||||
db
|
||||
.prepare("DELETE FROM collection_runs WHERE distribution_id = ?")
|
||||
.bind(distributionId),
|
||||
];
|
||||
if (!current.publish_url) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE partners SET completed_total = completed_total + 1
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(current.partner_id),
|
||||
);
|
||||
}
|
||||
await db.batch(statements);
|
||||
if (isScheduled && current.collection_start_date) {
|
||||
await createCollectionRunTasks(
|
||||
db,
|
||||
current.task_id,
|
||||
current.collection_start_date,
|
||||
collectionDays,
|
||||
);
|
||||
runInBackground(
|
||||
runDueScheduledCollections(
|
||||
db,
|
||||
Date.now(),
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
"catchup",
|
||||
current.task_id,
|
||||
).catch(() => undefined),
|
||||
"collection catchup after publish URL update",
|
||||
);
|
||||
}
|
||||
runInBackground(
|
||||
enrichDistributionAccount(
|
||||
db,
|
||||
distributionId,
|
||||
publishUrl,
|
||||
current.account_nickname,
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
).catch(() => undefined),
|
||||
"account enrichment after publish URL update",
|
||||
);
|
||||
} else if (body.action === "save_collection_schedule") {
|
||||
const taskId = String(body.taskId ?? "").trim();
|
||||
const startDate = String(body.startDate ?? "").trim();
|
||||
|
||||
@@ -32,6 +32,8 @@ const toolOutputSchema = z.object({
|
||||
due_date: z.string(),
|
||||
sheet_name: z.string(),
|
||||
note_count: z.number().int().nonnegative(),
|
||||
platform: z.enum(["小红书", "抖音"]),
|
||||
content_format: z.enum(["image_text", "video"]),
|
||||
claim_url: z.string().url(),
|
||||
});
|
||||
|
||||
@@ -57,7 +59,7 @@ function createServer(context: McpRequestContext) {
|
||||
{
|
||||
title: "创建 KOC 分发任务",
|
||||
description:
|
||||
"读取飞书电子表格中的标题、正文和配图,在 KOC LOOP 创建分发任务,并返回可直接发给 KOC 的领取链接。飞书表格若有多个工作表,链接必须包含目标 sheet 参数。",
|
||||
"读取飞书电子表格中的标题、正文及图片或视频,在 KOC LOOP 创建小红书/抖音分发任务,并返回可直接发给 KOC 的领取链接。飞书表格若有多个工作表,链接必须包含目标 sheet 参数。",
|
||||
inputSchema: z.object({
|
||||
feishu_url: z
|
||||
.string()
|
||||
@@ -74,6 +76,14 @@ function createServer(context: McpRequestContext) {
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe("品牌或项目名称;未提供时系统记录为“未设置项目”"),
|
||||
platform: z
|
||||
.enum(["小红书", "抖音"])
|
||||
.optional()
|
||||
.describe("发布平台,默认小红书"),
|
||||
content_format: z
|
||||
.enum(["image_text", "video"])
|
||||
.optional()
|
||||
.describe("内容形式:image_text 图文,video 视频;默认图文"),
|
||||
}),
|
||||
outputSchema: toolOutputSchema,
|
||||
annotations: {
|
||||
@@ -83,7 +93,7 @@ function createServer(context: McpRequestContext) {
|
||||
openWorldHint: true,
|
||||
},
|
||||
},
|
||||
async ({ feishu_url, task_name, due_date, brand_project }) => {
|
||||
async ({ feishu_url, task_name, due_date, brand_project, platform, content_format }) => {
|
||||
try {
|
||||
const bindings = getBindings();
|
||||
const portalUrl = String(bindings.KOC_PORTAL_URL ?? "").trim();
|
||||
@@ -96,6 +106,8 @@ function createServer(context: McpRequestContext) {
|
||||
name: task_name,
|
||||
brand: brand_project?.trim() || "未设置项目",
|
||||
dueAt: due_date,
|
||||
platform: platform ?? "小红书",
|
||||
contentFormat: content_format ?? "image_text",
|
||||
},
|
||||
bindings,
|
||||
{ deduplicate: true },
|
||||
@@ -108,6 +120,8 @@ function createServer(context: McpRequestContext) {
|
||||
due_date: result.dueAt,
|
||||
sheet_name: result.sheetName,
|
||||
note_count: result.noteCount,
|
||||
platform: result.platform,
|
||||
content_format: result.contentFormat,
|
||||
claim_url: buildClaimUrl(portalUrl, result.shareToken),
|
||||
};
|
||||
const actionText = result.created ? "已创建" : "已找到相同任务";
|
||||
@@ -115,7 +129,7 @@ function createServer(context: McpRequestContext) {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${actionText}“${result.name}”,共 ${result.noteCount} 篇笔记。领取链接:${output.claim_url}`,
|
||||
text: `${actionText}“${result.name}”,平台:${result.platform},内容形式:${result.contentFormat === "video" ? "视频" : "图文"},共 ${result.noteCount} 篇。领取链接:${output.claim_url}`,
|
||||
},
|
||||
],
|
||||
structuredContent: output,
|
||||
|
||||
750
app/api/partner-batch-workbook/route.ts
Normal file
750
app/api/partner-batch-workbook/route.ts
Normal file
@@ -0,0 +1,750 @@
|
||||
import { getRuntimeEnv } from "../../../lib/runtime-env";
|
||||
import { runInBackground } from "../../../lib/background";
|
||||
import type { DatabaseStatement } from "../../../lib/database";
|
||||
import {
|
||||
downloadFeishuMedia,
|
||||
type FeishuBindings,
|
||||
} from "../../../lib/feishu-client";
|
||||
import {
|
||||
ensureSchema,
|
||||
getRawDb,
|
||||
getUploadBucket,
|
||||
hashText,
|
||||
uid,
|
||||
} from "../../../lib/mvp-db";
|
||||
import {
|
||||
PARTNER_BATCH_MAX_BYTES,
|
||||
buildPartnerBatchWorkbookColumns,
|
||||
parsePartnerBatchWorkbook,
|
||||
resolvePartnerWorkbookOrigin,
|
||||
} from "../../../lib/partner-batch-workbook";
|
||||
import {
|
||||
accountFromPublishLink,
|
||||
} from "../../../lib/partner-utils";
|
||||
import { extractPublishUrl } from "../../../lib/publish-url";
|
||||
import {
|
||||
partnerOptions,
|
||||
withPartnerCors,
|
||||
} from "../../../lib/partner-cors";
|
||||
import {
|
||||
buildRecoveryWorkbook,
|
||||
type RecoveryWorkbookImage,
|
||||
type RecoveryWorkbookRow,
|
||||
} from "../../../lib/recovery-workbook";
|
||||
import {
|
||||
resolveCollectionMcpConfig,
|
||||
type CollectionMcpBindings,
|
||||
} from "../../../lib/mcp-collection-client";
|
||||
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
|
||||
import {
|
||||
createCollectionRunTasks,
|
||||
runDueScheduledCollections,
|
||||
} from "../../../lib/collection-service";
|
||||
import { normalizeWorkbookImage } from "../../../lib/workbook-image";
|
||||
|
||||
const env = getRuntimeEnv();
|
||||
|
||||
type StoredAsset = {
|
||||
index: number;
|
||||
key: string;
|
||||
fileToken?: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
};
|
||||
|
||||
type BatchRow = {
|
||||
distribution_id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
source_row: number | null;
|
||||
image_assets: string;
|
||||
video_assets: string;
|
||||
publish_url: string | null;
|
||||
publish_screenshot_key: string | null;
|
||||
screenshot_key: string | null;
|
||||
partner_id: string;
|
||||
account_id: string | null;
|
||||
claimant_name: string;
|
||||
};
|
||||
|
||||
type TaskRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
task_type: string;
|
||||
collection_start_date: string | null;
|
||||
collection_days: string;
|
||||
platform: "小红书" | "抖音";
|
||||
content_format: "image_text" | "video";
|
||||
};
|
||||
|
||||
type BatchAccess = {
|
||||
task: TaskRow;
|
||||
rows: BatchRow[];
|
||||
};
|
||||
|
||||
function textValue(value: string | null, maxLength = 100) {
|
||||
return String(value ?? "").trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeFileName(value: string) {
|
||||
return value.replace(/[\\/:*?"<>|]/g, " ").trim().slice(0, 60) || "领取笔记";
|
||||
}
|
||||
|
||||
function exactArrayBuffer(bytes: Uint8Array) {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return copy.buffer;
|
||||
}
|
||||
|
||||
function contentTypeFromObject(object: { writeHttpMetadata(headers: Headers): void }) {
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
return headers.get("Content-Type") || "application/octet-stream";
|
||||
}
|
||||
|
||||
function parseAssets(
|
||||
value: string,
|
||||
prefixes = ["content-assets/", "task-assets/"],
|
||||
) {
|
||||
try {
|
||||
const assets = JSON.parse(value || "[]") as StoredAsset[];
|
||||
return Array.isArray(assets)
|
||||
? assets
|
||||
.filter(
|
||||
(asset) =>
|
||||
Number.isInteger(Number(asset.index)) &&
|
||||
Number(asset.index) > 0 &&
|
||||
typeof asset.key === "string" &&
|
||||
prefixes.some((prefix) => asset.key.startsWith(prefix)),
|
||||
)
|
||||
.map((asset) => ({ ...asset, index: Number(asset.index) }))
|
||||
.sort((left, right) => left.index - right.index)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadImage(
|
||||
key: string,
|
||||
description: string,
|
||||
fileToken?: string,
|
||||
width?: number | null,
|
||||
height?: number | null,
|
||||
compactSource = false,
|
||||
) {
|
||||
const bucket = getUploadBucket();
|
||||
let object = await bucket.get(key);
|
||||
if (!object && fileToken) {
|
||||
const media = await downloadFeishuMedia(
|
||||
fileToken,
|
||||
env as unknown as FeishuBindings,
|
||||
);
|
||||
await bucket.put(key, media.bytes, {
|
||||
httpMetadata: { contentType: media.contentType },
|
||||
customMetadata: { source: "feishu-api" },
|
||||
});
|
||||
object = await bucket.get(key);
|
||||
}
|
||||
if (!object) return null;
|
||||
return normalizeWorkbookImage(
|
||||
{
|
||||
bytes: new Uint8Array(await object.arrayBuffer()),
|
||||
contentType: contentTypeFromObject(object),
|
||||
description,
|
||||
width,
|
||||
height,
|
||||
} satisfies RecoveryWorkbookImage,
|
||||
compactSource
|
||||
? { maxDimension: 1600, outputFormat: "jpeg", jpegQuality: 82 }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
async function findAccess(
|
||||
taskToken: string,
|
||||
claimToken: string,
|
||||
delegationToken: string,
|
||||
): Promise<BatchAccess | null> {
|
||||
const db = getRawDb();
|
||||
const task = delegationToken
|
||||
? await db
|
||||
.prepare(
|
||||
`SELECT t.id, t.name, t.task_type, t.collection_start_date, t.collection_days,
|
||||
t.platform, t.content_format
|
||||
FROM tasks t
|
||||
JOIN delegation_bundles b ON b.task_id = t.id
|
||||
WHERE b.share_token = ? AND b.status = 'active'`,
|
||||
)
|
||||
.bind(delegationToken)
|
||||
.first<TaskRow>()
|
||||
: await db
|
||||
.prepare(
|
||||
`SELECT t.id, t.name, t.task_type, t.collection_start_date, t.collection_days,
|
||||
t.platform, t.content_format
|
||||
FROM tasks t
|
||||
JOIN claims cl ON cl.task_id = t.id
|
||||
WHERE t.share_token = ? AND cl.claim_token = ?`,
|
||||
)
|
||||
.bind(taskToken, claimToken)
|
||||
.first<TaskRow>();
|
||||
if (!task) return null;
|
||||
const rows = delegationToken
|
||||
? await db
|
||||
.prepare(
|
||||
`SELECT d.id AS distribution_id, d.partner_id, d.account_id,
|
||||
d.publish_url, d.publish_screenshot_key, d.screenshot_key,
|
||||
c.title, c.body, c.source_row, c.image_assets, c.video_assets,
|
||||
cl.claimant_name
|
||||
FROM distributions d
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
|
||||
JOIN claims cl ON cl.id = d.claim_id
|
||||
WHERE b.share_token = ? AND b.task_id = ? AND b.status = 'active'
|
||||
ORDER BY d.claimed_at, d.id`,
|
||||
)
|
||||
.bind(delegationToken, task.id)
|
||||
.all<BatchRow>()
|
||||
: await db
|
||||
.prepare(
|
||||
`SELECT d.id AS distribution_id, d.partner_id, d.account_id,
|
||||
d.publish_url, d.publish_screenshot_key, d.screenshot_key,
|
||||
c.title, c.body, c.source_row, c.image_assets, c.video_assets,
|
||||
cl.claimant_name
|
||||
FROM distributions d
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
JOIN claims cl ON cl.id = d.claim_id
|
||||
WHERE cl.claim_token = ? AND cl.task_id = ?
|
||||
ORDER BY d.claimed_at, d.id`,
|
||||
)
|
||||
.bind(claimToken, task.id)
|
||||
.all<BatchRow>();
|
||||
return { task, rows: rows.results };
|
||||
}
|
||||
|
||||
async function handleGet(request: Request) {
|
||||
try {
|
||||
await ensureSchema();
|
||||
const url = new URL(request.url);
|
||||
const taskToken = textValue(url.searchParams.get("task"));
|
||||
const claimToken = textValue(url.searchParams.get("claim"));
|
||||
const delegationToken = textValue(url.searchParams.get("share"));
|
||||
if (!delegationToken && (!taskToken || !claimToken)) {
|
||||
return Response.json({ error: "领取凭证不完整" }, { status: 400 });
|
||||
}
|
||||
const access = await findAccess(taskToken, claimToken, delegationToken);
|
||||
if (!access) return Response.json({ error: "领取凭证无效" }, { status: 403 });
|
||||
if (access.task.task_type !== "content_publish") {
|
||||
return Response.json({ error: "当前任务不支持笔记批量回填" }, { status: 400 });
|
||||
}
|
||||
const maxSourceImages = Math.max(
|
||||
0,
|
||||
...access.rows.map((row) => parseAssets(row.image_assets).length),
|
||||
);
|
||||
const maxSourceVideos = Math.max(
|
||||
0,
|
||||
...access.rows.map(
|
||||
(row) => parseAssets(row.video_assets, ["content-videos/"]).length,
|
||||
),
|
||||
);
|
||||
const columns = buildPartnerBatchWorkbookColumns({
|
||||
contentFormat: access.task.content_format,
|
||||
maxSourceImages,
|
||||
maxSourceVideos,
|
||||
});
|
||||
const downloadOrigin = resolvePartnerWorkbookOrigin(
|
||||
request,
|
||||
env.APP_ORIGIN,
|
||||
);
|
||||
const workbookRows: RecoveryWorkbookRow[] = [];
|
||||
for (let rowIndex = 0; rowIndex < access.rows.length; rowIndex += 1) {
|
||||
const row = access.rows[rowIndex];
|
||||
const images: RecoveryWorkbookRow["images"] = [];
|
||||
const hyperlinks: NonNullable<RecoveryWorkbookRow["hyperlinks"]> = [];
|
||||
const assets =
|
||||
columns.sourceImageCount > 0 ? parseAssets(row.image_assets) : [];
|
||||
for (let index = 0; index < assets.length; index += 1) {
|
||||
const asset = assets[index];
|
||||
const image = await loadImage(
|
||||
asset.key,
|
||||
`${row.title} 原图${asset.index}`,
|
||||
asset.fileToken,
|
||||
asset.width,
|
||||
asset.height,
|
||||
true,
|
||||
);
|
||||
if (image) {
|
||||
images.push({
|
||||
column: columns.sourceImageStartColumn + index,
|
||||
image,
|
||||
maxWidth: 160,
|
||||
maxHeight: 118,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (row.publish_screenshot_key) {
|
||||
const image = await loadImage(
|
||||
row.publish_screenshot_key,
|
||||
`${row.title} 笔记截图`,
|
||||
);
|
||||
if (image) {
|
||||
images.push({ column: columns.publishScreenshotColumn, image });
|
||||
}
|
||||
}
|
||||
if (row.screenshot_key) {
|
||||
const image = await loadImage(
|
||||
row.screenshot_key,
|
||||
`${row.title} 数据分析截图`,
|
||||
);
|
||||
if (image) {
|
||||
images.push({ column: columns.creatorScreenshotColumn, image });
|
||||
}
|
||||
}
|
||||
const videoAssets = parseAssets(row.video_assets, ["content-videos/"]);
|
||||
for (let index = 0; index < videoAssets.length; index += 1) {
|
||||
const params = new URLSearchParams({
|
||||
distribution: row.distribution_id,
|
||||
index: String(videoAssets[index].index),
|
||||
kind: "video",
|
||||
download: "1",
|
||||
});
|
||||
if (delegationToken) params.set("share", delegationToken);
|
||||
else {
|
||||
params.set("task", taskToken);
|
||||
params.set("claim", claimToken);
|
||||
}
|
||||
hyperlinks.push({
|
||||
column: columns.sourceVideoStartColumn + index,
|
||||
url: `${downloadOrigin}/api/partner-image?${params}`,
|
||||
});
|
||||
}
|
||||
workbookRows.push({
|
||||
cells: [
|
||||
rowIndex + 1,
|
||||
row.title,
|
||||
row.body,
|
||||
...Array.from({ length: columns.sourceImageCount }, () => ""),
|
||||
...Array.from(
|
||||
{ length: columns.sourceVideoCount },
|
||||
(_, index) => (index < videoAssets.length ? `下载视频${index + 1}` : ""),
|
||||
),
|
||||
row.publish_url || "",
|
||||
"",
|
||||
"",
|
||||
row.distribution_id,
|
||||
row.publish_screenshot_key || "",
|
||||
row.screenshot_key || "",
|
||||
],
|
||||
images,
|
||||
hyperlinks: [
|
||||
...hyperlinks,
|
||||
...(row.publish_url
|
||||
? [{ column: columns.publishUrlColumn, url: row.publish_url }]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
const workbook = buildRecoveryWorkbook({
|
||||
sheetName: "批量回填",
|
||||
headers: columns.headers,
|
||||
columnWidths: columns.columnWidths,
|
||||
rows: workbookRows,
|
||||
hiddenColumns: [
|
||||
columns.systemColumn,
|
||||
columns.systemColumn + 1,
|
||||
columns.systemColumn + 2,
|
||||
],
|
||||
});
|
||||
const fileName = `${safeFileName(access.task.name)}-批量回填.xlsx`;
|
||||
return new Response(exactArrayBuffer(workbook), {
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
"Content-Type":
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"Content-Disposition": `attachment; filename="koc-batch.xlsx"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "导出失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function extensionForImage(image: { fileName: string; contentType: string }) {
|
||||
const fromName = image.fileName.split(".").at(-1)?.replace(/[^a-zA-Z0-9]/g, "");
|
||||
if (fromName) return fromName.toLowerCase() === "jpeg" ? "jpg" : fromName;
|
||||
return image.contentType.includes("png")
|
||||
? "png"
|
||||
: image.contentType.includes("webp")
|
||||
? "webp"
|
||||
: image.contentType.includes("gif")
|
||||
? "gif"
|
||||
: "jpg";
|
||||
}
|
||||
|
||||
async function storeImportedImage(
|
||||
kind: "publish" | "creator",
|
||||
distributionId: string,
|
||||
image: { bytes: Uint8Array; contentType: string; fileName: string },
|
||||
) {
|
||||
const prefix = kind === "publish" ? "publish-evidence" : "creator-center";
|
||||
const key = `${prefix}/${distributionId}/${uid("sheet")}.${extensionForImage(image)}`;
|
||||
await getUploadBucket().put(key, image.bytes, {
|
||||
httpMetadata: { contentType: image.contentType },
|
||||
customMetadata: { source: "partner-batch-workbook" },
|
||||
});
|
||||
return key;
|
||||
}
|
||||
|
||||
async function isDifferentFromStoredImage(
|
||||
existingKey: string | null,
|
||||
image: { bytes: Uint8Array },
|
||||
) {
|
||||
if (!existingKey) return true;
|
||||
const stored = await getUploadBucket().get(existingKey);
|
||||
if (!stored) return true;
|
||||
const storedBytes = new Uint8Array(await stored.arrayBuffer());
|
||||
if (storedBytes.byteLength !== image.bytes.byteLength) return true;
|
||||
for (let index = 0; index < storedBytes.byteLength; index += 1) {
|
||||
if (storedBytes[index] !== image.bytes[index]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handlePost(request: Request) {
|
||||
try {
|
||||
await ensureSchema();
|
||||
const form = await request.formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File) || file.size === 0) {
|
||||
return Response.json({ error: "请选择填写完成的Excel表" }, { status: 400 });
|
||||
}
|
||||
if (file.size > PARTNER_BATCH_MAX_BYTES) {
|
||||
return Response.json({ error: "批量回填表不能超过80MB" }, { status: 400 });
|
||||
}
|
||||
if (!/\.xlsx$/i.test(file.name)) {
|
||||
return Response.json({ error: "仅支持系统导出的 .xlsx 表格" }, { status: 400 });
|
||||
}
|
||||
const taskToken = textValue(String(form.get("taskToken") ?? ""));
|
||||
const claimToken = textValue(String(form.get("claimToken") ?? ""));
|
||||
const delegationToken = textValue(String(form.get("delegationToken") ?? ""));
|
||||
const access = await findAccess(taskToken, claimToken, delegationToken);
|
||||
if (!access) return Response.json({ error: "领取凭证无效" }, { status: 403 });
|
||||
if (access.task.task_type !== "content_publish") {
|
||||
return Response.json({ error: "当前任务不支持笔记批量回填" }, { status: 400 });
|
||||
}
|
||||
const importedRows = parsePartnerBatchWorkbook(await file.arrayBuffer());
|
||||
const assignmentById = new Map(
|
||||
access.rows.map((row) => [row.distribution_id, row]),
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const errors: string[] = [];
|
||||
const prepared = importedRows.map((row) => {
|
||||
const assignment = assignmentById.get(row.distributionId);
|
||||
if (!assignment) {
|
||||
errors.push(`第${row.spreadsheetRow}行不属于当前领取批次,请重新导出表格`);
|
||||
} else if (seen.has(row.distributionId)) {
|
||||
errors.push(`第${row.spreadsheetRow}行笔记重复`);
|
||||
} else if (row.title && row.title !== assignment.title) {
|
||||
errors.push(`第${row.spreadsheetRow}行标题已被修改,请重新导出表格`);
|
||||
}
|
||||
seen.add(row.distributionId);
|
||||
const publishUrl = row.publishUrl
|
||||
? extractPublishUrl(row.publishUrl, access.task.platform)
|
||||
: assignment?.publish_url || "";
|
||||
if (row.publishUrl && !publishUrl) {
|
||||
errors.push(`第${row.spreadsheetRow}行发布链接不是有效的${access.task.platform}作品链接`);
|
||||
}
|
||||
const hasPublishScreenshot = Boolean(
|
||||
assignment?.publish_screenshot_key || row.publishScreenshot,
|
||||
);
|
||||
if (publishUrl && !hasPublishScreenshot) {
|
||||
errors.push(`第${row.spreadsheetRow}行填写了发布链接,请同时插入笔记截图`);
|
||||
}
|
||||
if (row.publishScreenshot && !publishUrl) {
|
||||
errors.push(`第${row.spreadsheetRow}行插入了笔记截图,请同时填写发布链接`);
|
||||
}
|
||||
if (row.creatorScreenshot && !publishUrl) {
|
||||
errors.push(`第${row.spreadsheetRow}行需先回填发布链接,再补数据分析截图`);
|
||||
}
|
||||
return {
|
||||
imported: row,
|
||||
assignment,
|
||||
publishUrl,
|
||||
};
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
return Response.json(
|
||||
{ error: errors.slice(0, 8).join(";"), errors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const db = getRawDb();
|
||||
let updatedRows = 0;
|
||||
let publishedCount = 0;
|
||||
let analysisScreenshotCount = 0;
|
||||
let noteScreenshotCount = 0;
|
||||
let publishUrlChangedCount = 0;
|
||||
const enrichments: Array<{ id: string; url: string; nickname: string }> = [];
|
||||
for (const item of prepared) {
|
||||
const assignment = item.assignment!;
|
||||
let publishScreenshotKey = assignment.publish_screenshot_key;
|
||||
let creatorScreenshotKey = assignment.screenshot_key;
|
||||
const hasNewPublishScreenshot = item.imported.publishScreenshot
|
||||
? await isDifferentFromStoredImage(
|
||||
assignment.publish_screenshot_key,
|
||||
item.imported.publishScreenshot,
|
||||
)
|
||||
: false;
|
||||
const hasNewCreatorScreenshot = item.imported.creatorScreenshot
|
||||
? await isDifferentFromStoredImage(
|
||||
assignment.screenshot_key,
|
||||
item.imported.creatorScreenshot,
|
||||
)
|
||||
: false;
|
||||
if (hasNewPublishScreenshot && item.imported.publishScreenshot) {
|
||||
publishScreenshotKey = await storeImportedImage(
|
||||
"publish",
|
||||
assignment.distribution_id,
|
||||
item.imported.publishScreenshot,
|
||||
);
|
||||
noteScreenshotCount += 1;
|
||||
}
|
||||
if (hasNewCreatorScreenshot && item.imported.creatorScreenshot) {
|
||||
creatorScreenshotKey = await storeImportedImage(
|
||||
"creator",
|
||||
assignment.distribution_id,
|
||||
item.imported.creatorScreenshot,
|
||||
);
|
||||
analysisScreenshotCount += 1;
|
||||
}
|
||||
const statements: DatabaseStatement[] = [];
|
||||
if (publishScreenshotKey !== assignment.publish_screenshot_key) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE distributions SET publish_screenshot_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
)
|
||||
.bind(publishScreenshotKey, assignment.distribution_id),
|
||||
);
|
||||
}
|
||||
if (creatorScreenshotKey !== assignment.screenshot_key) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE distributions SET screenshot_key = ?,
|
||||
ocr_status = CASE WHEN exposure IS NOT NULL AND views IS NOT NULL THEN ocr_status ELSE 'uploaded' END,
|
||||
updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
)
|
||||
.bind(creatorScreenshotKey, assignment.distribution_id),
|
||||
);
|
||||
}
|
||||
if (item.publishUrl && item.publishUrl !== assignment.publish_url) {
|
||||
const isReplacement = Boolean(assignment.publish_url);
|
||||
const account = accountFromPublishLink(
|
||||
item.publishUrl,
|
||||
access.task.platform,
|
||||
);
|
||||
if (!account) {
|
||||
return Response.json(
|
||||
{ error: `第${item.imported.spreadsheetRow}行发布链接格式不正确` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const matchedAccount = await db
|
||||
.prepare(
|
||||
`SELECT id FROM accounts
|
||||
WHERE platform = ? AND platform_uid = ?
|
||||
LIMIT 1`,
|
||||
)
|
||||
.bind(account.platform, account.platformUid)
|
||||
.first<{ id: string }>();
|
||||
const accountId =
|
||||
matchedAccount?.id ||
|
||||
`account-${hashText(`${account.platform}:${account.platformUid}`)}`;
|
||||
statements.push(
|
||||
...(isReplacement
|
||||
? [
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE distributions SET
|
||||
d2_likes = NULL, d2_comments = NULL, d2_collects = NULL,
|
||||
d5_likes = NULL, d5_comments = NULL, d5_collects = NULL,
|
||||
d7_likes = NULL, d7_comments = NULL, d7_collects = NULL,
|
||||
latest_likes = NULL, latest_comments = NULL,
|
||||
latest_collects = NULL, latest_shares = NULL,
|
||||
collection_status = 'pending',
|
||||
collection_status_description = '批量回填已更新链接,等待重新采集',
|
||||
collection_updated_at = NULL, last_collection_day = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(assignment.distribution_id),
|
||||
db
|
||||
.prepare("DELETE FROM collection_runs WHERE distribution_id = ?")
|
||||
.bind(assignment.distribution_id),
|
||||
]
|
||||
: []),
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO accounts
|
||||
(id, platform, platform_uid, nickname, profile_url,
|
||||
current_contact, post_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(platform, platform_uid) DO UPDATE SET
|
||||
nickname = excluded.nickname,
|
||||
profile_url = excluded.profile_url,
|
||||
current_contact = CASE
|
||||
WHEN excluded.current_contact != ''
|
||||
THEN excluded.current_contact
|
||||
ELSE accounts.current_contact
|
||||
END,
|
||||
last_seen_at = CURRENT_TIMESTAMP`,
|
||||
)
|
||||
.bind(
|
||||
accountId,
|
||||
account.platform,
|
||||
account.platformUid,
|
||||
account.nickname,
|
||||
account.profileUrl,
|
||||
assignment.claimant_name,
|
||||
),
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE distributions SET account_id = ?, publish_url = ?,
|
||||
publish_time = COALESCE(publish_time, CURRENT_TIMESTAMP),
|
||||
status = 'published', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(accountId, item.publishUrl, assignment.distribution_id),
|
||||
);
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE accounts SET post_count = (
|
||||
SELECT COUNT(*) FROM distributions WHERE account_id = ?
|
||||
) WHERE id = ?`,
|
||||
)
|
||||
.bind(accountId, accountId),
|
||||
);
|
||||
if (assignment.account_id && assignment.account_id !== accountId) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE accounts SET post_count = (
|
||||
SELECT COUNT(*) FROM distributions WHERE account_id = ?
|
||||
) WHERE id = ?`,
|
||||
)
|
||||
.bind(assignment.account_id, assignment.account_id),
|
||||
);
|
||||
}
|
||||
if (isReplacement) publishUrlChangedCount += 1;
|
||||
if (!assignment.publish_url) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE partners SET completed_total = completed_total + 1 WHERE id = ?`,
|
||||
)
|
||||
.bind(assignment.partner_id),
|
||||
);
|
||||
publishedCount += 1;
|
||||
}
|
||||
enrichments.push({
|
||||
id: assignment.distribution_id,
|
||||
url: item.publishUrl,
|
||||
nickname: account.nickname,
|
||||
});
|
||||
}
|
||||
if (statements.length > 0) {
|
||||
await db.batch(statements);
|
||||
updatedRows += 1;
|
||||
}
|
||||
}
|
||||
if (
|
||||
(publishedCount > 0 || publishUrlChangedCount > 0) &&
|
||||
access.task.collection_start_date &&
|
||||
access.task.collection_days !== "[]"
|
||||
) {
|
||||
let collectionDays: number[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(access.task.collection_days);
|
||||
if (Array.isArray(parsed)) collectionDays = parsed.map(Number);
|
||||
} catch {
|
||||
collectionDays = [];
|
||||
}
|
||||
if (collectionDays.length > 0) {
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE distributions SET collection_status = 'scheduled',
|
||||
collection_status_description = ?
|
||||
WHERE task_id = ? AND publish_url IS NOT NULL AND publish_url != ''`,
|
||||
)
|
||||
.bind(
|
||||
`已安排${collectionDays.length}个采集日,每日09:00执行`,
|
||||
access.task.id,
|
||||
)
|
||||
.run();
|
||||
await createCollectionRunTasks(
|
||||
db,
|
||||
access.task.id,
|
||||
access.task.collection_start_date,
|
||||
collectionDays,
|
||||
);
|
||||
runInBackground(
|
||||
runDueScheduledCollections(
|
||||
db,
|
||||
Date.now(),
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
"catchup",
|
||||
access.task.id,
|
||||
).catch(() => undefined),
|
||||
"collection catchup after batch publish update",
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const enrichment of enrichments) {
|
||||
runInBackground(
|
||||
enrichDistributionAccount(
|
||||
db,
|
||||
enrichment.id,
|
||||
enrichment.url,
|
||||
enrichment.nickname,
|
||||
resolveCollectionMcpConfig(env as unknown as CollectionMcpBindings),
|
||||
).catch(() => undefined),
|
||||
"batch distribution account enrichment",
|
||||
);
|
||||
}
|
||||
return Response.json({
|
||||
imported: true,
|
||||
updatedRows,
|
||||
publishedCount,
|
||||
publishUrlChangedCount,
|
||||
noteScreenshotCount,
|
||||
analysisScreenshotCount,
|
||||
skippedRows: importedRows.length - updatedRows,
|
||||
});
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "批量回填失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return withPartnerCors(request, await handleGet(request));
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return withPartnerCors(request, await handlePost(request));
|
||||
}
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return partnerOptions(request);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
withPartnerCors,
|
||||
} from "../../../lib/partner-cors";
|
||||
import { parseResultScreenshotKeys } from "../../../lib/result-screenshots";
|
||||
import { hasMp4FileSignature } from "../../../lib/video-file";
|
||||
|
||||
const env = getRuntimeEnv();
|
||||
|
||||
@@ -26,7 +27,11 @@ function textValue(value: string | null, maxLength = 100) {
|
||||
return String(value ?? "").trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function findAsset(value: string, imageIndex: number) {
|
||||
function findAsset(
|
||||
value: string,
|
||||
imageIndex: number,
|
||||
prefixes = ["content-assets/", "task-assets/"],
|
||||
) {
|
||||
try {
|
||||
const assets = JSON.parse(value) as StoredAsset[];
|
||||
return Array.isArray(assets)
|
||||
@@ -34,8 +39,7 @@ function findAsset(value: string, imageIndex: number) {
|
||||
(asset) =>
|
||||
asset.index === imageIndex &&
|
||||
typeof asset.key === "string" &&
|
||||
(asset.key.startsWith("content-assets/") ||
|
||||
asset.key.startsWith("task-assets/")) &&
|
||||
prefixes.some((prefix) => asset.key.startsWith(prefix)) &&
|
||||
(asset.fileToken === undefined ||
|
||||
typeof asset.fileToken === "string"),
|
||||
)
|
||||
@@ -55,18 +59,20 @@ async function handleGet(request: Request) {
|
||||
const distributionId = textValue(url.searchParams.get("distribution"));
|
||||
const imageIndex = Number(url.searchParams.get("index"));
|
||||
const imageKind = textValue(url.searchParams.get("kind"), 20);
|
||||
const downloadRequested = url.searchParams.get("download") === "1";
|
||||
if (
|
||||
(!delegationToken && (!taskToken || !claimToken)) ||
|
||||
!distributionId ||
|
||||
!Number.isInteger(imageIndex) ||
|
||||
imageIndex < 1
|
||||
) {
|
||||
return Response.json({ error: "图片链接不完整" }, { status: 400 });
|
||||
return Response.json({ error: "素材链接不完整" }, { status: 400 });
|
||||
}
|
||||
const row = delegationToken
|
||||
? await getRawDb()
|
||||
.prepare(
|
||||
`SELECT c.image_assets,
|
||||
c.video_assets,
|
||||
d.result_screenshot_key,
|
||||
d.publish_screenshot_key,
|
||||
d.screenshot_key
|
||||
@@ -80,6 +86,7 @@ async function handleGet(request: Request) {
|
||||
.bind(distributionId, delegationToken)
|
||||
.first<{
|
||||
image_assets: string;
|
||||
video_assets: string;
|
||||
result_screenshot_key: string | null;
|
||||
publish_screenshot_key: string | null;
|
||||
screenshot_key: string | null;
|
||||
@@ -87,6 +94,7 @@ async function handleGet(request: Request) {
|
||||
: await getRawDb()
|
||||
.prepare(
|
||||
`SELECT c.image_assets,
|
||||
c.video_assets,
|
||||
d.result_screenshot_key,
|
||||
d.publish_screenshot_key,
|
||||
d.screenshot_key
|
||||
@@ -102,6 +110,7 @@ async function handleGet(request: Request) {
|
||||
.bind(distributionId, claimToken, taskToken)
|
||||
.first<{
|
||||
image_assets: string;
|
||||
video_assets: string;
|
||||
result_screenshot_key: string | null;
|
||||
publish_screenshot_key: string | null;
|
||||
screenshot_key: string | null;
|
||||
@@ -124,36 +133,77 @@ async function handleGet(request: Request) {
|
||||
? row?.screenshot_key?.startsWith("creator-center/")
|
||||
? { index: 1, key: row.screenshot_key }
|
||||
: undefined
|
||||
: imageKind === "video"
|
||||
? row
|
||||
? findAsset(row.video_assets, imageIndex, ["content-videos/"])
|
||||
: undefined
|
||||
: row
|
||||
? findAsset(row.image_assets, imageIndex)
|
||||
: undefined;
|
||||
if (!asset?.key) {
|
||||
return Response.json({ error: "没有找到这张图片" }, { status: 404 });
|
||||
return Response.json({ error: "没有找到这个素材" }, { status: 404 });
|
||||
}
|
||||
const bucket = getUploadBucket();
|
||||
let object = await bucket.get(asset.key);
|
||||
if (!object && asset.fileToken) {
|
||||
let objectBytes = object ? await object.arrayBuffer() : null;
|
||||
const invalidStoredVideo =
|
||||
imageKind === "video" &&
|
||||
objectBytes !== null &&
|
||||
!hasMp4FileSignature(objectBytes);
|
||||
if ((!object || invalidStoredVideo) && asset.fileToken) {
|
||||
const media = await downloadFeishuMedia(
|
||||
asset.fileToken,
|
||||
env as unknown as FeishuBindings,
|
||||
fetch,
|
||||
{
|
||||
maxBytes: imageKind === "video" ? 500 * 1024 * 1024 : undefined,
|
||||
label: imageKind === "video" ? "视频" : "图片",
|
||||
},
|
||||
);
|
||||
if (imageKind === "video" && !hasMp4FileSignature(media.bytes)) {
|
||||
return Response.json(
|
||||
{ error: "源视频不是可下载的 MP4 文件,请重新上传 MP4 视频" },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
await bucket.put(asset.key, media.bytes, {
|
||||
httpMetadata: { contentType: media.contentType },
|
||||
httpMetadata: {
|
||||
contentType: imageKind === "video" ? "video/mp4" : media.contentType,
|
||||
},
|
||||
customMetadata: { source: "feishu-api" },
|
||||
});
|
||||
object = await bucket.get(asset.key);
|
||||
objectBytes = object ? await object.arrayBuffer() : media.bytes;
|
||||
}
|
||||
if (!object) {
|
||||
return Response.json({ error: "图片同步失败,请稍后重试" }, { status: 404 });
|
||||
if (!object || !objectBytes) {
|
||||
return Response.json({ error: "素材同步失败,请稍后重试" }, { status: 404 });
|
||||
}
|
||||
if (imageKind === "video" && !hasMp4FileSignature(objectBytes)) {
|
||||
return Response.json(
|
||||
{ error: "已存储的视频不是有效的 MP4 文件,请联系运营重新同步" },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
headers.set("Cache-Control", "private, max-age=3600");
|
||||
headers.set("Content-Disposition", `inline; filename="note-${imageIndex}"`);
|
||||
return new Response(await object.arrayBuffer(), { headers });
|
||||
if (imageKind === "video") {
|
||||
headers.set("Content-Type", "video/mp4");
|
||||
}
|
||||
headers.set("Content-Length", String(objectBytes.byteLength));
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
const fileName =
|
||||
imageKind === "video"
|
||||
? `video-${imageIndex}.mp4`
|
||||
: `image-${imageIndex}`;
|
||||
headers.set(
|
||||
"Content-Disposition",
|
||||
`${downloadRequested ? "attachment" : "inline"}; filename="${fileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||
);
|
||||
return new Response(objectBytes, { headers });
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "图片读取失败" },
|
||||
{ error: error instanceof Error ? error.message : "素材读取失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { DatabaseStatement } from "../../../lib/database";
|
||||
|
||||
const env = getRuntimeEnv();
|
||||
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
|
||||
import { createCollectionRunTasks } from "../../../lib/collection-service";
|
||||
import {
|
||||
createCollectionRunTasks,
|
||||
runDueScheduledCollections,
|
||||
} from "../../../lib/collection-service";
|
||||
import {
|
||||
resolveCollectionMcpConfig,
|
||||
type CollectionMcpBindings,
|
||||
@@ -17,8 +20,8 @@ import {
|
||||
} from "../../../lib/mvp-db";
|
||||
import {
|
||||
accountFromPublishLink,
|
||||
extractXhsPublishUrl,
|
||||
} from "../../../lib/partner-utils";
|
||||
import { extractPublishUrl } from "../../../lib/publish-url";
|
||||
import { parseClaimantIdentifier } from "../../../lib/claimant-identifier";
|
||||
import {
|
||||
partnerOptions,
|
||||
@@ -101,23 +104,28 @@ function publicImageAssets(value: unknown): ImageAsset[] {
|
||||
}
|
||||
}
|
||||
|
||||
type PartnerTask = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
quantity: number;
|
||||
claimed_quantity: number;
|
||||
due_at: string;
|
||||
status: string;
|
||||
task_type: string;
|
||||
platform: string;
|
||||
content_format: string;
|
||||
};
|
||||
|
||||
async function findTask(taskToken: string) {
|
||||
return getRawDb()
|
||||
.prepare(
|
||||
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status, task_type
|
||||
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||
task_type, platform, content_format
|
||||
FROM tasks WHERE share_token = ?`,
|
||||
)
|
||||
.bind(taskToken)
|
||||
.first<{
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
quantity: number;
|
||||
claimed_quantity: number;
|
||||
due_at: string;
|
||||
status: string;
|
||||
task_type: string;
|
||||
}>();
|
||||
.first<PartnerTask>();
|
||||
}
|
||||
|
||||
async function findDelegationAccess(delegationToken: string) {
|
||||
@@ -132,6 +140,8 @@ async function findDelegationAccess(delegationToken: string) {
|
||||
t.due_at,
|
||||
t.status,
|
||||
t.task_type,
|
||||
t.platform,
|
||||
t.content_format,
|
||||
b.id AS bundle_id,
|
||||
b.label AS bundle_label,
|
||||
b.quantity AS bundle_quantity,
|
||||
@@ -141,15 +151,7 @@ async function findDelegationAccess(delegationToken: string) {
|
||||
WHERE b.share_token = ? AND b.status = 'active'`,
|
||||
)
|
||||
.bind(delegationToken)
|
||||
.first<{
|
||||
id: string;
|
||||
name: string;
|
||||
brand: string;
|
||||
quantity: number;
|
||||
claimed_quantity: number;
|
||||
due_at: string;
|
||||
status: string;
|
||||
task_type: string;
|
||||
.first<PartnerTask & {
|
||||
bundle_id: string;
|
||||
bundle_label: string;
|
||||
bundle_quantity: number;
|
||||
@@ -172,13 +174,15 @@ async function findAccessibleAssignment(
|
||||
d.publish_screenshot_key,
|
||||
d.screenshot_key,
|
||||
d.result_screenshot_key,
|
||||
d.result_submitted_at`;
|
||||
d.result_submitted_at,
|
||||
c.claimant_name`;
|
||||
if (delegationToken) {
|
||||
return db
|
||||
.prepare(
|
||||
`${select}
|
||||
FROM distributions d
|
||||
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
|
||||
JOIN claims c ON c.id = d.claim_id
|
||||
WHERE d.id = ?
|
||||
AND b.share_token = ?
|
||||
AND b.task_id = ?
|
||||
@@ -194,6 +198,7 @@ async function findAccessibleAssignment(
|
||||
screenshot_key: string | null;
|
||||
result_screenshot_key: string | null;
|
||||
result_submitted_at: string | null;
|
||||
claimant_name: string;
|
||||
}>();
|
||||
}
|
||||
if (!claimToken) return null;
|
||||
@@ -214,6 +219,7 @@ async function findAccessibleAssignment(
|
||||
screenshot_key: string | null;
|
||||
result_screenshot_key: string | null;
|
||||
result_submitted_at: string | null;
|
||||
claimant_name: string;
|
||||
}>();
|
||||
}
|
||||
|
||||
@@ -284,6 +290,7 @@ async function handleGet(request: Request) {
|
||||
c.body,
|
||||
c.source_row,
|
||||
c.image_assets,
|
||||
c.video_assets,
|
||||
a.nickname AS account_nickname,
|
||||
b.id AS delegation_bundle_id,
|
||||
b.label AS delegation_label
|
||||
@@ -326,7 +333,9 @@ async function handleGet(request: Request) {
|
||||
assignments: assignments.results.map((assignment) => ({
|
||||
...assignment,
|
||||
images: publicImageAssets(assignment.image_assets),
|
||||
videos: publicImageAssets(assignment.video_assets),
|
||||
image_assets: undefined,
|
||||
video_assets: undefined,
|
||||
})),
|
||||
delegations: delegations.results,
|
||||
};
|
||||
@@ -349,6 +358,7 @@ async function handleGet(request: Request) {
|
||||
c.body,
|
||||
c.source_row,
|
||||
c.image_assets,
|
||||
c.video_assets,
|
||||
a.nickname AS account_nickname
|
||||
FROM distributions d
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
@@ -366,7 +376,9 @@ async function handleGet(request: Request) {
|
||||
assignments: assignments.results.map((assignment) => ({
|
||||
...assignment,
|
||||
images: publicImageAssets(assignment.image_assets),
|
||||
videos: publicImageAssets(assignment.video_assets),
|
||||
image_assets: undefined,
|
||||
video_assets: undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -379,6 +391,8 @@ async function handleGet(request: Request) {
|
||||
dueAt: task.due_at,
|
||||
status: task.status,
|
||||
type: task.task_type,
|
||||
platform: task.platform,
|
||||
contentFormat: task.content_format,
|
||||
}
|
||||
: {
|
||||
name: task.name,
|
||||
@@ -388,6 +402,8 @@ async function handleGet(request: Request) {
|
||||
dueAt: task.due_at,
|
||||
status: task.status,
|
||||
type: task.task_type,
|
||||
platform: task.platform,
|
||||
contentFormat: task.content_format,
|
||||
availableQuantity: available?.count ?? 0,
|
||||
},
|
||||
claim,
|
||||
@@ -798,14 +814,15 @@ async function handlePost(request: Request) {
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const publishUrl = extractXhsPublishUrl(publishInput);
|
||||
const platform = task.platform === "抖音" ? "抖音" : "小红书";
|
||||
const publishUrl = extractPublishUrl(publishInput, platform);
|
||||
if (!publishUrl) {
|
||||
return Response.json(
|
||||
{ error: "请粘贴包含小红书长链或短链的分享内容" },
|
||||
{ error: `请粘贴包含${platform}作品链接的分享内容` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const account = accountFromPublishLink(publishUrl);
|
||||
const account = accountFromPublishLink(publishUrl, platform);
|
||||
if (!account) {
|
||||
return Response.json({ error: "发布链接格式不正确" }, { status: 400 });
|
||||
}
|
||||
@@ -816,28 +833,69 @@ async function handlePost(request: Request) {
|
||||
delegationToken,
|
||||
);
|
||||
if (!assignment) {
|
||||
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
|
||||
return Response.json({ error: "作品与领取凭证不匹配" }, { status: 403 });
|
||||
}
|
||||
if (!assignment.publish_screenshot_key) {
|
||||
return Response.json({ error: "请先上传发布截图" }, { status: 400 });
|
||||
}
|
||||
const reuseExistingAccount =
|
||||
assignment.publish_url === publishUrl && assignment.account_id;
|
||||
const matchedAccount = reuseExistingAccount
|
||||
? null
|
||||
: await db
|
||||
.prepare(
|
||||
`SELECT id FROM accounts
|
||||
WHERE platform = ? AND platform_uid = ?
|
||||
LIMIT 1`,
|
||||
)
|
||||
.bind(account.platform, account.platformUid)
|
||||
.first<{ id: string }>();
|
||||
const accountId =
|
||||
reuseExistingAccount ||
|
||||
matchedAccount?.id ||
|
||||
`account-${hashText(`${account.platform}:${account.platformUid}`)}`;
|
||||
const statements: DatabaseStatement[] = [];
|
||||
const publishUrlChanged = Boolean(
|
||||
assignment.publish_url && assignment.publish_url !== publishUrl,
|
||||
);
|
||||
if (publishUrlChanged) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE distributions SET
|
||||
d2_likes = NULL, d2_comments = NULL, d2_collects = NULL,
|
||||
d5_likes = NULL, d5_comments = NULL, d5_collects = NULL,
|
||||
d7_likes = NULL, d7_comments = NULL, d7_collects = NULL,
|
||||
latest_likes = NULL, latest_comments = NULL,
|
||||
latest_collects = NULL, latest_shares = NULL,
|
||||
collection_status = 'pending',
|
||||
collection_status_description = '发布链接已更新,等待重新采集',
|
||||
collection_updated_at = NULL, last_collection_day = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(assignment.id),
|
||||
db
|
||||
.prepare("DELETE FROM collection_runs WHERE distribution_id = ?")
|
||||
.bind(assignment.id),
|
||||
);
|
||||
}
|
||||
if (!reuseExistingAccount) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO accounts
|
||||
(id, platform, platform_uid, nickname, profile_url, post_count)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
(id, platform, platform_uid, nickname, profile_url,
|
||||
current_contact, post_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(platform, platform_uid) DO UPDATE SET
|
||||
nickname = excluded.nickname,
|
||||
profile_url = excluded.profile_url,
|
||||
post_count = accounts.post_count + ?,
|
||||
current_contact = CASE
|
||||
WHEN excluded.current_contact != ''
|
||||
THEN excluded.current_contact
|
||||
ELSE accounts.current_contact
|
||||
END,
|
||||
last_seen_at = CURRENT_TIMESTAMP`,
|
||||
)
|
||||
.bind(
|
||||
@@ -846,7 +904,7 @@ async function handlePost(request: Request) {
|
||||
account.platformUid,
|
||||
account.nickname,
|
||||
account.profileUrl,
|
||||
assignment.publish_url ? 0 : 1,
|
||||
assignment.claimant_name,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -863,6 +921,26 @@ async function handlePost(request: Request) {
|
||||
)
|
||||
.bind(accountId, publishUrl, assignment.id),
|
||||
);
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE accounts SET post_count = (
|
||||
SELECT COUNT(*) FROM distributions WHERE account_id = ?
|
||||
) WHERE id = ?`,
|
||||
)
|
||||
.bind(accountId, accountId),
|
||||
);
|
||||
if (assignment.account_id && assignment.account_id !== accountId) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE accounts SET post_count = (
|
||||
SELECT COUNT(*) FROM distributions WHERE account_id = ?
|
||||
) WHERE id = ?`,
|
||||
)
|
||||
.bind(assignment.account_id, assignment.account_id),
|
||||
);
|
||||
}
|
||||
if (!assignment.publish_url) {
|
||||
statements.push(
|
||||
db
|
||||
@@ -896,26 +974,46 @@ async function handlePost(request: Request) {
|
||||
collectionDays = [];
|
||||
}
|
||||
if (collectionDays.length > 0) {
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE distributions SET collection_status = 'scheduled',
|
||||
collection_status_description = ? WHERE id = ?`,
|
||||
)
|
||||
.bind(
|
||||
`已安排${collectionDays.length}个采集日,每日09:00执行`,
|
||||
assignment.id,
|
||||
)
|
||||
.run();
|
||||
await createCollectionRunTasks(
|
||||
db,
|
||||
task.id,
|
||||
collectionSchedule.collection_start_date,
|
||||
collectionDays,
|
||||
);
|
||||
runInBackground(
|
||||
runDueScheduledCollections(
|
||||
db,
|
||||
Date.now(),
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
"catchup",
|
||||
task.id,
|
||||
).catch(() => undefined),
|
||||
"collection catchup after partner publish update",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (account.platform === "小红书") {
|
||||
const enrichment = enrichDistributionAccount(
|
||||
db,
|
||||
assignment.id,
|
||||
publishUrl,
|
||||
account.nickname,
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
).catch(() => undefined);
|
||||
runInBackground(enrichment, "distribution account enrichment");
|
||||
}
|
||||
const enrichment = enrichDistributionAccount(
|
||||
db,
|
||||
assignment.id,
|
||||
publishUrl,
|
||||
account.nickname,
|
||||
resolveCollectionMcpConfig(
|
||||
env as unknown as CollectionMcpBindings,
|
||||
),
|
||||
).catch(() => undefined);
|
||||
runInBackground(enrichment, "distribution account enrichment");
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type RecoveryWorkbookRow,
|
||||
} from "../../../lib/recovery-workbook";
|
||||
import { consumeMcpExportToken } from "../../../lib/mcp-export-token";
|
||||
import { normalizeWorkbookImage } from "../../../lib/workbook-image";
|
||||
|
||||
const env = getRuntimeEnv();
|
||||
|
||||
@@ -53,6 +54,7 @@ type ExportRow = {
|
||||
latest_likes: number | null;
|
||||
latest_comments: number | null;
|
||||
latest_collects: number | null;
|
||||
latest_shares: number | null;
|
||||
collection_status: string | null;
|
||||
collection_status_description: string | null;
|
||||
collection_updated_at: string | null;
|
||||
@@ -121,12 +123,16 @@ function latestMetrics(row: ExportRow) {
|
||||
const likes = row.latest_likes ?? legacyLikes;
|
||||
const comments = row.latest_comments ?? legacyComments;
|
||||
const collects = row.latest_collects ?? legacyCollects;
|
||||
const shares = row.latest_shares;
|
||||
return {
|
||||
likes,
|
||||
comments,
|
||||
collects,
|
||||
shares,
|
||||
total:
|
||||
likes === null ? null : likes + (comments ?? 0) + (collects ?? 0),
|
||||
likes === null
|
||||
? null
|
||||
: likes + (comments ?? 0) + (collects ?? 0) + (shares ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,13 +187,13 @@ async function loadImage(reference: ImageReference) {
|
||||
object = await bucket.get(reference.key);
|
||||
}
|
||||
if (!object) return null;
|
||||
return {
|
||||
return normalizeWorkbookImage({
|
||||
bytes: new Uint8Array(await object.arrayBuffer()),
|
||||
contentType: contentTypeFromObject(object),
|
||||
width: reference.width,
|
||||
height: reference.height,
|
||||
description: reference.description,
|
||||
} satisfies RecoveryWorkbookImage;
|
||||
} satisfies RecoveryWorkbookImage);
|
||||
}
|
||||
|
||||
async function loadImages(references: ImageReference[]) {
|
||||
@@ -236,9 +242,9 @@ export async function GET(request: Request) {
|
||||
}
|
||||
const db = getRawDb();
|
||||
const task = await db
|
||||
.prepare("SELECT id, name, brand FROM tasks WHERE id = ?")
|
||||
.prepare("SELECT id, name, brand, platform FROM tasks WHERE id = ?")
|
||||
.bind(taskId)
|
||||
.first<{ id: string; name: string; brand: string }>();
|
||||
.first<{ id: string; name: string; brand: string; platform: string }>();
|
||||
if (!task) {
|
||||
return Response.json({ error: "没有找到这个任务" }, { status: 404 });
|
||||
}
|
||||
@@ -269,6 +275,7 @@ export async function GET(request: Request) {
|
||||
d.latest_likes,
|
||||
d.latest_comments,
|
||||
d.latest_collects,
|
||||
d.latest_shares,
|
||||
d.collection_status,
|
||||
d.collection_status_description,
|
||||
d.collection_updated_at,
|
||||
@@ -318,18 +325,19 @@ export async function GET(request: Request) {
|
||||
}
|
||||
});
|
||||
const loadedImages = await loadImages(references);
|
||||
const isDouyin = task.platform === "抖音";
|
||||
const metricHeaders = isDouyin
|
||||
? ["点赞", "收藏", "转发", "评论", "总互动"]
|
||||
: ["点赞", "收藏", "评论", "总互动"];
|
||||
const headers = [
|
||||
"序号(不能改)",
|
||||
"标题",
|
||||
"笔记内容(正文+话题)",
|
||||
...Array.from({ length: maxContentImages }, (_, index) => `图片${index + 1}`),
|
||||
"小红书昵称",
|
||||
`${task.platform}昵称`,
|
||||
"发布链接",
|
||||
"发布时间",
|
||||
"点赞",
|
||||
"收藏",
|
||||
"评论",
|
||||
"总互动",
|
||||
...metricHeaders,
|
||||
"曝光量-实际(第7天)",
|
||||
"阅读量-实际(第7天)",
|
||||
"数据分析截图(单篇笔记数据分析截图)",
|
||||
@@ -341,7 +349,7 @@ export async function GET(request: Request) {
|
||||
];
|
||||
const originalImageStart = 3;
|
||||
const accountColumn = originalImageStart + maxContentImages;
|
||||
const creatorScreenshotColumn = accountColumn + 9;
|
||||
const creatorScreenshotColumn = accountColumn + (isDouyin ? 10 : 9);
|
||||
const publishScreenshotColumn = creatorScreenshotColumn + 1;
|
||||
const workbookRows: RecoveryWorkbookRow[] = rows.map((row, rowIndex) => {
|
||||
const metrics = latestMetrics(row);
|
||||
@@ -350,7 +358,7 @@ export async function GET(request: Request) {
|
||||
{ length: maxContentImages },
|
||||
(_, index) => {
|
||||
const asset = contentAssets.find((item) => item.index === index + 1);
|
||||
return asset && loadedImages.get(asset.key) ? "见图" : asset ? "图片读取失败" : "";
|
||||
return "";
|
||||
},
|
||||
);
|
||||
const creatorImage = row.screenshot_key
|
||||
@@ -369,12 +377,13 @@ export async function GET(request: Request) {
|
||||
formatExportDate(row.publish_time),
|
||||
metrics.likes,
|
||||
metrics.collects,
|
||||
...(isDouyin ? [metrics.shares] : []),
|
||||
metrics.comments,
|
||||
metrics.total,
|
||||
row.exposure,
|
||||
row.views,
|
||||
creatorImage ? "见图" : row.screenshot_key ? "截图读取失败" : "",
|
||||
publishImage ? "见图" : row.publish_screenshot_key ? "截图读取失败" : "",
|
||||
"",
|
||||
"",
|
||||
formatExportDate(row.collection_updated_at || row.updated_at),
|
||||
row.distribution_id ? collectionLabel(row) : "未领取",
|
||||
row.partner_name || "",
|
||||
@@ -403,6 +412,7 @@ export async function GET(request: Request) {
|
||||
20,
|
||||
11,
|
||||
11,
|
||||
...(isDouyin ? [11] : []),
|
||||
11,
|
||||
11,
|
||||
18,
|
||||
|
||||
@@ -15,8 +15,12 @@ type AccountRow = {
|
||||
profile_url: string;
|
||||
ip_location: string;
|
||||
followers: number;
|
||||
gender: string;
|
||||
bio: string;
|
||||
tags: string;
|
||||
post_count: number;
|
||||
cooperation_source: string;
|
||||
current_contact: string;
|
||||
first_seen_at: string;
|
||||
last_seen_at: string;
|
||||
};
|
||||
@@ -24,6 +28,7 @@ type AccountRow = {
|
||||
type CooperationRow = {
|
||||
account_id: string;
|
||||
partner_name: string;
|
||||
claimant_name: string | null;
|
||||
delegation_bundle_id: string | null;
|
||||
};
|
||||
|
||||
@@ -74,9 +79,11 @@ async function exportAccounts(accountIds: string[]) {
|
||||
`SELECT
|
||||
d.account_id,
|
||||
p.name AS partner_name,
|
||||
cl.claimant_name,
|
||||
d.delegation_bundle_id
|
||||
FROM distributions d
|
||||
JOIN partners p ON p.id = d.partner_id
|
||||
LEFT JOIN claims cl ON cl.id = d.claim_id
|
||||
WHERE d.account_id IS NOT NULL`,
|
||||
)
|
||||
.all<CooperationRow>(),
|
||||
@@ -109,8 +116,12 @@ async function exportAccounts(accountIds: string[]) {
|
||||
"账号主页",
|
||||
"IP地",
|
||||
"粉丝数",
|
||||
"性别",
|
||||
"简介",
|
||||
"标签",
|
||||
"合作发布数",
|
||||
"历史合作来源",
|
||||
"当前联系人",
|
||||
"资源归属",
|
||||
"首次合作时间",
|
||||
"最近合作时间",
|
||||
@@ -119,7 +130,13 @@ async function exportAccounts(accountIds: string[]) {
|
||||
const cooperation = cooperationByAccount.get(account.id) ?? [];
|
||||
const sources = [
|
||||
...new Set([
|
||||
...cooperation.map((item) => item.partner_name),
|
||||
...cooperation
|
||||
.filter(
|
||||
(item) =>
|
||||
!item.claimant_name ||
|
||||
item.partner_name !== item.claimant_name,
|
||||
)
|
||||
.map((item) => item.partner_name),
|
||||
...(account.cooperation_source || "")
|
||||
.split(/[、,,;;|]/)
|
||||
.map((item) => item.trim())
|
||||
@@ -138,8 +155,12 @@ async function exportAccounts(accountIds: string[]) {
|
||||
account.profile_url || "",
|
||||
account.ip_location || "待识别",
|
||||
account.followers,
|
||||
account.gender || "",
|
||||
account.bio || "",
|
||||
account.tags || "",
|
||||
account.post_count,
|
||||
sources.join("、"),
|
||||
account.current_contact || "",
|
||||
partnerManagedOnly ? "合作社资源 · 不可直联" : "可直联",
|
||||
formatExportDate(account.first_seen_at),
|
||||
formatExportDate(account.last_seen_at),
|
||||
@@ -161,9 +182,13 @@ async function exportAccounts(accountIds: string[]) {
|
||||
44,
|
||||
14,
|
||||
14,
|
||||
10,
|
||||
36,
|
||||
32,
|
||||
14,
|
||||
32,
|
||||
22,
|
||||
22,
|
||||
21,
|
||||
21,
|
||||
],
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
|
||||
import { runInBackground } from "../../../lib/background";
|
||||
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
|
||||
import {
|
||||
resolveCollectionMcpConfig,
|
||||
resolveXhsProfileDetailsFromMcp,
|
||||
resolveProfileDetailsFromMcp,
|
||||
resolveXhsPublicAccountDetails,
|
||||
type CollectionMcpBindings,
|
||||
} from "../../../lib/mcp-collection-client";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
normalizeProfileUrl,
|
||||
parseResourceImportFile,
|
||||
RESOURCE_IMPORT_MAX_BYTES,
|
||||
RESOURCE_IMPORT_MAX_ROWS,
|
||||
resourcePlatformUid,
|
||||
resourceImportMissingFields,
|
||||
type ResourceImportRow,
|
||||
@@ -26,6 +28,9 @@ type AccountRow = {
|
||||
profile_url: string;
|
||||
ip_location: string;
|
||||
followers: number;
|
||||
gender: string;
|
||||
bio: string;
|
||||
tags: string;
|
||||
cooperation_source: string;
|
||||
};
|
||||
|
||||
@@ -36,6 +41,10 @@ type AnalyzedRow = ResourceImportRow & {
|
||||
cooperationSource: string;
|
||||
};
|
||||
|
||||
const RESOURCE_IMPORT_PREVIEW_ROWS = 100;
|
||||
const RESOURCE_IMPORT_SYNC_ENRICH_ROWS = 100;
|
||||
const RESOURCE_IMPORT_DB_BATCH_SIZE = 100;
|
||||
|
||||
function identityKey(platform: string, value: string) {
|
||||
return `${platform.trim().toLocaleLowerCase("zh-CN")}|${value
|
||||
.trim()
|
||||
@@ -46,7 +55,8 @@ async function loadAccounts() {
|
||||
return getRawDb()
|
||||
.prepare(
|
||||
`SELECT id, platform, platform_uid, public_account_id, nickname,
|
||||
profile_url, ip_location, followers, cooperation_source
|
||||
profile_url, ip_location, followers, gender, bio, tags,
|
||||
cooperation_source
|
||||
FROM accounts`,
|
||||
)
|
||||
.all<AccountRow>();
|
||||
@@ -71,7 +81,7 @@ async function mapConcurrent<T, R>(
|
||||
return results;
|
||||
}
|
||||
|
||||
async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
||||
function mergeExistingFields(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
||||
const existingByProfile = new Map<string, AccountRow>();
|
||||
for (const account of accounts) {
|
||||
const profileUrl = normalizeProfileUrl(account.profile_url || "");
|
||||
@@ -79,21 +89,30 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
||||
existingByProfile.set(identityKey(account.platform, profileUrl), account);
|
||||
}
|
||||
}
|
||||
const mcpConfig = resolveCollectionMcpConfig(
|
||||
getRuntimeEnv() as unknown as CollectionMcpBindings,
|
||||
);
|
||||
return mapConcurrent(rows, 4, async (row) => {
|
||||
if (row.errors.length > 0 || !row.profileUrl || row.platform !== "小红书") {
|
||||
return rows.map((row) => {
|
||||
if (
|
||||
row.errors.length > 0 ||
|
||||
!row.profileUrl ||
|
||||
!["小红书", "抖音"].includes(row.platform)
|
||||
) {
|
||||
return row;
|
||||
}
|
||||
const existing = existingByProfile.get(identityKey(row.platform, row.profileUrl));
|
||||
const existingNickname =
|
||||
existing?.nickname && existing.nickname !== "待识别账号"
|
||||
? existing.nickname
|
||||
: "";
|
||||
const existingIpLocation =
|
||||
existing?.ip_location && existing.ip_location !== "待识别"
|
||||
? existing.ip_location
|
||||
: "";
|
||||
const baseline: ResourceImportRow = {
|
||||
const existingGender: ResourceImportRow["gender"] =
|
||||
existing?.gender === "男" || existing?.gender === "女"
|
||||
? existing.gender
|
||||
: "";
|
||||
return {
|
||||
...row,
|
||||
nickname: row.nickname || existing?.nickname || "",
|
||||
nickname: row.nickname || existingNickname,
|
||||
publicAccountId: row.publicAccountId || existing?.public_account_id || "",
|
||||
ipLocation: row.ipLocation || existingIpLocation,
|
||||
followers: row.followersResolved
|
||||
@@ -101,7 +120,33 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
||||
: Number(existing?.followers || 0),
|
||||
followersResolved:
|
||||
row.followersResolved || Number(existing?.followers || 0) > 0,
|
||||
gender: row.gender || existingGender,
|
||||
bio: row.bio || existing?.bio || "",
|
||||
tags: row.tags.length > 0
|
||||
? row.tags
|
||||
: (existing?.tags || "")
|
||||
.split(/[,,、;;|]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 5),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
||||
const mcpConfig = resolveCollectionMcpConfig(
|
||||
getRuntimeEnv() as unknown as CollectionMcpBindings,
|
||||
);
|
||||
const baselineRows = mergeExistingFields(rows, accounts);
|
||||
return mapConcurrent(baselineRows, 4, async (baseline) => {
|
||||
const row = baseline;
|
||||
if (
|
||||
row.errors.length > 0 ||
|
||||
!row.profileUrl ||
|
||||
!["小红书", "抖音"].includes(row.platform)
|
||||
) {
|
||||
return row;
|
||||
}
|
||||
if (resourceImportMissingFields(baseline).length === 0) {
|
||||
return baseline;
|
||||
}
|
||||
@@ -111,20 +156,42 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
||||
redId: string | null;
|
||||
followers: number | null;
|
||||
ipLocation: string | null;
|
||||
} = await resolveXhsProfileDetailsFromMcp(row.profileUrl, mcpConfig).catch(
|
||||
() => ({ nickname: null, redId: null, followers: null, ipLocation: null }),
|
||||
);
|
||||
gender: "" | "男" | "女";
|
||||
bio: string;
|
||||
recentNoteTitles: string[];
|
||||
providerTags: string[];
|
||||
} = await resolveProfileDetailsFromMcp(
|
||||
row.profileUrl,
|
||||
row.platform === "抖音" ? "抖音" : "小红书",
|
||||
mcpConfig,
|
||||
).catch(() => ({
|
||||
nickname: null,
|
||||
redId: null,
|
||||
followers: null,
|
||||
ipLocation: null,
|
||||
gender: "" as const,
|
||||
bio: "",
|
||||
recentNoteTitles: [],
|
||||
providerTags: [],
|
||||
}));
|
||||
const mcpResult = {
|
||||
nickname: baseline.nickname || details.nickname?.trim() || "",
|
||||
publicAccountId: baseline.publicAccountId || details.redId?.trim() || "",
|
||||
ipLocation: baseline.ipLocation || details.ipLocation?.trim() || "",
|
||||
followersResolved: baseline.followersResolved || details.followers !== null,
|
||||
gender: baseline.gender || details.gender,
|
||||
bio: baseline.bio || details.bio,
|
||||
tags: baseline.tags,
|
||||
};
|
||||
if (resourceImportMissingFields(mcpResult).length > 0) {
|
||||
if (
|
||||
row.platform === "小红书" &&
|
||||
resourceImportMissingFields(mcpResult).length > 0
|
||||
) {
|
||||
const publicDetails = await resolveXhsPublicAccountDetails(row.profileUrl).catch(
|
||||
() => ({ nickname: null, redId: null, followers: null, ipLocation: null }),
|
||||
);
|
||||
details = {
|
||||
...details,
|
||||
nickname: details.nickname || publicDetails.nickname,
|
||||
redId: details.redId || publicDetails.redId,
|
||||
followers: details.followers ?? publicDetails.followers,
|
||||
@@ -141,6 +208,9 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
||||
: (details.followers ?? 0),
|
||||
followersResolved:
|
||||
baseline.followersResolved || details.followers !== null,
|
||||
gender: baseline.gender || details.gender,
|
||||
bio: baseline.bio || details.bio,
|
||||
tags: baseline.tags,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -198,6 +268,12 @@ function analyzeRows(rows: ResourceImportRow[], accountRows: AccountRow[]) {
|
||||
profile_url: row.profileUrl || existing?.profile_url || "",
|
||||
ip_location: row.ipLocation || existing?.ip_location || "待识别",
|
||||
followers: row.followers || existing?.followers || 0,
|
||||
gender: row.gender || existing?.gender || "",
|
||||
bio: row.bio || existing?.bio || "",
|
||||
tags: (row.tags.length > 0
|
||||
? row.tags
|
||||
: (existing?.tags || "").split(/[,,、;;|]/).filter(Boolean)
|
||||
).slice(0, 5).join(","),
|
||||
cooperation_source: analyzed.cooperationSource,
|
||||
};
|
||||
if (row.profileUrl) profileMap.set(identityKey(row.platform, row.profileUrl), virtual);
|
||||
@@ -219,6 +295,123 @@ function summarize(rows: AnalyzedRow[]) {
|
||||
};
|
||||
}
|
||||
|
||||
function previewAnalyzedRows(rows: AnalyzedRow[]) {
|
||||
const errorRows = rows.filter((row) => row.action === "error");
|
||||
if (errorRows.length === 0) {
|
||||
return rows.slice(0, RESOURCE_IMPORT_PREVIEW_ROWS);
|
||||
}
|
||||
const importableRows = rows.filter((row) => row.action !== "error");
|
||||
return [
|
||||
...errorRows.slice(0, RESOURCE_IMPORT_PREVIEW_ROWS),
|
||||
...importableRows.slice(
|
||||
0,
|
||||
Math.max(0, RESOURCE_IMPORT_PREVIEW_ROWS - errorRows.length),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function statementForAnalyzedRow(
|
||||
db: ReturnType<typeof getRawDb>,
|
||||
row: AnalyzedRow,
|
||||
) {
|
||||
return row.action === "update"
|
||||
? db
|
||||
.prepare(
|
||||
`UPDATE accounts SET
|
||||
nickname = ?,
|
||||
public_account_id = CASE WHEN ? != '' THEN ? ELSE public_account_id END,
|
||||
profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END,
|
||||
ip_location = CASE
|
||||
WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END,
|
||||
followers = CASE WHEN ? = 1 THEN ? ELSE followers END,
|
||||
gender = CASE WHEN ? != '' THEN ? ELSE gender END,
|
||||
bio = CASE WHEN ? != '' THEN ? ELSE bio END,
|
||||
tags = CASE WHEN ? != '' THEN ? ELSE tags END,
|
||||
cooperation_source = ?,
|
||||
last_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(
|
||||
row.nickname || "待识别账号",
|
||||
row.publicAccountId,
|
||||
row.publicAccountId,
|
||||
row.profileUrl,
|
||||
row.profileUrl,
|
||||
row.ipLocation,
|
||||
row.ipLocation,
|
||||
row.ipLocation,
|
||||
row.followersResolved ? 1 : 0,
|
||||
row.followers,
|
||||
row.gender,
|
||||
row.gender,
|
||||
row.bio,
|
||||
row.bio,
|
||||
row.tags.join(","),
|
||||
row.tags.join(","),
|
||||
row.cooperationSource,
|
||||
row.accountId,
|
||||
)
|
||||
: db
|
||||
.prepare(
|
||||
`INSERT INTO accounts
|
||||
(id, platform, platform_uid, public_account_id, nickname,
|
||||
profile_url, ip_location, followers, post_count, avg_views,
|
||||
gender, bio, tags, cooperation_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
row.accountId,
|
||||
row.platform,
|
||||
row.platformUid,
|
||||
row.publicAccountId,
|
||||
row.nickname || "待识别账号",
|
||||
row.profileUrl,
|
||||
row.ipLocation || "待识别",
|
||||
row.followers,
|
||||
row.gender,
|
||||
row.bio,
|
||||
row.tags.join(","),
|
||||
row.cooperationSource,
|
||||
);
|
||||
}
|
||||
|
||||
async function writeAnalyzedRows(rows: AnalyzedRow[]) {
|
||||
const db = getRawDb();
|
||||
const statements = rows
|
||||
.filter((row) => row.action !== "error")
|
||||
.map((row) => statementForAnalyzedRow(db, row));
|
||||
for (let index = 0; index < statements.length; index += RESOURCE_IMPORT_DB_BATCH_SIZE) {
|
||||
await db.batch(statements.slice(index, index + RESOURCE_IMPORT_DB_BATCH_SIZE));
|
||||
}
|
||||
}
|
||||
|
||||
function deferredEnrichmentCount(rows: ResourceImportRow[]) {
|
||||
return rows.filter(
|
||||
(row) =>
|
||||
row.errors.length === 0 &&
|
||||
row.profileUrl &&
|
||||
resourceImportMissingFields(row).length > 0,
|
||||
).length;
|
||||
}
|
||||
|
||||
async function enrichImportedRowsInBackground(rows: ResourceImportRow[]) {
|
||||
const accounts = await loadAccounts();
|
||||
const baselineRows = mergeExistingFields(rows, accounts.results);
|
||||
const missingRows = baselineRows.filter(
|
||||
(row) =>
|
||||
row.errors.length === 0 &&
|
||||
row.profileUrl &&
|
||||
resourceImportMissingFields(row).length > 0,
|
||||
);
|
||||
if (missingRows.length === 0) return;
|
||||
const enriched = await enrichRows(missingRows, accounts.results);
|
||||
const latestAccounts = await loadAccounts();
|
||||
const analyzed = analyzeRows(enriched, latestAccounts.results).filter(
|
||||
(row) => row.action !== "error",
|
||||
);
|
||||
await writeAnalyzedRows(analyzed);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!(await isManagerRequest(request))) return managerForbidden();
|
||||
try {
|
||||
@@ -230,17 +423,30 @@ export async function POST(request: Request) {
|
||||
return Response.json({ error: "请选择需要导入的 Excel 文件" }, { status: 400 });
|
||||
}
|
||||
if (file.size <= 0 || file.size > RESOURCE_IMPORT_MAX_BYTES) {
|
||||
return Response.json({ error: "文件不能为空,且不能超过 5MB" }, { status: 400 });
|
||||
return Response.json({ error: "文件不能为空,且不能超过 20MB" }, { status: 400 });
|
||||
}
|
||||
const rows = parseResourceImportFile(file.name, new Uint8Array(await file.arrayBuffer()));
|
||||
const accounts = await loadAccounts();
|
||||
const enriched = await enrichRows(rows, accounts.results);
|
||||
const analyzed = analyzeRows(enriched, accounts.results);
|
||||
const shouldEnrichSynchronously = rows.length <= RESOURCE_IMPORT_SYNC_ENRICH_ROWS;
|
||||
const preparedRows = shouldEnrichSynchronously
|
||||
? await enrichRows(rows, accounts.results)
|
||||
: mergeExistingFields(rows, accounts.results);
|
||||
const analyzed = analyzeRows(preparedRows, accounts.results);
|
||||
const summary = summarize(analyzed);
|
||||
const importableRows = analyzed.filter((row) => row.action !== "error");
|
||||
const importableRowNumbers = new Set(
|
||||
importableRows.map((row) => row.rowNumber),
|
||||
);
|
||||
const importablePreparedRows = preparedRows.filter((row) =>
|
||||
importableRowNumbers.has(row.rowNumber),
|
||||
);
|
||||
const deferredEnrichment = shouldEnrichSynchronously
|
||||
? 0
|
||||
: deferredEnrichmentCount(importablePreparedRows);
|
||||
if (mode !== "commit") {
|
||||
return Response.json({
|
||||
summary,
|
||||
rows: analyzed.slice(0, 100).map((row) => ({
|
||||
rows: previewAnalyzedRows(analyzed).map((row) => ({
|
||||
rowNumber: row.rowNumber,
|
||||
platform: row.platform,
|
||||
nickname: row.nickname,
|
||||
@@ -248,74 +454,44 @@ export async function POST(request: Request) {
|
||||
profileUrl: row.profileUrl,
|
||||
ipLocation: row.ipLocation,
|
||||
followers: row.followers,
|
||||
gender: row.gender,
|
||||
bio: row.bio,
|
||||
tags: row.tags,
|
||||
cooperationSource: row.cooperationSource,
|
||||
action: row.action,
|
||||
errors: row.errors,
|
||||
})),
|
||||
truncated: analyzed.length > 100,
|
||||
truncated: analyzed.length > RESOURCE_IMPORT_PREVIEW_ROWS,
|
||||
deferredEnrichment,
|
||||
maxRows: RESOURCE_IMPORT_MAX_ROWS,
|
||||
});
|
||||
}
|
||||
if (summary.error > 0) {
|
||||
if (importableRows.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `有 ${summary.error} 行数据未通过校验,请修正后重新上传`, summary },
|
||||
{ error: "没有可导入的有效账号,请修正异常数据后重新上传", summary },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const db = getRawDb();
|
||||
const statements = analyzed.map((row) =>
|
||||
row.action === "update"
|
||||
? db
|
||||
.prepare(
|
||||
`UPDATE accounts SET
|
||||
nickname = ?,
|
||||
public_account_id = CASE WHEN ? != '' THEN ? ELSE public_account_id END,
|
||||
profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END,
|
||||
ip_location = CASE
|
||||
WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END,
|
||||
followers = CASE WHEN ? = 1 THEN ? ELSE followers END,
|
||||
cooperation_source = ?,
|
||||
last_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(
|
||||
row.nickname,
|
||||
row.publicAccountId,
|
||||
row.publicAccountId,
|
||||
row.profileUrl,
|
||||
row.profileUrl,
|
||||
row.ipLocation,
|
||||
row.ipLocation,
|
||||
row.ipLocation,
|
||||
row.followersResolved ? 1 : 0,
|
||||
row.followers,
|
||||
row.cooperationSource,
|
||||
row.accountId,
|
||||
)
|
||||
: db
|
||||
.prepare(
|
||||
`INSERT INTO accounts
|
||||
(id, platform, platform_uid, public_account_id, nickname,
|
||||
profile_url, ip_location, followers, post_count, avg_views,
|
||||
cooperation_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?)`,
|
||||
)
|
||||
.bind(
|
||||
row.accountId,
|
||||
row.platform,
|
||||
row.platformUid,
|
||||
row.publicAccountId,
|
||||
row.nickname,
|
||||
row.profileUrl,
|
||||
row.ipLocation,
|
||||
row.followers,
|
||||
row.cooperationSource,
|
||||
),
|
||||
);
|
||||
if (statements.length > 0) await db.batch(statements);
|
||||
await writeAnalyzedRows(importableRows);
|
||||
if (deferredEnrichment > 0) {
|
||||
const importableSourceRows = rows.filter((row) =>
|
||||
importableRowNumbers.has(row.rowNumber),
|
||||
);
|
||||
runInBackground(
|
||||
enrichImportedRowsInBackground(importableSourceRows),
|
||||
"bulk resource profile enrichment",
|
||||
);
|
||||
}
|
||||
return Response.json({
|
||||
summary,
|
||||
message: `已导入 ${summary.create} 个新账号,更新 ${summary.update} 个已有账号`,
|
||||
deferredEnrichment,
|
||||
message: `已导入 ${summary.create} 个新账号,更新 ${summary.update} 个已有账号${
|
||||
summary.error > 0 ? `;跳过 ${summary.error} 条异常数据` : ""
|
||||
}${
|
||||
deferredEnrichment > 0
|
||||
? `;${deferredEnrichment} 个账号的缺失公开资料将在后台补全`
|
||||
: ""
|
||||
}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "导入失败";
|
||||
|
||||
734
app/globals.css
734
app/globals.css
@@ -235,6 +235,109 @@ button:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.platform-badge {
|
||||
display: inline-flex !important;
|
||||
width: auto !important;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
gap: 5px;
|
||||
margin: 0 !important;
|
||||
padding: 2px 7px 2px 3px;
|
||||
border: 1px solid #e3e8e5;
|
||||
border-radius: 8px;
|
||||
color: #52615c !important;
|
||||
background: #f7f9f7;
|
||||
font-size: 9px !important;
|
||||
font-weight: 700;
|
||||
line-height: 1 !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platform-badge.compact {
|
||||
height: 19px;
|
||||
gap: 4px;
|
||||
padding: 2px 5px 2px 2px;
|
||||
border-radius: 6px;
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
.platform-logo {
|
||||
display: grid !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
margin: 0 !important;
|
||||
border-radius: 5px;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.platform-badge.compact .platform-logo {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.platform-logo.xiaohongshu {
|
||||
color: white !important;
|
||||
background: #ff2442;
|
||||
}
|
||||
|
||||
.platform-logo.xiaohongshu b {
|
||||
color: inherit;
|
||||
font-size: 5px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.12em;
|
||||
transform: translateX(-0.2px);
|
||||
}
|
||||
|
||||
.platform-badge.compact .platform-logo.xiaohongshu b {
|
||||
font-size: 4px;
|
||||
}
|
||||
|
||||
.platform-logo.douyin {
|
||||
background: #080b12;
|
||||
}
|
||||
|
||||
.platform-logo.douyin svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.platform-badge.compact .platform-logo.douyin svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.platform-logo.douyin .douyin-cyan { fill: #25f4ee; transform: translate(-0.7px, 0.7px); }
|
||||
.platform-logo.douyin .douyin-red { fill: #fe2c55; transform: translate(0.7px, -0.3px); }
|
||||
.platform-logo.douyin .douyin-white { fill: #fff; }
|
||||
|
||||
.platform-logo.other {
|
||||
color: white !important;
|
||||
background: #7b8783;
|
||||
}
|
||||
|
||||
.platform-logo.other b {
|
||||
color: inherit;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.platform-meta-line {
|
||||
display: inline-flex !important;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.platform-meta-line > span {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
@@ -1508,13 +1611,270 @@ a {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.task-scope-head > div:nth-child(2) > span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
.task-scope-subline {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.task-scope-subline > span:first-child {
|
||||
color: #929c98;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.content-format-badge {
|
||||
display: inline-flex;
|
||||
height: 19px;
|
||||
align-items: center;
|
||||
padding: 0 7px;
|
||||
border: 1px solid #dce8e3;
|
||||
border-radius: 6px;
|
||||
color: #477064;
|
||||
background: #f0f7f4;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.content-format-badge.video {
|
||||
border-color: #ddd6ed;
|
||||
color: #6a568c;
|
||||
background: #f5f1fb;
|
||||
}
|
||||
|
||||
.content-format-badge.screenshot {
|
||||
border-color: #d5e6eb;
|
||||
color: #47727d;
|
||||
background: #eef7f9;
|
||||
}
|
||||
|
||||
.distribution-task-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 9px;
|
||||
padding: 13px;
|
||||
border: 1px solid #e5ebe8;
|
||||
border-radius: 12px;
|
||||
background: #f8faf8;
|
||||
}
|
||||
|
||||
.distribution-task-search {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-width: 240px;
|
||||
height: 38px;
|
||||
flex: 1 1 320px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dfe6e2;
|
||||
border-radius: 9px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.distribution-task-search:focus-within {
|
||||
border-color: #74b79f;
|
||||
box-shadow: 0 0 0 3px rgb(31 143 107 / 0.08);
|
||||
}
|
||||
|
||||
.distribution-task-search > span {
|
||||
display: grid;
|
||||
width: 18px;
|
||||
flex: 0 0 18px;
|
||||
place-items: center;
|
||||
color: #83918c;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.distribution-task-search input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.distribution-task-filter-group {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.distribution-task-filter-combobox {
|
||||
position: relative;
|
||||
width: 150px;
|
||||
flex: 0 1 150px;
|
||||
}
|
||||
|
||||
.distribution-task-filter-combobox.brand {
|
||||
width: 176px;
|
||||
flex-basis: 176px;
|
||||
}
|
||||
|
||||
.distribution-task-filter-input {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
flex: none;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #dfe6e2;
|
||||
border-radius: 9px;
|
||||
outline: 0;
|
||||
color: #28332f;
|
||||
background: white;
|
||||
font-size: 9px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.distribution-task-filter-input > span {
|
||||
display: grid;
|
||||
width: 16px;
|
||||
flex: 0 0 16px;
|
||||
place-items: center;
|
||||
color: #83918c;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.distribution-task-filter-input input {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: #28332f;
|
||||
background: transparent;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.distribution-task-filter-input:hover,
|
||||
.distribution-task-filter-input:focus-within {
|
||||
border-color: #74b79f;
|
||||
box-shadow: 0 0 0 3px rgb(31 143 107 / 0.08);
|
||||
}
|
||||
|
||||
.distribution-task-filter-menu {
|
||||
position: absolute;
|
||||
z-index: 60;
|
||||
top: calc(100% + 7px);
|
||||
left: 0;
|
||||
display: grid;
|
||||
width: max(100%, 230px);
|
||||
padding: 8px;
|
||||
border: 1px solid #dce8e3;
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
box-shadow: 0 16px 36px rgb(20 45 37 / 0.16);
|
||||
}
|
||||
|
||||
.distribution-task-filter-menu-options {
|
||||
display: grid;
|
||||
max-height: 240px;
|
||||
gap: 3px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.distribution-task-filter-menu-options button {
|
||||
display: flex;
|
||||
min-height: 34px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #34423d;
|
||||
background: transparent;
|
||||
font-size: 9px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.distribution-task-filter-menu-options button:hover,
|
||||
.distribution-task-filter-menu-options button:focus-visible {
|
||||
outline: 0;
|
||||
background: #f3f9f6;
|
||||
}
|
||||
|
||||
.distribution-task-filter-menu-options button.selected {
|
||||
color: var(--green-deep);
|
||||
background: #eaf6f1;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.distribution-task-filter-menu-options button small {
|
||||
color: #6f9d8d;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.distribution-task-filter-empty {
|
||||
padding: 12px 10px;
|
||||
color: #98a39f;
|
||||
font-size: 9px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.distribution-task-clear {
|
||||
height: 38px;
|
||||
padding: 0 6px;
|
||||
border: 0;
|
||||
color: var(--green-deep);
|
||||
background: transparent;
|
||||
font-size: 9px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.distribution-task-count {
|
||||
margin-left: auto;
|
||||
color: #8c9894;
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.distribution-task-count b {
|
||||
color: var(--ink);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.distribution-task-empty {
|
||||
display: grid;
|
||||
min-height: 190px;
|
||||
place-content: center;
|
||||
gap: 7px;
|
||||
border: 1px dashed #dce5e1;
|
||||
border-radius: 15px;
|
||||
color: #8d9894;
|
||||
background: #fbfcfb;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.distribution-task-empty strong {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.distribution-task-empty span {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.task-source-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2024,6 +2384,17 @@ a {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.import-preview-warning {
|
||||
margin: 8px 0 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #f0d5cc;
|
||||
border-radius: 8px;
|
||||
color: #a95037;
|
||||
background: #fff7f3;
|
||||
font-size: 9px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
@@ -2037,136 +2408,299 @@ a {
|
||||
.resource-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.resource-card {
|
||||
padding: 18px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e9e6;
|
||||
border-radius: 14px;
|
||||
border-top-width: 3px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
transition: 150ms ease;
|
||||
box-shadow: 0 8px 24px rgb(23 56 45 / 0.035);
|
||||
transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.resource-card.platform-xhs {
|
||||
border-top-color: #ff5470;
|
||||
}
|
||||
|
||||
.resource-card.platform-douyin {
|
||||
border-top-color: #25383a;
|
||||
}
|
||||
|
||||
.resource-card.platform-other {
|
||||
border-top-color: #8b9692;
|
||||
}
|
||||
|
||||
.resource-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateY(-2px);
|
||||
border-color: #d6e0dc;
|
||||
box-shadow: 0 14px 34px rgb(23 56 45 / 0.09);
|
||||
}
|
||||
|
||||
.resource-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.resource-card-head > div:nth-child(2) {
|
||||
.resource-card-head .resource-profile-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.resource-profile-avatar.avatar.xlarge {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border: 3px solid #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px #e6ebe8;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.resource-card-head h3 {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: #1f2c28;
|
||||
font-size: 14px;
|
||||
font-weight: 720;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-card-head span {
|
||||
margin-top: 4px;
|
||||
color: #8c9894;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.verified-dot {
|
||||
display: grid;
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #fff !important;
|
||||
background: var(--green);
|
||||
font-size: 9px !important;
|
||||
}
|
||||
|
||||
.resource-account-id {
|
||||
.resource-name-line {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-top: 14px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
color: #77847f;
|
||||
background: #f5f7f5;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.resource-account-id span {
|
||||
.resource-name-line h3 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gender-icon {
|
||||
display: inline-grid;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex: 0 0 auto;
|
||||
font-size: 8px;
|
||||
place-items: center;
|
||||
margin: 0 !important;
|
||||
border-radius: 50%;
|
||||
font-size: 11px !important;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.resource-account-id strong {
|
||||
.gender-icon.male {
|
||||
color: #347a9c !important;
|
||||
background: #e8f4fa;
|
||||
}
|
||||
|
||||
.gender-icon.female {
|
||||
color: #b95778 !important;
|
||||
background: #fbeaf0;
|
||||
}
|
||||
|
||||
.resource-account-number {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.resource-account-number span {
|
||||
flex: 0 0 auto;
|
||||
color: #9aa4a0;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.resource-account-number strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #40534d;
|
||||
color: #63706c;
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
user-select: all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-platform-line {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.resource-current-contact {
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
line-height: 1.4;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.resource-current-contact strong {
|
||||
color: var(--ink-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.resource-location {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #7e8a86;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-bio {
|
||||
display: -webkit-box;
|
||||
min-height: 43px;
|
||||
margin: 13px 0 0;
|
||||
overflow: hidden;
|
||||
color: #58635f;
|
||||
font-size: 10px;
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.resource-bio.empty {
|
||||
min-height: auto;
|
||||
color: #adb5b2;
|
||||
}
|
||||
|
||||
.resource-tags {
|
||||
min-height: 23px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.resource-tags > div {
|
||||
display: flex;
|
||||
max-height: 50px;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resource-tags b {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #dcebe5;
|
||||
border-radius: 7px;
|
||||
color: #32765f;
|
||||
background: #f4faf7;
|
||||
font-size: 9px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.resource-card.platform-xhs .resource-tags b {
|
||||
border-color: #f2dce2;
|
||||
color: #9b4e63;
|
||||
background: #fff2f5;
|
||||
}
|
||||
|
||||
.resource-tags.empty b {
|
||||
border-color: #e7ebe9;
|
||||
color: #a0aaa6;
|
||||
background: #f7f8f7;
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.resource-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
margin-top: 12px;
|
||||
padding: 13px 0;
|
||||
border-block: 1px solid #eef1ef;
|
||||
padding: 9px 10px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #f6f8f7;
|
||||
}
|
||||
|
||||
.resource-metrics > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #eef1ef;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
padding-inline: 9px;
|
||||
border-right: 1px solid #e1e7e4;
|
||||
}
|
||||
|
||||
.resource-metrics > div:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.resource-metrics > div:last-child {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
padding-right: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.resource-metrics span,
|
||||
.resource-source > span {
|
||||
color: #9ba4a1;
|
||||
font-size: 8px;
|
||||
.resource-metrics span {
|
||||
color: #98a39f;
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-metrics strong {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
color: #24332e;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-source {
|
||||
margin-top: 13px;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.resource-source > span {
|
||||
flex: 0 0 auto;
|
||||
color: #9ba4a1;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.resource-source > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 5px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resource-source b {
|
||||
padding: 5px 7px;
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
color: #567068;
|
||||
background: #eef4f1;
|
||||
font-size: 8px;
|
||||
font-size: 9px;
|
||||
font-weight: 580;
|
||||
}
|
||||
|
||||
@@ -2175,17 +2709,31 @@ a {
|
||||
background: #fff3e8;
|
||||
}
|
||||
|
||||
.resource-source b.empty {
|
||||
color: #9fa8a5;
|
||||
background: #f4f6f5;
|
||||
}
|
||||
|
||||
.resource-card-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 14px;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #eef1ef;
|
||||
color: #9aa39f;
|
||||
font-size: 8px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.resource-card-foot .resource-source {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.resource-card-foot a {
|
||||
color: var(--green);
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-empty {
|
||||
@@ -2635,6 +3183,34 @@ a {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.recovery-row-actions {
|
||||
display: flex;
|
||||
min-width: 82px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.publish-url-edit-button {
|
||||
padding: 2px 0;
|
||||
border: 0;
|
||||
color: #168565;
|
||||
background: transparent;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.publish-url-edit-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.publish-url-edit-button:disabled {
|
||||
color: #aab4b0;
|
||||
cursor: wait;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
display: inline-flex;
|
||||
height: 28px;
|
||||
@@ -3829,6 +4405,11 @@ label small {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.distribution-task-count {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.workflow-track {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -4014,6 +4595,35 @@ label small {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.distribution-task-toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.distribution-task-search,
|
||||
.distribution-task-filter-group,
|
||||
.distribution-task-filter-combobox,
|
||||
.distribution-task-filter-combobox.brand {
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.distribution-task-filter-group {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.distribution-task-clear {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.distribution-task-count {
|
||||
display: flex;
|
||||
width: auto;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.resource-search,
|
||||
.resource-filter-search {
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user