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:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user