diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 7457157..b82754f 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -17,6 +17,7 @@ const appState = { selectedVideos: { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 }, documents: [], discoveryQuery: "", + currentPlatform: localStorage.getItem(STORAGE_KEY + ":currentPlatform") || "", selectedProjectId: "", selectedAssistantId: "", lastSeenAt: Number(localStorage.getItem(STORAGE_KEY + ":lastSeenAt") || Date.now()), @@ -43,6 +44,52 @@ const ACTIVE_PLATFORMS = [ { value: "wechat_video", label: "微信视频号" } ]; const ACTIVE_PLATFORM_CHIPS = ["全平台", "抖音", "小红书", "B站", "快手", "视频号"]; +const PLATFORM_REGISTRY = { + douyin: { + label: "抖音", + shortLabel: "抖音", + workbenchReady: true, + routes: { + accounts: "/v2/douyin/accounts", + workspace: (accountId) => `/v2/douyin/accounts/${encodeURIComponent(accountId)}/workspace`, + videos: (accountId) => `/v2/douyin/accounts/${encodeURIComponent(accountId)}/videos?limit=80`, + analyzeAccount: (accountId) => `/v2/douyin/accounts/${encodeURIComponent(accountId)}/analysis`, + analyzeTopVideos: (accountId) => `/v2/douyin/accounts/${encodeURIComponent(accountId)}/videos/analyze-top`, + similarSearches: "/v2/douyin/similar-searches", + similarSearchDetail: (searchId) => `/v2/douyin/similar-searches/${encodeURIComponent(searchId)}`, + benchmarkLinks: (accountId) => `/v2/douyin/accounts/${encodeURIComponent(accountId)}/benchmark-links`, + trackingAccounts: "/v2/douyin/tracking/accounts", + trackingDigest: "/v2/douyin/tracking/digest", + trackingRefresh: "/v2/douyin/tracking/refresh", + trackingCursor: "/v2/douyin/tracking/cursor", + trackingAccountRefresh: (trackedAccountId) => `/v2/douyin/tracking/accounts/${encodeURIComponent(trackedAccountId)}/refresh` + } + }, + xiaohongshu: { + label: "小红书", + shortLabel: "小红书", + workbenchReady: false, + pendingText: "小红书工作台待接入" + }, + bilibili: { + label: "哔哩哔哩", + shortLabel: "B站", + workbenchReady: false, + pendingText: "B站工作台待接入" + }, + kuaishou: { + label: "快手", + shortLabel: "快手", + workbenchReady: false, + pendingText: "快手工作台待接入" + }, + wechat_video: { + label: "微信视频号", + shortLabel: "视频号", + workbenchReady: false, + pendingText: "视频号工作台待接入" + } +}; const INTEGRATION_META = { local_model: { label: "本机模型", @@ -107,6 +154,90 @@ function platformLabel(value) { return matched?.label || String(value || "抖音"); } +function getPlatformMeta(value) { + return PLATFORM_REGISTRY[normalizePlatformValue(value, "")] || null; +} + +function getPlatformShortLabel(value) { + return getPlatformMeta(value)?.shortLabel || platformLabel(value); +} + +function isWorkbenchPlatform(value) { + return Boolean(getPlatformMeta(value)?.workbenchReady); +} + +function getWorkbenchRoute(platform, key, ...args) { + const routes = getPlatformMeta(platform)?.routes; + if (!routes) return ""; + const route = routes[key]; + if (typeof route === "function") return route(...args); + return route || ""; +} + +function setCurrentPlatform(value) { + const normalized = normalizePlatformValue(value, ""); + appState.currentPlatform = normalized; + if (normalized) { + localStorage.setItem(STORAGE_KEY + ":currentPlatform", normalized); + } else { + localStorage.removeItem(STORAGE_KEY + ":currentPlatform"); + } +} + +function getAccountPlatform(account) { + return normalizePlatformValue( + account?.platform + || account?.source_platform + || account?.metadata?.platform + || "", + "douyin" + ); +} + +function getAccountHandle(account) { + return String( + account?.handle + || account?.douyin_id + || account?.xhs_id + || account?.bilibili_uid + || account?.kuaishou_id + || account?.wechat_video_id + || account?.uid + || account?.username + || "" + ).trim(); +} + +function getAccountProfileUrl(account) { + return String(account?.profile_url || account?.source_url || account?.homepage_url || "").trim(); +} + +function getAccountName(account) { + return String(account?.nickname || getAccountHandle(account) || "未命名账号").trim(); +} + +function getAccountSubtitle(account) { + return getAccountHandle(account) || getAccountProfileUrl(account) || platformLabel(getAccountPlatform(account)); +} + +function getPendingWorkbenchReason(platform) { + const meta = getPlatformMeta(platform); + return meta?.pendingText || `${platformLabel(platform)}工作台待接入`; +} + +function getPreferredPlatform() { + const selectedAccountPlatform = getAccountPlatform(getSelectedAccount()); + if (selectedAccountPlatform && isWorkbenchPlatform(selectedAccountPlatform)) return selectedAccountPlatform; + const current = normalizePlatformValue(appState.currentPlatform, ""); + if (current && isWorkbenchPlatform(current)) return current; + const sourcePlatform = normalizePlatformValue( + safeArray(appState.contentSources).find((item) => isWorkbenchPlatform(item.platform))?.platform || "", + "" + ); + if (sourcePlatform) return sourcePlatform; + return "douyin"; +} + function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") @@ -574,6 +705,7 @@ async function logoutSession() { appState.contentSources = []; appState.accounts = []; appState.selectedAccountId = ""; + appState.currentPlatform = ""; appState.selectedAssistantId = ""; appState.selectedWorkspace = null; appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 }; @@ -587,6 +719,7 @@ async function logoutSession() { appState.lastGeneratedCopy = null; appState.lastSimilaritySearch = null; appState.lastJobDetail = null; + localStorage.removeItem(STORAGE_KEY + ":currentPlatform"); renderAll(); } @@ -601,14 +734,23 @@ async function loadKnowledgeDocuments(knowledgeBases) { return groups.flat().slice(0, 12); } -async function loadDouyinAccount(accountId) { +async function loadPlatformAccount(platform, accountId) { if (!accountId) return; + const normalizedPlatform = normalizePlatformValue(platform, getPreferredPlatform()); appState.selectedAccountId = accountId; - const supportsAccountVideos = backendSupports("/v2/douyin/accounts/{account_id}/videos"); + setCurrentPlatform(normalizedPlatform); + const workspacePath = getWorkbenchRoute(normalizedPlatform, "workspace", accountId); + if (!workspacePath) { + appState.selectedWorkspace = null; + appState.selectedVideos = { items: [], meta: {}, top_scored_video_ids: [], latest_video_ids: [], high_score_threshold: 60 }; + return; + } + const videosPath = getWorkbenchRoute(normalizedPlatform, "videos", accountId); + const supportsAccountVideos = videosPath && backendSupports(`/v2/${normalizedPlatform}/accounts/{account_id}/videos`); const [workspace, videos] = await Promise.all([ - storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/workspace`), + storyforgeFetch(workspacePath), supportsAccountVideos - ? storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(accountId)}/videos?limit=80`).catch(() => ({ + ? storyforgeFetch(videosPath).catch(() => ({ items: [], meta: {}, top_scored_video_ids: [], @@ -651,16 +793,20 @@ async function bootstrap() { return; } appState.backendCapabilities = await loadBackendCapabilities(appState.session.backendUrl).catch(() => null); - const supportsTracking = backendSupports("/v2/douyin/tracking/accounts"); - const supportsTrackingDigest = backendSupports("/v2/douyin/tracking/digest"); + const preferredPlatform = getPreferredPlatform(); + setCurrentPlatform(preferredPlatform); + const accountListPath = getWorkbenchRoute(preferredPlatform, "accounts"); + const trackingAccountsPath = getWorkbenchRoute(preferredPlatform, "trackingAccounts"); + const trackingDigestPath = getWorkbenchRoute(preferredPlatform, "trackingDigest"); + const supportsTrackingDigest = trackingDigestPath && backendSupports(trackingDigestPath); const supportsReviews = backendSupports("/v2/reviews"); const supportsIntegrationHealth = backendSupports("/v2/integrations/health"); const supportsLocalModels = backendSupports("/v2/integrations/local-models"); const [dashboard, contentSources, accounts, trackingAccountsPayload, reviews, integrationHealth, localModelCatalog] = await Promise.all([ storyforgeFetch("/v2/me/dashboard"), storyforgeFetch("/v2/content-sources").catch(() => []), - storyforgeFetch("/v2/douyin/accounts").catch(() => []), - supportsTracking ? storyforgeFetch("/v2/douyin/tracking/accounts").catch(() => ({ items: [], cursor_last_seen_at: "" })) : Promise.resolve({ items: [], cursor_last_seen_at: "" }), + accountListPath ? storyforgeFetch(accountListPath).catch(() => []) : Promise.resolve([]), + trackingAccountsPath ? storyforgeFetch(trackingAccountsPath).catch(() => ({ items: [], cursor_last_seen_at: "" })) : Promise.resolve({ items: [], cursor_last_seen_at: "" }), supportsReviews ? storyforgeFetch("/v2/reviews").catch(() => []) : Promise.resolve([]), supportsIntegrationHealth ? storyforgeFetch("/v2/integrations/health").catch(() => null) : Promise.resolve(null), supportsLocalModels ? storyforgeFetch("/v2/integrations/local-models").catch(() => null) : Promise.resolve(null) @@ -670,8 +816,8 @@ async function bootstrap() { setLastSeenAt(trackingCursorLastSeenAt); } const trackingSince = trackingCursorLastSeenAt || getTrackingSinceIso(); - const trackingDigest = supportsTrackingDigest - ? await storyforgeFetch(`/v2/douyin/tracking/digest?since=${encodeURIComponent(trackingSince)}&limit=24`).catch(() => ({ + const trackingDigest = trackingDigestPath + ? await storyforgeFetch(`${trackingDigestPath}?since=${encodeURIComponent(trackingSince)}&limit=24`).catch(() => ({ items: [], tracked_accounts: [], cursor_last_seen_at: trackingCursorLastSeenAt @@ -696,7 +842,8 @@ async function bootstrap() { const selectedAccountExists = appState.accounts.some((item) => item.id === appState.selectedAccountId); const nextAccountId = selectedAccountExists ? appState.selectedAccountId : appState.accounts[0]?.id || ""; if (nextAccountId) { - await loadDouyinAccount(nextAccountId); + const nextAccount = appState.accounts.find((item) => item.id === nextAccountId) || null; + await loadPlatformAccount(getAccountPlatform(nextAccount), nextAccountId); } else { appState.selectedAccountId = ""; appState.selectedWorkspace = null; @@ -714,13 +861,15 @@ async function bootstrap() { } async function markTrackingDigestRead() { - if (!backendSupports("/v2/douyin/tracking/cursor")) { + const platform = getPreferredPlatform(); + const trackingCursorPath = getWorkbenchRoute(platform, "trackingCursor"); + if (!trackingCursorPath || !backendSupports(trackingCursorPath)) { rememberAction("当前后端暂不支持", "这套 live collector 还没有接入跟踪已读游标。", "orange"); renderAll(); return; } const nextSeenAt = new Date().toISOString(); - await storyforgeFetch("/v2/douyin/tracking/cursor", { + await storyforgeFetch(trackingCursorPath, { method: "POST", body: { last_seen_at: nextSeenAt } }); @@ -728,14 +877,16 @@ async function markTrackingDigestRead() { } async function refreshTrackingAccountsAction() { - if (!backendSupports("/v2/douyin/tracking/refresh")) { + const platform = getPreferredPlatform(); + const trackingRefreshPath = getWorkbenchRoute(platform, "trackingRefresh"); + if (!trackingRefreshPath || !backendSupports(trackingRefreshPath)) { rememberAction("当前后端暂不支持", "这套 live collector 还没有接入批量跟踪同步。", "orange"); renderAll(); return; } setBusy(true, "正在同步跟踪账号..."); try { - const payload = await storyforgeFetch("/v2/douyin/tracking/refresh", { + const payload = await storyforgeFetch(trackingRefreshPath, { method: "POST" }); rememberAction( @@ -754,14 +905,16 @@ async function refreshTrackedAccountAction(trackedAccountId) { if (!trackedAccountId) { throw new Error("trackedAccountId is required"); } - if (!backendSupports("/v2/douyin/tracking/accounts/{tracked_account_id}/refresh")) { + const platform = getPreferredPlatform(); + const trackingRefreshPath = getWorkbenchRoute(platform, "trackingAccountRefresh", trackedAccountId); + if (!trackingRefreshPath || !backendSupports(`/v2/${platform}/tracking/accounts/{tracked_account_id}/refresh`)) { rememberAction("当前后端暂不支持", "这套 live collector 还没有接入单账号跟踪同步。", "orange"); renderAll(); return; } setBusy(true, "正在同步该跟踪账号..."); try { - const payload = await storyforgeFetch(`/v2/douyin/tracking/accounts/${encodeURIComponent(trackedAccountId)}/refresh`, { + const payload = await storyforgeFetch(trackingRefreshPath, { method: "POST" }); const success = payload.success !== false; @@ -849,17 +1002,21 @@ function getReviewById(reviewId) { function getContentSourcesForAccount(account) { if (!account) return []; - const profileUrl = String(account.profile_url || "").trim(); - const douyinId = String(account.douyin_id || "").trim(); - const nickname = String(account.nickname || "").trim(); + const platform = getAccountPlatform(account); + const profileUrl = getAccountProfileUrl(account); + const handle = getAccountHandle(account); + const nickname = getAccountName(account); return safeArray(appState.contentSources).filter((source) => { const sourceUrl = String(source.source_url || "").trim(); - const handle = String(source.handle || "").trim(); + const sourceHandle = String(source.handle || "").trim(); const title = String(source.title || "").trim(); + const sourcePlatform = normalizePlatformValue(source.platform || "", platform); return ( - (profileUrl && sourceUrl === profileUrl) || - (douyinId && handle === douyinId) || - (nickname && title.includes(nickname)) + sourcePlatform === platform && ( + (profileUrl && sourceUrl === profileUrl) || + (handle && sourceHandle === handle) || + (nickname && title.includes(nickname)) + ) ); }); } @@ -1195,6 +1352,9 @@ function markSavedCandidate(candidate, links) { async function saveCandidateAsBenchmark(candidateIndex, relationType = "benchmark") { const account = requireSelectedAccountRow(); + const platform = getAccountPlatform(account); + const benchmarkPath = getWorkbenchRoute(platform, "benchmarkLinks", account.id); + if (!benchmarkPath) throw new Error(getPendingWorkbenchReason(platform)); const candidate = safeArray(appState.lastSimilaritySearch?.candidates)[Number(candidateIndex)]; if (!candidate) throw new Error("当前候选不存在,请先重新查相似"); const payload = { @@ -1207,7 +1367,7 @@ async function saveCandidateAsBenchmark(candidateIndex, relationType = "benchmar if (!payload.target_account_ids.length && !payload.target_profile_urls.length) { throw new Error("当前候选没有可保存的账号或主页链接"); } - const result = await storyforgeFetch(`/v2/douyin/accounts/${encodeURIComponent(account.id)}/benchmark-links`, { + const result = await storyforgeFetch(benchmarkPath, { method: "POST", body: payload }); @@ -1411,10 +1571,10 @@ function renderDashboardScreen() { ${accounts.slice(0, 3).map((account) => `