Files
geo/apps/admin-web/src/views/MediaView.vue
T

754 lines
19 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons-vue'
import type { DesktopAccountInfo, MediaPlatform } from '@geo/shared-types'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { mediaApi, resolveApiURL, tenantAccountsApi } from '@/lib/api'
import { resolveAccountCheckedAt, resolveAccountHealth } from '@/lib/desktop-account-runtime'
import { 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 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 loading = computed(() => platformsQuery.isPending.value || accountsQuery.isPending.value)
const refreshing = computed(() => platformsQuery.isFetching.value || accountsQuery.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)
})
})
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()])
}
</script>
<template>
<div class="media-view">
<section class="media-view__top-card">
<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 :loading="refreshing" @click="refreshAll">
<template #icon><ReloadOutlined /></template>
刷新状态
</a-button>
</div>
</div>
<div class="overview-strip border-t">
<div class="overview-chip">
<span>账号总数</span>
<strong>{{ overview.total }}</strong>
</div>
<div class="overview-chip">
<span>可立即发布</span>
<strong>{{ overview.immediate }}</strong>
</div>
<div class="overview-chip">
<span>离线排队</span>
<strong>{{ overview.queued }}</strong>
</div>
<div class="overview-chip">
<span>需处理</span>
<strong>{{ overview.attention }}</strong>
</div>
</div>
</section>
<section class="panel media-list-panel">
<div class="media-list-toolbar">
<a-input
v-model:value="searchQuery"
placeholder="搜索昵称、UID、平台、客户端"
class="media-search"
allow-clear
>
<template #prefix>
<SearchOutlined style="color: #8c8c8c" />
</template>
</a-input>
<a-select v-model:value="selectedHealth" style="width: 160px">
<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 class="media-list-header">
<h3 class="panel-title">已绑定媒体账号</h3>
</div>
<a-spin :spinning="loading">
<a-empty
v-if="!filteredAccounts.length"
description="当前还没有可展示的媒体账号,请先在桌面端绑定。"
/>
<section v-else class="media-grid">
<article v-for="account in filteredAccounts" :key="account.id" class="media-card">
<div class="media-card__head">
<div class="media-card__identity">
<span class="media-card__avatar" :style="{ background: account.platformAccent }">
<img
v-if="account.avatarUrl"
:src="account.avatarUrl"
:alt="account.displayName"
referrerpolicy="no-referrer"
/>
<span v-else>{{ accountInitial(account) }}</span>
</span>
<div class="media-card__identity-copy">
<h3>
{{ account.displayName }}
<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 }">{{ account.platformShortName }}</span>
</h3>
<p>
<span class="media-card__platform-name">{{ account.platformLabel }}</span>
<span class="media-card__uid-divider">|</span>
<span class="media-card__uid">UID: {{ account.platformUid }}</span>
</p>
</div>
</div>
</div>
<div class="media-card__tags">
<a-tag :color="healthColor(account.health)">
{{ healthLabel(account.health) }}
</a-tag>
<a-tag :color="publishStateColor(account.publishState)">
{{ publishStateLabel(account.publishState) }}
</a-tag>
<a-tooltip :title="clientStatusText(account)" placement="top">
<a-tag :color="clientStatusColor(account)" class="client-status-tag">
{{ clientStatusText(account) }}
</a-tag>
</a-tooltip>
</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>
<div v-if="account.deleteRequestedAt || account.publishHint" class="media-card__footer">
<span v-if="account.deleteRequestedAt" class="footer-badge footer-badge--warning">
已申请解绑
</span>
<span v-else-if="account.publishHint" class="footer-badge">
{{ account.publishHint }}
</span>
</div>
</article>
</section>
</a-spin>
</section>
</div>
</template>
<style scoped>
.media-view {
display: flex;
flex-direction: column;
gap: 20px;
}
.media-view__top-card {
background: #fff;
border: 1px solid #e6edf5;
border-radius: 12px;
overflow: hidden;
}
.media-view__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 24px;
}
.media-view__header-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
}
.media-view__header-title p {
margin: 6px 0 0;
font-size: 13px;
color: #8c8c8c;
line-height: 1.7;
}
.media-view__header-actions {
display: flex;
gap: 12px;
}
.overview-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.overview-chip {
padding: 18px 24px;
border-top: 1px solid #eef2f7;
border-right: 1px solid #eef2f7;
}
.overview-chip:last-child {
border-right: none;
}
.overview-chip span {
display: block;
color: #8c8c8c;
font-size: 12px;
}
.overview-chip strong {
display: block;
margin-top: 8px;
color: #1a1a1a;
font-size: 24px;
font-weight: 700;
}
.panel {
background: #fff;
border-radius: 12px;
padding: 24px;
border: 1px solid #e6edf5;
}
.media-list-panel {
display: flex;
flex-direction: column;
gap: 20px;
}
.media-list-toolbar {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
}
.media-search {
width: 320px;
}
.media-list-header {
display: flex;
flex-direction: column;
gap: 6px;
}
.panel-title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
}
.panel-desc {
margin: 0;
color: #8c8c8c;
font-size: 13px;
line-height: 1.7;
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 18px;
}
.media-card {
border: 1px solid #f0f0f0;
border-radius: 12px;
padding: 20px;
background: #ffffff;
display: flex;
flex-direction: column;
gap: 16px;
transition: all 0.3s ease;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
}
.media-card:hover {
box-shadow: 0 16px 32px -6px rgba(0, 0, 0, 0.08);
border-color: transparent;
transform: translateY(-2px);
}
.media-card__head {
display: flex;
align-items: center;
}
.media-card__identity {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.media-card__avatar {
width: 48px;
height: 48px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
font-weight: 600;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.media-card__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-card__identity-copy {
min-width: 0;
}
.media-card__identity-copy h3 {
margin: 0;
color: #1e293b;
font-size: 16px;
font-weight: 600;
line-height: 1.4;
display: flex;
align-items: center;
gap: 6px;
}
.media-card__inline-platform-icon {
width: 16px;
height: 16px;
object-fit: contain;
}
.media-card__inline-platform-text {
font-size: 12px;
font-weight: 700;
background: #f8fafc;
padding: 2px 6px;
border-radius: 4px;
}
.media-card__identity-copy p {
margin: 4px 0 0;
color: #64748b;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.media-card__platform-name {
font-weight: 500;
}
.media-card__uid-divider {
color: #cbd5e1;
}
.media-card__uid {
color: #94a3b8;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.media-card__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.media-card__tags :deep(.ant-tag) {
margin: 0;
border: none;
border-radius: 6px;
font-weight: 500;
padding: 2px 8px;
}
.media-card__meta-box {
background: #f8fafc;
border-radius: 8px;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.meta-box-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.meta-label {
color: #64748b;
}
.meta-value {
color: #334155;
font-weight: 500;
}
.client-status-tag {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.media-card__footer {
padding-top: 4px;
}
.footer-badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #f3f6fb;
color: #516074;
font-size: 12px;
font-weight: 600;
line-height: 1.6;
}
.footer-badge--warning {
background: #fff7e6;
color: #d48806;
}
@media (max-width: 900px) {
.overview-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-chip:nth-child(2) {
border-right: none;
}
.overview-chip:nth-child(n + 3) {
border-top: 1px solid #eef2f7;
}
.media-list-toolbar {
flex-direction: column;
align-items: stretch;
}
.media-search {
width: 100%;
}
}
</style>