Files
geo/apps/admin-web/src/components/ArticlePublishStatus.vue
T

609 lines
15 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { QuestionCircleOutlined, UserOutlined } from '@ant-design/icons-vue'
import type { MediaPlatform } from '@geo/shared-types'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { articlesApi, mediaApi } from '@/lib/api'
import { getPublishStatusMeta } from '@/lib/display'
import { getPublishPlatformMeta, normalizePublishPlatformId } from '@/lib/publish-platforms'
2026-05-20 15:37:25 +08:00
import { useCompanyStore } from '@/stores/company'
interface PublishPlatformEntry {
key: string
name: string
shortName: string
accent: string
accountName: string
avatarUrl: string
status: PublishRecordStatus
}
type PublishRecordStatus = 'success' | 'failed' | 'publishing'
interface PublishPlatformSection {
key: PublishRecordStatus
label: string
tone: 'success' | 'failed' | 'pending'
entries: PublishPlatformEntry[]
}
const props = withDefaults(
defineProps<{
status?: string | null
articleId?: number | null
showPlatforms?: boolean
}>(),
{
status: null,
articleId: null,
showPlatforms: true,
},
)
const { t } = useI18n()
2026-05-20 15:37:25 +08:00
const companyStore = useCompanyStore()
const popoverOpen = ref(false)
const shouldLoadRecords = computed(() => Boolean(props.articleId) && props.status !== 'unpublished')
const shouldLoadPlatforms = computed(() => props.showPlatforms && shouldLoadRecords.value)
const platformsQuery = useQuery({
queryKey: ['media', 'platforms', 'publish-status'],
enabled: computed(() => popoverOpen.value && shouldLoadPlatforms.value),
queryFn: () => mediaApi.platforms(),
staleTime: 5 * 60 * 1000,
})
const publishRecordsQuery = useQuery({
2026-05-20 15:37:25 +08:00
queryKey: computed(() => [
'articles',
'publish-records',
companyStore.currentBrandId,
props.articleId,
]),
enabled: computed(() => shouldLoadRecords.value && Boolean(companyStore.currentBrandId)),
queryFn: () => articlesApi.publishRecords(props.articleId as number),
staleTime: 30 * 1000,
})
const hasPopover = computed(() => props.showPlatforms && shouldLoadRecords.value)
const platformMap = computed(() => {
return new Map(
(platformsQuery.data.value ?? []).map((platform: MediaPlatform) => [
normalizePublishPlatformId(platform.platform_id),
platform,
]),
)
})
const platformEntries = computed<PublishPlatformEntry[]>(() => {
const seen = new Set<string>()
const entries: PublishPlatformEntry[] = []
for (const record of publishRecordsQuery.data.value ?? []) {
const key = `${record.platform_id}:${record.platform_account_id}`
if (seen.has(key)) {
continue
}
seen.add(key)
const normalizedId = normalizePublishPlatformId(record.platform_id)
const platform = platformMap.value.get(normalizedId)
const fallback = getPublishPlatformMeta(normalizedId)
entries.push({
key,
name: record.platform_name || platform?.name || fallback.name,
shortName: platform?.short_name || fallback.shortName,
accent: platform?.accent_color || fallback.accent,
accountName: record.platform_nickname || '--',
avatarUrl: '',
status: normalizePublishRecordStatus(record.status),
})
}
return entries
})
const successEntries = computed(() =>
platformEntries.value.filter((entry) => entry.status === 'success'),
)
const failedEntries = computed(() =>
platformEntries.value.filter((entry) => entry.status === 'failed'),
)
const pendingEntries = computed(() =>
platformEntries.value.filter((entry) => entry.status === 'publishing'),
)
const displayStatus = computed(() => {
if (successEntries.value.length > 0 && failedEntries.value.length > 0) {
return 'partial_success'
}
if (failedEntries.value.length > 0) {
return 'failed'
}
if (pendingEntries.value.length > 0) {
return 'publishing'
}
if (successEntries.value.length > 0) {
return 'success'
}
if (props.status === 'partial_success') {
return 'partial_success'
}
if (props.status === 'publish_success' || props.status === 'published') {
return 'success'
}
if (props.status === 'publish_failed') {
return 'failed'
}
if (props.status === 'pending_review') {
return 'failed'
}
return props.status ?? null
})
const statusMeta = computed(() => getPublishStatusMeta(displayStatus.value))
const statusTone = computed(() => {
switch (displayStatus.value) {
case 'success':
return 'success'
case 'failed':
return 'failed'
case 'publishing':
return 'processing'
case 'partial_success':
return 'warning'
default:
return 'default'
}
})
const popoverSections = computed<PublishPlatformSection[]>(() => {
const sections: PublishPlatformSection[] = []
if (failedEntries.value.length > 0) {
sections.push({
key: 'failed',
label: `${getPublishStatusMeta('failed').label}`,
tone: 'failed',
entries: failedEntries.value,
})
}
if (pendingEntries.value.length > 0) {
sections.push({
key: 'publishing',
label: `${getPublishStatusMeta('publishing').label}`,
tone: 'pending',
entries: pendingEntries.value,
})
}
if (successEntries.value.length > 0) {
sections.push({
key: 'success',
label: `${getPublishStatusMeta('success').label}`,
tone: 'success',
entries: successEntries.value,
})
}
return sections
})
function handlePopoverChange(nextOpen: boolean): void {
popoverOpen.value = nextOpen
}
function normalizePublishRecordStatus(status?: string | null): PublishRecordStatus {
switch (String(status ?? '').trim()) {
case 'success':
case 'published':
case 'publish_success':
return 'success'
case 'publishing':
case 'running':
case 'queued':
return 'publishing'
case 'pending_review':
return 'failed'
case 'failed':
case 'publish_failed':
default:
return 'failed'
}
}
function getAccountInitial(accountName: string): string {
const trimmed = String(accountName ?? '').trim()
if (!trimmed || trimmed === '--') {
return ''
}
return trimmed.slice(0, 1).toUpperCase()
}
</script>
<template>
<a-popover
v-if="hasPopover"
placement="top"
trigger="hover"
overlay-class-name="article-publish-status__popover-overlay"
@openChange="handlePopoverChange"
>
<template #content>
<div class="article-publish-status__popover">
<div
v-if="publishRecordsQuery.isPending.value || platformsQuery.isPending.value"
class="article-publish-status__loading"
>
<a-spin size="small" />
<span>{{ t('common.loading') }}</span>
</div>
<div v-else-if="popoverSections.length" class="article-publish-status__sections">
<section
v-for="section in popoverSections"
:key="section.key"
class="article-publish-status__section"
>
<header
class="article-publish-status__section-title"
:class="`article-publish-status__section-title--${section.tone}`"
>
<span class="article-publish-status__section-dot" />
<span>{{ section.label }}</span>
</header>
<div class="article-publish-status__platforms">
<a-popover
v-for="entry in section.entries"
:key="entry.key"
placement="top"
overlay-class-name="article-publish-status__account-popover"
>
<template #content>
<div class="article-publish-status__account-card">
<span class="article-publish-status__account-avatar">
<img
v-if="entry.avatarUrl"
:src="entry.avatarUrl"
:alt="entry.accountName"
class="article-publish-status__account-avatar-img"
referrerpolicy="no-referrer"
/>
<template v-else-if="getAccountInitial(entry.accountName)">
{{ getAccountInitial(entry.accountName) }}
</template>
<UserOutlined v-else />
</span>
<span class="article-publish-status__account-name">
{{ entry.accountName }}
</span>
</div>
</template>
<div
class="article-publish-status__platform-chip"
:class="`article-publish-status__platform-chip--${section.tone}`"
>
<span
class="article-publish-status__platform-mark"
:style="{ background: entry.accent }"
>
{{ entry.shortName }}
</span>
<span class="article-publish-status__platform-name">{{ entry.name }}</span>
</div>
</a-popover>
</div>
</section>
</div>
<span v-else class="article-publish-status__empty">
{{ t('media.records.empty') }}
</span>
</div>
</template>
<span class="article-publish-status__trigger">
<span
class="article-publish-status__badge"
:class="`article-publish-status__badge--${statusTone}`"
>
<span class="article-publish-status__badge-dot" />
<span>{{ statusMeta.label }}</span>
<QuestionCircleOutlined class="article-publish-status__icon" />
</span>
</span>
</a-popover>
<span
v-else
class="article-publish-status__badge"
:class="`article-publish-status__badge--${statusTone}`"
>
<span class="article-publish-status__badge-dot" />
<span>{{ statusMeta.label }}</span>
</span>
</template>
<style scoped>
.article-publish-status__trigger {
display: inline-flex;
}
.article-publish-status__badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 3px 12px;
border: 1px solid transparent;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
line-height: 22px;
white-space: nowrap;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.article-publish-status__badge--success {
background: #f4fbec;
border-color: #b7eb8f;
color: #4d6b18;
}
.article-publish-status__badge--failed {
background: #fff3f0;
border-color: #ffccc7;
color: #cf1322;
}
.article-publish-status__badge--processing {
background: #eff6ff;
border-color: #bfdbfe;
color: #1d4ed8;
}
.article-publish-status__badge--warning {
background: #fff8e6;
border-color: #ffd591;
color: #ad6800;
}
.article-publish-status__badge--default {
background: #f7f8fa;
border-color: #d9d9d9;
color: #595959;
}
.article-publish-status__badge-dot {
width: 6px;
height: 6px;
border-radius: 999px;
flex: 0 0 auto;
}
.article-publish-status__badge--success .article-publish-status__badge-dot,
.article-publish-status__section-title--success .article-publish-status__section-dot {
background: #52c41a;
}
.article-publish-status__badge--failed .article-publish-status__badge-dot,
.article-publish-status__section-title--failed .article-publish-status__section-dot {
background: #ff4d4f;
}
.article-publish-status__badge--processing .article-publish-status__badge-dot,
.article-publish-status__section-title--pending .article-publish-status__section-dot {
background: #1677ff;
}
.article-publish-status__badge--warning .article-publish-status__badge-dot {
background: #faad14;
}
.article-publish-status__badge--default .article-publish-status__badge-dot {
background: #98a2b3;
}
.article-publish-status__icon {
font-size: 13px;
opacity: 0.72;
}
.article-publish-status__popover {
min-width: 360px;
}
.article-publish-status__loading {
display: inline-flex;
align-items: center;
gap: 8px;
color: #667085;
font-size: 13px;
}
.article-publish-status__sections {
display: flex;
flex-direction: column;
gap: 18px;
}
.article-publish-status__section {
display: flex;
flex-direction: column;
gap: 12px;
}
.article-publish-status__section-title {
display: inline-flex;
align-items: center;
gap: 10px;
color: #344054;
font-size: 14px;
font-weight: 600;
line-height: 1.25;
}
.article-publish-status__section-dot {
width: 8px;
height: 8px;
border-radius: 999px;
flex: 0 0 auto;
}
.article-publish-status__platforms {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
}
.article-publish-status__platform-chip {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 4px 12px 4px 4px;
border-radius: 6px;
background: #f9f9f9;
border: 1px solid transparent;
color: #141414;
font-size: 14px;
font-weight: 400;
cursor: pointer;
transition: all 0.2s ease;
}
.article-publish-status__platform-chip:hover {
transform: translateY(-1px);
background: #f0f0f0;
}
.article-publish-status__platform-chip--success:hover {
background: #f0f0f0;
}
.article-publish-status__platform-chip--failed {
background: #fffafa;
border-color: #ffccc7;
}
.article-publish-status__platform-chip--failed:hover {
border-color: rgba(255, 77, 79, 0.24);
}
.article-publish-status__platform-chip--pending {
background: #f0f5ff;
border-color: #d6e4ff;
}
.article-publish-status__platform-mark {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 4px;
border-radius: 4px;
color: #fff;
font-size: 12px;
font-weight: 500;
box-shadow: none;
}
.article-publish-status__platform-name {
letter-spacing: 0.01em;
}
.article-publish-status__empty {
color: #98a2b3;
font-size: 13px;
}
.article-publish-status__account-card {
display: flex;
align-items: center;
gap: 6px;
min-width: auto;
height: 30px;
padding: 0 10px;
background: #f8f9fa;
border-radius: 4px;
}
.article-publish-status__account-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
background: #f0f2f5;
color: #1f2937;
font-size: 12px;
font-weight: 600;
overflow: hidden;
}
.article-publish-status__account-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.article-publish-status__account-name {
color: #344054;
font-size: 13px;
font-weight: 500;
line-height: 1.2;
}
:deep(.article-publish-status__popover-overlay .ant-popover-inner) {
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
}
:deep(.article-publish-status__popover-overlay .ant-popover-inner-content) {
padding: 0;
}
:deep(.article-publish-status__popover-overlay .ant-popover-arrow:before) {
background: #fff;
}
:deep(.article-publish-status__account-popover .ant-popover-inner) {
border-radius: 6px;
padding: 6px 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
:deep(.article-publish-status__account-popover .ant-popover-inner-content) {
color: #344054;
}
:deep(.article-publish-status__account-popover .ant-popover-arrow:before) {
background: #fff;
}
</style>