From 09295d11a172c4deac7eaa8e0a6b2d08d7544ee8 Mon Sep 17 00:00:00 2001 From: liangxu Date: Mon, 20 Apr 2026 15:40:18 +0800 Subject: [PATCH] feat(monitoring): decouple AI platforms from media_platforms and expand catalog to 6 Add ai_platforms table + shared catalog (yuanbao/kimi/wenxin/deepseek/doubao/qwen), drop FKs from platform_accounts and desktop_tasks to media_platforms, and wire target_account_id + platform into DesktopTaskEvent so the desktop runtime no longer has to infer them from local state. Desktop client gains generic AI page detection for binding, drops stale-business-date monitor tasks at the edge, and refactors AccountsView/AiPlatformsView around the shared catalog. Admin tracking view surfaces per-platform sampling status cards. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/admin-web/src/views/TrackingView.vue | 267 +++++- apps/desktop-client/package.json | 1 + .../desktop-client/src/main/account-binder.ts | 566 +++++++++++- .../src/main/runtime-controller.ts | 90 +- .../src/renderer/lib/client-errors.ts | 6 +- .../src/renderer/lib/media-catalog.ts | 43 +- .../src/renderer/views/AccountsView.vue | 817 ++++++++---------- .../src/renderer/views/AiPlatformsView.vue | 809 +++++++++++------ .../renderer/views/PublishManagementView.vue | 94 +- packages/shared-types/src/ai-platforms.js | 1 + packages/shared-types/src/ai-platforms.ts | 76 ++ packages/shared-types/src/index.ts | 4 + pnpm-lock.yaml | 3 + .../tenant/app/desktop_account_service.go | 4 +- .../tenant/app/desktop_task_events.go | 18 +- .../tenant/app/desktop_task_service.go | 18 +- .../tenant/app/monitoring_projection.go | 2 +- .../internal/tenant/app/monitoring_service.go | 12 +- .../tenant/app/publish_job_service.go | 18 +- ...nd_decouple_desktop_platform_refs.down.sql | 17 + ..._and_decouple_desktop_platform_refs.up.sql | 50 ++ 21 files changed, 2073 insertions(+), 843 deletions(-) create mode 100644 packages/shared-types/src/ai-platforms.js create mode 100644 packages/shared-types/src/ai-platforms.ts create mode 100644 server/migrations/20260421100040_create_ai_platform_catalog_and_decouple_desktop_platform_refs.down.sql create mode 100644 server/migrations/20260421100040_create_ai_platform_catalog_and_decouple_desktop_platform_refs.up.sql diff --git a/apps/admin-web/src/views/TrackingView.vue b/apps/admin-web/src/views/TrackingView.vue index 21b728c..38b0949 100644 --- a/apps/admin-web/src/views/TrackingView.vue +++ b/apps/admin-web/src/views/TrackingView.vue @@ -8,15 +8,17 @@ import { message } from "ant-design-vue"; import dayjs from "dayjs"; import type { Brand, + DesktopAccountInfo, MonitoringHotQuestion, MonitoringTimeBucket, } from "@geo/shared-types"; +import { aiPlatformCatalog } from "@geo/shared-types"; import { computed, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; import { useRoute, useRouter } from "vue-router"; import PageHero from "@/components/PageHero.vue"; -import { brandsApi, monitoringApi } from "@/lib/api"; +import { brandsApi, monitoringApi, tenantAccountsApi } from "@/lib/api"; import { formatDateTime } from "@/lib/display"; import { formatError } from "@/lib/errors"; import { kickoffMonitoringCycle } from "@/lib/monitoring-plugin"; @@ -42,6 +44,18 @@ type CitationRankingRow = { glyph: string; }; +type AIPlatformStatusCard = { + id: string; + label: string; + shortName: string; + accent: string; + description: string; + account: DesktopAccountInfo | null; + sampleStatus: string; + lastSampledAt: string | null; + actualSampleCount: number; +}; + function formatTrackingBusinessDate(value: Date): string { const parts = new Intl.DateTimeFormat("en-US", { timeZone: trackingBusinessTimeZone, @@ -74,15 +88,14 @@ const platformAppearanceMap: Record ({ + label: platform.label, + value: platform.id, + })), ] as const; const trackingPlatformIds = new Set( - trackingPlatformOptions - .map((item) => item.value) - .filter((value) => value !== "all"), + aiPlatformCatalog.map((item) => item.id), ); const selectedBrandId = useStorage("tracking_selected_brand", null); @@ -254,6 +267,11 @@ const dashboardQuery = useQuery({ }), }); +const aiAccountsQuery = useQuery({ + queryKey: ["tracking", "ai-platform-accounts"], + queryFn: () => tenantAccountsApi.list(), +}); + const collectNowMutation = useMutation({ mutationFn: async () => { if (!selectedKeywordId.value) { @@ -320,6 +338,43 @@ const hotQuestions = computed(() => { return dashboardQuery.data.value?.hot_questions ?? []; }); +const aiPlatformStatusCards = computed(() => { + const accountGroups = new Map(); + for (const account of aiAccountsQuery.data.value ?? []) { + if (!trackingPlatformIds.has(account.platform) || account.deleted_at) { + continue; + } + const list = accountGroups.get(account.platform) ?? []; + list.push(account); + accountGroups.set(account.platform, list); + } + + const breakdownMap = new Map( + (dashboardQuery.data.value?.platform_breakdown ?? []).map((item) => [item.ai_platform_id, item]), + ); + + return aiPlatformCatalog.map((platform) => { + const matchedAccounts = [...(accountGroups.get(platform.id) ?? [])].sort((left, right) => { + const leftAt = Date.parse(left.verified_at ?? "") || 0; + const rightAt = Date.parse(right.verified_at ?? "") || 0; + return rightAt - leftAt; + }); + const breakdown = breakdownMap.get(platform.id); + + return { + id: platform.id, + label: platform.label, + shortName: platform.shortName, + accent: platform.accent, + description: platform.description, + account: matchedAccounts[0] ?? null, + sampleStatus: breakdown?.platform_sample_status ?? "pending", + lastSampledAt: breakdown?.last_sampled_at ?? null, + actualSampleCount: breakdown?.actual_sample_count ?? 0, + }; + }); +}); + const selectedMetricCards = computed(() => { const overview = dashboardQuery.data.value?.overview; const buckets = dashboardQuery.data.value?.brand_time_buckets ?? []; @@ -499,6 +554,48 @@ function formatPercent(value: number | null | undefined): string { return `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%`; } +function accountAuthLabel(account: DesktopAccountInfo | null): string { + if (!account) { + return "未绑定"; + } + + switch (account.health) { + case "live": + return "授权正常"; + case "captcha": + return "待处理"; + case "expired": + return "授权过期"; + default: + return "风险观察"; + } +} + +function accountAuthColor(account: DesktopAccountInfo | null): string { + if (!account) { + return "default"; + } + + switch (account.health) { + case "live": + return "green"; + case "captcha": + return "gold"; + case "expired": + return "red"; + default: + return "orange"; + } +} + +function desktopStatusLabel(account: DesktopAccountInfo | null): string { + if (!account?.client_id) { + return "未绑定桌面端"; + } + + return account.client_online ? "客户端在线" : "客户端离线"; +} + function formatCount(value: number | null | undefined): string { if (value === null || value === undefined) { return "--"; @@ -752,6 +849,73 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean { + +
+
+

AI 平台状态

+ + 只读展示 desktop-client 的绑定、授权和当前采样状态。授权入口放在桌面端,抓取过程静默执行。 + +
+
+ +
+
+
+
+ + {{ platform.shortName }} + +
+ {{ platform.label }} +

{{ platform.description }}

+
+
+ + + {{ accountAuthLabel(platform.account) }} + +
+ +
+
+ 绑定状态 + {{ platform.account ? "已绑定" : "未绑定" }} +
+
+ 客户端 + {{ desktopStatusLabel(platform.account) }} +
+
+ 采样状态 + + {{ statusLabel(platform.sampleStatus) }} + +
+
+ 最近采样 + {{ platform.lastSampledAt ? formatDateTime(platform.lastSampledAt) : "--" }} +
+
+ 样本数 + {{ formatCount(platform.actualSampleCount) }} +
+
+ 账号 + {{ platform.account.display_name }} +
+
+
+
+
+
@@ -960,6 +1124,95 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean { grid-template-columns: minmax(0, 1fr); } +.tracking-panel--ai-platforms { + overflow: hidden; +} + +.ai-platform-status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-top: 12px; +} + +.ai-platform-status-card { + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px; + border-radius: 16px; + border: 1px solid #e6edf5; + background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%); +} + +.ai-platform-status-card__head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.ai-platform-status-card__identity { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.ai-platform-status-card__identity strong { + display: block; + color: #111827; + font-size: 16px; + line-height: 1.2; +} + +.ai-platform-status-card__identity p { + margin: 6px 0 0; + color: #667085; + line-height: 1.5; + font-size: 12px; +} + +.ai-platform-status-card__badge { + width: 42px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 14px; + border: 1px solid transparent; + font-size: 16px; + font-weight: 700; + flex-shrink: 0; +} + +.ai-platform-status-card__meta { + display: flex; + flex-direction: column; + gap: 10px; +} + +.ai-platform-status-card__row { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + padding: 10px 12px; + border-radius: 12px; + background: #f8fafc; +} + +.ai-platform-status-card__row span { + color: #667085; + font-size: 12px; +} + +.ai-platform-status-card__row strong { + color: #111827; + font-size: 13px; + text-align: right; + word-break: break-all; +} + .tracking-citation-section { display: flex; flex-direction: column; diff --git a/apps/desktop-client/package.json b/apps/desktop-client/package.json index afff018..0cb2056 100644 --- a/apps/desktop-client/package.json +++ b/apps/desktop-client/package.json @@ -22,6 +22,7 @@ "electron-updater": "^6.0.0", "marked": "^17.0.5", "pino": "^9.0.0", + "playwright-core": "^1.55.0", "vue": "^3.5.31", "vue-router": "^4.5.1" }, diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index e3dfc97..b388d90 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { BrowserWindow, app, nativeTheme, session as electronSession } from "electron/main"; import type { Session, WebContents } from "electron/main"; +import { aiPlatformCatalog } from "@geo/shared-types"; import type { DesktopAccountInfo } from "@geo/shared-types"; import { @@ -679,6 +680,423 @@ function sanitizeDetectedAccount(account: DetectedAccount): DetectedAccount { }; } +interface GenericAIPageState { + displayName: string | null; + avatarUrl: string | null; + fingerprint: string | null; + credentialFingerprint: string | null; + currentPath: string | null; + strongAuthSignalCount: number; + loginSignalCount: number; + loggedOutSignalCount: number; +} + +async function readGenericAIPageState( + webContents: WebContents | null | undefined, +): Promise { + if (!webContents || webContents.isDestroyed()) { + return null; + } + + const state = await webContents.executeJavaScript( + `(() => { + try { + const normalize = (value) => { + if (typeof value !== "string") return null; + const trimmed = value.trim().replace(/\\s+/g, " "); + return trimmed || null; + }; + const isVisible = (node) => { + if (!(node instanceof Element)) { + return false; + } + const style = window.getComputedStyle(node); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + const rect = node.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const bodyText = normalize(document.body?.innerText || ""); + const stopWords = /^(登录|注册|设置|帮助|历史|聊天|新建|菜单|账户|账号|个人中心|联网搜索|深度思考|工具|更多|安装电脑版|下载电脑版|DeepSeek|元宝|混元|豆包|Kimi|Qwen|通义千问)$/i; + const loginPattern = /(登录|注册|立即登录|扫码登录|微信登录|QQ登录|手机号登录|请先登录)/i; + const loggedOutPattern = /(未登录|扫码登录|快捷登录|使用其他头像、昵称或账号|登录后继续|请先登录|立即登录|微信快捷登录)/i; + const credentialKeyPattern = /(token|session|auth|login|passport|ticket|jwt|refresh|access)/i; + const isInsideAuthSurface = (node) => { + if (!(node instanceof Element)) { + return false; + } + let current = node; + for (let depth = 0; current && depth < 8; depth += 1) { + const descriptor = [ + current.getAttribute('role') || '', + current.getAttribute('aria-modal') || '', + current.getAttribute('data-testid') || '', + current.id || '', + current.className || '', + ].join(' '); + if (/(dialog|modal|popup|popover|sheet|drawer|login|signin|auth|scan|qrcode|qrlogin)/i.test(descriptor)) { + return true; + } + + const text = normalize(current.textContent || ''); + if (text && text.length <= 120 && loggedOutPattern.test(text)) { + return true; + } + + current = current.parentElement; + } + return false; + }; + const pickGreetingName = () => { + if (!bodyText) { + return null; + } + const patterns = [ + /(?:^|\\n)\\s*(?:hi|hello|你好|嗨)[,,\\s]+([^\\n,,]{1,20})/i, + /(?:^|\\n)\\s*欢迎回来[,,\\s]*([^\\n]{1,20})/i, + ]; + for (const pattern of patterns) { + const matched = bodyText.match(pattern); + const candidate = normalize(matched?.[1] || ""); + if (candidate && !stopWords.test(candidate)) { + return candidate; + } + } + return null; + }; + const pickDisplayName = () => { + const selectors = [ + '[data-testid*="user"]', + '[data-testid*="profile"]', + '[class*="user-name"]', + '[class*="username"]', + '[class*="nickname"]', + '[class*="profile"] [class*="name"]', + '[class*="avatar"] + *', + '[class*="account"] [class*="name"]', + 'aside [class*="name"]', + 'nav [class*="name"]', + ]; + for (const selector of selectors) { + const nodes = document.querySelectorAll(selector); + for (const node of nodes) { + if (!isVisible(node)) continue; + if (isInsideAuthSurface(node)) continue; + const text = normalize(node.textContent || ""); + if (!text) continue; + if (text.length < 2 || text.length > 48) continue; + if (stopWords.test(text)) continue; + return text; + } + } + return null; + }; + const pickAvatar = () => { + const selectors = [ + '[class*="avatar"] img', + '[data-testid*="avatar"] img', + '[class*="profile"] img', + '[class*="account"] img', + 'img[alt*="头像"]', + ]; + for (const selector of selectors) { + const node = document.querySelector(selector); + if (!node || !isVisible(node)) continue; + if (isInsideAuthSurface(node)) continue; + const src = normalize(node && 'src' in node ? node.src : null); + if (src && /(logo|icon|favicon)/i.test(src)) continue; + if (src) return src; + } + return null; + }; + const collectLoginSignals = () => { + const matches = []; + if (bodyText && loginPattern.test(bodyText)) { + matches.push('body-text'); + } + const selectors = ['button', 'a', '[role="button"]', '[aria-haspopup="dialog"]']; + for (const selector of selectors) { + const nodes = document.querySelectorAll(selector); + for (const node of nodes) { + if (!isVisible(node)) continue; + const text = normalize(node.textContent || ""); + if (!text || !loginPattern.test(text)) continue; + matches.push(text); + if (matches.length >= 4) { + return matches; + } + } + } + return matches; + }; + const collectLoggedOutSignals = () => { + const matches = []; + if (bodyText && loggedOutPattern.test(bodyText)) { + matches.push('body-text'); + } + const selectors = ['button', 'a', '[role="button"]', '[aria-haspopup="dialog"]', '[role="dialog"]']; + for (const selector of selectors) { + const nodes = document.querySelectorAll(selector); + for (const node of nodes) { + if (!isVisible(node)) continue; + const text = normalize(node.textContent || ""); + if (!text || !loggedOutPattern.test(text)) continue; + matches.push(text); + if (matches.length >= 4) { + return matches; + } + } + } + return matches; + }; + const collectCredentialStorage = (storage, prefix) => { + const items = []; + try { + const limit = Math.min(storage.length, 40); + for (let index = 0; index < limit; index += 1) { + const key = storage.key(index); + if (!key || !credentialKeyPattern.test(key)) { + continue; + } + const value = storage.getItem(key); + const text = String(value || '').slice(0, 240).trim(); + if (!text) { + continue; + } + items.push(prefix + ':' + key + '=' + text); + } + } catch { + return items; + } + return items; + }; + const collectStorage = (storage, prefix) => { + const items = []; + try { + const limit = Math.min(storage.length, 40); + for (let index = 0; index < limit; index += 1) { + const key = storage.key(index); + if (!key || !/(user|uid|token|session|auth|account|profile|login|member|nick|name)/i.test(key)) { + continue; + } + const value = storage.getItem(key); + items.push(prefix + ':' + key + '=' + String(value || '').slice(0, 160)); + } + } catch { + return items; + } + return items; + }; + const displayName = pickGreetingName() || pickDisplayName(); + const avatarUrl = pickAvatar(); + const strongSignals = []; + if (displayName) { + strongSignals.push('displayName'); + } + if (avatarUrl) { + strongSignals.push('avatar'); + } + if (bodyText && /(?:^|\\n)\\s*(?:hi|hello|你好|嗨)[,,\\s]+/i.test(bodyText)) { + strongSignals.push('greeting'); + } + const loginSignals = collectLoginSignals(); + const loggedOutSignals = collectLoggedOutSignals(); + const credentialFingerprintParts = [ + ...collectCredentialStorage(window.localStorage, 'local'), + ...collectCredentialStorage(window.sessionStorage, 'session'), + ]; + const fingerprintParts = [ + document.cookie || '', + window.location.origin || '', + document.title || '', + ...collectStorage(window.localStorage, 'local'), + ...collectStorage(window.sessionStorage, 'session'), + ].filter(Boolean); + return { + displayName, + avatarUrl, + fingerprint: fingerprintParts.length ? fingerprintParts.join('||') : null, + credentialFingerprint: credentialFingerprintParts.length + ? credentialFingerprintParts.join('||') + : null, + currentPath: normalize(window.location.pathname || ''), + strongAuthSignalCount: strongSignals.length, + loginSignalCount: loginSignals.length, + loggedOutSignalCount: loggedOutSignals.length, + }; + } catch { + return null; + } + })();`, + true, + ).catch(() => null); + + if (!state || typeof state !== "object") { + return null; + } + + const typed = state as Partial; + return { + displayName: normalizeText(typed.displayName), + avatarUrl: normalizeRemoteUrl(typed.avatarUrl), + fingerprint: normalizeText(typed.fingerprint), + credentialFingerprint: normalizeText(typed.credentialFingerprint), + currentPath: normalizeText(typed.currentPath), + strongAuthSignalCount: + typeof typed.strongAuthSignalCount === "number" && Number.isFinite(typed.strongAuthSignalCount) + ? typed.strongAuthSignalCount + : 0, + loginSignalCount: + typeof typed.loginSignalCount === "number" && Number.isFinite(typed.loginSignalCount) + ? typed.loginSignalCount + : 0, + loggedOutSignalCount: + typeof typed.loggedOutSignalCount === "number" && Number.isFinite(typed.loggedOutSignalCount) + ? typed.loggedOutSignalCount + : 0, + }; +} + +function isAIPlatformBinding(platformId: string): boolean { + return aiPlatformCatalog.some((item) => item.id === platformId); +} + +function normalizeURLPath(pathname: string): string { + const normalized = pathname.replace(/\/+$/, ""); + return normalized === "/" ? "" : normalized; +} + +function safeParseURL(input: string): URL | null { + try { + return new URL(input); + } catch { + return null; + } +} + +function genericAIConversationPath(pathname: string): boolean { + return /^\/(?:chat|c|conversation)(?:\/|$)/i.test(pathname); +} + +function isLikelyGenericAICredentialName(name: string): boolean { + return /(token|session|auth|login|passport|ticket|jwt|refresh|access)/i.test(name); +} + +function isLikelyGenericAICredentialValue(value: string | null | undefined): boolean { + const normalized = normalizeText(value); + if (!normalized) { + return false; + } + + if (/^(true|false|null|undefined|0|1)$/i.test(normalized)) { + return false; + } + + return normalized.length >= 8; +} + +function genericAIPageLooksAuthenticated(currentURL: string, pageState: GenericAIPageState | null): boolean { + const current = safeParseURL(currentURL); + const currentPath = normalizeURLPath(current?.pathname ?? pageState?.currentPath ?? ""); + const strongAuthSignalCount = pageState?.strongAuthSignalCount ?? 0; + const loginSignalCount = pageState?.loginSignalCount ?? 0; + const loggedOutSignalCount = pageState?.loggedOutSignalCount ?? 0; + const onConversationPath = genericAIConversationPath(currentPath); + + if (loggedOutSignalCount > 0) { + return false; + } + + if (loginSignalCount > 0 && strongAuthSignalCount === 0 && !onConversationPath) { + return false; + } + + if (loginSignalCount > 0) { + return strongAuthSignalCount >= 2 && !onConversationPath; + } + + return strongAuthSignalCount > 0 || onConversationPath; +} + +async function buildGenericAISessionFingerprint( + platformId: string, + session: Session, + pageState?: GenericAIPageState | null, +): Promise { + const parts: string[] = []; + + if (pageState?.credentialFingerprint) { + parts.push(pageState.credentialFingerprint); + } + + const platformMeta = aiPlatformCatalog.find((item) => item.id === platformId) ?? null; + if (platformMeta) { + const urls = new Set([platformMeta.loginUrl, platformMeta.consoleUrl]); + for (const url of urls) { + try { + const cookies = await session.cookies.get({ url }); + const authCookies = cookies.filter((cookie) => { + return isLikelyGenericAICredentialName(cookie.name) + && isLikelyGenericAICredentialValue(cookie.value); + }); + if (authCookies.length > 0) { + const serialized = authCookies + .map((cookie) => `${cookie.domain}|${cookie.name}=${cookie.value}`) + .sort() + .join(";"); + parts.push(`${url}:${serialized}`); + } + } catch { + continue; + } + } + } + + if (parts.length === 0) { + return null; + } + + return boundedIdentity(`${platformId}-session`, parts.join("||")); +} + +async function detectGenericAIPlatform( + platformId: string, + label: string, + context: DetectContext, +): Promise { + if (!context.webContents || context.webContents.isDestroyed()) { + return null; + } + + const currentURL = context.webContents && !context.webContents.isDestroyed() + ? context.webContents.getURL() + : ""; + const platformMeta = aiPlatformCatalog.find((item) => item.id === platformId) ?? null; + if (!platformMeta) { + return null; + } + + if (currentURL && looksLikeLoginRedirect(currentURL, platformMeta.loginUrl)) { + return null; + } + + const pageState = await readGenericAIPageState(context.webContents).catch(() => null); + if (!genericAIPageLooksAuthenticated(currentURL, pageState)) { + return null; + } + const fingerprint = await buildGenericAISessionFingerprint(platformId, context.session, pageState); + if (!fingerprint) { + return null; + } + + return sanitizeDetectedAccount({ + platformUid: fingerprint, + displayName: pageState?.displayName ?? `${label} 会话`, + avatarUrl: pageState?.avatarUrl ?? null, + }); +} + async function detectToutiao({ session, webContents }: DetectContext): Promise { const pageResponse = await pageFetchJson( webContents, @@ -875,11 +1293,37 @@ async function resolvePublishAccountProfileFromHandle( return await detectToutiaoFromSession(handle.session); } - const definition = publishBindingDefinitions[account.platform]; + const definition = platformBindingDefinitions[account.platform]; if (!definition) { return null; } + if (isAIPlatformBinding(account.platform)) { + const window = createBoundWindow( + `${definition.label} 静默识别`, + definition.consoleUrl, + handle.session, + { show: false }, + ); + + try { + await waitForWindowNavigationSettled(window); + if (window.isDestroyed()) { + return null; + } + + const detected = await definition.detect({ + session: handle.session, + webContents: window.webContents, + }).catch(() => null); + return toPublishAccountProfile(detected); + } finally { + if (!window.isDestroyed()) { + window.destroy(); + } + } + } + const detected = await definition.detect({ session: handle.session, webContents: undefined as unknown as WebContents, @@ -994,6 +1438,30 @@ async function waitForWindowNavigationSettled(window: BrowserWindow, timeoutMs = } } +async function flushSessionPersistence(target: Session): Promise { + const maybeFlushStorageData = (target as Session & { + flushStorageData?: () => Promise; + }).flushStorageData; + + if (typeof maybeFlushStorageData === "function") { + try { + await maybeFlushStorageData.call(target); + } catch (error) { + console.warn("[desktop-session] flushStorageData failed", { + message: error instanceof Error ? error.message : String(error), + }); + } + } + + try { + await target.cookies.flushStore(); + } catch (error) { + console.warn("[desktop-session] flushStore failed", { + message: error instanceof Error ? error.message : String(error), + }); + } +} + async function verifyToutiaoConsoleAccess(window: BrowserWindow, session: Session): Promise { if (window.isDestroyed()) { return false; @@ -1046,10 +1514,6 @@ function looksLikeLoginRedirect(currentURL: string, loginURL: string): boolean { return false; } - if (currentURL.startsWith(loginURL)) { - return true; - } - try { const current = new URL(currentURL); const login = new URL(loginURL); @@ -1057,8 +1521,13 @@ function looksLikeLoginRedirect(currentURL: string, loginURL: string): boolean { return false; } - const currentPath = current.pathname.replace(/\/+$/, ""); - const loginPath = login.pathname.replace(/\/+$/, ""); + const currentPath = normalizeURLPath(current.pathname); + const loginPath = normalizeURLPath(login.pathname); + if (!loginPath) { + return /(^|[/?#=&._-])(login|signin|sign-in|signup|sign-up|register|auth|oauth|passport|scan|qrcode|qrlogin|wxlogin|sso)(?=$|[/?#=&._-])/i + .test(`${current.pathname}${current.search}${current.hash}`); + } + return currentPath === loginPath || currentPath.startsWith(`${loginPath}/`); } catch { return false; @@ -1069,7 +1538,7 @@ async function verifyPublishAccountConsoleAccess( account: PublishAccountIdentity, handle: SessionHandle, ): Promise { - const definition = publishBindingDefinitions[account.platform]; + const definition = platformBindingDefinitions[account.platform]; if (!definition) { return false; } @@ -1096,6 +1565,14 @@ async function verifyPublishAccountConsoleAccess( return false; } + if (isAIPlatformBinding(account.platform)) { + const detected = await definition.detect({ + session: handle.session, + webContents: window.webContents, + }).catch(() => null); + return detected !== null; + } + return !looksLikeLoginRedirect(currentURL, definition.loginUrl); } finally { if (!window.isDestroyed()) { @@ -1470,6 +1947,24 @@ const publishBindingDefinitions: Record = Object.fromEntries( + aiPlatformCatalog.map((platform) => [ + platform.id, + { + id: platform.id, + label: platform.label, + loginUrl: platform.loginUrl, + consoleUrl: platform.consoleUrl, + detect: (context: DetectContext) => detectGenericAIPlatform(platform.id, platform.label, context), + }, + ]), +) as Record; + +const platformBindingDefinitions: Record = { + ...publishBindingDefinitions, + ...aiBindingDefinitions, +}; + function createBoundWindow( title: string, targetURL: string, @@ -1542,9 +2037,9 @@ function clearActiveBindOperation(platformId: string, window: BrowserWindow): vo } export async function bindPublishAccount(platformId: string): Promise { - const definition = publishBindingDefinitions[platformId]; + const definition = platformBindingDefinitions[platformId]; if (!definition) { - throw new Error(`desktop_publish_platform_not_supported:${platformId}`); + throw new Error(`desktop_platform_not_supported:${platformId}`); } cleanupInactiveBindOperations(); @@ -1615,7 +2110,12 @@ export async function bindPublishAccount(platformId: string): Promise { - const definition = publishBindingDefinitions[account.platform]; + const definition = platformBindingDefinitions[account.platform]; if (!definition) { - throw new Error(`desktop_publish_platform_not_supported:${account.platform}`); + throw new Error(`desktop_platform_not_supported:${account.platform}`); } const handle = await ensurePublishAccountSessionHandle(account); diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index 7e6406b..5ce0803 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -11,6 +11,7 @@ import type { JsonValue, LeaseDesktopTaskResponse, } from "@geo/shared-types"; +import { isAIPlatformId } from "@geo/shared-types"; import { doubaoAdapter, @@ -80,6 +81,7 @@ const heartbeatIntervalMs = 25_000; const pullIntervalMs = 60_000; const leaseExtendIntervalMs = 60_000; const maxActivityItems = 60; +const monitorBusinessTimeZone = "Asia/Shanghai"; interface RuntimeTaskRecord { id: string; @@ -456,10 +458,10 @@ function handleTaskEvent(event: DesktopTaskEventMessage): void { const next: RuntimeTaskRecord = { id: event.task_id, jobId: event.job_id, - title: existing?.title ?? defaultTaskTitle(event.kind, existing?.platform), + title: existing?.title ?? defaultTaskTitle(event.kind, event.platform || existing?.platform), kind: event.kind, - platform: existing?.platform ?? inferPlatformFromAccount(existing?.accountId ?? null), - accountId: existing?.accountId ?? "", + platform: event.platform || existing?.platform || inferPlatformFromAccount(event.target_account_id), + accountId: event.target_account_id || existing?.accountId || "", accountName: existing?.accountName ?? "待同步账号", clientId: event.target_client_id, status: event.status, @@ -548,6 +550,7 @@ async function syncAccounts(source: "startup" | "manual"): Promise { state.accountProfiles.clear(); const syncedAccounts: Array = await Promise.all(accounts.map(async (account) => { const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid; + const isAIPlatform = isAIPlatformId(account.platform); const identity = { id: account.id, platform: account.platform, @@ -560,7 +563,18 @@ async function syncAccounts(source: "startup" | "manual"): Promise { return null; } - if (local.profile && !sameAccountPlatformUid(local.profile.platformUid, normalizedPlatformUid)) { + if (local.profile) { + state.accountProfiles.set(account.id, local.profile); + } + + const normalizedLocalPlatformUid = local.profile + ? (normalizeAccountPlatformUid(local.profile.platformUid) || local.profile.platformUid) + : null; + const hasPlatformUidMismatch = Boolean( + normalizedLocalPlatformUid && !sameAccountPlatformUid(normalizedLocalPlatformUid, normalizedPlatformUid), + ); + + if (hasPlatformUidMismatch && !isAIPlatform) { return { ...account, platform_uid: normalizedPlatformUid, @@ -571,6 +585,8 @@ async function syncAccounts(source: "startup" | "manual"): Promise { let resolvedAccount: DesktopAccountInfo = { ...account, platform_uid: normalizedPlatformUid, + display_name: local.profile?.displayName ?? account.display_name, + avatar_url: local.profile?.avatarUrl ?? account.avatar_url, health: local.availability === "expired" ? ("expired" as const) : ("live" as const), }; @@ -967,6 +983,24 @@ async function executeTaskAdapter( } } + const staleBusinessDate = resolveStaleMonitoringBusinessDate(payload); + if (staleBusinessDate) { + return { + status: "unknown", + payload: { + ...payload, + dropped_by_client: true, + dropped_reason: "stale_business_date", + }, + error: { + code: "desktop_monitor_task_stale", + message: "monitor task is stale and has been dropped by desktop client", + business_date: staleBusinessDate, + }, + summary: `${task.title} 已过业务日 ${staleBusinessDate},按漏采策略直接丢弃。`, + }; + } + const adapter = selectMonitorAdapter(task.platform); if (!adapter) { return buildScaffoldResult(task, payload, "monitor adapter is not implemented yet"); @@ -1017,7 +1051,7 @@ function buildScaffoldResult( message: "desktop runtime adapter is not implemented for this platform", detail, }, - summary: `${task.title} 执行失败:当前平台的 desktop 发布适配器尚未实现。`, + summary: `${task.title} 执行失败:当前平台的 desktop ${task.kind === "publish" ? "发布" : "监测"}适配器尚未实现。`, }; } @@ -1062,6 +1096,50 @@ function toPositiveInt(value: JsonValue | null): number | null { return null; } +function resolveStaleMonitoringBusinessDate(payload: Record): string | null { + const businessDate = resolveMonitoringBusinessDate(payload); + if (!businessDate) { + return null; + } + + return businessDate < currentMonitoringBusinessDate() ? businessDate : null; +} + +function resolveMonitoringBusinessDate(payload: Record): string | null { + const candidates = [ + payload.business_date, + payload.businessDate, + payload.metric_date, + payload.date, + ]; + + for (const candidate of candidates) { + if (typeof candidate !== "string") { + continue; + } + const normalized = candidate.trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + return normalized; + } + } + + return null; +} + +function currentMonitoringBusinessDate(now = new Date()): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: monitorBusinessTimeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(now); + + const year = parts.find((part) => part.type === "year")?.value ?? ""; + const month = parts.find((part) => part.type === "month")?.value ?? ""; + const day = parts.find((part) => part.type === "day")?.value ?? ""; + return `${year}-${month}-${day}`; +} + function updateTaskProgress(taskId: string, summary: string): void { const existing = state.tasks.get(taskId); if (!existing) { @@ -1354,7 +1432,9 @@ function isDesktopTaskEvent(value: unknown): value is DesktopTaskEventMessage { typeof event.type === "string" && typeof event.task_id === "string" && typeof event.job_id === "string" + && typeof event.target_account_id === "string" && typeof event.target_client_id === "string" + && typeof event.platform === "string" && typeof event.kind === "string" && typeof event.status === "string" && typeof event.updated_at === "string", diff --git a/apps/desktop-client/src/renderer/lib/client-errors.ts b/apps/desktop-client/src/renderer/lib/client-errors.ts index f748222..c175529 100644 --- a/apps/desktop-client/src/renderer/lib/client-errors.ts +++ b/apps/desktop-client/src/renderer/lib/client-errors.ts @@ -42,7 +42,7 @@ function presentClientError(kind: ClientActionKind, error: unknown): ClientError ? "账号绑定失败" : kind === "unbind-account" ? "账号解绑失败" - : "打开创作台失败", + : "打开平台失败", ); if (message === "desktop_account_bind_window_closed") { @@ -77,7 +77,7 @@ function presentClientError(kind: ClientActionKind, error: unknown): ClientError }; } - if (message.startsWith("desktop_publish_platform_not_supported:")) { + if (message.startsWith("desktop_platform_not_supported:")) { const platformId = message.split(":")[1] || "当前平台"; return { tone: "error", @@ -105,7 +105,7 @@ function presentClientError(kind: ClientActionKind, error: unknown): ClientError if (kind === "open-console") { return { tone: "error", - title: "打开创作台失败", + title: "打开平台失败", content: message, }; } diff --git a/apps/desktop-client/src/renderer/lib/media-catalog.ts b/apps/desktop-client/src/renderer/lib/media-catalog.ts index 32dc008..91255e8 100644 --- a/apps/desktop-client/src/renderer/lib/media-catalog.ts +++ b/apps/desktop-client/src/renderer/lib/media-catalog.ts @@ -11,6 +11,7 @@ import logoWangyihao from "../assets/logos/logo_wangyihao.png"; import logoWeixinGzh from "../assets/logos/logo_weixin_gzh.svg"; import logoZhihu from "../assets/logos/logo_zhihu.png"; import logoZol from "../assets/logos/logo_zol.png"; +import { aiPlatformCatalog } from "@geo/shared-types"; export interface DesktopMediaDefinition { id: string; @@ -156,37 +157,15 @@ export const desktopPublishMediaCatalog: DesktopMediaDefinition[] = [ }, ]; -export const desktopMonitoringMediaCatalog: DesktopMediaDefinition[] = [ - { - id: "deepseek", - label: "DeepSeek", - shortName: "D", - accent: "#4468ff", - loginUrl: "https://chat.deepseek.com/", - logoUrl: null, - category: "monitoring", - description: "AI 监测 / 登录后可采集", - }, - { - id: "qwen", - label: "通义千问", - shortName: "Q", - accent: "#6b46ff", - loginUrl: "https://www.qianwen.com/", - logoUrl: null, - category: "monitoring", - description: "AI 监测 / 匿名可用", - }, - { - id: "doubao", - label: "豆包", - shortName: "豆", - accent: "#00a870", - loginUrl: "https://www.doubao.com/", - logoUrl: null, - category: "monitoring", - description: "AI 监测 / 匿名可用", - }, -]; +export const desktopMonitoringMediaCatalog: DesktopMediaDefinition[] = aiPlatformCatalog.map((platform) => ({ + id: platform.id, + label: platform.label, + shortName: platform.shortName, + accent: platform.accent, + loginUrl: platform.loginUrl, + logoUrl: null, + category: "monitoring", + description: platform.description, +})); export const desktopMediaCatalog = [...desktopPublishMediaCatalog, ...desktopMonitoringMediaCatalog]; diff --git a/apps/desktop-client/src/renderer/views/AccountsView.vue b/apps/desktop-client/src/renderer/views/AccountsView.vue index 927e994..ab39c79 100644 --- a/apps/desktop-client/src/renderer/views/AccountsView.vue +++ b/apps/desktop-client/src/renderer/views/AccountsView.vue @@ -1,6 +1,6 @@