diff --git a/.gitignore b/.gitignore index ed0e05c..d6fb771 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ yarn-error.log* # typescript next-env.d.ts +*.tsbuildinfo /dist/ /.wrangler/ /outputs/ diff --git a/app/admin-app.tsx b/app/admin-app.tsx index 5f63210..7eaf551 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -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 ( + + + {platform || "未知平台"} + + ); +} + function statusLabel(status: string) { const labels: Record = { 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(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 }) { {taskForm.taskType === "content_publish" ? ( <> +
+ + +
{sourcePreview && (
@@ -1078,7 +1246,11 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { 工作表 {sourcePreview.sheetId} · 已匹配 {sourcePreview.columns.length} 个字段
- {sourcePreview.rowCount} 篇 + + {sourcePreview.rowCount} 条 · {taskForm.contentFormat === "video" + ? `${sourcePreview.videoCount} 个视频` + : `${sourcePreview.imageCount} 张图片`} +
{sourcePreview.columns.map((column) => {column})} @@ -1332,7 +1504,7 @@ function OverviewPage({

今天最需要推进

把已发布笔记的数据收回来

-

按任务选择采集日期,系统在当天09:00更新点赞、收藏、评论和总互动。

+

按任务选择采集日期,系统在当天09:00更新点赞、收藏、评论、转发和总互动。

@@ -1349,7 +1521,14 @@ function OverviewPage({
{task.brand.slice(0, 1)}
-
{task.name}{task.brand}
+
+ {task.name} + + {task.brand} + + {task.content_format === "video" ? "视频" : "图文"} + +
{progress}%
@@ -1409,12 +1588,16 @@ function TasksPage({
{task.brand.slice(0, 1)}

{task.name}

{task.brand}
- - {task.task_type === "screenshot_collect" - ? "截图回收 · 关键词任务" - : task.source_sheet_name - ? `飞书 · ${task.source_sheet_name}` - : "历史示例内容表"} + + {task.task_type === "screenshot_collect" ? ( + 截图回收 · 关键词任务 + ) : ( + <> + + {task.content_format === "video" ? "视频" : "图文"} + {task.source_sheet_name ? `飞书 ${task.source_sheet_name}` : "历史示例内容表"} + + )}
@@ -1489,7 +1672,13 @@ function TaskScopeList({

{task.name}

- {task.brand} · {formatDate(task.due_at)} 截止 +
+ {task.brand} · {formatDate(task.due_at)} 截止 + + + {isScreenshotTask ? "截图回收" : task.content_format === "video" ? "视频" : "图文"} + +
{statusLabel(task.status)} @@ -1560,7 +1749,19 @@ function TaskDetailHeader({

{label}

{task.name}

- {task.brand} · {task.quantity} {task.task_type === "screenshot_collect" ? "份" : "篇"} · {formatDate(task.due_at)} 截止 + + {task.brand} + {task.task_type === "screenshot_collect" ? ( + 截图回收 + ) : ( + <> + + {task.content_format === "video" ? "视频" : "图文"} + + )} + {task.quantity} {task.task_type === "screenshot_collect" ? "份" : "篇"} + {formatDate(task.due_at)} 截止 +
{task.task_type !== "screenshot_collect" && task.source_url && ( @@ -1585,6 +1786,74 @@ function DistributionPage({ onRelease: (distribution: Distribution) => Promise; }) { const [selectedTaskId, setSelectedTaskId] = useState(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(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(null); const selectedTask = tasks.find((task) => task.id === selectedTaskId); if (!selectedTask) { @@ -1594,12 +1863,110 @@ function DistributionPage({

按任务管理

选择一个分发任务

每个任务独立查看领取、发布和账号识别进度,不混排不同项目的笔记。

- +
+ +
+ {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 ( +
+
+ + setOpenTaskFilter(filter.key)} + onChange={(event) => { + setTaskFilterValue(filter.key, event.target.value); + setOpenTaskFilter(filter.key); + }} + onKeyDown={(event) => { + if (event.key === "Escape") setOpenTaskFilter(null); + }} + /> +
+ {openTaskFilter === filter.key ? ( +
+
+ {visibleOptions.map((option) => ( + + ))} + {visibleOptions.length === 0 ? ( + 没有匹配项 + ) : null} +
+
+ ) : null} +
+ ); + })} +
+ + {filteredTasks.length} 个任务 +
+ {filteredTasks.length > 0 ? ( + + ) : ( +
+ 没有符合条件的任务 + 可以调整任务名称或清空筛选条件 +
+ )} ); } @@ -1737,7 +2104,7 @@ function DistributionTable({ {item.account_nickname ? (
{item.account_nickname.slice(0, 1)}
-
{item.account_nickname}{item.account_platform}
+
{item.account_nickname}
) : ( 回填链接后识别 @@ -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} + ))} @@ -2145,9 +2522,9 @@ function ResourcesPage({
setQuery(event.target.value)} - placeholder="搜索账号名称 / 账号ID" + placeholder="搜索账号名称 / 账号ID / 当前联系人 / 标签" type="search" value={query} /> @@ -2203,36 +2580,86 @@ function ResourcesPage({
- {filteredAccounts.map(({ account, sources, partnerManagedOnly }) => ( -
+ {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 ( +
-
{account.nickname.slice(0, 1)}
-

{account.nickname}

{account.platform} · {account.ip_location}
- -
-
- {account.platform === "小红书" ? "小红书号" : account.platform === "抖音" ? "抖音号" : "账号"} - {account.public_account_id || "待识别"} -
-
-
粉丝{formatNumber(account.followers)}
-
合作发布{account.post_count}
-
-
- 历史合作来源 -
- {sources.map((source) => {source})} - {partnerManagedOnly && ( - 合作社资源 · 不可直联 - )} +
{account.nickname.slice(0, 1)}
+
+
+

{account.nickname}

+ {account.gender === "男" && ( + + )} + {account.gender === "女" && ( + + )} +
+
+ {accountIdLabel} + {account.public_account_id || "待识别"} +
+
+ + IP属地 · {account.ip_location || "待识别"} +
+
+ 当前联系人 + {account.current_contact || "待补充"} +
+

+ {hasBio ? rawBio : "暂无简介"} +

+
+
+ {accountTags.length > 0 + ? accountTags.map((tag) => {tag}) + : 待打标} +
+
+
+
粉丝{formatNumber(account.followers)}
+
合作发布{account.post_count}
+
最近合作{formatDate(account.last_seen_at)}
+
- 最近合作 {formatDate(account.last_seen_at)} - {account.profile_url && 查看主页 ↗} +
+ 合作来源 +
+ {sources.map((source) => {source})} + {partnerManagedOnly && ( + 合作社资源 · 不可直联 + )} + {sources.length === 0 && !partnerManagedOnly && 暂无} +
+
+ {profileLink && {profileLink.label}}
- ))} + ); + })}
{filteredAccounts.length === 0 && (
@@ -2278,11 +2705,11 @@ function ResourcesPage({ }} /> {importFile ? importFile.name : "选择 Excel / CSV 文件"} - 单次最多 100 个账号,文件不超过 5MB + 单次最多 10,000 个账号,文件不超过 20MB {!importPreview && (
-
还没有模板?只有账号链接必填;已有昵称、账号ID、IP属地、粉丝数和合作来源可直接填写,系统只补全空缺字段。
+
还没有模板?只有账号链接必填;性别、简介、标签等资料均可选填,多个标签请用逗号分隔且最多 5 个,系统只补全空缺字段。
下载模板
)} @@ -2301,7 +2728,7 @@ function ResourcesPage({ {importPreview.rows.slice(0, 8).map((row) => (
{row.rowNumber} - {row.nickname || "—"}{row.platform || "未填写平台"} + {row.nickname || "—"} {row.publicAccountId || "主页识别"} {row.ipLocation}{row.cooperationSource || "未填写来源"} @@ -2311,7 +2738,17 @@ function ResourcesPage({ ))}
{(importPreview.summary.total > 8 || importPreview.truncated) && ( -

这里只预览前 8 行,确认导入时会处理全部数据。

+

这里只预览 8 行,异常数据会优先展示;确认导入时会处理全部有效数据。

+ )} + {importPreview.summary.error > 0 && ( +

+ {importPreview.summary.error} 条异常数据将自动跳过,不会导入;其余 {importPreview.summary.create + importPreview.summary.update} 个有效账号可正常导入。 +

+ )} + {Boolean(importPreview.deferredEnrichment) && ( +

+ 其中 {importPreview.deferredEnrichment} 个账号缺少公开资料;确认后会先导入资源库,再在后台逐步补全。 +

)}
)} @@ -2331,7 +2768,7 @@ function ResourcesPage({ - ) : ( - - )} +
+ {item.screenshot_key && !hasCreatorMetrics(item) ? ( + + ) : ( + + )} + {canUpdatePublishUrl && ( + + )} +
); diff --git a/app/api/action/route.ts b/app/api/action/route.ts index 31170ce..75ff44b 100644 --- a/app/api/action/route.ts +++ b/app/api/action/route.ts @@ -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(); diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 4d05b3b..eadb9ba 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -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, diff --git a/app/api/partner-batch-workbook/route.ts b/app/api/partner-batch-workbook/route.ts new file mode 100644 index 0000000..8240257 --- /dev/null +++ b/app/api/partner-batch-workbook/route.ts @@ -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 { + 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() + : 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(); + 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() + : 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(); + 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 = []; + 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(); + 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); +} diff --git a/app/api/partner-image/route.ts b/app/api/partner-image/route.ts index 032d2e3..c928132 100644 --- a/app/api/partner-image/route.ts +++ b/app/api/partner-image/route.ts @@ -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 }, ); } diff --git a/app/api/partner/route.ts b/app/api/partner/route.ts index 813341f..0ab805d 100644 --- a/app/api/partner/route.ts +++ b/app/api/partner/route.ts @@ -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(); } 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(); } 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 }); } diff --git a/app/api/recovery-export/route.ts b/app/api/recovery-export/route.ts index d4ed82a..6c8cad5 100644 --- a/app/api/recovery-export/route.ts +++ b/app/api/recovery-export/route.ts @@ -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, diff --git a/app/api/resources-export/route.ts b/app/api/resources-export/route.ts index ef31c15..89f2eb0 100644 --- a/app/api/resources-export/route.ts +++ b/app/api/resources-export/route.ts @@ -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(), @@ -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, ], diff --git a/app/api/resources-import/route.ts b/app/api/resources-import/route.ts index 26b3022..778d000 100644 --- a/app/api/resources-import/route.ts +++ b/app/api/resources-import/route.ts @@ -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(); @@ -71,7 +81,7 @@ async function mapConcurrent( return results; } -async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) { +function mergeExistingFields(rows: ResourceImportRow[], accounts: AccountRow[]) { const existingByProfile = new Map(); 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, + 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 : "导入失败"; diff --git a/app/globals.css b/app/globals.css index 1a9d597..60c4e03 100644 --- a/app/globals.css +++ b/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%; diff --git a/db/schema.ts b/db/schema.ts index 7f5ab7b..ad4b968 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -37,6 +37,10 @@ export const tasks = mysqlTable( taskType: varchar("task_type", { length: 32 }) .notNull() .default("content_publish"), + platform: varchar("platform", { length: 32 }).notNull().default("小红书"), + contentFormat: varchar("content_format", { length: 32 }) + .notNull() + .default("image_text"), sourceUrl: text("source_url").notNull(), sourceSheetId: varchar("source_sheet_id", { length: 255 }).notNull().default(""), sourceSheetName: varchar("source_sheet_name", { length: 255 }).notNull().default(""), @@ -59,6 +63,7 @@ export const contents = mysqlTable("contents", { title: text("title").notNull(), body: text("body").notNull(), imageAssets: text("image_assets").notNull(), + videoAssets: text("video_assets").notNull(), status: varchar("status", { length: 32 }).notNull().default("available"), source: varchar("source", { length: 255 }).notNull().default("飞书内容表"), sourceRow: int("source_row"), @@ -76,11 +81,17 @@ export const accounts = mysqlTable( profileUrl: text("profile_url").notNull(), ipLocation: varchar("ip_location", { length: 255 }).notNull().default("待识别"), followers: int("followers").notNull().default(0), + gender: varchar("gender", { length: 16 }).notNull().default(""), + bio: text("bio").notNull().default(""), + tags: varchar("tags", { length: 500 }).notNull().default(""), postCount: int("post_count").notNull().default(0), avgViews: int("avg_views").notNull().default(0), cooperationSource: varchar("cooperation_source", { length: 500 }) .notNull() .default(""), + currentContact: varchar("current_contact", { length: 255 }) + .notNull() + .default(""), firstSeenAt: timestamp("first_seen_at"), lastSeenAt: timestamp("last_seen_at"), }, @@ -162,6 +173,7 @@ export const distributions = mysqlTable("distributions", { latestLikes: int("latest_likes"), latestComments: int("latest_comments"), latestCollects: int("latest_collects"), + latestShares: int("latest_shares"), collectionStatus: varchar("collection_status", { length: 32 }) .notNull() .default("pending"), @@ -184,6 +196,7 @@ export const collectionRuns = mysqlTable( likes: int("likes"), comments: int("comments"), collects: int("collects"), + shares: int("shares"), statusDescription: text("status_description"), startedAt: datetime("started_at", { mode: "string", fsp: 3 }), completedAt: datetime("completed_at", { mode: "string", fsp: 3 }), diff --git a/deploy/nginx/koc-loop.conf b/deploy/nginx/koc-loop.conf index bcd2f5a..8e02c34 100644 --- a/deploy/nginx/koc-loop.conf +++ b/deploy/nginx/koc-loop.conf @@ -2,7 +2,8 @@ server { listen 80; server_name _; - client_max_body_size 10m; + # 批量回填 Excel 会内嵌多篇笔记原图和截图。 + client_max_body_size 85m; location = /koc { return 301 /koc/$is_args$args; @@ -21,7 +22,8 @@ server { proxy_buffering off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; - proxy_set_header Host $host; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; @@ -30,7 +32,8 @@ server { location / { proxy_pass http://app:3000; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/design-qa-comparison.png b/design-qa-comparison.png new file mode 100644 index 0000000..26e3341 Binary files /dev/null and b/design-qa-comparison.png differ diff --git a/design-qa-current-filter.png b/design-qa-current-filter.png new file mode 100644 index 0000000..592d548 Binary files /dev/null and b/design-qa-current-filter.png differ diff --git a/design-qa-filter-comparison.png b/design-qa-filter-comparison.png new file mode 100644 index 0000000..5f047a4 Binary files /dev/null and b/design-qa-filter-comparison.png differ diff --git a/design-qa-implementation.png b/design-qa-implementation.png new file mode 100644 index 0000000..a633548 Binary files /dev/null and b/design-qa-implementation.png differ diff --git a/design-qa-inline-filter-comparison.png b/design-qa-inline-filter-comparison.png new file mode 100644 index 0000000..aa9ac21 Binary files /dev/null and b/design-qa-inline-filter-comparison.png differ diff --git a/design-qa-inline-filter.png b/design-qa-inline-filter.png new file mode 100644 index 0000000..d905518 Binary files /dev/null and b/design-qa-inline-filter.png differ diff --git a/design-qa-resource-card-alignment-comparison.png b/design-qa-resource-card-alignment-comparison.png new file mode 100644 index 0000000..f1a52f0 Binary files /dev/null and b/design-qa-resource-card-alignment-comparison.png differ diff --git a/design-qa-resource-card-alignment-final.png b/design-qa-resource-card-alignment-final.png new file mode 100644 index 0000000..8e59887 Binary files /dev/null and b/design-qa-resource-card-alignment-final.png differ diff --git a/design-qa-resource-card-comparison.png b/design-qa-resource-card-comparison.png new file mode 100644 index 0000000..e437553 Binary files /dev/null and b/design-qa-resource-card-comparison.png differ diff --git a/design-qa-resource-card-metrics-alignment-comparison.png b/design-qa-resource-card-metrics-alignment-comparison.png new file mode 100644 index 0000000..75402ce Binary files /dev/null and b/design-qa-resource-card-metrics-alignment-comparison.png differ diff --git a/design-qa-resource-card-metrics-alignment-final.png b/design-qa-resource-card-metrics-alignment-final.png new file mode 100644 index 0000000..3e9c189 Binary files /dev/null and b/design-qa-resource-card-metrics-alignment-final.png differ diff --git a/design-qa-resource-card-metrics-alignment-two-column.png b/design-qa-resource-card-metrics-alignment-two-column.png new file mode 100644 index 0000000..06cca95 Binary files /dev/null and b/design-qa-resource-card-metrics-alignment-two-column.png differ diff --git a/design-qa-resource-cards-final.png b/design-qa-resource-cards-final.png new file mode 100644 index 0000000..873d3f5 Binary files /dev/null and b/design-qa-resource-cards-final.png differ diff --git a/design-qa-resource-cards-v1.png b/design-qa-resource-cards-v1.png new file mode 100644 index 0000000..5388fcf Binary files /dev/null and b/design-qa-resource-cards-v1.png differ diff --git a/design-qa-resource-cards-v2.png b/design-qa-resource-cards-v2.png new file mode 100644 index 0000000..22c6856 Binary files /dev/null and b/design-qa-resource-cards-v2.png differ diff --git a/design-qa.md b/design-qa.md index 8b5ce0c..27c6f45 100644 --- a/design-qa.md +++ b/design-qa.md @@ -45,3 +45,249 @@ 3. 重新构建后,浏览器识别到 1 条可点击标题;点击实际打开对应小红书页面,未回填行仍不可点击。 final result: passed + +--- + +# KOC LOOP 任务筛选常驻搜索框设计 QA + +## 验证对象 + +- 用户目标截图:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-67b0b080-8ea8-4504-82eb-348e8cf4ec19.png` +- 浏览器实现截图:`/private/tmp/koc-distributions-direct-search.png` +- CSS 视口:842 × 778,设备像素比 2 +- 本地页面:`http://localhost:8080/?nav=distributions` + +## 调整结果 + +1. 品牌/项目、内容类型、平台不再使用“先点下拉、再输入搜索”的两层结构,筛选栏直接展示三个可输入的搜索框。 +2. 输入内容时立即进行模糊筛选;聚焦或输入后,下方仅展示匹配候选项,不再重复显示第二个搜索框。 +3. 宽屏下四个搜索框保持同一行;当前窄视口下任务名称独占一行,三个分类搜索保持在下一行,不互相遮挡。 +4. 保留键盘与无障碍语义,三个分类搜索均使用 `combobox`,候选项使用 `listbox` / `option`。 + +## 功能验证 + +- 默认展示 6 个任务。 +- 品牌/项目直接输入“美团”后,候选项仅显示“美团”和“美团医美”,任务结果同步缩小为 2 个。 +- 清空输入后恢复 6 个任务。 +- 静态验收测试 14 项全部通过,正式构建通过;本地 Docker 应用已重新构建并加载新页面。 + +final result: passed + +--- + +# KOC LOOP 任务筛选下拉遮挡设计 QA + +## 验证对象 + +- 用户问题截图:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-bd2d14b7-472f-43a5-b997-1b6ff9528ba1.png` +- 浏览器实现截图:`/Users/wufp/Documents/koc-loop/design-qa-inline-filter.png` +- 修复前后并排对照:`/Users/wufp/Documents/koc-loop/design-qa-inline-filter-comparison.png` +- CSS 视口:1280 × 720,设备像素比 2 +- 本地页面:`http://localhost:8080/?nav=distributions` + +## 问题与调整 + +1. 之前仍使用浏览器原生 `select`,系统级下拉层会悬浮在页面上,因此即使搜索框本身布局正常,展开菜单仍会遮住搜索区或任务卡片。 +2. 品牌/项目、内容类型、平台三个筛选器改为页面内选择面板,面板使用正常文档流布局,展开时把任务卡片向下推。 +3. 同一时间只展开一个筛选器;选择选项后自动收起,并保留键盘焦点、`aria-expanded`、`listbox` 和 `option` 语义。 + +## 布局与功能验证 + +- 内容类型面板展开后:面板 `position: static`,搜索框底部为 379px,面板顶部为 388px,两者无重叠。 +- 第一张任务卡片顶部为 480px,面板底部为 452px,任务卡片位于面板下方,未被覆盖。 +- 选择“视频”后面板自动收起,任务数由 6 个缩小为 1 个;清空筛选后恢复 6 个任务。 +- 浏览器实测搜索框、三个筛选按钮和任务卡片均可正常操作,交互过程中未出现页面报错。 +- TypeScript 构建、静态验收及共 65 项测试全部通过;本地 Docker 服务已重新构建并通过健康检查。 + +final result: passed + +--- + +# KOC LOOP 任务分发筛选栏设计 QA + +## 验证对象 + +- 用户问题截图:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-4c32c7e3-58e0-4262-b625-c02b20ec4490.png` +- 浏览器实现截图:`/Users/wufp/Documents/koc-loop/design-qa-implementation.png` +- 修复前后并排对照:`/Users/wufp/Documents/koc-loop/design-qa-comparison.png` +- CSS 视口:1280 × 720 +- 本地页面:`http://localhost:8080/?nav=distributions` + +## 问题与调整 + +1. 全局 `label` 布局把搜索图标和输入框纵向堆叠,导致搜索框被拆成上下两层。 +2. 搜索框现在显式使用横向布局,并清除继承的外边距;图标使用固定宽度居中对齐。 +3. 品牌/项目下拉框加宽,选中值和下拉菜单锚点保持稳定,不再挤压搜索区域。 + +## 功能验证 + +- 输入“视频”后,结果从 6 个任务缩小为 1 个任务,筛选栏没有发生错位。 +- 清空搜索后恢复展示 6 个任务。 +- 浏览器控制台无错误。 +- TypeScript、静态验收测试和正式构建均通过。 +- 本地 Docker 服务已重建并通过健康检查。 + +final result: passed + +--- + +# KOC LOOP 可搜索任务筛选浮层设计 QA + +## 验证对象 + +- 参考截图:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-fc7ce949-d8ea-4a3b-afa6-04a778fdb8dc.png` +- 浏览器实现截图:`/Users/wufp/Documents/koc-loop/design-qa-current-filter.png` +- 参考与实现并排对照:`/Users/wufp/Documents/koc-loop/design-qa-filter-comparison.png` +- 本地页面:`http://localhost:8080/?nav=distributions` + +## 布局与交互验证 + +- 品牌/项目、内容类型、平台均改为紧凑的页面内浮层,不再使用系统原生下拉,也不会把任务卡片整体向下推。 +- 浮层锚定在当前筛选按钮下方,宽度与筛选控件协调,未遮挡任务名称搜索框及其他筛选按钮。 +- 浮层顶部支持输入模糊查询;输入“医”后只显示“美团医美”,可继续点击完成筛选。 +- 同一时间只展开一个筛选器,支持点击页面其他位置或按 Esc 收起。 +- 浏览器实测筛选栏、任务卡片和按钮均保持稳定,没有布局跳动。 + +## 数据修复验证 + +- 抖音作品重新采集后获得点赞 5,684、收藏 565、转发 6,878、评论 332。 +- 抖音主页链接改用短链跳转携带的真实 `sec_uid`,不再用数字内部用户 ID 拼接主页。 +- MCP 的公开主页解析对当前账号仍返回资源不存在,因此公开抖音号、粉丝数和 IP 暂时保持待识别,不再写入错误数据。 +- 相关自动化测试共 67 项全部通过,本地 Docker 服务已重建并通过健康检查。 + +final result: passed +# KOC LOOP 任务分发横向搜索框设计 QA + +## 验证对象 + +- 用户问题截图:`codex-clipboard-252bf483-c3d1-4d70-abfb-d54587a300a2.png` +- 浏览器实现截图:`/private/tmp/koc-loop-distribution-filters-fixed.png` +- 本地页面:`http://localhost:8080/?nav=distributions` + +## 问题与调整 + +1. 品牌/项目、内容类型、平台三个筛选框使用通用表单标签,继承了纵向排列样式,导致图标和提示文字上下分离。 +2. 筛选框改为独立容器,并显式使用横向排列和垂直居中。 +3. 保留原有模糊搜索、筛选逻辑及整体视觉规范。 + +## 布局与功能验证 + +- 三个筛选框内图标、输入文字与容器的垂直中心误差均为 0px。 +- 四个搜索框保持同排展示,无相互遮挡、无异常换行、无额外浮层。 +- 项目构建通过;页面渲染测试 14/14 通过;本地 Docker 运行版本已更新。 + +最终结果:通过。 + +--- + +# KOC LOOP KOC资源卡片密度优化设计 QA + +## 验证对象 + +- 参考卡片:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-615400a6-0bd1-4110-b280-f7fdc29bf705.png` +- 原页面参考:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-5bf2bfb5-a50e-4c08-a407-192fdcf20b58.png` +- 第一轮实现截图:`/Users/wufp/Documents/koc-loop/design-qa-resource-cards-v2.png` +- 最终浏览器截图:`/Users/wufp/Documents/koc-loop/design-qa-resource-cards-final.png` +- 聚焦卡片并排对照:`/Users/wufp/Documents/koc-loop/design-qa-resource-card-comparison.png` +- 本地页面:`http://localhost:8080/`,KOC资源状态 + +## 环境与归一化 + +- CSS 视口:1280 × 720;设备像素比 2;浏览器截图按 1280 × 720 CSS 像素输出。 +- 参考卡片像素:478 × 700;最终完整页面截图:1280 × 720。 +- 聚焦对照将实现中的 304px 宽卡片等比放大到 478px,并与参考卡片并排查看;没有把两张独立截图当作同一对比证据。 +- 桌面状态为三列卡片;同时验证 960px 两列、640px 一列。 + +## 完整画面对比 + +- 信息架构采用参考图的“头像 → 身份信息 → 简介 → 标签”顺序,同时保留 KOC LOOP 原有的粉丝、合作发布、合作来源和主页入口。 +- 卡片高度由旧版的大块分区缩短到首屏实测约 274—278px;三列宽度均为 304px,页面没有横向溢出。 +- 小红书使用粉红顶部识别线和柔和粉色标签,抖音使用深色顶部识别线,继续沿用现有平台标识。 + +## 聚焦区域检查 + +- 字体与层级:昵称 14px 并提高字重;账号号、属地、标签和数据从第一轮偏小的 8px 提升至 9—11px,信息仍紧凑但可读性更好。 +- 间距与布局:圆形头像、昵称与性别保持同一视觉组;账号号、平台和属地收进头像右侧;粉丝、合作发布、最近合作合并为一条浅底数据栏。 +- 颜色与视觉标记:标签颜色与平台呼应,状态对比足够;卡片 hover 只使用轻微位移和阴影,不改变布局。 +- 图片与资产:当前账号数据没有头像 URL,因此保留现有首字母头像作为明确的数据缺失状态,没有伪造真人头像;平台标识继续使用项目已有资产。 +- 文案与内容:真实简介最多展示三行;“未填写简介”“还没有简介”等占位内容统一折叠为“暂无简介”;无标签显示“待打标”;合作来源和主页入口合并到底部同一行。 + +## 交互与响应式验证 + +- 输入标签“美食探店”后,结果正确缩小为 3 个账号;清空后恢复完整列表。 +- 主页入口保持可见并保留现有跳转目标;首屏六张卡片均检测到有效入口文案。 +- 960px 视口为两列,640px 视口为一列,两个断点均无横向溢出。 +- 浏览器控制台无 error;本地应用、MySQL、Nginx 均正常运行。 +- 正式构建及完整自动化测试通过,共 82 项,无失败。 + +## 迭代记录 + +1. 第一轮已完成资料卡层级和高度压缩,但聚焦截图显示辅助文字偏小,无简介账号的底部留白仍较明显,记为 P2。 +2. 第二轮提高辅助文字字号,移除固定最小高度,并把合作来源与主页入口合并为同一底栏。 +3. 复查后卡片高度稳定在约 274—278px,关键内容可读,桌面与移动断点无溢出;先前 P2 已解决。 + +## 结论 + +- 没有遗留 P0、P1 或 P2 问题。 +- P3 后续项:如果 MCP 未来提供可靠头像 URL,可将首字母头像替换成真实头像,进一步接近参考图。 + +final result: passed + +--- + +# KOC LOOP KOC资源卡片底栏对齐设计 QA + +## 验证对象 + +- 用户标注截图:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-fa2c031d-52ba-4ef5-bd70-2bf74adfa52d.png` +- 最终浏览器截图:`/Users/wufp/Documents/koc-loop/design-qa-resource-card-alignment-final.png` +- 归一化并排对照:`/Users/wufp/Documents/koc-loop/design-qa-resource-card-alignment-comparison.png` +- 本地页面:`http://localhost:8080/?nav=resources` + +## 问题与调整 + +1. 不同账号的简介和标签数量不一致时,合作来源与主页入口会跟随前方内容上下浮动,导致同一排卡片的底部结构不齐。 +2. 卡片保持纵向弹性布局,底栏改为自动占用剩余空间并贴住卡片底部;不增加固定高度,不改变数据或交互逻辑。 +3. 三列桌面视口下实测前三张卡片高度均为 267.9px,底栏顶部均为 333.4px,底栏底部均为 365.9px,误差为 0px。 + +## 验证结果 + +- CSS 视口:1280 × 720;三列卡片状态。 +- 不同简介、标签数量下,合作来源、来源标签和主页入口保持同一条水平基线。 +- 浏览器控制台无 error;页面 hover 位移不会改变静止状态的布局基线。 +- 页面渲染测试与 TypeScript 检查通过;本地 Docker 已重建。 + +final result: passed + +--- + +# KOC LOOP KOC资源卡片数据栏对齐设计 QA + +## 验证对象 + +- 用户标注截图:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-78335120-710a-4a7b-96f9-b1046191c788.png` +- 双列实现截图:`/Users/wufp/Documents/koc-loop/design-qa-resource-card-metrics-alignment-two-column.png` +- 三列实现截图:`/Users/wufp/Documents/koc-loop/design-qa-resource-card-metrics-alignment-final.png` +- 归一化并排对照:`/Users/wufp/Documents/koc-loop/design-qa-resource-card-metrics-alignment-comparison.png` +- 本地页面:`http://localhost:8080/?nav=resources` + +## 环境与归一化 + +- 用户截图为 1674 × 1180px;双列实现截图为 837 × 591px。 +- 并排对照将用户截图归一化为 837 × 591px,与实现截图使用同一双列宽度和页面状态进行聚焦比较。 +- 同时在 1280 × 720 三列桌面视口复测,确认调整不依赖双列断点。 + +## 问题与调整 + +1. 先前仅把合作来源底栏贴底,数据栏仍跟随简介和标签数量上下浮动,形成 P2 对齐问题。 +2. 将弹性留白移动到标签区之后,数据栏与合作来源底栏作为一个完整结构贴住卡片底部;数据内容、标签和交互均未改动。 + +## 验证结果 + +- 双列第一排两张卡片的数据栏顶部均为 233.4px、底部均为 267.9px;底栏顶部均为 277.9px、底部均为 310.4px。 +- 双列第二排两张卡片的数据栏顶部均为 525.3px、底部均为 559.8px;底栏顶部均为 569.8px、底部均为 602.3px。 +- 三列前三张卡片的数据栏与底栏上下边界误差均为 0px。 +- 字体、颜色、图片资产和文案未发生变化;浏览器控制台无 error。 +- 页面渲染测试 14/14、TypeScript 检查和 Docker 构建均通过。 + +final result: passed diff --git a/koc-portal/app/batch-workbook-upload.ts b/koc-portal/app/batch-workbook-upload.ts new file mode 100644 index 0000000..2c30d66 --- /dev/null +++ b/koc-portal/app/batch-workbook-upload.ts @@ -0,0 +1,341 @@ +import { strFromU8, unzipSync, zipSync } from "fflate"; + +export const PARTNER_BATCH_UPLOAD_MAX_BYTES = 80_000_000; + +type WorkbookCell = { + reference: string; + row: number; + column: number; + attributes: string; + body: string; + value: string; +}; + +type ScreenshotColumns = { + headerRow: number; + columns: Set; +}; + +function decodeXml(value: string) { + return value + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&") + .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))) + .replace(/&#x([0-9a-f]+);/gi, (_, code) => + String.fromCodePoint(Number.parseInt(code, 16)), + ); +} + +function textNodes(xml: string) { + return [...xml.matchAll(/]*>([\s\S]*?)<\/t>/g)] + .map((match) => decodeXml(match[1])) + .join(""); +} + +function columnIndex(reference: string) { + const letters = reference.match(/^[A-Z]+/i)?.[0]?.toUpperCase() ?? ""; + let result = 0; + for (const letter of letters) result = result * 26 + letter.charCodeAt(0) - 64; + return Math.max(0, result - 1); +} + +function normalizeHeader(value: string) { + return value.replace(/[\s_\-()()]/g, "").toLocaleLowerCase("zh-CN"); +} + +function parseCells(worksheetXml: string, sharedStrings: string[]) { + const cells: WorkbookCell[] = []; + for (const match of worksheetXml.matchAll(/]*)>([\s\S]*?)<\/c>/g)) { + const attributes = match[1]; + const body = match[2]; + const reference = attributes.match(/\br="([A-Z]+\d+)"/i)?.[1] ?? ""; + if (!reference) continue; + const type = attributes.match(/\bt="([^"]+)"/)?.[1] ?? ""; + const rawValue = body.match(/([\s\S]*?)<\/v>/)?.[1] ?? ""; + const value = + type === "s" + ? sharedStrings[Number(rawValue)] ?? "" + : type === "inlineStr" + ? textNodes(body) + : decodeXml(rawValue); + cells.push({ + reference, + row: Number(reference.match(/\d+$/)?.[0] ?? 0), + column: columnIndex(reference), + attributes, + body, + value: value.trim(), + }); + } + return cells; +} + +function findScreenshotColumns(cells: WorkbookCell[]): ScreenshotColumns { + for (let row = 1; row <= 8; row += 1) { + const columns = new Set(); + for (const cell of cells) { + if (cell.row !== row) continue; + const header = normalizeHeader(cell.value); + if ( + header === normalizeHeader("笔记截图") || + header === normalizeHeader("发布截图") || + header === normalizeHeader("数据分析截图") || + header === normalizeHeader("数据分析截图(单篇笔记数据分析截图)") || + header === normalizeHeader("创作者中心截图") + ) { + columns.add(cell.column); + } + } + if (columns.size >= 2) return { headerRow: row, columns }; + } + throw new Error("表格结构不正确,请使用本领取页面导出的批量回填表"); +} + +function relationshipMap(xml: string) { + const relationships = new Map(); + for (const match of xml.matchAll(/]*)\/?\s*>/g)) { + const id = match[1].match(/\bId="([^"]+)"/)?.[1] ?? ""; + const target = match[1].match(/\bTarget="([^"]+)"/)?.[1] ?? ""; + if (id && target) relationships.set(id, decodeXml(target)); + } + return relationships; +} + +function normalizeZipPath(value: string) { + const result: string[] = []; + for (const part of value.split("/")) { + if (!part || part === ".") continue; + if (part === "..") result.pop(); + else result.push(part); + } + return result.join("/"); +} + +function resolveZipPath(base: string, target: string) { + const slash = base.lastIndexOf("/"); + const directory = slash >= 0 ? base.slice(0, slash + 1) : ""; + return normalizeZipPath(`${directory}${target}`); +} + +function wpsScreenshotMedia( + entries: Record, + cells: WorkbookCell[], + screenshotColumns: ScreenshotColumns, +) { + const result = new Set(); + let expected = 0; + const screenshotIds = new Set(); + for (const cell of cells) { + if ( + cell.row <= screenshotColumns.headerRow || + !screenshotColumns.columns.has(cell.column) + ) { + continue; + } + const id = decodeXml(cell.body).match(/DISPIMG\("([^"]+)"/i)?.[1]; + if (id) { + expected += 1; + screenshotIds.add(id); + } + } + if (screenshotIds.size === 0) return { result, expected, resolved: 0 }; + + const cellImagesXml = entries["xl/cellimages.xml"] + ? strFromU8(entries["xl/cellimages.xml"]) + : ""; + const relationships = relationshipMap( + entries["xl/_rels/cellimages.xml.rels"] + ? strFromU8(entries["xl/_rels/cellimages.xml.rels"]) + : "", + ); + let resolved = 0; + for (const match of cellImagesXml.matchAll( + /<(?:etc:)?cellImage\b[^>]*>([\s\S]*?)<\/(?:etc:)?cellImage>/g, + )) { + const id = match[1].match(/<(?:xdr:)?cNvPr\b[^>]*\bname="([^"]+)"/)?.[1]; + const relationshipId = match[1].match( + /<(?:a:)?blip\b[^>]*\br:embed="([^"]+)"/, + )?.[1]; + if (!id || !relationshipId || !screenshotIds.has(id)) continue; + const target = relationships.get(relationshipId); + if (!target) continue; + result.add(resolveZipPath("xl/cellimages.xml", target)); + resolved += 1; + } + return { result, expected, resolved }; +} + +function drawingScreenshotMedia( + entries: Record, + screenshotColumns: ScreenshotColumns, +) { + const result = new Set(); + let expected = 0; + let resolved = 0; + const worksheetXml = entries["xl/worksheets/sheet1.xml"] + ? strFromU8(entries["xl/worksheets/sheet1.xml"]) + : ""; + const sheetRelationships = relationshipMap( + entries["xl/worksheets/_rels/sheet1.xml.rels"] + ? strFromU8(entries["xl/worksheets/_rels/sheet1.xml.rels"]) + : "", + ); + const drawingId = worksheetXml.match(/]*r:id="([^"]+)"/)?.[1]; + const drawingTarget = drawingId ? sheetRelationships.get(drawingId) : undefined; + if (!drawingTarget) return { result, expected, resolved }; + const drawingPath = resolveZipPath("xl/worksheets/sheet1.xml", drawingTarget); + const drawingXml = entries[drawingPath] ? strFromU8(entries[drawingPath]) : ""; + const relationshipPath = `${drawingPath.slice(0, drawingPath.lastIndexOf("/") + 1)}_rels/${drawingPath.slice(drawingPath.lastIndexOf("/") + 1)}.rels`; + const drawingRelationships = relationshipMap( + entries[relationshipPath] ? strFromU8(entries[relationshipPath]) : "", + ); + for (const anchor of drawingXml.matchAll( + /]*>[\s\S]*?([\s\S]*?)<\/xdr:from>[\s\S]*?]*r:embed="([^"]+)"[\s\S]*?<\/xdr:(?:oneCellAnchor|twoCellAnchor)>/g, + )) { + const column = Number(anchor[1].match(/(\d+)<\/xdr:col>/)?.[1]); + const zeroBasedRow = Number(anchor[1].match(/(\d+)<\/xdr:row>/)?.[1]); + if ( + !Number.isInteger(column) || + !Number.isInteger(zeroBasedRow) || + zeroBasedRow + 1 <= screenshotColumns.headerRow || + !screenshotColumns.columns.has(column) + ) { + continue; + } + expected += 1; + const target = drawingRelationships.get(anchor[2]); + if (!target) continue; + result.add(resolveZipPath(drawingPath, target)); + resolved += 1; + } + return { result, expected, resolved }; +} + +function richValueScreenshotMedia( + entries: Record, + cells: WorkbookCell[], + screenshotColumns: ScreenshotColumns, +) { + const result = new Set(); + let expected = 0; + let resolved = 0; + const metadataXml = entries["xl/metadata.xml"] + ? strFromU8(entries["xl/metadata.xml"]) + : ""; + const richValueXml = entries["xl/richData/rdrichvalue.xml"] + ? strFromU8(entries["xl/richData/rdrichvalue.xml"]) + : ""; + const richValueRelXml = entries["xl/richData/richValueRel.xml"] + ? strFromU8(entries["xl/richData/richValueRel.xml"]) + : ""; + const relationships = relationshipMap( + entries["xl/richData/_rels/richValueRel.xml.rels"] + ? strFromU8(entries["xl/richData/_rels/richValueRel.xml.rels"]) + : "", + ); + if (!metadataXml || !richValueXml || !richValueRelXml || !relationships.size) { + return { result, expected, resolved }; + } + const valueMetadataXml = + metadataXml.match(/]*>([\s\S]*?)<\/valueMetadata>/)?.[1] ?? + ""; + const metadataToRichValue = [ + ...valueMetadataXml.matchAll( + /]*>[\s\S]*?]*\bv="(\d+)"[^>]*\/>[\s\S]*?<\/bk>/g, + ), + ].map((match) => Number(match[1])); + const richValueToRelationship = [ + ...richValueXml.matchAll(/]*>([\s\S]*?)<\/rv>/g), + ].map((match) => Number(match[1].match(/(\d+)<\/v>/)?.[1] ?? -1)); + const relationshipIds = [ + ...richValueRelXml.matchAll(/]*\br:id="([^"]+)"[^>]*\/>/g), + ].map((match) => match[1]); + + for (const cell of cells) { + if ( + cell.row <= screenshotColumns.headerRow || + !screenshotColumns.columns.has(cell.column) + ) { + continue; + } + const metadataIndex = Number(cell.attributes.match(/\bvm="(\d+)"/)?.[1] ?? 0); + if (!metadataIndex) continue; + expected += 1; + const richValueIndex = metadataToRichValue[metadataIndex - 1]; + const relationshipIndex = richValueToRelationship[richValueIndex]; + const relationshipId = relationshipIds[relationshipIndex]; + const target = relationships.get(relationshipId); + if (!target) continue; + result.add(resolveZipPath("xl/richData/richValueRel.xml", target)); + resolved += 1; + } + return { result, expected, resolved }; +} + +export type CompactedPartnerBatchWorkbook = { + bytes: Uint8Array; + removedMediaCount: number; + preservedScreenshotCount: number; +}; + +/** + * Oversized exports are usually caused by full-resolution source images. The + * upload only needs the two screenshot columns, so retain those image entries + * and omit source media from the temporary upload copy. + */ +export function compactPartnerBatchWorkbookForUpload( + input: Uint8Array, +): CompactedPartnerBatchWorkbook { + const isMediaFile = (name: string) => + name.startsWith("xl/media/") && !name.endsWith("/"); + const structure = unzipSync(input, { + filter: (file) => !isMediaFile(file.name), + }); + const worksheetXml = structure["xl/worksheets/sheet1.xml"] + ? strFromU8(structure["xl/worksheets/sheet1.xml"]) + : ""; + if (!worksheetXml) throw new Error("Excel 中没有可读取的批量回填工作表"); + const sharedXml = structure["xl/sharedStrings.xml"] + ? strFromU8(structure["xl/sharedStrings.xml"]) + : ""; + const sharedStrings = [ + ...sharedXml.matchAll(/]*>([\s\S]*?)<\/si>/g), + ].map((match) => textNodes(match[1])); + const cells = parseCells(worksheetXml, sharedStrings); + const screenshotColumns = findScreenshotColumns(cells); + const formats = [ + wpsScreenshotMedia(structure, cells, screenshotColumns), + drawingScreenshotMedia(structure, screenshotColumns), + richValueScreenshotMedia(structure, cells, screenshotColumns), + ]; + const screenshotMedia = new Set(); + let expectedScreenshotCount = 0; + let resolvedScreenshotCount = 0; + for (const format of formats) { + expectedScreenshotCount += format.expected; + resolvedScreenshotCount += format.resolved; + for (const name of format.result) screenshotMedia.add(name); + } + if (resolvedScreenshotCount < expectedScreenshotCount) { + throw new Error("表格中的回填截图无法完整识别,请重新导出最新版回填表"); + } + + let mediaCount = 0; + const entries = unzipSync(input, { + filter: (file) => { + if (!isMediaFile(file.name)) return true; + mediaCount += 1; + return screenshotMedia.has(file.name); + }, + }); + const bytes = zipSync(entries, { level: 6 }); + return { + bytes, + removedMediaCount: Math.max(0, mediaCount - screenshotMedia.size), + preservedScreenshotCount: screenshotMedia.size, + }; +} diff --git a/koc-portal/app/globals.css b/koc-portal/app/globals.css index 890ba84..1e6b409 100644 --- a/koc-portal/app/globals.css +++ b/koc-portal/app/globals.css @@ -48,6 +48,104 @@ button:disabled { opacity: 0.5; } +.platform-badge { + display: inline-flex !important; + width: auto !important; + height: 24px; + align-items: center; + flex: none; + gap: 5px; + margin: 0 !important; + padding: 2px 7px 2px 3px; + border: 1px solid #e1e7e4; + border-radius: 8px; + color: #52615c !important; + background: rgb(255 255 255 / 0.92); + font-size: 9px !important; + font-weight: 720; + 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-meta-line, +.hero-platform-line { + display: inline-flex !important; + min-width: 0; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.platform-meta-line > span, +.hero-platform-line > * { + margin: 0 !important; +} + +.hero-platform-line { + margin-bottom: 10px; +} + .portal-shell { width: min(100%, 1120px); min-height: 100vh; @@ -679,6 +777,15 @@ footer { cursor: not-allowed; } +.batch-workbook-input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + opacity: 0; + pointer-events: none; +} + .share-composer { display: grid; grid-template-columns: minmax(0, 1fr) minmax(190px, 0.45fr) auto; @@ -843,6 +950,34 @@ footer { text-align: center; } +.note-thumb.platform-video-thumb { + display: grid; + place-items: center; +} + +.platform-badge.logo-only { + width: 34px !important; + height: 34px; + padding: 0; + border: 0; + background: transparent; +} + +.platform-badge.logo-only .platform-logo { + width: 34px !important; + height: 34px !important; + border-radius: 9px; +} + +.platform-badge.logo-only .platform-logo.douyin svg { + width: 29px; + height: 29px; +} + +.platform-badge.logo-only .platform-logo.xiaohongshu b { + font-size: 8px; +} + .note-index { display: grid; width: 34px; @@ -1135,12 +1270,32 @@ footer { white-space: pre-wrap; } -.note-images { +.note-images, +.note-videos { margin-top: 30px; padding-top: 24px; border-top: 1px solid #edf0ee; } +.note-video-grid { + display: grid; + gap: 14px; +} + +.note-video-card { + overflow: hidden; + border: 1px solid #e4e9e6; + border-radius: 12px; + background: #102a22; +} + +.note-video-card video { + display: block; + width: 100%; + max-height: 680px; + background: #0b1f19; +} + .note-images-heading { display: flex; align-items: flex-end; @@ -1234,7 +1389,8 @@ footer { font-weight: 650; } -.note-image-actions button { +.note-image-actions button, +.note-image-actions a { height: 28px; padding: 0 10px; border: 1px solid #cfe1d9; @@ -1243,9 +1399,12 @@ footer { background: #f2f8f5; font-size: 8px; font-weight: 680; + line-height: 26px; + text-decoration: none; } -.note-image-actions button:hover { +.note-image-actions button:hover, +.note-image-actions a:hover { border-color: #9fc9b8; background: #eaf5f0; } @@ -1782,9 +1941,14 @@ footer { .section-actions { width: 100%; + flex-wrap: wrap; justify-content: space-between; } + .section-actions > span { + margin-right: auto; + } + .share-composer { grid-template-columns: 1fr; } diff --git a/koc-portal/app/page.tsx b/koc-portal/app/page.tsx index 68999cd..8dbf0f3 100644 --- a/koc-portal/app/page.tsx +++ b/koc-portal/app/page.tsx @@ -6,6 +6,10 @@ import { formatShanghaiDate as formatDate, parseStoredDate, } from "./date-utils"; +import { + compactPartnerBatchWorkbookForUpload, + PARTNER_BATCH_UPLOAD_MAX_BYTES, +} from "./batch-workbook-upload"; type Assignment = { id: string; @@ -30,6 +34,11 @@ type Assignment = { width: number | null; height: number | null; }>; + videos: Array<{ + index: number; + width: number | null; + height: number | null; + }>; }; type DelegationSummary = { @@ -63,6 +72,8 @@ type TaskPayload = { dueAt: string; status: string; type: "content_publish" | "screenshot_collect"; + platform: "小红书" | "抖音"; + contentFormat: "image_text" | "video"; }; claim: null | { id: string; @@ -92,7 +103,12 @@ function resolveAdminOrigin() { return window.location.origin; } -function partnerApi(path: "/api/partner" | "/api/partner-upload") { +function partnerApi( + path: + | "/api/partner" + | "/api/partner-upload" + | "/api/partner-batch-workbook", +) { return `${resolveAdminOrigin()}${path}`; } @@ -155,6 +171,36 @@ function safeFileBase(item: Assignment) { ); } +function PlatformBadge({ + platform, + compact = false, + logoOnly = false, +}: { + platform: "小红书" | "抖音"; + compact?: boolean; + logoOnly?: boolean; +}) { + return ( + + + {!logoOnly && {platform}} + + ); +} + function exactArrayBuffer(bytes: Uint8Array) { const copy = new Uint8Array(bytes.byteLength); copy.set(bytes); @@ -277,6 +323,7 @@ export default function Home() { const [adminOrigin, setAdminOrigin] = useState(""); const [downloadingImage, setDownloadingImage] = useState(null); const [batchDownloading, setBatchDownloading] = useState(false); + const [batchWorkbookWorking, setBatchWorkbookWorking] = useState(false); const [creatorWorking, setCreatorWorking] = useState(false); const [creatorStage, setCreatorStage] = useState(""); const [selectedForShare, setSelectedForShare] = useState([]); @@ -289,6 +336,7 @@ export default function Home() { } | null>(null); const [noteContentCollapsed, setNoteContentCollapsed] = useState(false); const submitCardRef = useRef(null); + const batchWorkbookInputRef = useRef(null); const publishScreenshotPreview = useFilePreview(screenshot); const creatorScreenshotPreview = useFilePreview(creatorScreenshot); const taskResultPreviews = useMemo( @@ -504,6 +552,25 @@ export default function Home() { return `${adminOrigin}/api/partner-image?${params}`; }; + const noteVideoUrl = ( + item: Assignment, + videoIndex: number, + download = false, + ) => { + const params = new URLSearchParams({ + distribution: item.id, + index: String(videoIndex), + kind: "video", + }); + if (download) params.set("download", "1"); + if (delegationToken) params.set("share", delegationToken); + else { + params.set("task", taskToken); + params.set("claim", claimToken); + } + return `${adminOrigin}/api/partner-image?${params}`; + }; + const taskResultImageUrl = (item: Assignment, imageIndex: number) => { const params = new URLSearchParams({ distribution: item.id, @@ -644,6 +711,112 @@ export default function Home() { } }; + const batchWorkbookParams = () => { + const params = new URLSearchParams(); + if (delegationToken) params.set("share", delegationToken); + else { + params.set("task", taskToken); + params.set("claim", claimToken); + } + return params; + }; + + const exportBatchWorkbook = async () => { + try { + setBatchWorkbookWorking(true); + const response = await fetch( + `${partnerApi("/api/partner-batch-workbook")}?${batchWorkbookParams()}`, + { cache: "no-store" }, + ); + if (!response.ok) { + const result = (await response.json()) as { error?: string }; + throw new Error(result.error || "Excel导出失败"); + } + const disposition = response.headers.get("Content-Disposition") || ""; + const encodedName = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1]; + const fileName = encodedName + ? decodeURIComponent(encodedName) + : `${payload?.task.name || "领取笔记"}-批量回填.xlsx`; + downloadBlob(await response.blob(), fileName); + setToast("Excel已导出,填写后从本页面上传即可批量回填"); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "Excel导出失败"); + } finally { + setBatchWorkbookWorking(false); + } + }; + + const importBatchWorkbook = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + try { + setBatchWorkbookWorking(true); + let uploadBody: Blob = file; + let compacted = false; + if (file.size > PARTNER_BATCH_UPLOAD_MAX_BYTES) { + setToast("文件较大,正在保留回填截图并精简原图…"); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + const compactedWorkbook = compactPartnerBatchWorkbookForUpload( + new Uint8Array(await file.arrayBuffer()), + ); + if (compactedWorkbook.bytes.byteLength > PARTNER_BATCH_UPLOAD_MAX_BYTES) { + throw new Error("精简后的回填表仍超过80MB,请重新导出最新版回填表"); + } + uploadBody = new Blob([exactArrayBuffer(compactedWorkbook.bytes)], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + compacted = true; + } + const form = new FormData(); + form.set("file", uploadBody, file.name); + form.set("taskToken", taskToken); + form.set("claimToken", claimToken); + form.set("delegationToken", delegationToken); + const response = await fetch(partnerApi("/api/partner-batch-workbook"), { + method: "POST", + body: form, + }); + const contentType = response.headers.get("Content-Type") || ""; + const result = (contentType.includes("application/json") + ? await response.json() + : { + error: + response.status === 413 + ? "回填表超过上传限制,请重新导出最新版回填表" + : "批量回填服务暂时不可用,请稍后重试", + }) as { + error?: string; + updatedRows?: number; + publishedCount?: number; + noteScreenshotCount?: number; + analysisScreenshotCount?: number; + }; + if (!response.ok) throw new Error(result.error || "批量回填失败"); + await loadTask(taskToken, claimToken, delegationToken); + const details = [ + result.publishedCount + ? `${result.publishedCount}篇发布信息` + : "", + result.noteScreenshotCount + ? `${result.noteScreenshotCount}张笔记截图` + : "", + result.analysisScreenshotCount + ? `${result.analysisScreenshotCount}张数据分析截图` + : "", + ].filter(Boolean); + setToast( + details.length > 0 + ? `${compacted ? "文件已自动精简," : ""}已更新${details.join("、")}` + : `${compacted ? "文件已自动精简," : ""}表格已读取,没有需要更新的数据`, + ); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "批量回填失败"); + } finally { + setBatchWorkbookWorking(false); + } + }; + const prepareImage = async (item: Assignment, imageIndex: number) => { const response = await fetch(noteImageUrl(item, imageIndex)); if (!response.ok) throw new Error("图片读取失败"); @@ -987,7 +1160,10 @@ export default function Home() { 截图任务 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1} 只需上传结果截图 -

小红书搜索关键词

+
+

小红书搜索关键词

+ +

{selected.title}

@@ -1261,7 +1472,7 @@ export default function Home() { inputMode="url" value={publishUrl} onChange={(event) => setPublishUrl(event.target.value)} - placeholder="可粘贴小红书长链、短链或整段分享文案" + placeholder={`可粘贴${payload.task.platform}作品链接或整段分享文案`} required /> @@ -1478,8 +1689,10 @@ export default function Home() { : "合作社转派发布包"}

{payload.task.name}

-

- {payload.task.brand} · {formatDate(payload.task.dueAt)}前{isScreenshotTask ? "提交" : "发布"} +

+ {payload.task.brand} + + {formatDate(payload.task.dueAt)}前{isScreenshotTask ? "提交" : "发布"} {payload.delegation ? ` · ${payload.delegation.label}` : ""}

@@ -1500,7 +1713,7 @@ export default function Home() { {isClaimOwner ? isScreenshotTask ? "打开一份查看关键词和要求,完成后单独上传截图;也可以转派给底层KOC" - : "打开一篇,查看内容并单独回填;也可以选择笔记转派给底层KOC" + : "可逐篇回填,也可导出Excel填写后批量上传;还可以选择笔记转派给底层KOC" : isScreenshotTask ? "打开任务查看搜索关键词和要求,完成后逐份上传截图" : "打开一篇查看完整内容,发布后逐篇回填"} @@ -1508,6 +1721,31 @@ export default function Home() {
{activeBatch.assignments.length} {isScreenshotTask ? "份" : "篇"} + {!isScreenshotTask && ( + <> + + + void importBatchWorkbook(event)} + /> + + )} {isClaimOwner && (