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) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 15:40:18 +08:00
parent 25dad49ed3
commit 09295d11a1
21 changed files with 2073 additions and 843 deletions
+260 -7
View File
@@ -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<string, { color: string; surface: string; gl
const trackingPlatformOptions = [
{ label: "全部", value: "all" },
{ label: "DeepSeek", value: "deepseek" },
{ label: "千问", value: "qwen" },
{ label: "豆包", value: "doubao" },
...aiPlatformCatalog.map((platform) => ({
label: platform.label,
value: platform.id,
})),
] as const;
const trackingPlatformIds = new Set<string>(
trackingPlatformOptions
.map((item) => item.value)
.filter((value) => value !== "all"),
aiPlatformCatalog.map((item) => item.id),
);
const selectedBrandId = useStorage<number | null>("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<AIPlatformStatusCard[]>(() => {
const accountGroups = new Map<string, DesktopAccountInfo[]>();
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 {
</a-card>
</div>
<a-card class="tracking-panel tracking-panel--ai-platforms" :loading="dashboardQuery.isLoading.value || aiAccountsQuery.isLoading.value">
<div class="tracking-panel__header">
<div>
<h3 class="panel-title">AI 平台状态</h3>
<span class="tracking-panel__helper">
只读展示 desktop-client 的绑定授权和当前采样状态授权入口放在桌面端抓取过程静默执行
</span>
</div>
</div>
<div class="ai-platform-status-grid">
<article
v-for="platform in aiPlatformStatusCards"
:key="platform.id"
class="ai-platform-status-card"
>
<div class="ai-platform-status-card__head">
<div class="ai-platform-status-card__identity">
<span
class="ai-platform-status-card__badge"
:style="{ backgroundColor: `${platform.accent}14`, color: platform.accent, borderColor: `${platform.accent}30` }"
>
{{ platform.shortName }}
</span>
<div>
<strong>{{ platform.label }}</strong>
<p>{{ platform.description }}</p>
</div>
</div>
<a-tag :color="accountAuthColor(platform.account)">
{{ accountAuthLabel(platform.account) }}
</a-tag>
</div>
<div class="ai-platform-status-card__meta">
<div class="ai-platform-status-card__row">
<span>绑定状态</span>
<strong>{{ platform.account ? "已绑定" : "未绑定" }}</strong>
</div>
<div class="ai-platform-status-card__row">
<span>客户端</span>
<strong>{{ desktopStatusLabel(platform.account) }}</strong>
</div>
<div class="ai-platform-status-card__row">
<span>采样状态</span>
<a-tag :color="statusTagColor(platform.sampleStatus)">
{{ statusLabel(platform.sampleStatus) }}
</a-tag>
</div>
<div class="ai-platform-status-card__row">
<span>最近采样</span>
<strong>{{ platform.lastSampledAt ? formatDateTime(platform.lastSampledAt) : "--" }}</strong>
</div>
<div class="ai-platform-status-card__row">
<span>样本数</span>
<strong>{{ formatCount(platform.actualSampleCount) }}</strong>
</div>
<div v-if="platform.account" class="ai-platform-status-card__row">
<span>账号</span>
<strong>{{ platform.account.display_name }}</strong>
</div>
</div>
</article>
</div>
</a-card>
<div class="tracking-grid tracking-grid--middle">
<a-card class="tracking-panel" :loading="dashboardQuery.isLoading.value">
<div class="tracking-panel__header">
@@ -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;
+1
View File
@@ -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"
},
+541 -25
View File
@@ -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<GenericAIPageState | null> {
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<GenericAIPageState>;
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<string | null> {
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<DetectedAccount | null> {
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<DetectedAccount | null> {
const pageResponse = await pageFetchJson<ToutiaoMediaInfoResponse>(
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<void> {
const maybeFlushStorageData = (target as Session & {
flushStorageData?: () => Promise<void>;
}).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<boolean> {
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<boolean> {
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<string, PublishPlatformBindingDefinition
},
};
const aiBindingDefinitions: Record<string, PublishPlatformBindingDefinition> = 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<string, PublishPlatformBindingDefinition>;
const platformBindingDefinitions: Record<string, PublishPlatformBindingDefinition> = {
...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<DesktopAccountInfo> {
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<DesktopAcc
detectReady = false;
return;
}
detectReady = isWindowReadyForDetection(window);
const currentURL = window.webContents.getURL();
const fallbackReady =
/^https?:\/\//i.test(currentURL)
&& !currentURL.startsWith("data:text/html")
&& !window.webContents.isLoading();
detectReady = isWindowReadyForDetection(window) || fallbackReady;
};
window.webContents.on("did-finish-load", syncDetectionState);
@@ -1641,6 +2141,8 @@ export async function bindPublishAccount(platformId: string): Promise<DesktopAcc
console.info("[desktop-bind] detect tick", {
platform: definition.id,
detectReady,
canProbe,
loading: window.webContents.isLoading(),
url: currentURL,
});
const detected = await definition
@@ -1687,20 +2189,34 @@ export async function bindPublishAccount(platformId: string): Promise<DesktopAcc
normalizedDetected,
);
const account = await upsertDesktopAccount({
platform: definition.id,
platform_uid: normalizedDetected.platformUid,
display_name: normalizedDetected.displayName,
avatar_url: normalizedDetected.avatarUrl,
account_fingerprint: normalizedDetected.platformUid,
health: "live",
verified_at: new Date().toISOString(),
tags: [],
});
await flushSessionPersistence(handle.session);
settleSuccess(account);
try {
const account = await upsertDesktopAccount({
platform: definition.id,
platform_uid: normalizedDetected.platformUid,
display_name: normalizedDetected.displayName,
avatar_url: normalizedDetected.avatarUrl,
account_fingerprint: normalizedDetected.platformUid,
health: "live",
verified_at: new Date().toISOString(),
tags: [],
});
settleSuccess(account);
} catch (error) {
console.error("[desktop-bind] upsert failed", {
platform: definition.id,
url: currentURL,
message: error instanceof Error ? error.message : String(error),
});
if (!window.isDestroyed()) {
window.setTitle(`${definition.label} 绑定保存失败,保留窗口重试`);
}
return;
}
} catch (error) {
console.error("[desktop-bind] upsert failed", {
console.error("[desktop-bind] bind failed", {
platform: definition.id,
url: currentURL,
message: error instanceof Error ? error.message : String(error),
@@ -1739,9 +2255,9 @@ export async function bindPublishAccount(platformId: string): Promise<DesktopAcc
}
export async function openPublishAccountConsole(account: PublishAccountIdentity): Promise<void> {
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);
@@ -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<void> {
state.accountProfiles.clear();
const syncedAccounts: Array<DesktopAccountInfo | null> = 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<void> {
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<void> {
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, JsonValue>): string | null {
const businessDate = resolveMonitoringBusinessDate(payload);
if (!businessDate) {
return null;
}
return businessDate < currentMonitoringBusinessDate() ? businessDate : null;
}
function resolveMonitoringBusinessDate(payload: Record<string, JsonValue>): 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",
@@ -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,
};
}
@@ -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];
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { SearchOutlined, ReloadOutlined, ApiOutlined, ArrowRightOutlined, DeleteOutlined } from "@ant-design/icons-vue";
import { SearchOutlined, ReloadOutlined, ApiOutlined, ArrowRightOutlined, DeleteOutlined, LinkOutlined, SyncOutlined } from "@ant-design/icons-vue";
import StatusBadge from "../components/StatusBadge.vue";
import SurfaceCard from "../components/SurfaceCard.vue";
@@ -208,536 +208,464 @@ async function unbindAccount(account: AccountRow) {
}
const tableColumns = [
{ title: "昵称", key: "name", dataIndex: "name" },
{ title: "平台", key: "platform", dataIndex: "platform" },
{ title: "授权状态", key: "status", dataIndex: "status" },
{ title: "本地会话", key: "session", dataIndex: "session" },
{ title: "授权时间", key: "syncTime", dataIndex: "syncTime" },
{ title: "昵称 / UID", key: "name", dataIndex: "name" },
{ title: "平台", key: "platform", dataIndex: "platform", width: 140 },
{ title: "授权状态", key: "status", dataIndex: "status", width: 120 },
{ title: "本地会话", key: "session", dataIndex: "session", width: 140 },
{ title: "授权时间", key: "syncTime", dataIndex: "syncTime", width: 180 },
{ title: "操作", key: "actions", align: "right" as const }
];
</script>
<template>
<section class="media-view">
<section class="media-view__top-card">
<div class="media-view__header">
<div class="media-view__header-title">
<h2>账号管理</h2>
<p>先把平台授权跑通上面直接发起真实登录绑定下面看已授权账号状态和进入创作台入口</p>
<section class="page-container">
<section class="hero-card">
<div class="hero-header">
<div class="hero-title">
<p class="eyebrow">MEDIA ACCOUNT MANAGEMENT</p>
<h2>媒体账号管理</h2>
</div>
<div class="media-view__header-actions">
<a-button :loading="loading" @click="refreshAccounts">
<div class="hero-actions">
<a-button type="primary" ghost class="modern-btn" :loading="loading" @click="refreshAccounts">
<template #icon><ReloadOutlined /></template>
刷新状态
</a-button>
</div>
</div>
<div class="overview-strip border-t">
<div class="overview-chip">
<span>已授权</span>
<strong>{{ overview.authorized }}</strong>
<div class="stats-strip">
<div class="stat-card">
<div class="stat-label">已授权</div>
<div class="stat-value">{{ overview.authorized }}</div>
</div>
<div class="overview-chip">
<span>待处理</span>
<strong>{{ overview.attention }}</strong>
<div class="stat-card">
<div class="stat-label">待处理</div>
<div class="stat-value">{{ overview.attention }}</div>
</div>
<div class="overview-chip">
<span>风险观察</span>
<strong>{{ overview.risk }}</strong>
<div class="stat-card">
<div class="stat-label">风险观察</div>
<div class="stat-value">{{ overview.risk }}</div>
</div>
<div class="overview-chip">
<span>在线客户端</span>
<strong>{{ overview.onlineClients }}</strong>
<div class="stat-card">
<div class="stat-label">在线客户端</div>
<div class="stat-value">{{ overview.onlineClients }}</div>
</div>
</div>
</section>
<section class="media-list-wrapper panel">
<div class="media-list-content">
<h4 class="media-list-title">选择平台进行授权</h4>
<p class="media-list-desc">点击平台即可发起真实登录登录成功后自动探测账号并回写到当前 desktop client</p>
<section class="media-grid">
<article
v-for="platform in platformCards"
:key="platform.id"
class="media-card"
:class="[
selectedPlatform === platform.id ? 'media-card--active' : '',
platform.latestAccount ? 'media-card--success' : 'media-card--default'
]"
@click="selectPlatform(platform.id)"
>
<div class="media-card__head">
<div class="media-card__identity">
<span class="media-card__badge" :style="{ color: platform.accent }">
<img v-if="platform.logoUrl" :src="platform.logoUrl" :alt="platform.label" style="width: 20px; height: 20px; object-fit: contain;" />
<span v-else>{{ platform.shortName }}</span>
</span>
<div class="media-card__identity-text">
<h3>{{ platform.label }}</h3>
<p>{{ platform.count > 0 ? `${platform.count} 个账号` : "未绑定" }}</p>
</div>
<!-- Content Sections -->
<section class="content-section">
<div class="section-header">
<h3 class="section-title">选择平台进行授权</h3>
</div>
<div class="grid-layout">
<article
v-for="platform in platformCards"
:key="platform.id"
class="modern-card media-card-clickable"
:class="[
selectedPlatform === platform.id ? 'is-active' : '',
platform.latestAccount ? 'is-bound' : 'is-unbound'
]"
@click="selectPlatform(platform.id)"
>
<div class="card-header">
<div class="brand-info">
<span class="brand-logo" :style="{ color: platform.accent, background: '#ffffff', boxShadow: '0 2px 4px rgba(0,0,0,0.03), inset 0 0 0 1px #e2e8f0' }">
<img v-if="platform.logoUrl" :src="platform.logoUrl" :alt="platform.label" style="width: 24px; height: 24px; object-fit: contain;" />
<span v-else>{{ platform.shortName }}</span>
</span>
<div class="brand-text">
<h4>{{ platform.label }}</h4>
<p>{{ platform.count > 0 ? `${platform.count} 个账号` : "未绑定" }}</p>
</div>
</div>
<!-- Dynamic Status Badge/Tag could be added here if needed -->
</div>
<div class="media-card__footer">
<div class="media-card__status">
<span v-if="platform.latestAccount" class="status-dot success"></span>
<span v-else class="status-dot default"></span>
<span class="status-text">{{ platform.latestAccount ? '已接入' : '待接入' }}</span>
</div>
<div class="media-card__actions">
<div class="card-footer" style="padding-top: 16px; margin-top: auto; border-top: 1px dashed #e2e8f0; display:flex; justify-content: space-between; align-items:center;">
<div class="media-card__status" style="display:flex; align-items:center; gap: 8px;">
<span :class="['status-dot', platform.latestAccount ? 'success' : 'default']"></span>
<span style="font-size: 13px; color: #64748b; font-weight: 500;">
{{ platform.latestAccount ? '已接入' : '待接入' }}
</span>
</div>
<div class="action-group">
<a-button
v-if="platform.count > 0"
class="action-btn-publish"
:loading="bindPendingPlatformId === platform.id"
@click.stop="bindPlatform(platform.id)"
>
新增账号 <ApiOutlined />
新增账号
<template #icon><ApiOutlined /></template>
</a-button>
<a-button
v-else
class="action-btn-bind"
type="primary"
class="action-btn-bind modern-btn"
style="border-radius: 8px; font-weight: 600;"
:loading="bindPendingPlatformId === platform.id"
@click.stop="bindPlatform(platform.id)"
>
绑定账号 <ArrowRightOutlined />
绑定账号
<template #icon><ArrowRightOutlined /></template>
</a-button>
</div>
</div>
</article>
</section>
</div>
</div>
</article>
</div>
</section>
<div class="panel list-panel">
<div class="table-header">
<h3 class="panel-title">授权账号列表</h3>
<p class="panel-desc">当前仅展示本机存在缓存分区或会话的媒体账号并实时校验授权是否过期</p>
<div class="media-list-toolbar">
<div class="toolbar-filters">
<a-select
v-model:value="selectedPlatform"
style="width: 160px"
>
<!-- Table Details Section -->
<section class="content-section">
<div class="hero-card" style="padding-bottom: 0;">
<div class="hero-header" style="padding: 24px 32px 16px; border-bottom: 1px solid #eef2f6; align-items: center;">
<div style="flex: 1;">
<h3 class="section-title">授权账号列表</h3>
</div>
<!-- Filters ToolBar -->
<div style="display: flex; gap: 12px; align-items: center;">
<a-select v-model:value="selectedPlatform" style="width: 140px; border-radius: 8px;">
<a-select-option value="all">所有平台</a-select-option>
<a-select-option v-for="platform in platformCards" :key="platform.id" :value="platform.id">
{{ platform.label }}
</a-select-option>
</a-select>
<a-select
v-model:value="selectedStatus"
style="width: 160px"
>
<a-select v-model:value="selectedStatus" style="width: 140px;">
<a-select-option v-for="option in statusOptions" :key="option.value" :value="option.value">
{{ option.label }}
</a-select-option>
</a-select>
<a-input
v-model:value="searchQuery"
placeholder="搜索昵称、UID、平台或本地分区"
style="width: 280px"
allow-clear
>
<template #prefix>
<SearchOutlined style="color: #bfbfbf" />
</template>
<a-input v-model:value="searchQuery" placeholder="搜索昵称、UID..." style="width: 220px;" allow-clear>
<template #prefix><SearchOutlined style="color: #94a3b8;" /></template>
</a-input>
</div>
</div>
</div>
<a-table
class="modern-table"
:columns="tableColumns"
:data-source="filteredAccounts"
:pagination="false"
>
<template #emptyText>
<a-empty description="暂无匹配账号" />
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="cell-primary-secondary">
<div class="user-avatar-wrap">
<span class="avatar-circle" :style="{ background: platformMeta(record.platform).accent }">
<img v-if="record.avatarUrl" :src="record.avatarUrl" :alt="record.displayName" />
<span v-else class="avatar-initial">{{ accountInitial(record) }}</span>
</span>
<div class="info-stack">
<span class="title">{{ record.displayName }}</span>
<span class="subtitle">{{ record.platformUid }}</span>
</div>
</div>
</div>
<a-table class="modern-table" :columns="tableColumns" :data-source="filteredAccounts" :pagination="false">
<template #emptyText>
<div style="padding: 60px; color: #94a3b8;"><a-empty description="暂无匹配账号,请重置过滤或绑定新账号" /></div>
</template>
<template v-else-if="column.key === 'platform'">
<div class="platform-display">
<span class="platform-logo-mini">
<img v-if="platformMeta(record.platform).logoUrl" :src="platformMeta(record.platform).logoUrl!" />
<span v-else :style="{ color: platformMeta(record.platform).accent, fontWeight: 600 }">
{{ platformMeta(record.platform).shortName }}
</span>
</span>
<span>{{ platformMeta(record.platform).label }}</span>
</div>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.health === 'live' ? 'green' : record.health === 'expired' ? 'red' : record.health === 'captcha' ? 'orange' : 'default'">
{{ authStateLabel(record) }}
</a-tag>
</template>
<template v-else-if="column.key === 'session'">
<div class="cell-primary-secondary">
<div class="info-stack">
<span class="title">{{ sessionStateLabel(record) }}</span>
<span class="subtitle">{{ record.online ? "本机在线" : "本机离线" }}</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'syncTime'">
<div class="cell-primary-secondary">
<div class="info-stack">
<span class="title">{{ formatDateTime(record.lastSyncAt) }}</span>
<span class="subtitle">{{ record.partition }}</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'actions'">
<div class="action-buttons">
<a-button
type="primary"
size="small"
:loading="consolePendingAccountId === record.id"
@click="openConsole(record)"
>
创作台
</a-button>
<a-button
size="small"
:loading="bindPendingPlatformId === record.platform"
@click="bindPlatform(record.platform)"
>
重新授权
</a-button>
<a-popconfirm
title="解绑这个账号?"
description="会删除当前账号绑定,并清理本机的本地 session / partition 缓存。"
ok-text="确认解绑"
cancel-text="取消"
@confirm="unbindAccount(record)"
>
<a-button
danger
size="small"
:loading="unbindPendingAccountId === record.id"
>
<template #icon><DeleteOutlined /></template>
解绑
</a-button>
</a-popconfirm>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="cell-primary-secondary">
<div class="user-avatar-wrap">
<span class="avatar-circle" :style="{ background: platformMeta(record.platform).accent }">
<img v-if="record.avatarUrl" :src="record.avatarUrl" :alt="record.displayName" />
<span v-else class="avatar-initial">{{ accountInitial(record) }}</span>
</span>
<div class="info-stack">
<span class="title">{{ record.displayName }}</span>
<span class="subtitle mono-text">{{ record.platformUid }}</span>
</div>
</div>
</div>
</template>
<template v-else-if="column.key === 'platform'">
<div class="platform-display">
<span class="platform-logo-mini">
<img v-if="platformMeta(record.platform).logoUrl" :src="platformMeta(record.platform).logoUrl!" />
<span v-else :style="{ color: platformMeta(record.platform).accent, fontWeight: 700 }">
{{ platformMeta(record.platform).shortName }}
</span>
</span>
<span style="font-weight: 600; font-size: 13px; color: #0f172a;">{{ platformMeta(record.platform).label }}</span>
</div>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.health === 'live' ? 'success' : record.health === 'expired' ? 'error' : record.health === 'captcha' ? 'warning' : 'default'" style="border-radius: 999px; font-weight: 600; padding: 2px 10px;">
{{ authStateLabel(record) }}
</a-tag>
</template>
<template v-else-if="column.key === 'session'">
<div class="cell-primary-secondary">
<div class="info-stack">
<span class="title">{{ sessionStateLabel(record) }}</span>
<span class="subtitle">{{ record.online ? "本机在线" : "本机离线" }}</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'syncTime'">
<div class="cell-primary-secondary">
<div class="info-stack">
<span class="title">{{ formatDateTime(record.lastSyncAt) }}</span>
<span class="subtitle">{{ record.partition }}</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'actions'">
<div class="table-actions">
<a-tooltip title="打开创作台" placement="top">
<a-button type="text" class="action-btn" :loading="consolePendingAccountId === record.id" @click="openConsole(record)">
<template #icon><LinkOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="重新授权" placement="top">
<a-button type="text" class="action-btn" :loading="bindPendingPlatformId === record.platform" @click="bindPlatform(record.platform)">
<template #icon><SyncOutlined /></template>
</a-button>
</a-tooltip>
<a-popconfirm title="确定解绑这个账号?" placement="topRight" @confirm="unbindAccount(record)">
<a-tooltip title="解绑" placement="top">
<a-button danger type="text" class="action-btn danger-btn" :loading="unbindPendingAccountId === record.id">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-tooltip>
</a-popconfirm>
</div>
</template>
</template>
</template>
</a-table>
</div>
</a-table>
</div>
</section>
</section>
</template>
<style scoped>
.media-view {
.page-container {
display: flex;
flex-direction: column;
gap: 32px;
padding-bottom: 40px;
max-width: 1400px;
}
/* Hero Section */
.hero-card {
background: #ffffff;
border-radius: 20px;
border: 1px solid #eef2f6;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px -2px rgba(0, 0, 0, 0.01);
overflow: hidden;
}
.hero-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 36px 40px;
}
.eyebrow {
margin: 0 0 12px;
color: #64748b;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.hero-title h2 {
margin: 0;
font-size: 32px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.02em;
}
.summary {
margin: 16px 0 0;
max-width: 800px;
color: #475569;
line-height: 1.6;
font-size: 15px;
}
.modern-btn {
border-radius: 8px;
height: 40px;
padding: 0 20px;
font-weight: 600;
}
/* Stats Strip */
.stats-strip {
display: flex;
padding: 24px 40px;
background: #f8fafc;
border-top: 1px solid #eef2f6;
gap: 40px;
}
.stat-card {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.stat-label {
font-size: 14px;
font-weight: 500;
color: #64748b;
}
.stat-value {
font-size: 40px;
font-weight: 700;
color: #0f172a;
line-height: 1;
font-feature-settings: "tnum";
}
/* Content Section */
.content-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.section-header {
padding: 0 8px;
}
.section-title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #0f172a;
}
.section-desc {
margin: 8px 0 0;
color: #64748b;
font-size: 15px;
line-height: 1.5;
}
.grid-layout {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.media-view__top-card {
background: #fff;
border: 1px solid #e6edf5;
border-radius: 12px;
overflow: hidden;
}
.media-view__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 24px;
}
.media-view__header-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
}
.media-view__header-title p {
margin: 6px 0 0 0;
font-size: 13px;
color: #8c8c8c;
}
.media-view__header-actions {
display: flex;
gap: 12px;
}
.overview-strip {
display: flex;
background: #fafafb;
padding: 16px 24px;
gap: 32px;
}
.border-t {
border-top: 1px solid #e6edf5;
}
.overview-chip {
/* Modern Card Layout */
.modern-card {
display: flex;
flex-direction: column;
}
.overview-chip span {
font-size: 13px;
color: #595959;
}
.overview-chip strong {
margin-top: 4px;
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.2;
}
.panel {
background: #ffffff;
border-radius: 12px;
border: 1px solid #e6edf5;
display: flex;
flex-direction: column;
}
.media-list-wrapper {
overflow: hidden;
}
.media-list-content {
border-radius: 20px;
border: 1px solid #e2e8f0;
padding: 24px;
}
.media-list-title {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 4px;
}
.media-list-desc {
font-size: 13px;
color: #8c8c8c;
margin-bottom: 20px;
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.media-card {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.02);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
padding: 16px;
border: 1px solid #e6edf5;
border-radius: 12px;
background: #fff;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
}
.media-card-clickable {
cursor: pointer;
}
.media-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(31, 41, 55, 0.08);
border-color: #1677ff;
.media-card-clickable:hover {
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.01);
border-color: #cbd5e1;
}
.media-card--active {
border-color: #1677ff;
background: #f0f7ff;
.modern-card.is-active {
border-color: #0ea5e9;
box-shadow: 0 0 0 1px #0ea5e9, 0 4px 6px -1px rgba(14, 165, 233, 0.1);
background: #f0f9ff;
}
.media-card__head {
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
margin-bottom: 8px; /* Reduced because content goes into footer */
}
.media-card__identity {
.brand-info {
display: flex;
gap: 16px;
align-items: center;
gap: 12px;
}
.media-card__badge {
display: inline-flex;
.brand-logo {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
background: #ffffff;
border: 1px solid #f0f0f0;
box-shadow: 0 2px 4px rgba(0,0,0,0.02);
font-size: 18px;
font-weight: 700;
border-radius: 12px;
font-size: 20px;
font-weight: 800;
}
.media-card__identity-text {
display: flex;
flex-direction: column;
}
.media-card__identity-text h3 {
.brand-text h4 {
margin: 0;
color: #1a1a1a;
font-size: 15px;
font-weight: 600;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.media-card__identity-text p {
margin: 2px 0 0;
color: #8c8c8c;
font-size: 12px;
}
.media-card__footer {
margin-top: 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
.media-card__status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #8c8c8c;
.brand-text p {
margin: 4px 0 0;
color: #64748b;
font-size: 13px;
font-weight: 500;
}
.status-dot {
width: 8px;
height: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
}
.status-dot.success { background-color: #52c41a; }
.status-dot.default { background-color: #d9d9d9; }
.action-btn-bind {
border-radius: 6px;
font-size: 13px;
color: #fff;
background: #1677ff;
border: 1px solid #1677ff;
height: 32px;
padding: 0 12px;
display: flex;
align-items: center;
gap: 6px;
}
.action-btn-bind:hover {
background: #4096ff;
color: #fff;
border-color: #4096ff;
}
.status-dot.success { background-color: #10b981; }
.status-dot.default { background-color: #cbd5e1; }
.action-btn-publish {
border-radius: 6px;
border-radius: 8px;
font-size: 13px;
color: #1677ff;
background: #e6f4ff;
border: 1px solid #91caff;
height: 32px;
padding: 0 12px;
font-weight: 600;
color: #0284c7;
background: #e0f2fe;
border: 1px solid #bae6fd;
height: 34px;
padding: 0 14px;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.action-btn-publish:hover {
background: #bae0ff;
color: #1677ff;
border-color: #69b1ff;
background: #bae6fd;
color: #0369a1;
border-color: #7dd3fc;
}
/* Table Card Panel */
.list-panel {
padding-bottom: 24px;
}
.table-header {
padding: 24px;
border-bottom: 1px solid #e6edf5;
}
.panel-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
}
.panel-desc {
margin: 4px 0 0;
font-size: 13px;
color: #8c8c8c;
}
.media-list-toolbar {
margin-top: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.toolbar-filters {
display: flex;
align-items: center;
gap: 12px;
.action-btn-bind {
border-radius: 8px;
height: 34px;
}
/* Modern Enterprise Table Styles */
:deep(.modern-table) {
padding: 0 24px;
padding: 0 32px 32px;
}
:deep(.modern-table .ant-table-thead > tr > th) {
background-color: #fcfcfd !important;
color: #4b5563;
background-color: transparent !important;
color: #64748b;
font-weight: 600;
border-bottom: 1px solid #f3f4f6;
padding: 12px 16px;
font-size: 13px;
border-bottom: 1px solid #e2e8f0;
padding: 16px 12px;
}
:deep(.modern-table .ant-table-tbody > tr > td) {
padding: 16px;
border-bottom: 1px solid #f3f4f6;
color: #1f2937;
padding: 16px 12px;
border-bottom: 1px solid #f1f5f9;
color: #0f172a;
}
:deep(.modern-table .ant-table-tbody > tr:last-child > td) {
@@ -745,7 +673,7 @@ const tableColumns = [
}
:deep(.modern-table .ant-table-tbody > tr:hover > td) {
background-color: #f9fafb !important;
background-color: #f8fafc !important;
}
.cell-primary-secondary {
@@ -757,31 +685,35 @@ const tableColumns = [
flex-direction: column;
}
.info-stack .title {
font-weight: 500;
color: #1f2937;
font-weight: 600;
color: #0f172a;
font-size: 14px;
}
.info-stack .subtitle {
color: #6b7280;
font-size: 12px;
margin-top: 2px;
color: #64748b;
font-size: 13px;
margin-top: 4px;
}
.info-stack .mono-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.user-avatar-wrap {
display: flex;
align-items: center;
gap: 12px;
gap: 16px;
}
.avatar-circle {
width: 36px;
height: 36px;
width: 44px;
height: 44px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05);
}
.avatar-circle img {
width: 100%;
@@ -789,36 +721,51 @@ const tableColumns = [
object-fit: cover;
}
.avatar-initial {
color: #fff;
font-weight: 600;
color: #ffffff;
font-size: 16px;
font-weight: 700;
}
.platform-display {
display: flex;
align-items: center;
gap: 8px;
gap: 12px;
font-size: 14px;
color: #4b5563;
color: #0f172a;
}
.platform-logo-mini {
width: 24px;
height: 24px;
background: #f3f4f6;
border-radius: 6px;
width: 32px;
height: 32px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(0,0,0,0.05), inset 0 0 0 1px #e2e8f0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.platform-logo-mini img {
width: 16px;
height: 16px;
width: 20px;
height: 20px;
object-fit: contain;
}
.action-buttons {
.table-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
align-items: center;
}
/* Responsiveness */
@media (max-width: 1024px) {
.hero-header {
flex-direction: column;
gap: 24px;
}
.stats-strip {
flex-wrap: wrap;
gap: 24px;
}
}
</style>
@@ -1,10 +1,17 @@
<script setup lang="ts">
import { computed } from "vue";
import { ReloadOutlined, WarningOutlined } from "@ant-design/icons-vue";
import {
ArrowRightOutlined,
DeleteOutlined,
LinkOutlined,
ReloadOutlined,
WarningOutlined,
} from "@ant-design/icons-vue";
import { notification } from "ant-design-vue";
import { computed, ref } from "vue";
import StatusBadge from "../components/StatusBadge.vue";
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
import { formatDateTime, titleCaseToken } from "../lib/formatters";
import { showClientActionError } from "../lib/client-errors";
import { formatDateTime } from "../lib/formatters";
import { desktopMonitoringMediaCatalog } from "../lib/media-catalog";
import type { RuntimeAccount } from "../types";
@@ -12,9 +19,9 @@ type AccountRow = Readonly<Omit<RuntimeAccount, "tags">> & { tags: readonly stri
const { snapshot, refreshAccounts, loading } = useDesktopRuntime();
function handleCardClick() {
void refreshAccounts();
}
const bindPendingPlatformId = ref<string | null>(null);
const openPendingAccountId = ref<string | null>(null);
const unbindPendingAccountId = ref<string | null>(null);
const aiAccounts = computed(() =>
(snapshot.value?.accounts ?? []).filter((account) =>
@@ -23,12 +30,23 @@ const aiAccounts = computed(() =>
);
const overview = computed(() => ({
configuredPlatforms: desktopMonitoringMediaCatalog.length,
bound: aiAccounts.value.length,
live: aiAccounts.value.filter((item) => item.health === "live").length,
attention: aiAccounts.value.filter((item) => item.health !== "live").length,
configuredPlatforms: desktopMonitoringMediaCatalog.length,
onlineClients: snapshot.value?.summary.onlineClients ?? 0,
}));
const platformCards = computed(() =>
desktopMonitoringMediaCatalog.map((platform) => {
const matched = aiAccounts.value.filter((account) => account.platform === platform.id);
return {
...platform,
account: matched[0] ?? null,
duplicateCount: Math.max(0, matched.length - 1),
};
}),
);
function authLabel(account: AccountRow | null) {
if (!account) {
return "未绑定";
@@ -36,9 +54,9 @@ function authLabel(account: AccountRow | null) {
switch (account.health) {
case "live":
return "已绑定";
return "授权正常";
case "captcha":
return "待补登";
return "待处理";
case "expired":
return "授权过期";
default:
@@ -46,373 +64,592 @@ function authLabel(account: AccountRow | null) {
}
}
const platformCards = computed(() =>
desktopMonitoringMediaCatalog.map((platform) => {
const matched = aiAccounts.value.filter((account) => account.platform === platform.id);
return {
...platform,
account: matched[0] ?? null,
duplicate: matched.length > 1,
};
}),
);
function sessionTone(account: AccountRow | null) {
function authColor(account: AccountRow | null) {
if (!account) {
return "default";
}
switch (account.sessionState) {
case "hot":
switch (account.health) {
case "live":
return "success";
case "warm":
return "processing";
case "captcha":
return "warning";
case "expired":
return "error";
default:
return "default";
}
}
function sessionLabel(account: AccountRow | null): string {
if (!account) {
return "无本地缓存";
}
function sessionLabel(account: AccountRow): string {
switch (account.sessionState) {
case "hot":
return "活跃会话";
case "warm":
return "本地缓存";
return "缓存";
default:
return "冷缓存";
return "冷启动";
}
}
function shortName(value: string): string {
return titleCaseToken(value).slice(0, 1);
function onlineLabel(account: AccountRow): string {
return account.online ? "客户端在线" : "客户端离线";
}
function showActionNotification(
type: "success" | "info",
title: string,
description: string,
) {
notification[type]({
message: title,
description,
placement: "topRight",
duration: 3.2,
});
}
async function bindPlatform(platformId: string) {
bindPendingPlatformId.value = platformId;
try {
const account = await window.desktopBridge.app.bindPublishAccount(platformId);
const platformLabel = desktopMonitoringMediaCatalog.find((item) => item.id === platformId)?.label ?? platformId;
showActionNotification("success", "绑定成功", `${account.display_name} 已绑定到 ${platformLabel}`);
await refreshAccounts();
} catch (error) {
showClientActionError("bind-account", error);
} finally {
bindPendingPlatformId.value = null;
}
}
async function openPlatform(account: AccountRow) {
openPendingAccountId.value = account.id;
try {
await window.desktopBridge.app.openPublishAccountConsole({
id: account.id,
platform: account.platform,
platformUid: account.platformUid,
displayName: account.displayName,
});
} catch (error) {
showClientActionError("open-console", error);
} finally {
openPendingAccountId.value = null;
}
}
async function unbindPlatform(account: AccountRow) {
unbindPendingAccountId.value = account.id;
try {
await window.desktopBridge.app.unbindPublishAccount(account.id, account.syncVersion);
showActionNotification("info", "解绑成功", `${account.displayName} 已从 ${desktopMonitoringMediaCatalog.find((item) => item.id === account.platform)?.label ?? account.platform} 移除`);
await refreshAccounts();
} catch (error) {
showClientActionError("unbind-account", error);
} finally {
unbindPendingAccountId.value = null;
}
}
</script>
<template>
<section class="media-view">
<section class="media-view__top-card">
<div class="media-view__header">
<div class="media-view__header-title">
<p class="eyebrow" style="color: #8c8c8c; font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em; margin-bottom: 6px;">AI PLATFORMS</p>
<section class="page-container">
<section class="hero-card">
<div class="hero-header">
<div class="hero-title">
<p class="eyebrow">AI PLATFORMS</p>
<h2>AI 平台管理</h2>
<p class="summary" style="margin-top: 10px; line-height: 1.6; color: #595959; font-size: 13px; max-width: 800px;">
AI 平台按每个平台只绑定一个账号建模所以这里直接看本机缓存下的绑定状态会话热度和过期情况
<p class="summary">
首次点击授权会打开前台登录窗口登录完成后绑定到当前 partition后续数据抓取与巡检一律走隐藏窗口用户看不见采集过程
</p>
</div>
<div class="media-view__header-actions">
<a-button :loading="loading" @click="refreshAccounts">
<div class="hero-actions">
<a-button type="primary" ghost class="modern-btn" :loading="loading" @click="refreshAccounts">
<template #icon><ReloadOutlined /></template>
刷新数据
刷新状态
</a-button>
</div>
</div>
<div class="overview-strip border-t">
<div class="overview-chip">
<span>已绑定平台</span>
<strong>{{ overview.bound }}</strong>
<div class="stats-strip">
<div class="stat-card">
<div class="stat-label">支持平台</div>
<div class="stat-value">{{ overview.configuredPlatforms }}</div>
</div>
<div class="overview-chip">
<span>正常可用</span>
<strong>{{ overview.live }}</strong>
<div class="stat-card">
<div class="stat-label">已绑定</div>
<div class="stat-value">{{ overview.bound }}</div>
</div>
<div class="overview-chip">
<span>待处理</span>
<strong>{{ overview.attention }}</strong>
<div class="stat-card">
<div class="stat-label">授权正常</div>
<div class="stat-value">{{ overview.live }}</div>
</div>
<div class="overview-chip">
<span>支持平台</span>
<strong>{{ overview.configuredPlatforms }}</strong>
<div class="stat-card">
<div class="stat-label">在线客户端</div>
<div class="stat-value">{{ overview.onlineClients }}</div>
</div>
</div>
</section>
<section class="media-list-wrapper panel">
<div class="media-list-content">
<p class="eyebrow" style="color: #8c8c8c; font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em; margin-bottom: 6px;">SINGLE BINDING</p>
<h4 class="media-list-title" style="font-size: 16px; margin-bottom: 8px;">每个平台最多绑定一个 AI 账号</h4>
<p class="media-list-desc">这里使用桌面端内置的 AI 平台目录当前以 DeepSeek通义千问豆包为一平台一绑定模型</p>
<section class="media-grid">
<article
v-for="platform in platformCards"
:key="platform.id"
class="media-card"
:class="platform.account ? 'media-card--bound' : 'media-card--unbound'"
@click="handleCardClick"
>
<div class="media-card__head">
<div class="media-card__identity">
<span class="media-card__badge" :style="{ backgroundColor: platform.accent + '15', color: platform.accent, borderColor: platform.accent + '30' }">
{{ platform.shortName || shortName(platform.label) }}
</span>
<div class="media-card__identity-text">
<h3>{{ platform.label }}</h3>
<p>{{ platform.description }}</p>
</div>
<section class="content-section">
<div class="section-header">
<h3 class="section-title">支持的大模型</h3>
</div>
<div class="grid-layout">
<article
v-for="platform in platformCards"
:key="platform.id"
class="modern-card"
:class="platform.account ? 'is-bound' : 'is-unbound'"
>
<div class="card-header">
<div class="brand-info">
<div
class="brand-logo"
:style="{ backgroundColor: `${platform.accent}14`, color: platform.accent, border: `1px solid ${platform.accent}24` }"
>
{{ platform.shortName }}
</div>
<div class="brand-text">
<h4>{{ platform.label }}</h4>
<p>{{ platform.description }}</p>
</div>
<a-tag :color="platform.account ? (platform.account.health === 'live' ? 'green' : 'orange') : 'default'" style="margin: 0; border-radius: 12px; font-size: 12px; border: 1px solid #e6edf5;">
{{ authLabel(platform.account) }}
</a-tag>
</div>
<a-tag :color="authColor(platform.account)" class="status-tag">
{{ authLabel(platform.account) }}
</a-tag>
</div>
<div v-if="platform.account" class="account-shell">
<div class="account-row-info">
<span>当前账号</span>
<strong>{{ platform.account.displayName }}</strong>
<div v-if="platform.account" class="card-body">
<div class="info-list">
<div class="info-row">
<span class="info-label">当前账号</span>
<span class="info-value primary-text">{{ platform.account.displayName }}</span>
</div>
<div class="account-row-info">
<span>平台 UID</span>
<strong>{{ platform.account.platformUid }}</strong>
<div class="info-row">
<span class="info-label">平台 UID</span>
<span class="info-value mono-text" :title="platform.account.platformUid">{{ platform.account.platformUid }}</span>
</div>
<div class="account-row-info">
<span>本地会话</span>
<strong>{{ sessionLabel(platform.account) }}</strong>
<div class="info-row">
<span class="info-label">本地会话</span>
<span class="info-value">{{ sessionLabel(platform.account) }}</span>
</div>
<div class="account-row-info">
<span>最近同步</span>
<strong>{{ formatDateTime(platform.account.lastSyncAt) }}</strong>
</div>
<div class="status-row-badges">
<a-badge :status="sessionTone(platform.account) as any" :text="`Session: ${platform.account.sessionState}`" />
<a-badge :status="platform.account.online ? 'success' : 'warning'" :text="platform.account.online ? 'Online' : 'Offline'" style="margin-left: 16px;" />
<div class="info-row">
<span class="info-label">最近同步</span>
<span class="info-value">{{ formatDateTime(platform.account.lastSyncAt) }}</span>
</div>
</div>
<div v-else class="empty-card-inner">
<strong>当前未绑定账号</strong>
<p>这个平台现在还没有登录态后续接入真实登录流后可以在这里发起单平台绑定</p>
<div class="card-footer">
<div class="online-status">
<a-badge :status="platform.account.online ? 'success' : 'default'" :text="onlineLabel(platform.account)" />
</div>
<div class="action-group">
<a-tooltip title="打开平台" placement="top">
<a-button
type="text"
class="action-btn"
:loading="openPendingAccountId === platform.account.id"
@click="openPlatform(platform.account)"
>
<template #icon><LinkOutlined /></template>
</a-button>
</a-tooltip>
<a-popconfirm
title="确认解绑这个 AI 账号吗?"
ok-text="解绑"
cancel-text="取消"
@confirm="unbindPlatform(platform.account)"
>
<a-tooltip title="解绑" placement="top">
<a-button
type="text"
class="action-btn danger-btn"
:loading="unbindPendingAccountId === platform.account.id"
>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-tooltip>
</a-popconfirm>
</div>
</div>
</div>
<div v-if="platform.duplicate" style="margin-top: 12px;">
<a-alert type="error" show-icon message="发现多个账号绑定到同一 AI 平台,这和当前“一平台一绑定”的约束冲突。" banner />
<div v-else class="card-empty-state">
<div class="empty-content">
<h5>当前未绑定账号</h5>
<p>首次授权会打开登录窗口绑定完成后后续监测统一在隐藏窗口里静默执行</p>
</div>
</article>
</section>
<a-button
type="primary"
class="auth-btn"
:loading="bindPendingPlatformId === platform.id"
@click="bindPlatform(platform.id)"
>
立即授权
<template #icon><ArrowRightOutlined /></template>
</a-button>
</div>
<div v-if="platform.duplicateCount > 0" class="card-alert">
<a-alert
type="warning"
show-icon
banner
>
<template #message>
发现额外 {{ platform.duplicateCount }} 个同平台账号缓存当前卡片只保留主账号展示
</template>
<template #icon><WarningOutlined /></template>
</a-alert>
</div>
</article>
</div>
</section>
</section>
</template>
<style scoped>
.media-view {
.page-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.media-view__top-card {
background: #fff;
border: 1px solid #e6edf5;
border-radius: 12px;
overflow: hidden;
}
.media-view__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 24px;
}
.media-view__header-title h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
}
.overview-strip {
display: flex;
background: #fafafb;
padding: 16px 24px;
gap: 32px;
padding-bottom: 40px;
max-width: 1400px;
}
.border-t {
border-top: 1px solid #e6edf5;
}
.overview-chip {
display: flex;
flex-direction: column;
/* Hero Section */
.hero-card {
background: #ffffff;
border: 1px solid #e6edf5;
padding: 16px 20px;
border-radius: 12px;
min-width: 140px;
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
}
.overview-chip span {
font-size: 13px;
color: #8c8c8c;
}
.overview-chip strong {
margin-top: 6px;
font-size: 28px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.2;
}
.panel {
background: #ffffff;
border-radius: 12px;
border: 1px solid #e6edf5;
display: flex;
flex-direction: column;
}
.media-list-wrapper {
border-radius: 20px;
border: 1px solid #eef2f6;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px -2px rgba(0, 0, 0, 0.01);
overflow: hidden;
}
.media-list-content {
padding: 24px;
}
.media-list-title {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
}
.media-list-desc {
font-size: 13px;
color: #8c8c8c;
margin-bottom: 20px;
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.media-card {
position: relative;
overflow: hidden;
padding: 20px;
border: 1px solid #e6edf5;
border-radius: 12px;
background: #fafafb;
transition: all 0.2s ease;
.hero-header {
display: flex;
flex-direction: column;
}
.media-card:hover {
border-color: #1677ff;
box-shadow: 0 8px 24px rgba(22, 119, 255, 0.06);
}
.media-card--bound {
background: #ffffff;
}
.media-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
align-items: flex-start;
padding: 36px 40px;
}
.media-card__identity {
.eyebrow {
margin: 0 0 12px;
color: #64748b;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.hero-title h2 {
margin: 0;
font-size: 32px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.02em;
}
.summary {
margin: 16px 0 0;
max-width: 800px;
color: #475569;
line-height: 1.6;
font-size: 15px;
}
.modern-btn {
border-radius: 8px;
height: 40px;
padding: 0 20px;
font-weight: 600;
}
/* Stats Strip */
.stats-strip {
display: flex;
align-items: center;
gap: 12px;
padding: 24px 40px;
background: #f8fafc;
border-top: 1px solid #eef2f6;
gap: 40px;
}
.media-card__badge {
display: inline-flex;
.stat-card {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.stat-label {
font-size: 14px;
font-weight: 500;
color: #64748b;
}
.stat-value {
font-size: 40px;
font-weight: 700;
color: #0f172a;
line-height: 1;
font-feature-settings: "tnum";
}
/* Content Section Elements */
.content-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.section-header {
padding: 0 8px;
}
.section-title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #0f172a;
}
.section-desc {
margin: 8px 0 0;
color: #64748b;
font-size: 15px;
line-height: 1.5;
}
.grid-layout {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 24px;
}
/* Modern Card Design */
.modern-card {
display: flex;
flex-direction: column;
background: #ffffff;
border-radius: 20px;
border: 1px solid #e2e8f0;
padding: 28px;
min-height: 380px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.02);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.modern-card:hover {
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.01);
border-color: #cbd5e1;
}
/* Card Header */
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
}
.brand-info {
display: flex;
gap: 16px;
align-items: center;
}
.brand-logo {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
font-size: 18px;
border-radius: 14px;
font-size: 22px;
font-weight: 700;
}
.media-card__identity-text {
display: flex;
flex-direction: column;
.brand-text h4 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
.media-card__identity-text h3 {
.brand-text p {
margin: 4px 0 0;
color: #64748b;
font-size: 14px;
}
.status-tag {
margin: 0;
color: #1a1a1a;
font-size: 15px;
border-radius: 9999px;
padding: 4px 12px;
font-weight: 600;
font-size: 13px;
border: 1px solid transparent; /* adjusted dynamically by Ant normally, but let's reset slightly */
}
/* Card Body & Info List */
.card-body {
display: flex;
flex-direction: column;
flex: 1;
}
.info-list {
background: #f8fafc;
border-radius: 12px;
padding: 4px 0;
margin-bottom: 24px;
border: 1px solid #f1f5f9;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
color: #64748b;
font-size: 14px;
}
.info-value {
font-size: 14px;
color: #334155;
font-weight: 500;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: right;
}
.primary-text {
color: #0f172a;
font-weight: 600;
}
.media-card__identity-text p {
margin: 2px 0 0;
color: #8c8c8c;
font-size: 12px;
}
.account-shell {
background: #fafafb;
border: 1px solid #e6edf5;
padding: 16px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 10px;
}
.empty-card-inner {
background: #ffffff;
border: 1px solid #f0f0f0;
padding: 16px;
border-radius: 8px;
}
.empty-card-inner strong {
display: block;
.mono-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
color: #1a1a1a;
color: #475569;
}
.empty-card-inner p {
margin: 6px 0 0;
color: #8c8c8c;
font-size: 12px;
line-height: 1.6;
}
.account-row-info {
/* Card Footer */
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
margin-top: auto;
padding-top: 20px;
border-top: 1px dashed #e2e8f0;
}
.account-row-info span {
color: #8c8c8c;
.action-group {
display: flex;
gap: 8px;
}
.account-row-info strong {
color: #1a1a1a;
font-weight: 500;
}
.status-row-badges {
margin-top: 6px;
padding-top: 12px;
border-top: 1px dashed #e6edf5;
.action-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: #64748b;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.action-btn:hover {
background: #f1f5f9;
color: #0ea5e9;
border-color: #e2e8f0;
}
.danger-btn:hover {
background: #fef2f2;
color: #ef4444;
border-color: #fee2e2;
}
/* Empty State */
.card-empty-state {
display: flex;
flex-direction: column;
justify-content: space-between;
flex: 1;
background: #f8fafc;
border-radius: 16px;
padding: 24px;
border: 1px dashed #cbd5e1;
}
.empty-content h5 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #0f172a;
}
.empty-content p {
margin: 12px 0 0;
color: #64748b;
line-height: 1.6;
font-size: 14px;
}
.auth-btn {
margin-top: 24px;
height: 44px;
border-radius: 10px;
font-weight: 600;
font-size: 15px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
}
.card-alert {
margin-top: 16px;
}
:deep(.card-alert .ant-alert) {
border-radius: 12px;
border: none;
background: #fffbeb;
}
/* Responsiveness */
@media (max-width: 1024px) {
.hero-header {
flex-direction: column;
gap: 24px;
}
.stats-strip {
flex-wrap: wrap;
gap: 24px;
}
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons-vue";
import { ClockCircleOutlined, ReloadOutlined, SearchOutlined, SendOutlined } from "@ant-design/icons-vue";
import type { DesktopPublishTaskListResponse, DesktopTaskInfo, JsonValue } from "@geo/shared-types";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
@@ -400,15 +400,29 @@ const tableColumns = [
<template v-else-if="column.key === 'actions'">
<div class="action-buttons">
<a-button
<a-popconfirm
v-if="!isPendingStatus(record.status)"
size="small"
:loading="actionPendingTaskId === record.id"
@click="retryTask(record.id)"
title="确定要重试本次发布吗?"
placement="topRight"
ok-text="确定"
cancel-text="取消"
@confirm="retryTask(record.id)"
>
再次发送
</a-button>
<span v-else class="action-placeholder">排队中</span>
<a-tooltip title="再次发送" placement="top">
<a-button
type="text"
class="icon-action-btn"
:loading="actionPendingTaskId === record.id"
>
<template #icon><SendOutlined /></template>
</a-button>
</a-tooltip>
</a-popconfirm>
<a-tooltip v-else title="排队中...">
<div class="action-placeholder-icon">
<ClockCircleOutlined />
</div>
</a-tooltip>
</div>
</template>
</template>
@@ -441,10 +455,11 @@ const tableColumns = [
}
.hero-copy {
padding: 24px;
padding: 28px;
border-radius: 12px;
border: 1px solid #e6edf5;
background: #ffffff;
border: 1px solid #e2e8f0;
background: linear-gradient(135deg, #ffffff, #f8fafc);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.03), 0 2px 4px -2px rgba(0, 0, 0, 0.03);
}
.eyebrow,
@@ -487,13 +502,47 @@ h2 {
.toolbar-stats,
.toolbar-search,
.status-cell,
.action-buttons {
.status-cell {
display: flex;
align-items: center;
gap: 10px;
}
.action-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.icon-action-btn {
color: #64748b;
border-radius: 6px;
transition: all 0.2s ease;
background: transparent;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
}
.icon-action-btn:hover {
background: #f1f5f9;
color: #0ea5e9;
}
.action-placeholder-icon {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 16px;
}
.toolbar-stats {
flex-wrap: wrap;
}
@@ -512,10 +561,12 @@ h2 {
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: #f5f7fa;
color: #4b5563;
background: #f8fafc;
color: #475569;
font-size: 12px;
font-weight: 600;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.cell-primary-secondary {
@@ -599,20 +650,23 @@ h2 {
:deep(.publish-table .ant-table-thead > tr > th) {
background: #f8fafc;
color: #4b5563;
font-size: 12px;
color: #475569;
font-size: 13px;
font-weight: 600;
border-bottom: 1px solid #e6edf5;
border-bottom: 1px solid #e2e8f0;
padding: 12px 16px;
}
:deep(.publish-table .ant-table-tbody > tr > td) {
border-bottom: 1px solid #eef2f7;
border-bottom: 1px solid #f1f5f9;
vertical-align: top;
background: #ffffff;
padding: 16px;
transition: background-color 0.2s ease;
}
:deep(.publish-table .publish-row--pending > td) {
background: #fffbeb;
background: #fefce8;
}
:deep(.publish-table .ant-table-tbody > tr:hover > td) {