d6866cd85b
- 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.
1700 lines
43 KiB
Vue
1700 lines
43 KiB
Vue
<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>
|