Files
geo/apps/admin-web/src/views/MediaView.vue
T
root d6866cd85b
Frontend CI / Frontend (push) Successful in 4m1s
Backend CI / Backend (push) Failing after 8m51s
feat: add downloadable release management for desktop client
- Extend DesktopClientReleaseCheckResponse with platform, arch, and channel fields.
- Introduce DesktopClientDownloadableRelease and DesktopClientDownloadableReleaseListResponse interfaces for managing downloadable releases.
- Implement ListDownloadable method in DesktopClientReleaseService to retrieve downloadable releases based on the specified channel.
- Update DesktopClientReleaseCheckResult to include platform, arch, and channel information.
- Add resolveInstallerDownloadURL method to handle download URL resolution for releases.
- Create new endpoint in DesktopReleaseHandler for listing downloadable releases.
- Update router to include the new downloadable releases route.
- Enhance tests to cover the new functionality for resolving download URLs and listing downloadable releases.
2026-05-31 19:28:35 +08:00

1700 lines
43 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 { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons-vue'
import type {
DesktopAccountInfo,
DesktopClientDownloadableRelease,
MediaPlatform,
} from '@geo/shared-types'
import { useQuery } from '@tanstack/vue-query'
import { message } from 'ant-design-vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { desktopClientApi, mediaApi, resolveApiURL, tenantAccountsApi } from '@/lib/api'
import { resolveAccountCheckedAt, resolveAccountHealth } from '@/lib/desktop-account-runtime'
import { formatBytes, formatDateTime } from '@/lib/display'
import {
getPublishPlatformMeta,
isMediaPublishAccount,
normalizePublishPlatformId,
} from '@/lib/publish-platforms'
type AccountHealthFilter = 'all' | 'live' | 'captcha' | 'risk' | 'expired'
type PublishState = 'immediate' | 'queued' | 'unavailable'
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
clientDeviceName: string | null
clientLastSeenAt: string | null
publishState: PublishState
publishHint: string
deleteRequestedAt: string | null
}
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 { t } = useI18n()
const searchQuery = ref('')
const selectedHealth = ref<AccountHealthFilter>('all')
const downloadingReleaseKey = ref<string | null>(null)
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 loading = computed(() => platformsQuery.isPending.value || accountsQuery.isPending.value)
const refreshing = computed(
() =>
platformsQuery.isFetching.value ||
accountsQuery.isFetching.value ||
downloadableReleasesQuery.isFetching.value,
)
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,
clientDeviceName: account.client_device_name,
clientLastSeenAt: account.client_last_seen_at,
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 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 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 (resolveAccountHealth(account) !== 'live') {
return 'unavailable'
}
if (!account.client_id) {
return 'unavailable'
}
return account.client_online === true ? 'immediate' : 'queued'
}
function resolvePublishHint(account: DesktopAccountInfo, publishState: PublishState): string {
if (publishState === 'immediate') {
return ''
}
if (publishState === 'queued') {
return '客户端当前离线,任务会先进入发布队列,等桌面端上线后自动继续。'
}
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 '未绑定'
}
return account.clientOnline ? '在线' : '离线'
}
function clientStatusColor(account: MediaAccountCard): string {
if (!account.clientId) {
return 'default'
}
return account.clientOnline ? 'green' : '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(),
])
}
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 releasePlatformMark(platform: string): string {
switch (platform) {
case 'darwin':
return 'MAC'
case 'win32':
return 'WIN'
case 'linux':
return 'LIN'
default:
return platform.slice(0, 3).toUpperCase()
}
}
function releasePlatformClass(platform: string): string {
return ['darwin', 'win32', 'linux'].includes(platform) ? platform : 'default'
}
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="banner-gradient-orbs">
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
</div>
<div class="media-view__header">
<div class="media-view__header-title">
<h2>
{{ t('route.media.title') }}
</h2>
<p>
展示客户端里当前已绑定的媒体账号集成发布队列状态提供多渠道账号集中式生命周期管理
</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="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>
</section>
<!-- 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>
</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>
</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: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
padding: 0;
box-shadow: 0 10px 30px rgba(148, 163, 184, 0.06);
}
/* Fuzzy Floating Gradient Orbs */
.banner-gradient-orbs {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
pointer-events: none;
z-index: 1;
}
.orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.6;
}
.orb-1 {
top: -40px;
right: 15%;
width: 250px;
height: 250px;
background: radial-gradient(circle, #e0e7ff 0%, transparent 80%);
animation: float-slow-1 12s infinite alternate ease-in-out;
}
.orb-2 {
bottom: -60px;
left: 10%;
width: 300px;
height: 300px;
background: radial-gradient(circle, #f5f3ff 0%, transparent 80%);
animation: float-slow-2 15s infinite alternate ease-in-out;
}
@keyframes float-slow-1 {
0% { transform: translate(0, 0) scale(1); }
100% { transform: translate(40px, 20px) scale(1.1); }
}
@keyframes float-slow-2 {
0% { transform: translate(0, 0) scale(1.1); }
100% { transform: translate(-30px, -20px) scale(0.9); }
}
.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.02em;
}
.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: #4f46e5;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(148, 163, 184, 0.08);
}
/* 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: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* Metric colored boxes styling */
.total-bg {
background: #e0e7ff;
color: #4f46e5;
border: 1px solid #c7d2fe;
}
.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.02em;
}
.chip-copy strong {
color: #0f172a;
font-size: 26px;
font-weight: 700;
margin-top: 4px;
line-height: 1;
font-family: 'Inter', system-ui, sans-serif;
letter-spacing: -0.02em;
}
/* Panel Layout Styling */
.panel {
background: #ffffff;
border-radius: 16px;
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.01em;
}
.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(99, 102, 241, 0.1);
color: #4f46e5;
border: 1px solid rgba(99, 102, 241, 0.2);
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 99px;
text-transform: uppercase;
letter-spacing: 0.05em;
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(99, 102, 241, 0.2), 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: 12px;
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: 12px;
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: #4f46e5;
border-color: #4f46e5;
border-radius: 8px;
height: 38px;
width: 100%;
font-weight: 600;
font-size: 13px;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.2);
transition: all 0.25s ease;
}
:deep(.btn-download.ant-btn-primary:hover:not(:disabled)) {
background: #4338ca;
border-color: #4338ca;
box-shadow: 0 6px 18px rgba(79, 70, 229, 0.3);
transform: translateY(-1px);
}
:deep(.btn-download.ant-btn-primary:disabled) {
background: #cbd5e1;
border-color: #cbd5e1;
color: #94a3b8;
box-shadow: none;
}
/* 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: 12px;
}
/* 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: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15);
}
: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: #4f46e5 !important;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15) !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: 14px;
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;
}
.media-card__identity {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
width: 100%;
}
/* 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.02em;
}
.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: 99px;
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) {
.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%;
}
.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;
}
}
@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;
}
}
</style>