Files
geo/apps/admin-web/src/components/ArticlePublishStatus.vue
T
root 162abdc97c
Backend CI / Backend (push) Has been cancelled
Frontend CI / Frontend (push) Failing after 1m39s
chore(frontend): introduce prettier + eslint and prune unused code
- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports)
- Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier
- Add root scripts: format, format:check, lint, lint:fix
- Reformat 257 files across admin-web, ops-web, desktop-client, packages
- Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters
- Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex
- Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:39:09 +08:00

602 lines
15 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 { 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'
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()
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({
queryKey: computed(() => ['articles', 'publish-records', props.articleId]),
enabled: shouldLoadRecords,
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>