Files
geo/apps/admin-web/src/views/MediaView.vue
T
root 97ae601cfd
Desktop Client Build / Resolve Build Metadata (push) Successful in 28s
Frontend CI / Frontend (push) Successful in 2m53s
Backend CI / Backend (push) Successful in 16m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m44s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 50s
fix tenant account unbind flow
2026-06-19 09:29:04 +08:00

3884 lines
98 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
ApiOutlined,
AppstoreOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
DeleteOutlined,
DesktopOutlined,
DownloadOutlined,
EditOutlined,
ExclamationCircleOutlined,
GlobalOutlined,
LinkOutlined,
LockOutlined,
MoreOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
SyncOutlined,
} from '@ant-design/icons-vue'
import type {
DesktopAccountInfo,
DesktopClientDownloadableRelease,
EnterpriseSiteCmsType,
EnterpriseSiteConnection,
MediaPlatform,
} from '@geo/shared-types'
import { useMutation, useQuery } from '@tanstack/vue-query'
import { Modal, message } from 'ant-design-vue'
import { computed, ref } from 'vue'
import {
desktopClientApi,
enterpriseSitesApi,
mediaApi,
resolveApiURL,
tenantAccountsApi,
} from '@/lib/api'
import { resolveAccountCheckedAt, resolveAccountHealth } from '@/lib/desktop-account-runtime'
import { formatBytes, formatDateTime } from '@/lib/display'
import { formatError, formatStoredErrorMessage } from '@/lib/errors'
import {
getPublishPlatformMeta,
isMediaPublishAccount,
normalizePublishPlatformId,
} from '@/lib/publish-platforms'
import { useCompanyStore } from '@/stores/company'
type AccountHealthFilter = 'all' | 'live' | 'captcha' | 'risk' | 'expired'
type PublishState = 'immediate' | 'queued' | 'unavailable'
type MediaScope = 'ugc' | 'enterprise'
type CmsType = EnterpriseSiteCmsType
interface MediaAccountCard {
id: string
platform: string
platformLabel: string
platformShortName: string
platformAccent: string
platformLogoUrl: string | null
displayName: string
platformUid: string
avatarUrl: string | null
health: DesktopAccountInfo['health']
verifiedAt: string | null
clientId: string | null
clientOnline: boolean | null
accountSessionOnline: boolean | null
taskConsumerOnline: boolean | null
clientDeviceName: string | null
clientLastSeenAt: string | null
queuedPublishTaskCount: number
oldestQueuedPublishTaskAt: string | null
publishState: PublishState
publishHint: string
deleteRequestedAt: string | null
}
interface EnterpriseSiteFormState {
name: string
siteUrl: string
cmsType: CmsType
appid: string
secret: string
defaultAcode: string
defaultMcode: string
defaultScode: string
}
const platformLogoCatalog: Record<string, string> = {
toutiaohao: '/logos/logo_toutiao.png',
baijiahao: '/logos/logo_baijiahao.png',
sohuhao: '/logos/logo_souhu.png',
qiehao: '/logos/logo_qiehao.png',
zhihu: '/logos/logo_zhihu.png',
wangyihao: '/logos/logo_wangyihao.png',
jianshu: '/logos/logo_jianshu.svg',
bilibili: '/logos/logo_bilibili.png',
juejin: '/logos/logo_juejin.png',
smzdm: '/logos/logo_smzdm.svg',
weixin_gzh: '/logos/logo_weixin_gzh.svg',
zol: '/logos/logo_zol.png',
dongchedi: '/logos/logo_dongchedi.svg',
}
const companyStore = useCompanyStore()
const activeMediaScope = ref<MediaScope>('ugc')
const searchQuery = ref('')
const selectedHealth = ref<AccountHealthFilter>('all')
const downloadingReleaseKey = ref<string | null>(null)
const requestingAccountDeleteId = ref<string | null>(null)
const unbindModalOpen = ref(false)
const unbindAccountTarget = ref<MediaAccountCard | null>(null)
const unbindVerifyInput = ref('')
const unbindConfirmLoading = ref(false)
const isUnbindVerified = computed(() => {
if (!unbindAccountTarget.value) return false
const targetName = unbindAccountTarget.value.displayName.trim()
const inputVal = unbindVerifyInput.value.trim()
return inputVal === targetName || inputVal === '解绑'
})
const enterpriseSiteModalOpen = ref(false)
const editingEnterpriseSiteId = ref<number | null>(null)
const deletingEnterpriseSiteId = ref<number | null>(null)
const failedEnterpriseSiteFavicons = ref<Record<string, boolean>>({})
const enterpriseSiteForm = ref<EnterpriseSiteFormState>({
name: '',
siteUrl: '',
cmsType: 'pbootcms',
appid: '',
secret: '',
defaultAcode: 'cn',
defaultMcode: '2',
defaultScode: '',
})
const mediaScopes: Array<{ key: MediaScope; label: string; meta: string }> = [
{ key: 'ugc', label: 'UGC媒体平台', meta: '桌面端账号' },
{ key: 'enterprise', label: '企业自建站点', meta: 'CMS连接' },
]
const cmsTypeOptions: Array<{ label: string; value: CmsType }> = [
{ label: 'PBootCMS', value: 'pbootcms' },
{ label: 'WordPress', value: 'wordpress' },
]
const platformsQuery = useQuery({
queryKey: ['media', 'platforms', 'media-view'],
queryFn: () => mediaApi.platforms(),
})
const accountsQuery = useQuery({
queryKey: ['tenant', 'desktop-accounts', 'media-view'],
queryFn: () => tenantAccountsApi.list(),
staleTime: 0,
refetchInterval: 5_000,
refetchOnWindowFocus: true,
})
const downloadableReleasesQuery = useQuery({
queryKey: ['tenant', 'desktop-client', 'downloadable-releases', 'stable'],
queryFn: async () => {
try {
return await desktopClientApi.downloadableReleases('stable')
} catch {
return desktopClientApi.publicFallbackDownloadableReleases()
}
},
staleTime: 60_000,
refetchOnWindowFocus: true,
})
const enterpriseSitesQuery = useQuery({
queryKey: ['tenant', 'enterprise-sites', 'media-view'],
queryFn: () => enterpriseSitesApi.list(),
staleTime: 10_000,
refetchOnWindowFocus: true,
})
const saveEnterpriseSiteMutation = useMutation({
mutationFn: () => saveEnterpriseSiteRequest(),
onSuccess: async (site) => {
enterpriseSiteModalOpen.value = false
const wasEditing = editingEnterpriseSiteId.value !== null
editingEnterpriseSiteId.value = null
message.success(wasEditing ? '站点连接已更新' : '站点凭证已保存')
await enterpriseSitesQuery.refetch()
if (!wasEditing) {
void enterpriseSitesApi
.ping(site.id)
.then(() => enterpriseSitesQuery.refetch())
.catch(() => undefined)
}
},
onError: (error) => message.error(formatError(error)),
})
const pingEnterpriseSiteMutation = useMutation({
mutationFn: (siteId: number) => enterpriseSitesApi.ping(siteId),
onSuccess: async () => {
message.success('站点连通正常')
await enterpriseSitesQuery.refetch()
},
onError: (error) => message.error(formatError(error)),
})
const syncEnterpriseCategoriesMutation = useMutation({
mutationFn: (siteId: number) => enterpriseSitesApi.syncCategories(siteId),
onSuccess: async (categories) => {
message.success(`已同步 ${categories.length} 个栏目`)
await enterpriseSitesQuery.refetch()
},
onError: (error) => message.error(formatError(error)),
})
const deleteEnterpriseSiteMutation = useMutation({
mutationFn: (siteId: number) => enterpriseSitesApi.remove(siteId),
onSuccess: async () => {
message.success('站点连接已删除')
await enterpriseSitesQuery.refetch()
},
onError: (error) => message.error(formatError(error)),
onSettled: () => {
deletingEnterpriseSiteId.value = null
},
})
const requestAccountDeleteMutation = useMutation({
mutationFn: (payload: { id: string; undo?: boolean }) =>
tenantAccountsApi.requestDelete(payload.id, { undo: payload.undo }),
onSuccess: async (_account, payload) => {
message.success(payload.undo ? '已撤销解绑申请' : '已申请解绑,客户端会自动清理本地授权')
await accountsQuery.refetch()
},
onError: (error) => message.error(formatError(error)),
onSettled: () => {
requestingAccountDeleteId.value = null
},
})
const removeAccountMutation = useMutation({
mutationFn: (id: string) => tenantAccountsApi.remove(id),
onSuccess: async (result) => {
const cancelledCount = result.cancelled_queued_publish_task_count ?? 0
message.success(
cancelledCount > 0
? `已完成解绑,并取消 ${cancelledCount} 个排队发布任务`
: '已完成解绑',
)
await accountsQuery.refetch()
},
onError: (error) => message.error(formatError(error)),
onSettled: () => {
requestingAccountDeleteId.value = null
},
})
const loading = computed(() => platformsQuery.isPending.value || accountsQuery.isPending.value)
const refreshing = computed(
() =>
platformsQuery.isFetching.value ||
accountsQuery.isFetching.value ||
downloadableReleasesQuery.isFetching.value ||
enterpriseSitesQuery.isFetching.value,
)
const enterpriseSiteModalTitle = computed(() =>
editingEnterpriseSiteId.value === null ? '新增企业站点' : '编辑企业站点',
)
const enterpriseSiteModalOkText = computed(() =>
editingEnterpriseSiteId.value === null ? '保存凭证' : '保存修改',
)
const enterpriseSiteAppIDLabel = computed(() =>
enterpriseSiteForm.value.cmsType === 'wordpress' ? '用户名' : 'AppID',
)
const enterpriseSiteAppIDPlaceholder = computed(() =>
enterpriseSiteForm.value.cmsType === 'wordpress'
? 'WordPress 用户名或邮箱'
: 'PBootCMS 后台 api_appid',
)
const enterpriseSiteSecretPlaceholder = computed(() => {
if (editingEnterpriseSiteId.value !== null) {
return '留空则保留原凭证'
}
return enterpriseSiteForm.value.cmsType === 'wordpress'
? 'WordPress Application Password'
: 'PBootCMS 后台 api_secret'
})
const enterpriseSiteDefaultScodeLabel = computed(() =>
enterpriseSiteForm.value.cmsType === 'wordpress' ? '默认分类 ID' : '默认栏目',
)
const enterpriseSiteDefaultScodePlaceholder = computed(() =>
enterpriseSiteForm.value.cmsType === 'wordpress' ? '例如:12,可选' : '可选',
)
const platformMap = computed(() => {
return new Map(
(platformsQuery.data.value ?? []).map((platform: MediaPlatform) => [
normalizePublishPlatformId(platform.platform_id),
platform,
]),
)
})
const mediaAccounts = computed<MediaAccountCard[]>(() => {
return [...(accountsQuery.data.value ?? [])]
.filter(isMediaPublishAccount)
.map((account) => {
const platformId = normalizePublishPlatformId(account.platform)
const platform = platformMap.value.get(platformId)
const fallback = getPublishPlatformMeta(platformId)
const publishState = resolvePublishState(account)
const health = resolveAccountHealth(account)
return {
id: account.id,
platform: platformId,
platformLabel: platform?.name || fallback.name,
platformShortName: platform?.short_name || fallback.shortName,
platformAccent: platform?.accent_color || fallback.accent,
platformLogoUrl: resolvePlatformLogoUrl(platformId, platform?.logo_url),
displayName: account.display_name,
platformUid: normalizePlatformUid(account.platform_uid),
avatarUrl: resolveApiURL(account.avatar_url),
health,
verifiedAt: resolveAccountCheckedAt(account),
clientId: account.client_id,
clientOnline: account.client_online,
accountSessionOnline: account.account_session_online ?? null,
taskConsumerOnline: account.task_consumer_online ?? null,
clientDeviceName: account.client_device_name,
clientLastSeenAt: account.client_last_seen_at,
queuedPublishTaskCount: account.queued_publish_task_count ?? 0,
oldestQueuedPublishTaskAt: account.oldest_queued_publish_task_at ?? null,
publishState,
publishHint: resolvePublishHint(account, publishState),
deleteRequestedAt: account.delete_requested_at,
}
})
.sort((left, right) => {
const publishGap = publishRank(left.publishState) - publishRank(right.publishState)
if (publishGap !== 0) {
return publishGap
}
const leftAt = Date.parse(left.verifiedAt ?? '') || 0
const rightAt = Date.parse(right.verifiedAt ?? '') || 0
return rightAt - leftAt
})
})
const overview = computed(() => ({
total: mediaAccounts.value.length,
live: mediaAccounts.value.filter((item) => item.health === 'live').length,
immediate: mediaAccounts.value.filter((item) => item.publishState === 'immediate').length,
queued: mediaAccounts.value.filter((item) => item.publishState === 'queued').length,
attention: mediaAccounts.value.filter((item) => item.publishState === 'unavailable').length,
}))
const enterpriseOverview = computed(() => {
const sites = enterpriseSites.value
const publishable = sites.filter((site) => site.status === 'connected').length
return {
total: sites.length,
publishable,
attention: sites.length - publishable,
categories: sites.reduce((acc, site) => acc + (site.category_count || 0), 0),
}
})
const filteredAccounts = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase()
return mediaAccounts.value.filter((account) => {
if (selectedHealth.value !== 'all' && account.health !== selectedHealth.value) {
return false
}
if (!keyword) {
return true
}
return [
account.displayName,
account.platformLabel,
account.platformUid,
account.clientDeviceName ?? '',
]
.join(' ')
.toLowerCase()
.includes(keyword)
})
})
const downloadableReleases = computed(() => downloadableReleasesQuery.data.value?.items ?? [])
const enterpriseSites = computed(() => enterpriseSitesQuery.data.value ?? [])
const downloadableReleasesError = computed(() => {
const error = downloadableReleasesQuery.error.value
if (!error) {
return ''
}
if (error instanceof Error) {
return error.message
}
return String(error)
})
function resolvePublishState(account: DesktopAccountInfo): PublishState {
if (account.delete_requested_at) {
return 'unavailable'
}
if (resolveAccountHealth(account) !== 'live') {
return 'unavailable'
}
if (!account.client_id) {
return 'unavailable'
}
return account.task_consumer_online === true ? 'immediate' : 'queued'
}
function resolvePublishHint(account: DesktopAccountInfo, publishState: PublishState): string {
if (account.delete_requested_at) {
return '此运行节点绑定正在解绑中,不会再接收新的发布任务。'
}
if (publishState === 'immediate') {
return ''
}
if (publishState === 'queued') {
const count = account.queued_publish_task_count ?? 0
const prefix = count > 0 ? `当前已有 ${count} 个发布任务排队。` : ''
if (account.client_online === true) {
return `${prefix}客户端心跳在线,但任务消费通道未就绪;任务会进入发布队列,通道恢复后自动继续。`
}
return `${prefix}客户端当前离线,任务会先进入发布队列,等桌面端上线后自动继续。`
}
if (resolveAccountHealth(account) !== 'live') {
return '授权状态异常,先在桌面端重新校验后再发布。'
}
return '还没有绑定桌面客户端,当前无法加入发布队列。'
}
function normalizePlatformUid(value?: string | null): string {
let normalized = String(value ?? '').trim()
while (normalized.toLowerCase().startsWith('platform:')) {
normalized = normalized.slice('platform:'.length).trim()
}
return normalized || '--'
}
function resolvePlatformLogoUrl(platformId: string, remoteLogoUrl?: string | null): string | null {
const localLogoUrl = platformLogoCatalog[platformId]
if (localLogoUrl) {
return localLogoUrl
}
const resolvedRemoteLogoUrl = resolveApiURL(remoteLogoUrl)
return resolvedRemoteLogoUrl || null
}
function accountInitial(account: MediaAccountCard): string {
const name = account.displayName.trim()
return name ? name.slice(0, 1).toUpperCase() : account.platformShortName
}
function publishRank(state: PublishState): number {
switch (state) {
case 'immediate':
return 0
case 'queued':
return 1
default:
return 2
}
}
function healthLabel(health: DesktopAccountInfo['health']): string {
switch (health) {
case 'live':
return '授权正常'
case 'captcha':
return '待处理'
case 'risk':
return '风险观察'
default:
return '授权异常'
}
}
function healthColor(health: DesktopAccountInfo['health']): string {
switch (health) {
case 'live':
return 'green'
case 'captcha':
return 'orange'
case 'risk':
return 'gold'
default:
return 'red'
}
}
function clientStatusLabel(account: MediaAccountCard): string {
if (!account.clientId) {
return '未绑定'
}
if (account.taskConsumerOnline) {
return '任务通道在线'
}
return account.clientOnline ? '心跳在线,任务通道离线' : '离线'
}
function clientStatusColor(account: MediaAccountCard): string {
if (!account.clientId) {
return 'default'
}
if (account.taskConsumerOnline) {
return 'green'
}
return account.clientOnline ? 'orange' : 'gold'
}
function clientStatusText(account: MediaAccountCard): string {
if (!account.clientId) {
return '未绑定桌面客户端'
}
const device = account.clientDeviceName?.trim()
return device ? `${clientStatusLabel(account)} · ${device}` : clientStatusLabel(account)
}
function publishStateLabel(state: PublishState): string {
switch (state) {
case 'immediate':
return '可立即发布'
case 'queued':
return '排队等待'
default:
return '不可发布'
}
}
function publishStateColor(state: PublishState): string {
switch (state) {
case 'immediate':
return 'green'
case 'queued':
return 'gold'
default:
return 'red'
}
}
async function refreshAll(): Promise<void> {
await Promise.all([
platformsQuery.refetch(),
accountsQuery.refetch(),
downloadableReleasesQuery.refetch(),
enterpriseSitesQuery.refetch(),
])
}
function openEnterpriseSiteModal(cmsType: CmsType = 'pbootcms'): void {
editingEnterpriseSiteId.value = null
enterpriseSiteForm.value = {
name: '',
siteUrl: '',
cmsType,
appid: '',
secret: '',
defaultAcode: cmsType === 'wordpress' ? '' : 'cn',
defaultMcode: cmsType === 'wordpress' ? '' : '2',
defaultScode: '',
}
enterpriseSiteModalOpen.value = true
}
function openEnterpriseSiteEditModal(site: EnterpriseSiteConnection): void {
editingEnterpriseSiteId.value = site.id
enterpriseSiteForm.value = {
name: site.name,
siteUrl: site.site_url,
cmsType: site.cms_type as CmsType,
appid: site.appid,
secret: '',
defaultAcode: site.default_acode ?? 'cn',
defaultMcode: site.default_mcode ?? '2',
defaultScode: site.default_scode ?? '',
}
enterpriseSiteModalOpen.value = true
}
function closeEnterpriseSiteModal(): void {
enterpriseSiteModalOpen.value = false
editingEnterpriseSiteId.value = null
}
function handleEnterpriseSiteCMSTypeChange(value: CmsType): void {
enterpriseSiteForm.value = {
...enterpriseSiteForm.value,
cmsType: value,
defaultAcode: value === 'wordpress' ? '' : 'cn',
defaultMcode: value === 'wordpress' ? '' : '2',
defaultScode: '',
}
}
function getEnterpriseSiteSelectPopupContainer(triggerNode: HTMLElement): HTMLElement {
return (triggerNode.closest('.ant-modal-content') as HTMLElement) || document.body
}
function normalizeSiteUrl(value: string): string {
const trimmed = value.trim()
if (!trimmed) {
return ''
}
return /^https?:\/\//i.test(trimmed)
? trimmed.replace(/\/+$/, '')
: `https://${trimmed}`.replace(/\/+$/, '')
}
async function saveEnterpriseSiteRequest(): Promise<EnterpriseSiteConnection> {
const form = enterpriseSiteForm.value
const siteUrl = normalizeSiteUrl(form.siteUrl)
const appid = form.appid.trim()
const secret = form.secret.trim()
const editingId = editingEnterpriseSiteId.value
if (!siteUrl || !appid || (editingId === null && !secret)) {
throw new Error('请填写站点域名、AppID、Secret')
}
let name = form.name.trim()
try {
name = name || new URL(siteUrl).hostname
} catch {
throw new Error('站点域名格式不正确')
}
if (editingId !== null) {
return enterpriseSitesApi.update(editingId, {
name,
site_url: siteUrl,
appid,
secret: secret || undefined,
default_acode: form.defaultAcode.trim() || null,
default_mcode: form.defaultMcode.trim() || null,
default_scode: form.defaultScode.trim() || null,
})
}
return enterpriseSitesApi.create({
name,
site_url: siteUrl,
cms_type: form.cmsType,
appid,
secret,
default_acode: form.defaultAcode.trim() || null,
default_mcode: form.defaultMcode.trim() || null,
default_scode: form.defaultScode.trim() || null,
})
}
function cmsTypeLabel(cmsType: CmsType): string {
return cmsTypeOptions.find((option) => option.value === cmsType)?.label ?? cmsType
}
function enterpriseSiteCredentialLabel(site: EnterpriseSiteConnection): string {
return String(site.cms_type).trim() === 'wordpress' ? '用户名' : 'AppID'
}
function enterpriseSiteStatusLabel(status: string): string {
switch (status) {
case 'connected':
return '已连通'
case 'error':
return '连接异常'
case 'disabled':
return '已禁用'
default:
return '待校验'
}
}
function enterpriseSiteStatusColor(status: string): string {
switch (status) {
case 'connected':
return 'green'
case 'error':
return 'red'
case 'disabled':
return 'default'
default:
return 'gold'
}
}
function syncEnterpriseCategories(site: EnterpriseSiteConnection): void {
syncEnterpriseCategoriesMutation.mutate(site.id)
}
function pingEnterpriseSite(site: EnterpriseSiteConnection): void {
pingEnterpriseSiteMutation.mutate(site.id)
}
function deleteEnterpriseSite(site: EnterpriseSiteConnection): void {
Modal.confirm({
title: '确认删除这个站点连接?',
content: '删除后,绑定该站点的发文通道将被清除,且无法恢复。确定继续吗?',
okText: '删除',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: () => {
deletingEnterpriseSiteId.value = site.id
deleteEnterpriseSiteMutation.mutate(site.id)
},
})
}
function requestUnbindAccount(account: MediaAccountCard): void {
unbindAccountTarget.value = account
unbindVerifyInput.value = ''
unbindConfirmLoading.value = false
unbindModalOpen.value = true
}
async function handleConfirmUnbind(): Promise<void> {
if (!isUnbindVerified.value || !unbindAccountTarget.value) return
unbindConfirmLoading.value = true
try {
requestingAccountDeleteId.value = unbindAccountTarget.value.id
await removeAccountMutation.mutateAsync(unbindAccountTarget.value.id)
unbindModalOpen.value = false
} catch (err) {
// Error is handled by removeAccountMutation onError
} finally {
unbindConfirmLoading.value = false
}
}
function undoUnbindAccount(account: MediaAccountCard): void {
requestingAccountDeleteId.value = account.id
requestAccountDeleteMutation.mutate({ id: account.id, undo: true })
}
function isAccountDeleteRequestPending(accountId: string): boolean {
return (
(requestAccountDeleteMutation.isPending.value || removeAccountMutation.isPending.value) &&
requestingAccountDeleteId.value === accountId
)
}
function enterpriseSiteFaviconURL(site: EnterpriseSiteConnection): string {
try {
const parsed = new URL(site.site_url)
return `${parsed.origin}/favicon.ico`
} catch {
return ''
}
}
function enterpriseSiteFaviconKey(site: EnterpriseSiteConnection): string {
return `${site.id}:${enterpriseSiteFaviconURL(site)}`
}
function shouldShowEnterpriseSiteFavicon(site: EnterpriseSiteConnection): boolean {
const faviconURL = enterpriseSiteFaviconURL(site)
return Boolean(faviconURL && !failedEnterpriseSiteFavicons.value[enterpriseSiteFaviconKey(site)])
}
function markEnterpriseSiteFaviconFailed(site: EnterpriseSiteConnection): void {
failedEnterpriseSiteFavicons.value = {
...failedEnterpriseSiteFavicons.value,
[enterpriseSiteFaviconKey(site)]: true,
}
}
function enterpriseSiteLastError(site: EnterpriseSiteConnection): string {
return formatStoredErrorMessage(site.last_error)
}
async function downloadRelease(release: DesktopClientDownloadableRelease): Promise<void> {
const key = releaseDownloadKey(release)
if (downloadingReleaseKey.value) {
return
}
downloadingReleaseKey.value = key
const downloadWindow = window.open('about:blank', '_blank')
if (downloadWindow) {
downloadWindow.opener = null
}
try {
const result = await desktopClientApi.publicDownloadURL({
platform: release.platform,
arch: release.arch,
channel: release.channel,
})
const downloadURL = String(result.download_url ?? '').trim()
if (!downloadURL) {
message.warning('下载地址为空')
downloadWindow?.close()
return
}
if (downloadWindow) {
downloadWindow.location.href = downloadURL
} else {
window.open(downloadURL, '_blank', 'noopener,noreferrer')
}
} catch (error) {
downloadWindow?.close()
const detail =
typeof error === 'object' && error !== null && 'detail' in error ? error.detail : null
const reason =
typeof detail === 'string' && detail.trim()
? detail
: error instanceof Error
? error.message
: '请稍后重试'
message.error(`获取下载地址失败:${reason}`)
} finally {
if (downloadingReleaseKey.value === key) {
downloadingReleaseKey.value = null
}
}
}
function releaseDownloadKey(release: DesktopClientDownloadableRelease): string {
return `${release.platform}:${release.arch}:${release.channel}`
}
function releaseTargetLabel(release: DesktopClientDownloadableRelease): string {
return `${releasePlatformLabel(release.platform)} ${releaseArchLabel(release.arch)}`
}
function releasePlatformLabel(platform: string): string {
switch (platform) {
case 'darwin':
return 'macOS'
case 'win32':
return 'Windows'
case 'linux':
return 'Linux'
default:
return platform
}
}
function releaseArchLabel(arch: string): string {
switch (arch) {
case 'arm64':
return 'ARM64'
case 'x64':
return 'x64'
case 'ia32':
return 'ia32'
case 'universal':
return 'Universal'
default:
return arch
}
}
function releaseDate(release: DesktopClientDownloadableRelease): string {
return formatDateTime(release.published_at ?? release.updated_at)
}
function releaseSize(value?: number | null): string {
return typeof value === 'number' && Number.isFinite(value) ? formatBytes(value) : '--'
}
</script>
<template>
<div class="media-view">
<!-- Premium Dark Banner Section -->
<section class="media-view__top-card">
<div class="media-view__header">
<div class="media-view__header-title">
<h2>媒体管理</h2>
<p> UGC 账号新闻源资源和企业自建站点分开管理发布链路按目标类型独立执行</p>
</div>
<div class="media-view__header-actions">
<a-button class="btn-refresh" :loading="refreshing" @click="refreshAll">
<template #icon><ReloadOutlined /></template>
刷新状态
</a-button>
</div>
</div>
<div class="media-scope-tabs">
<button
v-for="scope in mediaScopes"
:key="scope.key"
class="media-scope-tab"
:class="{ 'media-scope-tab--active': activeMediaScope === scope.key }"
type="button"
@click="activeMediaScope = scope.key"
>
<DesktopOutlined v-if="scope.key === 'ugc'" class="tab-icon" />
<GlobalOutlined v-else-if="scope.key === 'enterprise'" class="tab-icon" />
<span>{{ scope.label }}</span>
<small>{{ scope.meta }}</small>
</button>
</div>
<div v-if="activeMediaScope === 'ugc'" class="overview-strip border-t">
<div class="overview-chip">
<div class="chip-icon-box total-bg">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path
d="M12 2A10 10 0 0 0 2 12a10 10 0 0 0 10 10 10 10 0 0 0 10-10A10 10 0 0 0 12 2zm1 14h-2v-6h2v6zm0-8h-2V6h2v2z"
/>
</svg>
</div>
<div class="chip-copy">
<span>账号总数</span>
<strong>{{ overview.total }}</strong>
</div>
</div>
<div class="overview-chip">
<div class="chip-icon-box live-bg">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
</div>
<div class="chip-copy">
<span>可立即发布</span>
<strong>{{ overview.immediate }}</strong>
</div>
</div>
<div class="overview-chip">
<div class="chip-icon-box queued-bg">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path
d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"
/>
</svg>
</div>
<div class="chip-copy">
<span>排队等待</span>
<strong>{{ overview.queued }}</strong>
</div>
</div>
<div class="overview-chip">
<div class="chip-icon-box attention-bg">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
/>
</svg>
</div>
<div class="chip-copy">
<span>需处理</span>
<strong>{{ overview.attention }}</strong>
</div>
</div>
</div>
<div v-if="activeMediaScope === 'enterprise'" class="overview-strip border-t">
<div class="overview-chip">
<div class="chip-icon-box total-bg">
<GlobalOutlined />
</div>
<div class="chip-copy">
<span>站点总数</span>
<strong>{{ enterpriseOverview.total }}</strong>
</div>
</div>
<div class="overview-chip">
<div class="chip-icon-box live-bg">
<CheckCircleOutlined />
</div>
<div class="chip-copy">
<span>可发布站点</span>
<strong>{{ enterpriseOverview.publishable }}</strong>
</div>
</div>
<div class="overview-chip">
<div class="chip-icon-box queued-bg">
<CloseCircleOutlined />
</div>
<div class="chip-copy">
<span>待处理站点</span>
<strong>{{ enterpriseOverview.attention }}</strong>
</div>
</div>
<div class="overview-chip">
<div class="chip-icon-box total-bg">
<AppstoreOutlined />
</div>
<div class="chip-copy">
<span>栏目总数</span>
<strong>{{ enterpriseOverview.categories }}</strong>
</div>
</div>
</div>
</section>
<template v-if="activeMediaScope === 'enterprise'">
<section class="enterprise-grid enterprise-grid--single">
<article class="panel connector-panel">
<div class="panel-head-compact">
<div>
<h3 class="panel-title">企业自建站点</h3>
<p class="panel-desc">
管理客户官网品牌站行业站的连接凭证和连通状态发布结果统一进入发文管理查看
</p>
</div>
<div class="panel-head-actions">
<a-button
type="primary"
class="btn-enterprise-primary"
@click="openEnterpriseSiteModal()"
>
<template #icon><PlusOutlined /></template>
新增站点
</a-button>
</div>
</div>
<a-spin :spinning="enterpriseSitesQuery.isPending.value">
<div v-if="!enterpriseSites.length" class="connector-empty-premium">
<div class="empty-glow"></div>
<div class="empty-icon-wrapper">
<GlobalOutlined class="empty-globe" />
<PlusOutlined class="empty-plus" />
</div>
<h3>暂无企业自建站点</h3>
<p>
对接您的 CMS 站点 PBootCMSWordPress 系统将能够通过 API
自动将文章推送到您的自建网站实现多渠道一键发布与同步
</p>
<a-button
type="primary"
size="large"
class="btn-empty-add"
@click="openEnterpriseSiteModal()"
>
<template #icon><PlusOutlined /></template>
立即添加第一个站点
</a-button>
</div>
<div v-else class="site-connection-grid">
<article v-for="site in enterpriseSites" :key="site.id" class="site-connection-card">
<!-- Top accent based on status -->
<div class="site-card-accent" :class="`accent-${site.status || 'pending'}`"></div>
<div class="site-connection-card__header">
<span class="site-connection-card__icon">
<img
v-if="shouldShowEnterpriseSiteFavicon(site)"
:src="enterpriseSiteFaviconURL(site)"
:alt="`${site.name} 站点图标`"
class="site-connection-card__favicon"
@error="markEnterpriseSiteFaviconFailed(site)"
/>
<GlobalOutlined v-else />
</span>
<div class="site-connection-card__header-main">
<div class="site-name-row">
<h4>{{ site.name }}</h4>
<span class="site-badge site-badge--cms">
{{ cmsTypeLabel(site.cms_type as CmsType) }}
</span>
</div>
<span
class="site-badge site-badge--status"
:class="`site-badge--status-${site.status || 'pending'}`"
>
<span class="site-badge__dot"></span>
{{ enterpriseSiteStatusLabel(site.status) }}
</span>
</div>
</div>
<div class="site-connection-card__content">
<div class="site-url-box">
<a :href="site.site_url" target="_blank" rel="noreferrer" class="site-link">
<LinkOutlined class="link-icon" />
<span>{{ site.site_url }}</span>
</a>
</div>
<div class="site-meta-grid">
<div class="meta-cell">
<span class="meta-label">{{ enterpriseSiteCredentialLabel(site) }}</span>
<span class="meta-value" :title="site.appid">{{ site.appid }}</span>
</div>
<div class="meta-cell">
<span class="meta-label">栏目</span>
<span class="meta-value">{{ site.category_count }} </span>
</div>
<div class="meta-cell full-width">
<span class="meta-label">栏目同步</span>
<span class="meta-value">
{{ site.last_sync_at ? formatDateTime(site.last_sync_at) : '待同步' }}
</span>
</div>
</div>
</div>
<div class="site-connection-card__footer-actions">
<a-button
size="middle"
class="btn-action-ping"
:loading="
pingEnterpriseSiteMutation.isPending.value &&
pingEnterpriseSiteMutation.variables.value === site.id
"
@click="pingEnterpriseSite(site)"
>
<template #icon><ApiOutlined /></template>
测试连接
</a-button>
<div class="footer-actions-right">
<a-button
size="middle"
class="btn-action-sync"
:loading="
syncEnterpriseCategoriesMutation.isPending.value &&
syncEnterpriseCategoriesMutation.variables.value === site.id
"
@click="syncEnterpriseCategories(site)"
>
<template #icon><SyncOutlined /></template>
同步栏目
</a-button>
<a-dropdown :trigger="['click']" placement="bottomRight">
<a-button size="middle" class="action-more-btn" type="text" @click.stop>
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item key="edit" @click="openEnterpriseSiteEditModal(site)">
<EditOutlined />
<span>编辑站点</span>
</a-menu-item>
<a-menu-divider />
<a-menu-item key="delete" danger @click="deleteEnterpriseSite(site)">
<DeleteOutlined />
<span>删除站点</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<div
v-if="enterpriseSiteLastError(site)"
class="site-connection-card__error-banner"
>
<ExclamationCircleOutlined class="error-banner-icon" />
<div class="error-banner-content">
<div class="error-banner-title">连接失败诊断</div>
<div class="error-banner-desc">{{ enterpriseSiteLastError(site) }}</div>
</div>
</div>
</article>
</div>
</a-spin>
</article>
</section>
</template>
<template v-else>
<!-- Client Download Section (App Store Style) -->
<section class="panel client-download-panel">
<div class="client-download-head">
<div>
<div class="title-with-badge">
<h3 class="panel-title">客户端下载</h3>
<span class="badge-pulsing-stable">Stable</span>
</div>
<p class="panel-desc">高并发发布与任务执行节点版本根据后台发布配置自动更新与推送</p>
</div>
</div>
<a-spin :spinning="downloadableReleasesQuery.isPending.value">
<a-alert
v-if="downloadableReleasesError"
type="warning"
show-icon
class="premium-alert"
:message="`客户端列表读取失败:${downloadableReleasesError}`"
/>
<a-empty
v-else-if="!downloadableReleases.length"
class="premium-empty"
description="当前暂无可下载客户端,请等待运营后台发布。"
/>
<div v-else class="client-download-grid">
<article
v-for="release in downloadableReleases"
:key="release.id"
class="client-download-card"
>
<div class="client-download-card__body">
<div class="client-download-card__logo" :class="`logo-${release.platform}`">
<template v-if="release.platform === 'darwin'">
<svg viewBox="0 0 24 24" class="os-svg-icon">
<path
fill="currentColor"
d="M18.71,19.5 C17.88,20.74 17,21.95 15.66,21.97 C14.32,22 13.89,21.18 12.37,21.18 C10.84,21.18 10.37,21.95 9.1,22 C7.79,22.05 6.8,20.68 5.96,19.48 C4.25,17 2.94,12.45 4.7,9.39 C5.57,7.87 7.13,6.91 8.82,6.88 C10.1,6.86 11.32,7.75 12.11,7.75 C12.89,7.75 14.37,6.68 15.92,6.84 C16.57,6.87 18.39,7.1 19.56,8.82 C19.47,8.88 17.39,10.1 17.41,12.63 C17.44,15.65 20.06,16.66 20.1,16.67 C20.08,16.74 19.67,18.11 18.71,19.5 M15.97,4.17 C16.63,3.37 17.07,2.28 16.95,1 C16,1.04 14.9,1.6 14.24,2.38 C13.68,3.04 13.19,4.14 13.34,5.39 C14.39,5.47 15.4,4.88 15.97,4.17 Z"
/>
</svg>
</template>
<template v-else-if="release.platform === 'win32'">
<svg viewBox="0 0 24 24" class="os-svg-icon">
<path
fill="currentColor"
d="M0 3.449L9.75 2.1v9.45H0V3.449zM0 12.45h9.75v9.45L0 20.551V12.45zm11.25-10.549L24 0v11.55H11.25V1.901zM11.25 12.45H24v11.55l-12.75-1.901V12.45z"
/>
</svg>
</template>
<template v-else>
<svg
viewBox="0 0 24 24"
class="os-svg-icon"
stroke="currentColor"
fill="none"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
</template>
</div>
<div class="client-download-card__content">
<h4>{{ releaseTargetLabel(release) }}</h4>
<div class="meta-inline">
<span class="version-tag">v{{ release.version }}</span>
<span class="dot-separator"></span>
<span>{{ releaseSize(release.file_size_bytes) }}</span>
<span class="dot-separator"></span>
<span>{{ releaseDate(release) }}</span>
</div>
</div>
</div>
<div class="client-download-card__file">
<span class="file-icon">
<svg
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
</span>
<span class="file-name" :title="release.file_name || '安装包'">
{{ release.file_name || '安装包' }}
</span>
</div>
<div class="client-download-card__actions">
<a-button
type="primary"
class="btn-download"
:loading="downloadingReleaseKey === releaseDownloadKey(release)"
:disabled="Boolean(downloadingReleaseKey)"
@click="downloadRelease(release)"
>
<template #icon><DownloadOutlined /></template>
获取客户端
</a-button>
</div>
</article>
</div>
</a-spin>
</section>
<!-- Bound accounts grid section -->
<section class="panel media-list-panel">
<div class="media-list-header-row">
<div class="media-list-title-box">
<h3 class="panel-title">已绑定媒体账号</h3>
<p class="panel-desc">管理各媒体账号的授权效期排队就绪状态和客户端物理节点映射</p>
</div>
<div class="media-list-toolbar">
<a-input
v-model:value="searchQuery"
placeholder="搜索账号昵称、UID、发布渠道..."
class="media-search"
allow-clear
>
<template #prefix>
<SearchOutlined style="color: #a0aec0" />
</template>
</a-input>
<a-select v-model:value="selectedHealth" class="media-select" style="width: 150px">
<a-select-option value="all">全部状态</a-select-option>
<a-select-option value="live">授权正常</a-select-option>
<a-select-option value="captcha">待处理</a-select-option>
<a-select-option value="risk">风险观察</a-select-option>
<a-select-option value="expired">授权异常</a-select-option>
</a-select>
</div>
</div>
<a-spin :spinning="loading">
<a-empty
v-if="!filteredAccounts.length"
class="premium-empty"
description="没有符合过滤条件的媒体账号,请确保桌面端客户端在线并已绑定账号。"
/>
<section v-else class="media-grid">
<article v-for="account in filteredAccounts" :key="account.id" class="media-card">
<!-- Top brand line accent using accent color -->
<div
class="media-card__accent-bar"
:style="{ background: account.platformAccent }"
></div>
<div class="media-card__head">
<div class="media-card__identity">
<span
class="media-card__avatar"
:style="{ border: `2px solid ${account.platformAccent}` }"
>
<img
v-if="account.avatarUrl"
:src="account.avatarUrl"
:alt="account.displayName"
referrerpolicy="no-referrer"
/>
<span
v-else
class="avatar-initial"
:style="{ background: account.platformAccent }"
>
{{ accountInitial(account) }}
</span>
</span>
<div class="media-card__identity-copy">
<h3 :title="account.displayName">
{{ account.displayName }}
<span class="platform-logo-container">
<img
v-if="account.platformLogoUrl"
:src="account.platformLogoUrl"
:alt="account.platformLabel"
class="media-card__inline-platform-icon"
/>
<span
v-else
class="media-card__inline-platform-text"
:style="{
color: account.platformAccent,
background: `${account.platformAccent}15`,
}"
>
{{ account.platformShortName }}
</span>
</span>
</h3>
<p>
<span class="media-card__platform-name">{{ account.platformLabel }}</span>
<span class="media-card__uid-divider"></span>
<span class="media-card__uid" :title="account.platformUid">
ID: {{ account.platformUid }}
</span>
</p>
</div>
</div>
<a-dropdown :trigger="['click']" placement="bottomRight" overlayClassName="premium-unbind-dropdown">
<a-button
type="text"
class="media-card__more"
:loading="isAccountDeleteRequestPending(account.id)"
@click.stop
>
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item
v-if="account.deleteRequestedAt"
key="complete-unbind"
danger
@click="requestUnbindAccount(account)"
>
<DeleteOutlined />
<span>完成解绑</span>
</a-menu-item>
<a-menu-item
v-if="account.deleteRequestedAt"
key="undo-unbind"
@click="undoUnbindAccount(account)"
>
<SyncOutlined />
<span>撤销解绑申请</span>
</a-menu-item>
<a-menu-item
v-else
key="unbind"
danger
@click="requestUnbindAccount(account)"
>
<DeleteOutlined />
<span>解绑账号</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div class="media-card__tags">
<span class="badge-status" :class="`badge-status--${healthColor(account.health)}`">
<span class="indicator-dot"></span>
{{ healthLabel(account.health) }}
</span>
<span
class="badge-status"
:class="`badge-status--${publishStateColor(account.publishState)}`"
>
<span class="indicator-dot"></span>
{{ publishStateLabel(account.publishState) }}
</span>
</div>
<!-- Client mapping connection card -->
<div class="media-card__client-info">
<div class="client-info-inner">
<span class="client-label">运行节点</span>
<a-tooltip :title="clientStatusText(account)" placement="top">
<span
class="client-value"
:class="`client-value--${clientStatusColor(account)}`"
>
{{ clientStatusText(account) }}
</span>
</a-tooltip>
</div>
</div>
<div class="media-card__meta-box">
<div class="meta-box-item">
<span class="meta-label">安全校验</span>
<span class="meta-value">
{{ account.verifiedAt ? formatDateTime(account.verifiedAt) : '--' }}
</span>
</div>
<div class="meta-box-item">
<span class="meta-label">节点心跳</span>
<span class="meta-value">
{{ account.clientLastSeenAt ? formatDateTime(account.clientLastSeenAt) : '--' }}
</span>
</div>
</div>
<!-- Foot note panel -->
<div
v-if="account.deleteRequestedAt || account.publishHint"
class="media-card__footer"
>
<div v-if="account.deleteRequestedAt" class="footer-alert footer-alert--warning">
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"
/>
</svg>
<span>已申请解绑可完成服务端解绑</span>
</div>
<div v-else-if="account.publishHint" class="footer-alert">
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
<path
d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z"
/>
</svg>
<span :title="account.publishHint">{{ account.publishHint }}</span>
</div>
</div>
</article>
</section>
</a-spin>
</section>
</template>
<!-- Custom Account Unbind Modal -->
<a-modal
v-model:open="unbindModalOpen"
:destroy-on-close="true"
:footer="null"
:width="480"
class="premium-unbind-modal"
:closable="true"
>
<div class="unbind-modal-header">
<div class="unbind-warning-icon-wrapper">
<DeleteOutlined class="unbind-warning-icon" />
</div>
<h3 class="unbind-modal-title">解绑此运行节点上的媒体账号</h3>
<p class="unbind-modal-subtitle">
只解除当前卡片对应的运行节点绑定同一平台账号在其他节点上的绑定不受影响
</p>
</div>
<div class="unbind-account-preview" v-if="unbindAccountTarget">
<div class="preview-avatar-wrapper">
<img
v-if="unbindAccountTarget.avatarUrl"
:src="unbindAccountTarget.avatarUrl"
class="preview-avatar"
/>
<span
v-else
class="preview-avatar-placeholder"
:style="{ background: unbindAccountTarget.platformAccent }"
>
{{ accountInitial(unbindAccountTarget) }}
</span>
<img
v-if="unbindAccountTarget.platformLogoUrl"
:src="unbindAccountTarget.platformLogoUrl"
class="preview-platform-icon"
/>
</div>
<div class="preview-info">
<span class="preview-name">{{ unbindAccountTarget.displayName }}</span>
<span class="preview-meta">
{{ unbindAccountTarget.platformLabel }} · ID: {{ unbindAccountTarget.platformUid }}
</span>
<span class="preview-meta">
运行节点 · {{ unbindAccountTarget.clientDeviceName || '未命名客户端' }}
</span>
</div>
<div class="preview-status">
<span
class="badge-status"
:class="`badge-status--${unbindAccountTarget.clientOnline ? 'success' : 'warning'}`"
>
<span class="indicator-dot"></span>
{{ unbindAccountTarget.clientOnline ? '在线' : '离线' }}
</span>
</div>
</div>
<div class="unbind-consequences" v-if="unbindAccountTarget">
<div class="consequence-item">
<div class="consequence-icon-wrapper">
<ExclamationCircleOutlined />
</div>
<div class="consequence-text">
<strong>解除节点绑定</strong>系统服务端将移除此运行节点上的账号映射这张卡片不再接收发布任务
</div>
</div>
<div class="consequence-item">
<div class="consequence-icon-wrapper">
<LockOutlined />
</div>
<div class="consequence-text">
<strong>处理本地会话</strong>
<span v-if="unbindAccountTarget.clientOnline">
在线客户端同步后会清理当前节点的本地会话缓存
</span>
<span v-else>
当前运行节点离线服务端仍会立即完成解绑该节点下次同步时会清理本地会话缓存
</span>
</div>
</div>
<div class="consequence-item">
<div class="consequence-icon-wrapper">
<CloseCircleOutlined />
</div>
<div class="consequence-text">
<strong>取消排队发布</strong>发往此运行节点此账号的等待发布任务会被取消其他节点上的同账号任务不受影响
</div>
</div>
</div>
<div class="unbind-verification" v-if="unbindAccountTarget">
<div class="verification-label">
请输入账号名称 <span class="highlight-name">{{ unbindAccountTarget.displayName }}</span> 或输入 <span class="highlight-keyword">解绑</span> 进行确认
</div>
<a-input
v-model:value="unbindVerifyInput"
placeholder="请输入名称或“解绑”"
class="unbind-input"
@pressEnter="handleConfirmUnbind"
/>
</div>
<div class="unbind-modal-footer">
<a-button @click="unbindModalOpen = false" class="btn-cancel">取消</a-button>
<a-button
type="primary"
danger
:disabled="!isUnbindVerified"
:loading="unbindConfirmLoading"
@click="handleConfirmUnbind"
class="btn-confirm"
>
确认解绑
</a-button>
</div>
</a-modal>
<a-modal
v-model:open="enterpriseSiteModalOpen"
:title="enterpriseSiteModalTitle"
:destroy-on-close="true"
:ok-text="enterpriseSiteModalOkText"
cancel-text="取消"
:confirm-loading="saveEnterpriseSiteMutation.isPending.value"
@ok="saveEnterpriseSiteMutation.mutate()"
@cancel="closeEnterpriseSiteModal"
>
<div class="enterprise-site-form">
<div class="form-field">
<span>CMS 类型</span>
<a-select
v-model:value="enterpriseSiteForm.cmsType"
:disabled="editingEnterpriseSiteId !== null"
:options="cmsTypeOptions"
:get-popup-container="getEnterpriseSiteSelectPopupContainer"
popup-class-name="enterprise-site-cms-dropdown"
@change="handleEnterpriseSiteCMSTypeChange"
/>
</div>
<label class="form-field">
<span>站点名称</span>
<a-input v-model:value="enterpriseSiteForm.name" placeholder="例如:品牌官网" />
</label>
<label class="form-field">
<span>站点域名</span>
<a-input
v-model:value="enterpriseSiteForm.siteUrl"
placeholder="https://www.example.com"
/>
</label>
<label class="form-field">
<span>{{ enterpriseSiteAppIDLabel }}</span>
<a-input
v-model:value="enterpriseSiteForm.appid"
:placeholder="enterpriseSiteAppIDPlaceholder"
/>
</label>
<label class="form-field">
<span>Secret</span>
<a-input-password
v-model:value="enterpriseSiteForm.secret"
:placeholder="enterpriseSiteSecretPlaceholder"
/>
</label>
<div
class="enterprise-form-grid"
:class="{ 'enterprise-form-grid--wordpress': enterpriseSiteForm.cmsType === 'wordpress' }"
>
<template v-if="enterpriseSiteForm.cmsType !== 'wordpress'">
<label class="form-field">
<span>默认语言</span>
<a-input v-model:value="enterpriseSiteForm.defaultAcode" placeholder="cn" />
</label>
<label class="form-field">
<span>模型编码</span>
<a-input v-model:value="enterpriseSiteForm.defaultMcode" placeholder="2" />
</label>
</template>
<label class="form-field">
<span>{{ enterpriseSiteDefaultScodeLabel }}</span>
<a-input
v-model:value="enterpriseSiteForm.defaultScode"
:placeholder="enterpriseSiteDefaultScodePlaceholder"
/>
</label>
</div>
</div>
</a-modal>
</div>
</template>
<style scoped>
/* Page Layout */
.media-view {
display: flex;
flex-direction: column;
gap: 24px;
background-color: #f8fafc;
min-height: 100%;
}
/* Premium Dark Banner Section */
.media-view__top-card {
position: relative;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
padding: 0;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.media-view__header {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 32px;
z-index: 2;
}
.media-view__header-title {
max-width: 650px;
}
.media-view__header-title h2 {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #0f172a;
line-height: 1.3;
display: inline-flex;
align-items: center;
gap: 10px;
letter-spacing: 0;
}
.media-view__header-title p {
margin: 8px 0 0;
font-size: 13.5px;
color: #475569;
line-height: 1.6;
font-weight: 400;
}
.media-view__header-actions {
display: flex;
gap: 12px;
}
/* Premium Refresh Button Override */
:deep(.btn-refresh.ant-btn) {
background: #ffffff;
border: 1px solid #e2e8f0;
color: #0f172a;
border-radius: 8px;
height: 40px;
padding: 0 18px;
font-weight: 500;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 4px rgba(148, 163, 184, 0.05);
}
:deep(.btn-refresh.ant-btn:hover) {
background: #f8fafc;
border-color: #cbd5e1;
color: #1d4ed8;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(148, 163, 184, 0.08);
}
.media-scope-tabs {
position: relative;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 2px;
background: #f1f5f9;
padding: 4px;
border-radius: 10px;
margin: 0 32px 24px;
border: 1px solid rgba(148, 163, 184, 0.08);
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.03);
}
.media-scope-tab {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 38px;
padding: 0 18px;
border: none;
border-radius: 7px;
background: transparent;
color: #64748b;
font-size: 13.5px;
font-weight: 550;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
}
.media-scope-tab .tab-icon {
font-size: 14px;
color: #94a3b8;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.media-scope-tab small {
color: #94a3b8;
font-size: 11px;
font-weight: 500;
background: rgba(148, 163, 184, 0.06);
padding: 2px 6px;
border-radius: 4px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.media-scope-tab:hover {
color: #334155;
}
.media-scope-tab:hover .tab-icon {
color: #64748b;
}
.media-scope-tab--active {
background: #ffffff;
color: #0f172a;
font-weight: 600;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.05),
0 4px 12px rgba(15, 23, 42, 0.04);
}
.media-scope-tab--active .tab-icon {
color: #1677ff;
}
.media-scope-tab--active small {
color: #1677ff;
background: #e6f4ff;
font-weight: 500;
}
/* Metrics strip styling */
.overview-strip {
position: relative;
display: grid;
grid-template-columns: repeat(4, 1fr);
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(12px);
border-top: 1px solid #e2e8f0;
z-index: 2;
}
.overview-chip {
display: flex;
align-items: center;
gap: 16px;
padding: 24px 32px;
border-right: 1px solid #e2e8f0;
transition: all 0.25s ease;
}
.overview-chip:last-child {
border-right: none;
}
.overview-chip:hover {
background: rgba(255, 255, 255, 0.7);
}
.chip-icon-box {
width: 42px;
height: 42px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* Metric colored boxes styling */
.total-bg {
background: #dbeafe;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
.live-bg {
background: #d1fae5;
color: #059669;
border: 1px solid #a7f3d0;
}
.queued-bg {
background: #fef3c7;
color: #d97706;
border: 1px solid #fde68a;
}
.attention-bg {
background: #ffe4e6;
color: #e11d48;
border: 1px solid #fecdd3;
}
.chip-copy {
display: flex;
flex-direction: column;
}
.chip-copy span {
color: #64748b;
font-size: 12px;
font-weight: 500;
letter-spacing: 0;
}
.chip-copy strong {
color: #0f172a;
font-size: 26px;
font-weight: 700;
margin-top: 4px;
line-height: 1;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
letter-spacing: 0;
}
/* Panel Layout Styling */
.panel {
background: #ffffff;
border-radius: 8px;
padding: 32px;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 20px rgba(148, 163, 184, 0.05);
transition:
box-shadow 0.3s ease,
border-color 0.3s ease;
}
.panel:hover {
box-shadow: 0 10px 30px rgba(148, 163, 184, 0.08);
}
.panel-title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #0f172a;
letter-spacing: 0;
}
.panel-desc {
margin: 6px 0 0;
color: #64748b;
font-size: 13.5px;
line-height: 1.5;
}
/* Client download header and grid */
.client-download-panel {
display: flex;
flex-direction: column;
gap: 24px;
}
.client-download-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.title-with-badge {
display: flex;
align-items: center;
gap: 12px;
}
.badge-pulsing-stable {
background: rgba(37, 99, 235, 0.1);
color: #1d4ed8;
border: 1px solid rgba(37, 99, 235, 0.2);
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 8px;
text-transform: uppercase;
letter-spacing: 0;
position: relative;
overflow: hidden;
}
.badge-pulsing-stable::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(37, 99, 235, 0.18), transparent);
animation: shine-sweep 3s infinite linear;
}
@keyframes shine-sweep {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
.client-download-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 18px;
}
/* Client download cards */
.client-download-card {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 200px;
padding: 24px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.client-download-card:hover {
transform: translateY(-4px);
border-color: #cbd5e1;
box-shadow: 0 12px 24px rgba(148, 163, 184, 0.12);
}
.client-download-card__body {
display: flex;
gap: 16px;
align-items: flex-start;
}
/* Elegant custom OS SVGs logos wrapper */
.client-download-card__logo {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.3s ease;
}
.logo-darwin {
background: #0f172a;
color: #ffffff;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.15);
}
.logo-win32 {
background: #0078d4;
color: #ffffff;
box-shadow: 0 4px 12px rgba(0, 120, 212, 0.2);
}
.logo-default,
.logo-linux {
background: #1e293b;
color: #38bdf8;
box-shadow: 0 4px 12px rgba(30, 41, 59, 0.2);
}
.os-svg-icon {
width: 22px;
height: 22px;
}
.client-download-card__content {
min-width: 0;
}
.client-download-card__content h4 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #0f172a;
line-height: 1.3;
}
.meta-inline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
color: #64748b;
font-size: 12px;
}
.version-tag {
background: #f1f5f9;
color: #475569;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
}
.dot-separator {
color: #cbd5e1;
font-size: 8px;
}
/* Code-looking filename strip */
.client-download-card__file {
display: flex;
align-items: center;
gap: 8px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 12px;
margin-top: 18px;
margin-bottom: 18px;
overflow: hidden;
}
.file-icon {
color: #64748b;
display: flex;
align-items: center;
flex-shrink: 0;
}
.file-name {
color: #334155;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11.5px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-download-card__actions {
display: flex;
}
/* Download Button styling */
:deep(.btn-download.ant-btn-primary) {
background: #1d4ed8;
border-color: #1d4ed8;
border-radius: 8px;
height: 38px;
width: 100%;
font-weight: 600;
font-size: 13px;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2);
transition: all 0.25s ease;
}
:deep(.btn-download.ant-btn-primary:hover:not(:disabled)) {
background: #1e40af;
border-color: #1e40af;
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.26);
transform: translateY(-1px);
}
:deep(.btn-download.ant-btn-primary:disabled) {
background: #cbd5e1;
border-color: #cbd5e1;
color: #94a3b8;
box-shadow: none;
}
.enterprise-panel {
display: flex;
flex-direction: column;
gap: 26px;
}
.enterprise-panel__main {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 24px;
align-items: start;
}
.section-kicker {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 6px;
background: #eef2ff;
color: #1e3a8a;
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.enterprise-panel__copy h3 {
margin: 12px 0 0;
color: #0f172a;
font-size: 22px;
font-weight: 760;
line-height: 1.25;
}
.enterprise-panel__copy p {
max-width: 660px;
margin: 8px 0 0;
color: #475569;
font-size: 13.5px;
line-height: 1.7;
}
.enterprise-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
:deep(.btn-enterprise-primary.ant-btn-primary) {
height: 40px;
border-radius: 8px;
background: #1d4ed8;
border-color: #1d4ed8;
font-weight: 650;
box-shadow: none;
}
:deep(.btn-enterprise-primary.ant-btn-primary:hover) {
background: #1e40af;
border-color: #1e40af;
transform: translateY(-1px);
}
:deep(.btn-enterprise-secondary.ant-btn) {
height: 40px;
border-radius: 8px;
border-color: #cbd5e1;
color: #334155;
font-weight: 650;
}
.enterprise-flow {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.enterprise-step {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 14px;
min-height: 108px;
padding: 18px;
background: #ffffff;
border-right: 1px solid #e2e8f0;
}
.enterprise-step--action {
cursor: pointer;
transition:
background 0.2s ease,
box-shadow 0.2s ease;
}
.enterprise-step--action:hover {
background: #eff6ff;
box-shadow: inset 0 0 0 1px #93c5fd;
}
.enterprise-step:last-child {
border-right: 0;
}
.enterprise-step__index {
color: #1d4ed8;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
font-weight: 800;
}
.enterprise-step h4 {
margin: 0;
color: #0f172a;
font-size: 14px;
font-weight: 720;
}
.enterprise-step p {
margin: 6px 0 0;
color: #64748b;
font-size: 12.5px;
line-height: 1.55;
}
.enterprise-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(360px, 0.85fr);
gap: 24px;
}
.enterprise-grid--single {
grid-template-columns: 1fr;
width: 100%;
}
.connector-panel,
.adapter-panel {
display: flex;
flex-direction: column;
gap: 22px;
}
.panel-head-compact {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.connector-empty-premium {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 48px 24px;
border: 1px dashed #cbd5e1;
border-radius: 16px;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
overflow: hidden;
box-shadow: 0 4px 12px rgba(148, 163, 184, 0.02);
}
.empty-glow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 240px;
height: 240px;
background: radial-gradient(circle, rgba(37, 99, 235, 0.05) 0%, transparent 70%);
z-index: 1;
pointer-events: none;
}
.empty-icon-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border-radius: 20px;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
color: #2563eb;
margin-bottom: 20px;
border: 1px solid rgba(37, 99, 235, 0.1);
box-shadow: 0 8px 16px -4px rgba(37, 99, 235, 0.1);
z-index: 2;
}
.empty-globe {
font-size: 32px;
}
.empty-plus {
position: absolute;
bottom: -4px;
right: -4px;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: #2563eb;
color: #ffffff;
font-size: 10px;
border: 2px solid #ffffff;
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.2);
}
.connector-empty-premium h3 {
font-size: 18px;
font-weight: 700;
color: #0f172a;
margin: 0 0 8px;
z-index: 2;
}
.connector-empty-premium p {
max-width: 480px;
font-size: 13.5px;
line-height: 1.6;
color: #64748b;
margin: 0 0 24px;
z-index: 2;
}
.btn-empty-add.ant-btn {
height: 40px;
border-radius: 8px;
padding: 0 24px;
font-weight: 600;
background: #2563eb;
border-color: #2563eb;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2);
transition: all 0.2s ease;
z-index: 2;
}
.btn-empty-add.ant-btn:hover {
background: #1d4ed8;
border-color: #1d4ed8;
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.3);
transform: translateY(-1px);
}
.site-connection-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 24px;
width: 100%;
}
.site-connection-card {
position: relative;
display: flex;
flex-direction: column;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 24px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.02),
0 2px 4px -1px rgba(0, 0, 0, 0.01);
}
.site-connection-card:hover {
border-color: #cbd5e1;
box-shadow:
0 20px 25px -5px rgba(15, 23, 42, 0.08),
0 10px 10px -5px rgba(15, 23, 42, 0.04);
transform: translateY(-4px);
}
.site-card-accent {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
}
.site-card-accent.accent-connected {
background: linear-gradient(90deg, #10b981, #059669);
}
.site-card-accent.accent-error {
background: linear-gradient(90deg, #f43f5e, #e11d48);
}
.site-card-accent.accent-disabled {
background: linear-gradient(90deg, #94a3b8, #64748b);
}
.site-card-accent.accent-pending {
background: linear-gradient(90deg, #f59e0b, #d97706);
}
.site-connection-card__header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.site-connection-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
color: #2563eb;
font-size: 22px;
border: 1px solid rgba(37, 99, 235, 0.1);
flex-shrink: 0;
}
.site-connection-card__favicon {
width: 24px;
height: 24px;
border-radius: 4px;
object-fit: contain;
}
.site-connection-card__header-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.site-name-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.site-connection-card h4 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #0f172a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.site-badge {
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: 6px;
line-height: 1.4;
}
.site-badge--cms {
background: #eff6ff;
color: #1e40af;
border: 1px solid #bfdbfe;
}
.site-badge--status {
display: inline-flex;
align-items: center;
gap: 6px;
width: fit-content;
}
.site-badge__dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.site-badge--status-connected {
background: rgba(16, 185, 129, 0.08);
color: #059669;
border: 1px solid rgba(16, 185, 129, 0.15);
}
.site-badge--status-connected .site-badge__dot {
background: #10b981;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
animation: pulse-dot-green 2s infinite;
}
@keyframes pulse-dot-green {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
}
70% {
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
.site-badge--status-error {
background: rgba(244, 63, 94, 0.08);
color: #e11d48;
border: 1px solid rgba(244, 63, 94, 0.15);
}
.site-badge--status-error .site-badge__dot {
background: #f43f5e;
box-shadow: 0 0 0 2px rgba(244, 63, 94, 0.2);
animation: pulse-dot-red 2s infinite;
}
@keyframes pulse-dot-red {
0% {
box-shadow: 0 0 0 0 rgba(244, 63, 94, 0.7);
}
70% {
box-shadow: 0 0 0 6px rgba(244, 63, 94, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(244, 63, 94, 0);
}
}
.site-badge--status-disabled {
background: rgba(100, 116, 139, 0.08);
color: #475569;
border: 1px solid rgba(100, 116, 139, 0.15);
}
.site-badge--status-disabled .site-badge__dot {
background: #64748b;
}
.site-badge--status-pending {
background: rgba(245, 158, 11, 0.08);
color: #d97706;
border: 1px solid rgba(245, 158, 11, 0.15);
}
.site-badge--status-pending .site-badge__dot {
background: #f59e0b;
}
.site-url-box {
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 8px;
padding: 8px 12px;
margin-bottom: 16px;
}
.site-link {
display: flex;
align-items: center;
gap: 8px;
color: #64748b;
font-size: 13px;
transition: color 0.2s ease;
}
.site-link:hover {
color: #2563eb;
}
.site-link .link-icon {
color: #94a3b8;
transition: color 0.2s ease;
}
.site-link:hover .link-icon {
color: #2563eb;
}
.site-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 20px;
}
.meta-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.meta-cell.full-width {
grid-column: span 2;
}
.meta-cell .meta-label {
font-size: 11px;
color: #94a3b8;
font-weight: 500;
}
.meta-cell .meta-value {
font-size: 12.5px;
color: #475569;
font-weight: 600;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.site-connection-card__footer-actions {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid #f1f5f9;
padding-top: 16px;
margin-top: auto;
}
.footer-actions-right {
display: flex;
align-items: center;
gap: 8px;
}
.btn-action-ping.ant-btn,
.btn-action-sync.ant-btn {
height: 32px;
border-radius: 6px;
font-size: 12.5px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 6px;
border-color: #cbd5e1;
color: #475569;
background: #ffffff;
transition: all 0.2s ease;
}
.btn-action-ping.ant-btn:hover,
.btn-action-sync.ant-btn:hover {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
.action-more-btn.ant-btn-text {
height: 32px;
width: 32px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #64748b;
border: 1px solid #cbd5e1;
background: #ffffff;
transition: all 0.2s ease;
}
.action-more-btn.ant-btn-text:hover {
color: #0f172a;
background: #f1f5f9;
}
.site-connection-card__error-banner {
margin-top: 12px;
padding: 12px 14px;
border: 1px solid #fecdd3;
border-left: 4px solid #f43f5e;
border-radius: 8px;
background: #fff5f5;
display: flex;
align-items: flex-start;
gap: 10px;
}
.connector-sample {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-left: 3px solid #475569;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.connector-sample__line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0;
border-bottom: none;
}
.connector-sample__line span {
color: #475569;
font-size: 12px;
font-weight: 600;
background: #e2e8f0;
padding: 2px 8px;
border-radius: 4px;
flex-shrink: 0;
}
.connector-sample__line strong {
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
font-weight: 600;
overflow-wrap: anywhere;
text-align: right;
background: rgba(15, 23, 42, 0.03);
padding: 3px 8px;
border-radius: 5px;
border: 1px solid rgba(15, 23, 42, 0.05);
}
.adapter-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.adapter-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
padding: 14px 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #ffffff;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.adapter-row:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.03);
transform: translateY(-1px);
}
.adapter-row__mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
transition: all 0.2s ease;
}
.adapter-row--ready .adapter-row__mark {
background: #f0fdf4;
color: #16a34a;
border: 1px solid #dcfce7;
}
.adapter-row--planned {
background: #fafafb;
border-style: dashed;
}
.adapter-row--planned .adapter-row__mark {
background: #f1f5f9;
color: #64748b;
border: 1px solid #e2e8f0;
}
.adapter-row__body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.adapter-row__body strong {
color: #0f172a;
font-size: 14px;
font-weight: 600;
}
.adapter-row__body span {
color: #64748b;
font-size: 12px;
line-height: 1.4;
}
.enterprise-site-form {
display: flex;
flex-direction: column;
gap: 14px;
padding-top: 4px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 7px;
}
.form-field span {
color: #334155;
font-size: 13px;
font-weight: 650;
}
.enterprise-form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.enterprise-form-grid--wordpress {
grid-template-columns: 1fr;
}
.enterprise-publish-hint {
padding: 10px 12px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
color: #1e40af;
font-size: 12.5px;
line-height: 1.5;
}
.enterprise-publish-hint--warning {
border-color: #fde68a;
background: #fffbeb;
color: #92400e;
}
:global(.enterprise-site-cms-dropdown) {
z-index: 1100;
}
/* Spinners, alerts, empty state styling */
.premium-alert {
border-radius: 12px;
border: 1px solid #fecaca;
background: #fef2f2;
}
.premium-empty {
padding: 40px 0;
border: 1px dashed #e2e8f0;
border-radius: 8px;
}
/* Media list panel & search toolbar */
.media-list-panel {
display: flex;
flex-direction: column;
gap: 24px;
}
.media-list-header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
flex-wrap: wrap;
}
.media-list-title-box {
flex: 1;
min-width: 250px;
}
.media-list-toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
/* Styled Inputs & Selects */
:deep(.media-search.ant-input-affix-wrapper) {
width: 320px;
height: 40px;
border-radius: 8px;
border-color: #cbd5e1;
background-color: #ffffff;
padding-left: 12px;
transition: all 0.2s ease;
}
:deep(.media-search.ant-input-affix-wrapper:hover) {
border-color: #94a3b8;
}
:deep(.media-search.ant-input-affix-wrapper-focused) {
border-color: #1d4ed8;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14);
}
:deep(.media-select.ant-select .ant-select-selector) {
height: 40px !important;
border-radius: 8px !important;
border-color: #cbd5e1 !important;
padding: 4px 12px !important;
transition: all 0.2s ease;
}
:deep(.media-select.ant-select:hover .ant-select-selector) {
border-color: #94a3b8 !important;
}
:deep(.media-select.ant-select-focused .ant-select-selector) {
border-color: #1d4ed8 !important;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14) !important;
}
/* Media Card Grid */
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(295px, 1fr));
gap: 20px;
}
/* Premium Media Card */
.media-card {
position: relative;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-shadow: 0 4px 12px rgba(148, 163, 184, 0.03);
}
.media-card__accent-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
opacity: 0.85;
}
.media-card:hover {
transform: translateY(-5px);
border-color: #cbd5e1;
box-shadow: 0 16px 36px rgba(148, 163, 184, 0.12);
}
.media-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.media-card__identity {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
flex: 1;
}
.media-card__more.ant-btn-text {
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: 50%;
color: #64748b;
background: transparent;
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.2s ease;
}
.media-card__more.ant-btn-text:hover,
.media-card__more.ant-btn-text:focus {
color: #0f172a;
background: #f1f5f9;
border: none;
}
/* Circular Avatar glow container */
.media-card__avatar {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
background: #f1f5f9;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: transform 0.3s ease;
}
.media-card:hover .media-card__avatar {
transform: scale(1.05);
}
.media-card__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-initial {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-weight: 700;
font-size: 16px;
}
.media-card__identity-copy {
min-width: 0;
flex: 1;
}
.media-card__identity-copy h3 {
margin: 0;
color: #0f172a;
font-size: 15.5px;
font-weight: 700;
line-height: 1.3;
display: flex;
align-items: center;
gap: 6px;
}
.platform-logo-container {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.media-card__inline-platform-icon {
width: 16px;
height: 16px;
object-fit: contain;
}
.media-card__inline-platform-text {
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: 4px;
letter-spacing: 0;
}
.media-card__identity-copy p {
margin: 5px 0 0;
color: #64748b;
font-size: 12.5px;
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.media-card__platform-name {
font-weight: 600;
color: #475569;
}
.media-card__uid-divider {
color: #cbd5e1;
font-weight: bold;
}
.media-card__uid {
color: #94a3b8;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
/* Polished Pill Badges */
.media-card__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.badge-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
font-weight: 600;
padding: 3px 10px;
border-radius: 8px;
line-height: 1.4;
}
.indicator-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
}
/* Colorful status mapping */
.badge-status--green {
background: rgba(16, 185, 129, 0.08);
color: #059669;
border: 1px solid rgba(16, 185, 129, 0.15);
}
.badge-status--green .indicator-dot {
background-color: #10b981;
}
.badge-status--orange {
background: rgba(245, 158, 11, 0.08);
color: #d97706;
border: 1px solid rgba(245, 158, 11, 0.15);
}
.badge-status--orange .indicator-dot {
background-color: #f59e0b;
}
.badge-status--gold {
background: rgba(217, 119, 6, 0.08);
color: #b45309;
border: 1px solid rgba(217, 119, 6, 0.15);
}
.badge-status--gold .indicator-dot {
background-color: #d97706;
}
.badge-status--red {
background: rgba(244, 63, 94, 0.08);
color: #e11d48;
border: 1px solid rgba(244, 63, 94, 0.15);
}
.badge-status--red .indicator-dot {
background-color: #f43f5e;
}
.badge-status--default {
background: rgba(100, 116, 139, 0.08);
color: #475569;
border: 1px solid rgba(100, 116, 139, 0.15);
}
.badge-status--default .indicator-dot {
background-color: #64748b;
}
/* Client runtime block */
.media-card__client-info {
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 8px;
padding: 10px 12px;
}
.client-info-inner {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.client-label {
color: #64748b;
font-weight: 500;
}
.client-value {
font-weight: 600;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-value--green {
color: #059669;
}
.client-value--gold {
color: #d97706;
}
.client-value--default {
color: #64748b;
}
/* Metadata box */
.media-card__meta-box {
display: flex;
flex-direction: column;
gap: 6px;
border-top: 1px solid #f1f5f9;
padding-top: 12px;
}
.meta-box-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11.5px;
}
.meta-label {
color: #94a3b8;
font-weight: 500;
}
.meta-value {
color: #475569;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-weight: 500;
}
/* Card footer details hints */
.media-card__footer {
margin-top: auto;
border-top: 1px dashed #f1f5f9;
padding-top: 12px;
}
.footer-alert {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #64748b;
background: #f8fafc;
border-radius: 6px;
padding: 6px 10px;
line-height: 1.4;
}
.footer-alert svg {
flex-shrink: 0;
color: #94a3b8;
}
.footer-alert span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.footer-alert--warning {
background: #fffbeb;
color: #b45309;
border: 1px solid #fef3c7;
}
.footer-alert--warning svg {
color: #d97706;
}
/* Responsiveness adjustments */
@media (max-width: 1024px) {
.enterprise-panel__main,
.enterprise-grid {
grid-template-columns: 1fr;
}
.enterprise-actions {
justify-content: flex-start;
}
.enterprise-flow {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.enterprise-step:nth-child(2) {
border-right: 0;
}
.enterprise-step:nth-child(-n + 2) {
border-bottom: 1px solid #e2e8f0;
}
.overview-strip {
grid-template-columns: repeat(2, 1fr);
}
.overview-chip {
border-bottom: 1px solid #e2e8f0;
}
.overview-chip:nth-child(even) {
border-right: none;
}
.overview-chip:nth-child(n + 3) {
border-bottom: none;
}
}
@media (max-width: 768px) {
.media-view__header {
flex-direction: column;
align-items: stretch;
gap: 20px;
padding: 24px;
}
.media-view__header-actions {
width: 100%;
}
.media-view__header-actions :deep(.ant-btn) {
width: 100%;
}
.media-scope-tabs {
margin: 0 24px 20px;
display: flex;
padding: 4px;
}
.media-scope-tab {
flex: 1;
min-width: 0;
justify-content: center;
}
.site-connection-grid {
grid-template-columns: 1fr;
}
.overview-chip {
padding: 16px 20px;
}
.panel {
padding: 20px;
}
.media-list-header-row {
flex-direction: column;
align-items: stretch;
gap: 16px;
}
.media-list-toolbar {
flex-direction: column;
align-items: stretch;
width: 100%;
}
:deep(.media-search.ant-input-affix-wrapper) {
width: 100%;
}
:deep(.media-select.ant-select) {
width: 100% !important;
}
.enterprise-flow {
grid-template-columns: 1fr;
}
.enterprise-step,
.enterprise-step:nth-child(2) {
border-right: 0;
border-bottom: 1px solid #e2e8f0;
}
.enterprise-step:last-child {
border-bottom: 0;
}
.connector-empty {
align-items: flex-start;
}
.connector-sample__line,
.adapter-row {
grid-template-columns: 1fr;
}
.enterprise-form-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.overview-strip {
grid-template-columns: 1fr;
}
.overview-chip {
border-right: none;
border-bottom: 1px solid #e2e8f0;
}
.overview-chip:last-child {
border-bottom: none;
}
.client-download-grid,
.media-grid {
grid-template-columns: 1fr;
}
.site-connection-card {
padding: 16px;
}
.site-meta-grid {
grid-template-columns: 1fr;
gap: 8px;
}
.meta-cell.full-width {
grid-column: span 1;
}
.site-connection-card__footer-actions {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.btn-action-ping.ant-btn {
width: 100%;
justify-content: center;
}
.footer-actions-right {
justify-content: space-between;
width: 100%;
}
.btn-action-sync.ant-btn {
flex: 1;
justify-content: center;
}
}
:deep(.action-more-btn.ant-btn-text) {
color: #64748b;
background: transparent;
border: 1px solid #cbd5e1;
}
:deep(.action-more-btn.ant-btn-text:hover) {
color: #0f172a;
background: #f1f5f9;
}
:deep(.ant-dropdown-menu-item) {
padding: 8px 14px;
font-size: 13.5px;
color: #334155;
}
:deep(.ant-dropdown-menu-title-content) {
display: flex;
align-items: center;
gap: 10px;
}
:deep(.ant-dropdown-menu-item .anticon) {
font-size: 15px;
color: #64748b;
transition: color 0.2s ease;
}
:deep(.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-danger):hover .anticon) {
color: #2563eb;
}
:deep(.ant-dropdown-menu-item-danger:hover .anticon) {
color: #ffffff;
}
/* Custom Account Unbind Modal Styles */
.premium-unbind-modal :deep(.ant-modal-content) {
border-radius: 16px;
padding: 24px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.premium-unbind-modal :deep(.ant-modal-header) {
margin-bottom: 20px;
}
.unbind-modal-header {
text-align: center;
margin-bottom: 24px;
}
.unbind-warning-icon-wrapper {
width: 56px;
height: 56px;
background-color: #fef2f2;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
border: 1px solid #fee2e2;
animation: pulse-warning 2s infinite ease-in-out;
}
@keyframes pulse-warning {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.15);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 0 10px rgba(239, 68, 68, 0);
}
}
.unbind-warning-icon {
font-size: 24px;
color: #ef4444;
}
.unbind-modal-title {
font-size: 18px;
font-weight: 700;
color: #0f172a;
margin: 0 0 8px;
}
.unbind-modal-subtitle {
font-size: 13px;
color: #64748b;
margin: 0;
line-height: 1.5;
}
/* Account Preview inside Modal */
.unbind-account-preview {
display: flex;
align-items: center;
gap: 12px;
background-color: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 12px 16px;
margin-bottom: 20px;
}
.preview-avatar-wrapper {
position: relative;
width: 40px;
height: 40px;
flex-shrink: 0;
}
.preview-avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 1px solid #cbd5e1;
}
.preview-avatar-placeholder {
width: 100%;
height: 100%;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 600;
font-size: 14px;
}
.preview-platform-icon {
position: absolute;
bottom: -2px;
right: -2px;
width: 16px;
height: 16px;
background: white;
border-radius: 50%;
padding: 2px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.preview-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.preview-name {
font-size: 14px;
font-weight: 600;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-meta {
font-size: 12px;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-status {
flex-shrink: 0;
}
/* Consequences List */
.unbind-consequences {
display: flex;
flex-direction: column;
gap: 12px;
background-color: #fffaf0;
border: 1px dashed #feebc8;
border-radius: 12px;
padding: 16px;
margin-bottom: 24px;
}
.consequence-item {
display: flex;
gap: 10px;
align-items: flex-start;
}
.consequence-icon-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
margin-top: 2px;
color: #dd6b20;
}
.consequence-icon-wrapper .anticon {
font-size: 14px;
}
.consequence-text {
font-size: 13px;
color: #7b341e;
line-height: 1.5;
}
.consequence-text strong {
color: #dd6b20;
}
/* Verification Input */
.unbind-verification {
margin-bottom: 24px;
}
.verification-label {
font-size: 13px;
color: #475569;
margin-bottom: 8px;
line-height: 1.5;
}
.highlight-name {
font-family: monospace;
font-weight: 700;
color: #ef4444;
background-color: #fef2f2;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #fee2e2;
margin: 0 2px;
}
.highlight-keyword {
font-weight: 700;
color: #3b82f6;
background-color: #eff6ff;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #dbeafe;
margin: 0 2px;
}
.unbind-input.ant-input {
height: 40px;
border-radius: 8px;
border-color: #cbd5e1;
font-size: 14px;
transition: all 0.2s ease;
}
.unbind-input.ant-input:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12);
}
/* Footer Actions */
.unbind-modal-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.unbind-modal-footer .btn-cancel {
height: 40px;
border-radius: 8px;
padding: 0 16px;
font-size: 14px;
font-weight: 500;
color: #475569;
border-color: #cbd5e1;
}
.unbind-modal-footer .btn-cancel:hover {
color: #1e293b;
border-color: #94a3b8;
background: #f8fafc;
}
.unbind-modal-footer .btn-confirm {
height: 40px;
border-radius: 8px;
padding: 0 20px;
font-size: 14px;
font-weight: 600;
}
/* Premium Dropdown styles */
:global(.premium-unbind-dropdown .ant-dropdown-menu) {
border-radius: 12px !important;
padding: 6px !important;
border: 1px solid #f1f5f9 !important;
box-shadow: 0 10px 25px -5px rgba(15, 23, 42, 0.08), 0 8px 16px -6px rgba(15, 23, 42, 0.04) !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item) {
border-radius: 8px !important;
padding: 8px 16px !important;
font-size: 13.5px !important;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-danger):hover) {
background-color: #eff6ff !important;
color: #2563eb !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-danger):hover .anticon) {
color: #2563eb !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item-danger) {
color: #ef4444 !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item-danger .anticon) {
color: #ef4444 !important;
font-size: 15px !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item-danger:hover) {
background-color: #fef2f2 !important;
color: #dc2626 !important;
}
:global(.premium-unbind-dropdown .ant-dropdown-menu-item-danger:hover .anticon) {
color: #dc2626 !important;
}
</style>