Files
geo/apps/admin-web/src/components/PublishArticleModal.vue
T
root c6b7090536 feat(schedule): support random cover source for scheduled and manual publish
Add a cover mode to schedule tasks so auto-publish can pick a random cover
image instead of a fixed one, scoped to all images or a specific folder.

- migration: add cover_mode / cover_random_scope / cover_random_folder_id
  columns and check constraints on schedule_tasks
- backend: validate cover mode/scope/folder on create & update; the dispatch
  worker resolves a random active image (signed asset URL) at enqueue time
- frontend: new CoverSourceSelector component wired into GenerateTaskDrawer
  and PublishArticleModal, plus coverSource i18n strings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:02:39 +08:00

2991 lines
88 KiB
Vue

<script setup lang="ts">
import {
CloseCircleFilled,
InfoCircleFilled,
MinusCircleFilled,
SyncOutlined,
} from '@ant-design/icons-vue'
import { ApiClientError } from '@geo/http-client'
import type {
ArticleDetail,
ComplianceCheckResult,
ComplianceRuntimeStatus,
CreatePublishJobResponse,
EnterpriseSiteCategory,
EnterpriseSiteConnection,
EnterpriseSitePublishResponse,
GateDecisionLiteral,
PublishRecord,
ScheduleCoverMode,
ScheduleCoverRandomScope,
} from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message, notification } from 'ant-design-vue'
import { computed, ref, watch, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import CoverPickerModal from '@/components/CoverPickerModal.vue'
import CoverSourceSelector from '@/components/CoverSourceSelector.vue'
import {
articlesApi,
complianceApi,
enterpriseSitesApi,
imagesApi,
mediaApi,
publishJobsApi,
resolveApiURL,
tenantAccountsApi,
} from '@/lib/api'
import { coverUploadRequired, deriveCoverFileName } from '@/lib/cover-requirements'
import { formatError, formatStoredErrorMessage } from '@/lib/errors'
import {
accountInitial,
buildPublishAccountCards,
buildPublishPlatformMap,
clientStatusLabel,
clientStatusTooltip,
healthLabel,
publishStateLabel,
type PublishAccountCard,
} from '@/lib/publish-account-cards'
import { normalizePublishPlatformId } from '@/lib/publish-platforms'
import { useCompanyStore } from '@/stores/company'
type PublishTargetTab = 'media_accounts' | 'enterprise_sites'
type PublishType = 'publish' | 'draft'
interface EnterpriseSiteCard {
id: number
name: string
siteUrl: string
cmsTypeId: string
cmsType: string
status: string
statusText: string
selectable: boolean
disabledReason: string
categoryCount: number
defaultCategoryId: string
hasPublishFailure: boolean
}
const props = defineProps<{
open: boolean
articleId: number | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
published: []
}>()
const { t } = useI18n()
const queryClient = useQueryClient()
const companyStore = useCompanyStore()
const activeTargetTab = ref<PublishTargetTab>('media_accounts')
const selectedAccountIds = ref<string[]>([])
const selectedEnterpriseSiteIds = ref<number[]>([])
const enterpriseCategoriesBySiteId = ref<Record<number, EnterpriseSiteCategory[]>>({})
const enterpriseSiteCategoryIds = ref<Record<number, string>>({})
const enterprisePublishType = ref<PublishType>('publish')
const coverEnabled = ref(false)
const coverMode = ref<ScheduleCoverMode>('specific')
const coverRandomScope = ref<ScheduleCoverRandomScope>('all')
const coverRandomFolderId = ref<number | null>(null)
const coverAssetUrl = ref('')
const coverFileName = ref('')
const coverImageAssetId = ref<number | null>(null)
const coverPickerOpen = ref(false)
const selectionHydrated = ref(false)
const coverHydrated = ref(false)
const complianceResult = ref<ComplianceCheckResult | null>(null)
const publishAdmissionResult = ref<ComplianceCheckResult | null>(null)
const publishAdmissionLoading = ref(false)
const publishAdmissionError = ref<string | null>(null)
let publishAdmissionRunId = 0
const ackInProgress = ref(false)
const detailQuery = useQuery({
queryKey: computed(() => [
'articles',
'detail',
companyStore.currentBrandId,
props.articleId,
'publish-modal',
]),
enabled: computed(
() => props.open && Boolean(props.articleId) && Boolean(companyStore.currentBrandId),
),
queryFn: () => articlesApi.detail(props.articleId as number),
})
const accountsQuery = useQuery({
queryKey: ['tenant', 'desktop-accounts', 'publish-modal'],
enabled: computed(() => props.open),
queryFn: () => tenantAccountsApi.list(),
})
const publishRecordsQuery = useQuery({
queryKey: computed(() => [
'articles',
'publish-records',
companyStore.currentBrandId,
props.articleId,
'publish-modal',
]),
enabled: computed(
() => props.open && Boolean(props.articleId) && Boolean(companyStore.currentBrandId),
),
queryFn: () => articlesApi.publishRecords(props.articleId as number),
})
const platformsQuery = useQuery({
queryKey: ['media', 'platforms', 'publish-modal'],
enabled: computed(() => props.open),
queryFn: () => mediaApi.platforms(),
})
const enterpriseSitesQuery = useQuery({
queryKey: ['tenant', 'enterprise-sites', 'publish-modal'],
enabled: computed(() => props.open),
queryFn: () => enterpriseSitesApi.list(),
})
const complianceStatusQuery = useQuery({
queryKey: ['compliance', 'runtime-status', 'publish-modal'],
enabled: computed(() => props.open),
queryFn: () => complianceApi.runtimeStatus(),
})
watch(
() => props.open,
async (open) => {
if (!open) {
activeTargetTab.value = 'media_accounts'
selectedAccountIds.value = []
selectedEnterpriseSiteIds.value = []
enterpriseCategoriesBySiteId.value = {}
enterpriseSiteCategoryIds.value = {}
enterprisePublishType.value = 'publish'
coverMode.value = 'specific'
coverRandomScope.value = 'all'
coverRandomFolderId.value = null
coverAssetUrl.value = ''
coverFileName.value = ''
coverImageAssetId.value = null
coverEnabled.value = false
selectionHydrated.value = false
coverHydrated.value = false
complianceResult.value = null
publishAdmissionResult.value = null
publishAdmissionLoading.value = false
publishAdmissionError.value = null
return
}
selectionHydrated.value = false
coverHydrated.value = false
await Promise.allSettled([
props.articleId ? detailQuery.refetch() : Promise.resolve(),
props.articleId ? publishRecordsQuery.refetch() : Promise.resolve(),
accountsQuery.refetch(),
platformsQuery.refetch(),
enterpriseSitesQuery.refetch(),
complianceStatusQuery.refetch(),
])
},
{ immediate: true },
)
const platformMap = computed(() => buildPublishPlatformMap(platformsQuery.data.value ?? []))
const successfulPublishRecordKeys = computed(
() =>
new Set(
(publishRecordsQuery.data.value ?? [])
.filter((record) => isSuccessfulPublishRecord(record))
.map((record) => successfulPublishRecordKey(record.desktop_account_id, record.platform_id))
.filter((key): key is string => Boolean(key)),
),
)
const activePublishRecordStatusByKey = computed(() => {
const statusByKey = new Map<string, string>()
for (const record of publishRecordsQuery.data.value ?? []) {
if (!isActivePublishRecord(record)) {
continue
}
const key = successfulPublishRecordKey(record.desktop_account_id, record.platform_id)
if (!key || statusByKey.has(key)) {
continue
}
statusByKey.set(key, record.status)
}
return statusByKey
})
const publishedAccountIds = computed(
() =>
new Set(
(publishRecordsQuery.data.value ?? [])
.filter((record) => isSuccessfulPublishRecord(record))
.map((record) => record.desktop_account_id?.trim())
.filter((id): id is string => Boolean(id)),
),
)
const publishedAccountCount = computed(() => publishedAccountIds.value.size)
const successfulEnterpriseSiteIds = computed(
() =>
new Set(
(publishRecordsQuery.data.value ?? [])
.filter((record) => isSuccessfulEnterpriseSiteRecord(record))
.map((record) => record.target_connection_id)
.filter((id): id is number => typeof id === 'number'),
),
)
const activeEnterpriseRecordStatusBySiteId = computed(() => {
const statusBySiteId = new Map<number, string>()
for (const record of publishRecordsQuery.data.value ?? []) {
if (!isActiveEnterpriseSiteRecord(record) || typeof record.target_connection_id !== 'number') {
continue
}
if (statusBySiteId.has(record.target_connection_id)) {
continue
}
statusBySiteId.set(record.target_connection_id, record.status)
}
return statusBySiteId
})
const publishedEnterpriseSiteCount = computed(() => successfulEnterpriseSiteIds.value.size)
const accountCards = computed<PublishAccountCard[]>(() =>
buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value).map((card) => {
const alreadyPublished = successfulPublishRecordKeys.value.has(
successfulPublishRecordKey(card.id, card.platformId) ?? '',
)
if (!alreadyPublished) {
return card
}
return {
...card,
selectable: false,
statusText:
'这篇文章已用该账号在该平台发布成功,不能重复发布;如需重新发布,请复制或新建一篇文章。',
}
}),
)
const enterpriseSites = computed(() => enterpriseSitesQuery.data.value ?? [])
const enterpriseSiteCards = computed<EnterpriseSiteCard[]>(() =>
enterpriseSites.value.map((site) => {
const siteName =
site.name?.trim() || site.site_name?.trim() || site.site_url?.trim() || '企业站点'
const cmsType = String(site.cms_type ?? '').trim()
const alreadyPublished = successfulEnterpriseSiteIds.value.has(site.id)
const activeStatus = activeEnterpriseRecordStatusBySiteId.value.get(site.id)
const connectionStatus = enterpriseSiteConnectionStatus(site)
const publishError = enterpriseSiteLatestPublishError(site)
const disabledReason = resolveEnterpriseSiteDisabledReason(
site,
alreadyPublished,
activeStatus,
connectionStatus,
)
return {
id: site.id,
name: siteName,
siteUrl: site.site_url,
cmsTypeId: cmsType,
cmsType: cmsTypeLabel(cmsType),
status: connectionStatus,
statusText: alreadyPublished
? '已发布'
: activeStatus
? activeEnterpriseRecordLabel(activeStatus)
: publishError
? '发布失败'
: enterpriseSiteStatusLabel(connectionStatus),
selectable: !disabledReason,
disabledReason,
categoryCount: site.category_count,
defaultCategoryId: site.default_scode?.trim() || '',
hasPublishFailure: !disabledReason && Boolean(publishError),
}
}),
)
watch(
accountCards,
(cards) => {
const selectableIds = new Set(cards.filter((card) => card.selectable).map((card) => card.id))
selectedAccountIds.value = selectedAccountIds.value.filter((id) => selectableIds.has(id))
},
{ immediate: true },
)
watch(
enterpriseSiteCards,
(cards) => {
const selectableIds = new Set(cards.filter((card) => card.selectable).map((card) => card.id))
selectedEnterpriseSiteIds.value = selectedEnterpriseSiteIds.value.filter((id) =>
selectableIds.has(id),
)
},
{ immediate: true },
)
watchEffect(() => {
if (!props.open) {
return
}
if (!coverHydrated.value && !detailQuery.isPending.value && !detailQuery.isFetching.value) {
const initialUrl = resolveApiURL(detailQuery.data.value?.cover_asset_url)
coverAssetUrl.value = initialUrl
coverFileName.value = deriveCoverFileName(initialUrl)
coverImageAssetId.value = detailQuery.data.value?.cover_image_asset_id ?? null
coverEnabled.value = Boolean(initialUrl)
coverHydrated.value = true
}
if (selectionHydrated.value) {
return
}
if (
detailQuery.isPending.value ||
detailQuery.isFetching.value ||
accountsQuery.isPending.value ||
accountsQuery.isFetching.value ||
enterpriseSitesQuery.isPending.value ||
enterpriseSitesQuery.isFetching.value ||
publishRecordsQuery.isPending.value ||
publishRecordsQuery.isFetching.value ||
platformsQuery.isPending.value ||
platformsQuery.isFetching.value
) {
return
}
selectedAccountIds.value = []
selectionHydrated.value = true
})
const selectedCards = computed(() => {
const selected = new Set(selectedAccountIds.value)
return accountCards.value.filter((card) => selected.has(card.id))
})
const selectedEnterpriseSiteCards = computed(() => {
const selected = new Set(selectedEnterpriseSiteIds.value)
return enterpriseSiteCards.value.filter((card) => selected.has(card.id))
})
const modalTitle = computed(() => detailQuery.data.value?.title || t('article.untitled'))
const enterpriseTargetPlatformIds = computed(() =>
Array.from(
new Set(
selectedEnterpriseSiteCards.value
.map((site) => normalizeEnterprisePlatformId(site.cmsTypeId))
.filter((platformId): platformId is string => Boolean(platformId)),
),
),
)
const selectedPlatformIds = computed(() =>
activeTargetTab.value === 'enterprise_sites'
? enterpriseTargetPlatformIds.value
: Array.from(new Set(selectedCards.value.map((card) => card.platformId))),
)
watch([selectedPlatformIds, activeTargetTab], () => {
complianceResult.value = null
})
const selectedImmediateCount = computed(
() => selectedCards.value.filter((card) => card.publishState === 'immediate').length,
)
const selectedQueuedCount = computed(
() => selectedCards.value.filter((card) => card.publishState === 'queued').length,
)
const selectedTargetCount = computed(() =>
activeTargetTab.value === 'enterprise_sites'
? selectedEnterpriseSiteCards.value.length
: selectedCards.value.length,
)
const selectedPublishNowCount = computed(() =>
activeTargetTab.value === 'enterprise_sites'
? selectedEnterpriseSiteCards.value.length
: selectedImmediateCount.value,
)
const selectedQueueCount = computed(() =>
activeTargetTab.value === 'enterprise_sites' ? 0 : selectedQueuedCount.value,
)
const selectedTargetLabel = computed(() =>
activeTargetTab.value === 'enterprise_sites' ? '已选站点' : '已选账号',
)
const publishNowLabel = computed(() =>
activeTargetTab.value === 'enterprise_sites' ? '立即发文' : '立即发布',
)
const queueLabel = computed(() =>
activeTargetTab.value === 'enterprise_sites' ? '离线队列' : '离线排队',
)
const publishModalVisible = computed(() => props.open && !coverPickerOpen.value)
const coverRequired = computed(() => coverUploadRequired(selectedPlatformIds.value))
const effectiveCoverEnabled = computed(() => coverRequired.value || coverEnabled.value)
const normalizedCoverValue = computed(() =>
effectiveCoverEnabled.value && coverMode.value === 'specific' ? coverAssetUrl.value.trim() : '',
)
const randomCoverEnabled = computed(
() => effectiveCoverEnabled.value && coverMode.value === 'random',
)
const publishMutation = useMutation({
mutationFn: () => runPublishFlow(),
onSuccess: handlePublishSuccess,
onError: handlePublishError,
})
const syncEnterpriseCategoriesMutation = useMutation({
mutationFn: (siteId: number) => enterpriseSitesApi.syncCategories(siteId),
onSuccess: async (categories, siteId) => {
message.success(`已同步 ${categories.length} 个栏目`)
setEnterpriseCategories(siteId, categories)
await enterpriseSitesQuery.refetch()
},
onError: (error) => message.error(formatError(error)),
})
const complianceStatus = computed<ComplianceRuntimeStatus | null>(
() => complianceStatusQuery.data.value ?? null,
)
const publishComplianceEnabled = computed(
() =>
complianceStatus.value?.enabled === true &&
complianceStatus.value.enforcement_mode !== 'disabled',
)
const publishComplianceMode = computed(() => complianceStatus.value?.enforcement_mode ?? 'disabled')
const publishComplianceMandatory = computed(
() => publishComplianceEnabled.value && publishComplianceMode.value === 'mandatory',
)
const publishAdmissionPassed = computed(
() => complianceDisplayDecision(publishAdmissionResult.value) === 'pass',
)
function enterpriseSelectPopupContainer(triggerNode?: HTMLElement | null): HTMLElement {
return (triggerNode?.closest('.ant-modal-content') as HTMLElement | null) ?? document.body
}
const mandatoryAdmissionBlocking = computed(
() => publishComplianceMandatory.value && !publishAdmissionPassed.value,
)
const accountSelectionLocked = computed(
() => publishMutation.isPending.value || ackInProgress.value || mandatoryAdmissionBlocking.value,
)
const publishBusy = computed(() => publishMutation.isPending.value || ackInProgress.value)
const accountSelectionLockSpinning = computed(
() =>
publishAdmissionLoading.value ||
(publishComplianceMandatory.value &&
!publishAdmissionResult.value &&
!publishAdmissionError.value &&
!publishAdmissionPassed.value),
)
const accountSelectionLockMessage = computed(() => {
if (publishMutation.isPending.value || ackInProgress.value) {
return '发布任务提交中,请稍候。'
}
if (accountSelectionLockSpinning.value) {
return '强制敏感词检测中,检测通过后可选择目标账号。\n请回到文章编辑页,使用合规检测查看全部命中词并逐项修改。'
}
if (publishAdmissionError.value) {
return '强制敏感词检测暂时不可用,当前不能选择目标账号。\n请回到文章编辑页,使用合规检测查看全部命中词并逐项修改。'
}
return '当前文章未通过强制敏感词检测,暂不能选择目标账号。\n请回到文章编辑页,使用合规检测查看全部命中词并逐项修改。'
})
const okText = computed(() => {
if (publishComplianceMandatory.value && publishAdmissionLoading.value) {
return '检测中'
}
if (publishComplianceMandatory.value && publishAdmissionError.value) {
return '检测失败'
}
if (
publishComplianceMandatory.value &&
publishAdmissionResult.value &&
complianceDisplayDecision(publishAdmissionResult.value) !== 'pass'
) {
return '检测未通过'
}
if (
publishComplianceMandatory.value &&
complianceResult.value &&
complianceDisplayDecision(complianceResult.value) !== 'pass'
) {
return '检测未通过'
}
if (publishComplianceMandatory.value && !publishAdmissionPassed.value) {
return '检测中'
}
if (complianceDisplayDecision(complianceResult.value) === 'needs_ack') {
return '已了解风险,继续发布'
}
if (complianceDisplayDecision(complianceResult.value) === 'block') {
return '无法发布'
}
return '提交发布'
})
const okDisabled = computed(() => {
if (activeTargetTab.value === 'enterprise_sites' && !selectedEnterpriseSiteIds.value.length) {
return true
}
if (
activeTargetTab.value === 'enterprise_sites' &&
selectedEnterpriseSiteCards.value.some(
(site) => !enterpriseSiteCategoryIds.value[site.id]?.trim(),
)
) {
return true
}
if (coverRequired.value && !normalizedCoverValue.value && !randomCoverEnabled.value) {
return true
}
if (mandatoryAdmissionBlocking.value) {
return true
}
if (
publishComplianceMandatory.value &&
complianceResult.value &&
complianceDisplayDecision(complianceResult.value) !== 'pass'
) {
return true
}
return complianceDisplayDecision(complianceResult.value) === 'block'
})
const compliancePanelTitle = computed(() => {
if (complianceDisplayDecision(complianceResult.value) === 'needs_ack') {
return '发布前需要确认合规风险'
}
if (complianceDisplayDecision(complianceResult.value) === 'block') {
return '检测到敏感词,已阻断发布'
}
return ''
})
const publishCompliancePanelVisible = computed(
() =>
publishComplianceEnabled.value &&
(publishAdmissionLoading.value ||
Boolean(publishAdmissionResult.value) ||
Boolean(publishAdmissionError.value) ||
Boolean(complianceResult.value)),
)
const publishCompliancePanelTone = computed(() => {
const finalDecision = complianceDisplayDecision(complianceResult.value)
const admissionDecision = complianceDisplayDecision(publishAdmissionResult.value)
if (publishAdmissionError.value || finalDecision === 'block' || admissionDecision === 'block') {
return 'error'
}
if (publishAdmissionLoading.value) {
return 'info'
}
if (admissionDecision === 'needs_ack' || finalDecision === 'needs_ack') {
return publishComplianceMandatory.value ? 'error' : 'warning'
}
return 'success'
})
const publishCompliancePanelTitle = computed(() => {
if (publishAdmissionLoading.value) {
return publishComplianceMandatory.value ? '正在进行强制敏感词检测' : '正在加载敏感词检测状态'
}
if (publishAdmissionError.value) {
return '敏感词检测暂时不可用'
}
const finalDecision = complianceDisplayDecision(complianceResult.value)
const admissionDecision = complianceDisplayDecision(publishAdmissionResult.value)
if (finalDecision === 'block') {
return publishComplianceMandatory.value ? '检测到敏感词,已阻断发布' : '检测到敏感词风险'
}
if (finalDecision === 'needs_ack') {
return publishComplianceMandatory.value ? '检测到敏感词,已阻断发布' : '检测到敏感词风险'
}
if (finalDecision === 'pass') {
return '敏感词检测通过'
}
if (admissionDecision === 'block') {
return publishComplianceMandatory.value ? '检测到敏感词,已阻断发布' : '检测到敏感词风险'
}
if (admissionDecision === 'needs_ack') {
return publishComplianceMandatory.value ? '检测到敏感词,已阻断发布' : '检测到敏感词风险'
}
if (admissionDecision === 'pass') {
return '敏感词检测通过'
}
return compliancePanelTitle.value
})
const publishCompliancePanelDescription = computed(() => {
if (publishAdmissionLoading.value) {
return publishComplianceMandatory.value
? '检测完成前暂时不能选择目标账号或提交发布。'
: '仅提示确认模式不会阻断目标账号选择和发布操作。'
}
if (publishAdmissionError.value) {
return publishComplianceMandatory.value
? '当前为强制阻断模式,请稍后重试或联系运营处理。'
: '当前为仅提示确认模式,仍可继续选择账号并发布。'
}
const finalDecision = complianceDisplayDecision(complianceResult.value)
const admissionDecision = complianceDisplayDecision(publishAdmissionResult.value)
if (finalDecision === 'block') {
return publishComplianceMandatory.value
? '请回到文章编辑页,使用合规检测查看全部命中词并逐项修改。'
: '请回到文章编辑页,使用合规检测查看全部命中词。'
}
if (finalDecision === 'needs_ack') {
return publishComplianceMandatory.value
? '请回到文章编辑页,使用合规检测查看全部命中词并逐项修改。'
: '请回到文章编辑页,使用合规检测查看全部命中词;确认风险后仍可继续发布。'
}
if (finalDecision === 'pass') {
return '当前文章未命中敏感词风险。'
}
if (admissionDecision === 'block') {
return publishComplianceMandatory.value
? '请回到文章编辑页,使用合规检测查看全部命中词并逐项修改。'
: '请回到文章编辑页,使用合规检测查看全部命中词。'
}
if (admissionDecision === 'needs_ack') {
return publishComplianceMandatory.value
? '请回到文章编辑页,使用合规检测查看全部命中词并逐项修改。'
: '请回到文章编辑页,使用合规检测查看全部命中词;确认风险后仍可继续发布。'
}
if (admissionDecision === 'pass') {
return '当前文章未命中敏感词风险。'
}
return '请回到文章编辑页,使用合规检测查看全部命中词。'
})
const displayedComplianceResult = computed(
() => complianceResult.value ?? publishAdmissionResult.value,
)
const complianceAcknowledgedViolationIds = computed(() =>
(complianceResult.value?.violations ?? []).map((item) => item.id),
)
watch(
coverRequired,
(required) => {
if (required) {
coverEnabled.value = true
}
},
{ immediate: true },
)
watch(
[
() => props.open,
() => detailQuery.data.value?.id,
() => detailQuery.data.value?.current_version_id,
() => complianceStatusQuery.data.value?.enabled,
() => complianceStatusQuery.data.value?.enforcement_mode,
],
() => {
void runPublishAdmissionCheck()
},
{ immediate: true },
)
async function runPublishFlow(ackRecordId?: number) {
if (!props.articleId || !detailQuery.data.value) {
throw new Error('missing_article')
}
if (activeTargetTab.value === 'enterprise_sites') {
return runEnterpriseSitePublishFlow(ackRecordId)
}
if (!selectedAccountIds.value.length) {
throw new Error('no_accounts_selected')
}
if (coverRequired.value && !normalizedCoverValue.value && !randomCoverEnabled.value) {
throw new Error('cover_required_for_selected_platforms')
}
const freshDetail = await loadLatestPublishArticleDetail()
const article = await persistCoverIfNeeded(freshDetail, await resolvePublishCoverOverride())
const articleVersionId = article.current_version_id ?? detailQuery.data.value.current_version_id
if (!articleVersionId) {
throw new Error('invalid_article_version')
}
if (publishComplianceEnabled.value && !ackRecordId) {
const result = await complianceApi.checkArticle(article.id, {
article_version_id: articleVersionId,
title: article.title,
plaintext: article.markdown_content ?? '',
target_platforms: selectedPlatformIds.value,
trigger_source: 'publish_gate',
})
complianceResult.value = result
const decision = complianceDisplayDecision(result)
if (decision === 'block' || decision === 'needs_ack') {
throw new Error(`compliance_${decision}`)
}
}
return publishJobsApi.create({
title: article.title?.trim() || t('article.untitled'),
content_ref: {
article_id: article.id,
},
article_version_id: articleVersionId,
ack_record_id: ackRecordId,
accounts: selectedAccountIds.value.map((accountId) => ({
account_id: accountId,
})),
})
}
async function runEnterpriseSitePublishFlow(
ackRecordId?: number,
): Promise<EnterpriseSitePublishResponse[]> {
if (!props.articleId || !detailQuery.data.value) {
throw new Error('missing_article')
}
if (!selectedEnterpriseSiteIds.value.length) {
throw new Error('no_enterprise_sites_selected')
}
const missingCategorySite = selectedEnterpriseSiteCards.value.find(
(site) => !enterpriseSiteCategoryIds.value[site.id]?.trim(),
)
if (missingCategorySite) {
throw new Error('enterprise_site_category_required')
}
const freshDetail = await loadLatestPublishArticleDetail()
const article = await persistCoverIfNeeded(freshDetail, await resolvePublishCoverOverride())
const articleVersionId = article.current_version_id ?? detailQuery.data.value.current_version_id
if (!articleVersionId) {
throw new Error('invalid_article_version')
}
if (publishComplianceEnabled.value && !ackRecordId) {
const result = await complianceApi.checkArticle(article.id, {
article_version_id: articleVersionId,
title: article.title,
plaintext: article.markdown_content ?? '',
target_platforms: selectedPlatformIds.value,
trigger_source: 'publish_gate',
})
complianceResult.value = result
const decision = complianceDisplayDecision(result)
if (decision === 'block' || decision === 'needs_ack') {
throw new Error(`compliance_${decision}`)
}
}
const results: EnterpriseSitePublishResponse[] = []
for (const [index, site] of selectedEnterpriseSiteCards.value.entries()) {
const categoryId = enterpriseSiteCategoryIds.value[site.id]?.trim()
if (!categoryId) {
throw new Error('enterprise_site_category_required')
}
let siteAckRecordId = ackRecordId
if (ackRecordId && index > 0 && complianceResult.value?.record_id) {
const ack = await complianceApi.createAck(complianceResult.value.record_id, {
acknowledged_violation_ids: complianceAcknowledgedViolationIds.value,
})
siteAckRecordId = ack.id
}
results.push(
await enterpriseSitesApi.publish(site.id, {
article_id: article.id,
category_id: categoryId,
publish_type: enterprisePublishType.value,
author: 'Geo SaaS',
source: 'Geo SaaS',
ack_record_id: siteAckRecordId,
article_version_id: articleVersionId,
cover_asset_url: normalizedCoverValue.value || null,
cover_image_asset_id: normalizedCoverValue.value ? coverImageAssetId.value : null,
}),
)
}
return results
}
async function loadLatestPublishArticleDetail(): Promise<ArticleDetail> {
if (!props.articleId) {
throw new Error('missing_article')
}
const result = await detailQuery.refetch()
const article = result.data ?? detailQuery.data.value
if (!article) {
throw new Error('missing_article')
}
return article
}
async function handlePublishSuccess(
result: CreatePublishJobResponse | EnterpriseSitePublishResponse[],
) {
if (Array.isArray(result)) {
await handleEnterprisePublishSuccess(result)
return
}
const description = successDescription(result)
const options = {
message: result.created_task_ids.length === 0 ? '发布任务已存在' : '已提交发布任务',
description,
placement: 'topRight',
duration: 5,
} as const
if (result.created_task_ids.length === 0 && result.existing_task_ids.length > 0) {
notification.info(options)
} else {
notification.success(options)
}
await invalidatePublishModalQueries()
emit('published')
emit('update:open', false)
}
async function invalidatePublishModalQueries(): Promise<void> {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['articles'] }),
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
queryClient.invalidateQueries({ queryKey: ['tenant', 'desktop-accounts'] }),
queryClient.invalidateQueries({ queryKey: ['tenant', 'enterprise-sites'] }),
queryClient.invalidateQueries({ queryKey: ['articles', 'detail', props.articleId] }),
queryClient.invalidateQueries({
queryKey: ['articles', 'detail', props.articleId, 'publish-modal'],
}),
queryClient.invalidateQueries({ queryKey: ['articles', 'publish-records', props.articleId] }),
])
}
function handlePublishError(error: unknown) {
const gate = parseComplianceResultFromError(error)
if (gate) {
complianceResult.value = gate
return
}
const normalized = error instanceof Error ? error.message : ''
if (normalized === 'no_accounts_selected') {
message.warning('请先选择至少一个可发布账号')
return
}
if (normalized === 'no_enterprise_sites_selected') {
message.warning('请先选择至少一个企业自建站点')
return
}
if (normalized === 'enterprise_site_category_required') {
message.warning('请选择每个企业站点的 CMS 栏目')
return
}
if (normalized === 'cover_required_for_selected_platforms') {
message.warning(t('media.publish.messages.coverRequired'))
return
}
if (normalized === 'cover_random_folder_required') {
message.warning(t('coverSource.randomEmpty'))
return
}
if (normalized === 'cover_random_empty') {
message.warning('随机封面范围内暂无可用图片')
return
}
if (normalized === 'compliance_needs_ack' || normalized === 'compliance_block') {
return
}
if (error instanceof ApiClientError && error.message === 'desktop_publish_duplicate') {
message.warning('内容已在发布队列中,未重复提交')
return
}
message.error(formatError(error))
}
async function handleEnterprisePublishSuccess(
results: EnterpriseSitePublishResponse[],
): Promise<void> {
const succeeded = results.filter((item) => item.status === 'success')
const failed = results.filter((item) => item.status !== 'success')
if (failed.length > 0 && succeeded.length > 0) {
notification.warning({
message: '企业站发布部分成功',
description: `成功 ${succeeded.length} 个站点,失败 ${failed.length} 个站点。`,
placement: 'topRight',
duration: 6,
})
} else if (failed.length > 0) {
notification.error({
message: '企业站发布失败',
description:
formatStoredErrorMessage(failed[0]?.error_message) ||
'请检查站点凭证、栏目和 CMS 接口配置。',
placement: 'topRight',
duration: 6,
})
} else {
notification.success({
message: enterprisePublishType.value === 'draft' ? '企业站草稿已保存' : '企业站发布成功',
description:
succeeded.length === 1
? succeeded[0]?.external_article_url || 'CMS 已返回成功。'
: `${succeeded.length} 个企业站点已完成。`,
placement: 'topRight',
duration: 5,
})
}
await invalidatePublishModalQueries()
if (failed.length === 0) {
emit('published')
emit('update:open', false)
}
}
type PublishCoverOverride = {
url: string
assetId: number | null
}
async function persistCoverIfNeeded(
detail: ArticleDetail,
override?: PublishCoverOverride | null,
): Promise<ArticleDetail> {
const currentUrl = resolveApiURL(detail.cover_asset_url).trim()
const nextUrl = override ? override.url.trim() : normalizedCoverValue.value.trim()
const currentAssetId = detail.cover_image_asset_id ?? null
const nextAssetId = override ? override.assetId : coverImageAssetId.value
if (currentUrl === nextUrl && currentAssetId === nextAssetId) {
return detail
}
return articlesApi.update(detail.id, {
title: detail.title?.trim() || t('article.untitled'),
markdown_content: detail.markdown_content ?? '',
cover_asset_url: nextUrl || null,
cover_image_asset_id: nextAssetId,
})
}
async function resolvePublishCoverOverride(): Promise<PublishCoverOverride | null> {
if (!randomCoverEnabled.value) {
return null
}
if (coverRandomScope.value === 'folder' && !coverRandomFolderId.value) {
throw new Error('cover_random_folder_required')
}
const baseParams = {
folder_id: coverRandomScope.value === 'folder' ? coverRandomFolderId.value : undefined,
page: 1,
page_size: 100,
}
const firstPage = await imagesApi.list(baseParams)
if (!firstPage.items.length) {
throw new Error('cover_random_empty')
}
const pageCount = Math.max(1, Math.ceil(firstPage.total / baseParams.page_size))
const response =
pageCount > 1
? await imagesApi.list({
...baseParams,
page: Math.floor(Math.random() * pageCount) + 1,
})
: firstPage
if (!response.items.length) {
return {
url: firstPage.items[0].url,
assetId: firstPage.items[0].id,
}
}
const picked = response.items[Math.floor(Math.random() * response.items.length)]
return {
url: picked.url,
assetId: picked.id,
}
}
function successDescription(result: CreatePublishJobResponse): string {
const created = result.created_task_ids.length
const existing = result.existing_task_ids.length
const total = created + existing
if (total > 0) {
if (created === 0) {
return `${existing} 个账号已有发布记录或任务,未重复提交;如结果未知,请先核实平台侧结果。`
}
if (existing > 0) {
return `新建 ${created} 个发布任务;${existing} 个账号已有发布记录或任务,未重复提交。`
}
}
const selectedTotal = selectedCards.value.length
if (selectedTotal === 0) {
return '任务已进入发布队列。'
}
if (selectedQueuedCount.value === 0) {
return `${selectedTotal} 个账号,在线客户端会立即开始消费。`
}
if (selectedImmediateCount.value === 0) {
return `${selectedTotal} 个账号已进入离线队列,客户端上线后会自动继续发布。`
}
return `${selectedTotal} 个账号:${selectedImmediateCount.value} 个立即发布,${selectedQueuedCount.value} 个离线排队。`
}
function toggleAccount(accountId: string, selectable: boolean): void {
if (!selectable || accountSelectionLocked.value) {
return
}
if (selectedAccountIds.value.includes(accountId)) {
selectedAccountIds.value = selectedAccountIds.value.filter((id) => id !== accountId)
return
}
selectedAccountIds.value = [...selectedAccountIds.value, accountId]
}
function toggleEnterpriseSite(site: EnterpriseSiteCard): void {
if (!site.selectable || accountSelectionLocked.value) {
return
}
if (selectedEnterpriseSiteIds.value.includes(site.id)) {
selectedEnterpriseSiteIds.value = selectedEnterpriseSiteIds.value.filter((id) => id !== site.id)
return
}
selectedEnterpriseSiteIds.value = [...selectedEnterpriseSiteIds.value, site.id]
if (!enterpriseSiteCategoryIds.value[site.id]) {
enterpriseSiteCategoryIds.value = {
...enterpriseSiteCategoryIds.value,
[site.id]: site.defaultCategoryId,
}
}
void loadEnterpriseCategories(site.id)
}
function isSelected(accountId: string): boolean {
return selectedAccountIds.value.includes(accountId)
}
function isEnterpriseSiteSelected(siteId: number): boolean {
return selectedEnterpriseSiteIds.value.includes(siteId)
}
function isAlreadyPublished(account: PublishAccountCard): boolean {
return successfulPublishRecordKeys.value.has(
successfulPublishRecordKey(account.id, account.platformId) ?? '',
)
}
function activePublishRecordStatus(account: PublishAccountCard): string | null {
return (
activePublishRecordStatusByKey.value.get(
successfulPublishRecordKey(account.id, account.platformId) ?? '',
) ?? null
)
}
function activePublishRecordLabel(account: PublishAccountCard): string | null {
switch (activePublishRecordStatus(account)) {
case 'publishing':
case 'running':
return '发布中'
case 'queued':
case 'pending':
return '已排队'
default:
return null
}
}
function activeEnterpriseRecordLabel(status: string): string {
switch (String(status ?? '').trim()) {
case 'publishing':
case 'running':
return '发布中'
case 'queued':
case 'pending':
return '已排队'
default:
return '已提交'
}
}
function successfulPublishRecordKey(
desktopAccountId?: string | null,
platformId?: string | null,
): string | null {
const accountID = String(desktopAccountId ?? '').trim()
const platform = normalizePublishPlatformId(platformId)
if (!accountID || !platform) {
return null
}
return `${accountID}:${platform}`
}
function isSuccessfulPublishRecord(record: PublishRecord): boolean {
switch (String(record.status ?? '').trim()) {
case 'success':
return Boolean(record.desktop_account_id)
default:
return false
}
}
function isSuccessfulEnterpriseSiteRecord(record: PublishRecord): boolean {
return (
record.target_type === 'enterprise_site' && String(record.status ?? '').trim() === 'success'
)
}
function isActivePublishRecord(record: PublishRecord): boolean {
switch (String(record.status ?? '').trim()) {
case 'queued':
case 'pending':
case 'publishing':
case 'running':
return Boolean(record.desktop_account_id)
default:
return false
}
}
function isActiveEnterpriseSiteRecord(record: PublishRecord): boolean {
if (record.target_type !== 'enterprise_site') {
return false
}
switch (String(record.status ?? '').trim()) {
case 'queued':
case 'pending':
case 'publishing':
case 'running':
return true
default:
return false
}
}
function resolveEnterpriseSiteDisabledReason(
site: EnterpriseSiteConnection,
alreadyPublished: boolean,
activeStatus?: string,
connectionStatus = site.status,
): string {
if (alreadyPublished) {
return '这篇文章已成功发布到该企业站点;如需重新发布,请复制或新建一篇文章。'
}
if (activeStatus) {
return '该企业站点已有当前文章的发布任务,暂不能重复提交。'
}
if (site.status === 'disabled') {
return '该企业站点已禁用。'
}
if (connectionStatus === 'error') {
return formatStoredErrorMessage(site.last_error) || '该企业站点连接异常,请先测试连通性。'
}
return ''
}
function enterpriseSiteConnectionStatus(site: EnterpriseSiteConnection): string {
const status = String(site.status ?? '').trim()
const lastError = formatStoredErrorMessage(site.last_error)
const publishError = enterpriseSiteLatestPublishError(site)
if (status === 'error' && lastError && publishError && lastError === publishError) {
return 'connected'
}
return status
}
function enterpriseSiteLatestPublishError(site: EnterpriseSiteConnection): string {
const record = site.latest_record
if (!record) {
return ''
}
const status = String(record.status ?? '').trim()
if (status !== 'failed' && status !== 'publish_failed') {
return ''
}
return formatStoredErrorMessage(record.error_message)
}
function cmsTypeLabel(cmsType: string): string {
switch (String(cmsType ?? '').trim()) {
case 'pbootcms':
return 'PBootCMS'
case 'wordpress':
return 'WordPress'
default:
return cmsType || 'CMS'
}
}
function normalizeEnterprisePlatformId(cmsType: string): string {
const normalized = String(cmsType ?? '')
.trim()
.toLowerCase()
switch (normalized) {
case 'pbootcms':
return 'pbootcms'
case 'wordpress':
return 'wordpress'
default:
return normalized
}
}
function enterpriseSiteStatusLabel(status: string): string {
switch (String(status ?? '').trim()) {
case 'connected':
return '已连通'
case 'error':
return '连接异常'
case 'disabled':
return '已禁用'
default:
return '待校验'
}
}
function enterpriseSiteStatusColor(status: string): string {
switch (String(status ?? '').trim()) {
case 'connected':
return 'green'
case 'error':
return 'red'
case 'disabled':
return 'default'
default:
return 'gold'
}
}
function enterpriseCategoryOptions(siteId: number) {
return (enterpriseCategoriesBySiteId.value[siteId] ?? []).map((category) => ({
label: enterpriseCategoryLabel(category),
name: category.name,
slug: category.slug?.trim() ?? '',
value: category.remote_id,
}))
}
function enterpriseCategoryLabel(category: EnterpriseSiteCategory): string {
const slug = category.slug?.trim()
return slug ? `${category.name} / ${slug}` : category.name
}
function setEnterpriseCategories(siteId: number, categories: EnterpriseSiteCategory[]): void {
enterpriseCategoriesBySiteId.value = {
...enterpriseCategoriesBySiteId.value,
[siteId]: categories,
}
const current = enterpriseSiteCategoryIds.value[siteId]?.trim()
const currentExists = categories.some((category) => category.remote_id === current)
if ((!current || !currentExists) && categories.length > 0) {
enterpriseSiteCategoryIds.value = {
...enterpriseSiteCategoryIds.value,
[siteId]: categories[0].remote_id,
}
}
}
async function loadEnterpriseCategories(siteId: number): Promise<void> {
if (enterpriseCategoriesBySiteId.value[siteId]) {
return
}
try {
const categories = await enterpriseSitesApi.categories(siteId)
setEnterpriseCategories(siteId, categories)
} catch {
enterpriseCategoriesBySiteId.value = {
...enterpriseCategoriesBySiteId.value,
[siteId]: [],
}
}
}
function syncEnterpriseCategories(siteId: number): void {
syncEnterpriseCategoriesMutation.mutate(siteId)
}
function handleCoverToggle(checked: boolean): void {
if (coverRequired.value) {
coverEnabled.value = true
return
}
coverEnabled.value = checked
}
function handleCoverPicked(payload: {
url: string
fileName: string
assetId?: number | null
}): void {
coverAssetUrl.value = payload.url
coverFileName.value = payload.fileName
coverImageAssetId.value = payload.assetId ?? null
coverEnabled.value = true
coverMode.value = 'specific'
}
function handleRemoveCover(): void {
coverAssetUrl.value = ''
coverFileName.value = ''
coverImageAssetId.value = null
}
function handleCoverModeChange(value: ScheduleCoverMode): void {
coverMode.value = value
if (value === 'random') {
coverAssetUrl.value = ''
coverFileName.value = ''
coverImageAssetId.value = null
}
}
async function handleModalOk(): Promise<void> {
if (okDisabled.value) {
return
}
if (complianceDisplayDecision(complianceResult.value) === 'needs_ack') {
await publishWithAck()
return
}
publishMutation.mutate()
}
async function runPublishAdmissionCheck(): Promise<void> {
const runId = ++publishAdmissionRunId
if (!props.open) {
return
}
const status = complianceStatusQuery.data.value
if (!status || status.enabled !== true || status.enforcement_mode === 'disabled') {
publishAdmissionResult.value = null
publishAdmissionError.value = null
publishAdmissionLoading.value = false
return
}
const article = detailQuery.data.value
const articleVersionId = article?.current_version_id
if (!article || !props.articleId || !articleVersionId) {
publishAdmissionResult.value = null
publishAdmissionError.value = null
publishAdmissionLoading.value =
detailQuery.isPending.value ||
detailQuery.isFetching.value ||
complianceStatusQuery.isFetching.value
return
}
publishAdmissionLoading.value = true
publishAdmissionError.value = null
try {
const result = await complianceApi.checkArticle(article.id, {
article_version_id: articleVersionId,
title: article.title,
plaintext: article.markdown_content ?? '',
target_platforms: [],
trigger_source: 'publish_gate',
})
if (runId === publishAdmissionRunId && props.open) {
publishAdmissionResult.value = result
}
} catch (error) {
if (runId === publishAdmissionRunId && props.open) {
publishAdmissionError.value = formatError(error)
publishAdmissionResult.value = null
}
} finally {
if (runId === publishAdmissionRunId && props.open) {
publishAdmissionLoading.value = false
}
}
}
async function publishWithAck(): Promise<void> {
const result = complianceResult.value
if (!result || complianceDisplayDecision(result) !== 'needs_ack' || !result.record_id) {
publishMutation.mutate()
return
}
ackInProgress.value = true
try {
const ack = await complianceApi.createAck(result.record_id, {
acknowledged_violation_ids: complianceAcknowledgedViolationIds.value,
})
const publishResult = await runPublishFlow(ack.id)
await handlePublishSuccess(publishResult)
} catch (error) {
handlePublishError(error)
} finally {
ackInProgress.value = false
}
}
function parseComplianceResultFromError(error: unknown): ComplianceCheckResult | null {
if (!(error instanceof ApiClientError) || typeof error.detail !== 'string') {
return null
}
try {
const parsed = JSON.parse(error.detail) as Partial<ComplianceCheckResult>
if (typeof parsed.record_id === 'number' && typeof parsed.decision === 'string') {
return parsed as ComplianceCheckResult
}
} catch {
return null
}
return null
}
function hasComplianceHits(result: ComplianceCheckResult | null | undefined): boolean {
return Boolean(result && ((result.hit_count ?? 0) > 0 || (result.violations?.length ?? 0) > 0))
}
function complianceDisplayDecision(
result: ComplianceCheckResult | null | undefined,
): GateDecisionLiteral | undefined {
if (!result) {
return undefined
}
if (result.decision === 'pass' && (result.passed === false || hasComplianceHits(result))) {
if (result.enforcement_mode === 'mandatory') {
return 'block'
}
return 'needs_ack'
}
return result.decision
}
function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
const decision = complianceDisplayDecision(result)
if (decision === 'block') {
return 'error'
}
if (decision === 'needs_ack') {
return 'warning'
}
return 'info'
}
</script>
<template>
<a-modal
:open="publishModalVisible"
title="发布"
:width="980"
:confirm-loading="publishBusy"
:ok-text="okText"
:ok-button-props="{ disabled: okDisabled }"
:cancel-text="t('common.cancel')"
@ok="handleModalOk"
@cancel="emit('update:open', false)"
>
<div class="publish-modal">
<section class="publish-modal__hero">
<div>
<span class="publish-modal__eyebrow">发布标题</span>
<h3>{{ modalTitle }}</h3>
<p>
{{
activeTargetTab === 'enterprise_sites'
? '企业自建站点由服务端直接调用 CMS 插件发布,不占用桌面端账号。'
: '在线账号会立即消费,离线账号会自动排队;数据库保留任务真相,RabbitMQ 负责唤醒在线客户端。'
}}
</p>
</div>
<div class="publish-modal__hero-metrics">
<div class="publish-modal__metric">
<strong>{{ selectedTargetCount }}</strong>
<span>{{ selectedTargetLabel }}</span>
</div>
<div class="publish-modal__metric">
<strong>{{ selectedPublishNowCount }}</strong>
<span>{{ publishNowLabel }}</span>
</div>
<div class="publish-modal__metric">
<strong>{{ selectedQueueCount }}</strong>
<span>{{ queueLabel }}</span>
</div>
</div>
</section>
<section class="publish-modal__section">
<div class="publish-modal__section-header">
<div class="publish-modal__title-row">
<h3>
<span class="required-star">*</span>
发布目标
</h3>
<div
v-if="
accountSelectionLocked &&
!accountsQuery.isPending.value &&
!platformsQuery.isPending.value &&
!enterpriseSitesQuery.isPending.value
"
class="publish-modal__account-lock publish-modal__account-lock--inline"
:class="{
'publish-modal__account-lock--error':
!accountSelectionLockSpinning &&
(mandatoryAdmissionBlocking || publishAdmissionError),
}"
>
<a-spin v-if="accountSelectionLockSpinning" size="small" />
<CloseCircleFilled
v-else-if="mandatoryAdmissionBlocking || publishAdmissionError"
class="publish-modal__account-lock-icon"
/>
<InfoCircleFilled v-else class="publish-modal__account-lock-icon" />
<span>{{ accountSelectionLockMessage }}</span>
</div>
</div>
<p class="muted">
选择普通媒体账号会进入桌面端发布队列;选择企业自建站点会直接调用 CMS 插件发文。
<span
v-if="activeTargetTab === 'media_accounts' && publishedAccountCount > 0"
class="publish-modal__published-note"
>
其中 {{ publishedAccountCount }} 个因已成功发布禁用。
</span>
<span
v-else-if="activeTargetTab === 'enterprise_sites' && publishedEnterpriseSiteCount > 0"
class="publish-modal__published-note"
>
其中 {{ publishedEnterpriseSiteCount }} 个站点因已成功发布禁用。
</span>
</p>
</div>
<a-tabs v-model:activeKey="activeTargetTab" class="publish-modal__target-tabs">
<a-tab-pane key="media_accounts" tab="平台账号">
<div
v-if="
accountsQuery.isPending.value ||
platformsQuery.isPending.value ||
publishRecordsQuery.isPending.value
"
class="publish-modal__loading"
>
<a-skeleton active :paragraph="{ rows: 5 }" />
</div>
<template v-else>
<div v-if="accountCards.length" class="publish-modal__list">
<button
v-for="account in accountCards"
:key="account.id"
type="button"
class="publish-modal__card"
:class="{
'publish-modal__card--active': isSelected(account.id),
'publish-modal__card--disabled': !account.selectable || accountSelectionLocked,
}"
:aria-disabled="!account.selectable || accountSelectionLocked"
@click="toggleAccount(account.id, account.selectable)"
>
<div class="publish-modal__card-header">
<div class="publish-modal__card-identity">
<span
class="publish-modal__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="publish-modal__identity-copy">
<div class="publish-modal__account-name-row">
<a-tooltip :title="account.displayName" placement="top">
<strong>{{ account.displayName }}</strong>
</a-tooltip>
<a-tooltip :title="account.statusText" placement="top">
<span
class="publish-modal__state-pill publish-modal__state-pill--compact"
:class="
isAlreadyPublished(account)
? 'publish-modal__state-pill--published'
: `publish-modal__state-pill--${account.publishState}`
"
>
{{
isAlreadyPublished(account)
? '已发布'
: publishStateLabel(account.publishState)
}}
</span>
</a-tooltip>
</div>
<a-tooltip
:title="`${account.platformName} · ${account.platformUid}`"
placement="top"
>
<span class="publish-modal__platform-line">
<img
v-if="account.platformLogoUrl"
class="publish-modal__platform-logo"
:src="account.platformLogoUrl"
:alt="account.platformName"
referrerpolicy="no-referrer"
/>
<span
v-else
class="publish-modal__platform-logo publish-modal__platform-logo--fallback"
:style="{ background: account.platformAccent }"
>
{{ account.platformShortName }}
</span>
<span class="publish-modal__platform-text">
{{ account.platformName }} · {{ account.platformUid }}
</span>
</span>
</a-tooltip>
</div>
</div>
<span class="publish-modal__check">
<span v-if="isSelected(account.id)" class="publish-modal__check-inner"></span>
</span>
</div>
<div class="publish-modal__card-footer">
<div class="publish-modal__tags">
<span
class="status-badge"
:class="
account.health === 'live'
? 'status-badge--success'
: account.health === 'risk'
? 'status-badge--warning'
: 'status-badge--error'
"
>
<span class="status-badge__dot"></span>
{{ healthLabel(account.health) }}
</span>
<a-tooltip :title="clientStatusTooltip(account)" placement="top">
<span
class="status-badge publish-modal__client-status-tag"
:class="
account.clientOnline
? 'status-badge--success'
: account.clientId
? 'status-badge--neutral'
: 'status-badge--error'
"
>
<span class="status-badge__dot"></span>
{{ clientStatusLabel(account) }}
</span>
</a-tooltip>
<span
v-if="isAlreadyPublished(account)"
class="status-badge status-badge--published"
>
<span class="status-badge__dot"></span>
已发布
</span>
<span
v-else-if="activePublishRecordLabel(account)"
class="status-badge status-badge--processing"
>
<span class="status-badge__dot"></span>
{{ activePublishRecordLabel(account) }}
</span>
</div>
</div>
</button>
</div>
<a-empty v-else description="当前还没有可用的桌面媒体账号,请先在桌面端绑定。" />
</template>
</a-tab-pane>
<a-tab-pane key="enterprise_sites" tab="企业自建站点">
<div
v-if="enterpriseSitesQuery.isPending.value || publishRecordsQuery.isPending.value"
class="publish-modal__loading"
>
<a-skeleton active :paragraph="{ rows: 5 }" />
</div>
<template v-else>
<div class="publish-modal__enterprise-toolbar">
<span>发布方式</span>
<a-segmented
v-model:value="enterprisePublishType"
:options="[
{ label: '直接发布', value: 'publish' },
{ label: '保存草稿', value: 'draft' },
]"
/>
</div>
<div
v-if="enterpriseSiteCards.length"
class="publish-modal__list publish-modal__list--enterprise"
>
<article
v-for="site in enterpriseSiteCards"
:key="site.id"
class="publish-modal__card publish-modal__card--enterprise"
:class="{
'publish-modal__card--active': isEnterpriseSiteSelected(site.id),
'publish-modal__card--disabled': !site.selectable || accountSelectionLocked,
}"
>
<button
type="button"
class="publish-modal__enterprise-card-main"
:aria-disabled="!site.selectable || accountSelectionLocked"
@click="toggleEnterpriseSite(site)"
>
<div class="publish-modal__card-header">
<div class="publish-modal__card-identity">
<span class="publish-modal__avatar publish-modal__avatar--site">
{{ site.name.slice(0, 1).toUpperCase() }}
</span>
<div class="publish-modal__identity-copy">
<div class="publish-modal__account-name-row">
<a-tooltip :title="site.name" placement="top">
<strong>{{ site.name }}</strong>
</a-tooltip>
<a-tooltip
:title="site.disabledReason || site.statusText"
placement="top"
>
<span
class="publish-modal__state-pill publish-modal__state-pill--compact"
:class="
successfulEnterpriseSiteIds.has(site.id)
? 'publish-modal__state-pill--published'
: site.hasPublishFailure
? 'publish-modal__state-pill--queued'
: site.status === 'connected'
? 'publish-modal__state-pill--immediate'
: site.status === 'error'
? 'publish-modal__state-pill--unavailable'
: 'publish-modal__state-pill--queued'
"
>
{{ site.statusText }}
</span>
</a-tooltip>
</div>
<a-tooltip :title="site.siteUrl" placement="top">
<span class="publish-modal__platform-line">
<span
class="publish-modal__platform-logo publish-modal__platform-logo--fallback publish-modal__platform-logo--cms"
>
CMS
</span>
<span class="publish-modal__platform-text">
{{ site.cmsType }} · {{ site.siteUrl }}
</span>
</span>
</a-tooltip>
</div>
</div>
<span class="publish-modal__check">
<span
v-if="isEnterpriseSiteSelected(site.id)"
class="publish-modal__check-inner"
></span>
</span>
</div>
<div class="publish-modal__card-footer">
<div class="publish-modal__tags">
<span
class="status-badge"
:class="
site.status === 'connected'
? 'status-badge--success'
: 'status-badge--error'
"
>
<span class="status-badge__dot"></span>
{{ site.status === 'connected' ? '已连通' : '连通失败' }}
</span>
<span class="status-badge status-badge--info">
{{ site.categoryCount }} 个栏目
</span>
<span
v-if="successfulEnterpriseSiteIds.has(site.id)"
class="status-badge status-badge--published"
>
<span class="status-badge__dot"></span>
已发布
</span>
<span
v-else-if="activeEnterpriseRecordStatusBySiteId.has(site.id)"
class="status-badge status-badge--processing"
>
<span class="status-badge__dot"></span>
{{
activeEnterpriseRecordLabel(
activeEnterpriseRecordStatusBySiteId.get(site.id) ?? '',
)
}}
</span>
</div>
</div>
</button>
<div
v-if="isEnterpriseSiteSelected(site.id)"
class="publish-modal__enterprise-config"
@click.stop
>
<div class="publish-modal__config-title">
<svg
class="publish-modal__config-icon"
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="9" />
<rect x="14" y="3" width="7" height="5" />
<rect x="14" y="12" width="7" height="9" />
<rect x="3" y="16" width="7" height="5" />
</svg>
<span>配置发布栏目</span>
</div>
<div class="publish-modal__config-body">
<a-select
v-model:value="enterpriseSiteCategoryIds[site.id]"
:options="enterpriseCategoryOptions(site.id)"
:loading="syncEnterpriseCategoriesMutation.isPending.value"
:get-popup-container="enterpriseSelectPopupContainer"
:dropdown-match-select-width="true"
:dropdown-style="{ maxWidth: 'calc(100vw - 96px)' }"
popup-class-name="publish-modal__enterprise-select-dropdown"
class="publish-modal__enterprise-select"
placeholder="选择栏目"
@focus="loadEnterpriseCategories(site.id)"
@click.stop
>
<template #option="item">
<a-tooltip :title="item.label" placement="left">
<div class="publish-modal__select-option-text">
{{ item.label }}
</div>
</a-tooltip>
</template>
</a-select>
<a-button
class="publish-modal__sync-btn"
:loading="syncEnterpriseCategoriesMutation.isPending.value"
@click="syncEnterpriseCategories(site.id)"
>
<template #icon><SyncOutlined /></template>
同步栏目
</a-button>
</div>
</div>
<p v-if="site.disabledReason" class="publish-modal__site-warning">
{{ site.disabledReason }}
</p>
</article>
</div>
<a-empty v-else description="当前还没有企业自建站点,请先到媒体管理添加站点连接。" />
</template>
</a-tab-pane>
</a-tabs>
</section>
<section class="publish-modal__section">
<div class="publish-modal__section-header publish-modal__section-header--inline">
<h3>封面图</h3>
<a-switch
:checked="effectiveCoverEnabled"
:disabled="coverRequired"
@change="handleCoverToggle"
/>
</div>
<p class="muted">
{{
coverRequired
? t('media.publish.messages.coverRequired')
: '默认关闭。需要时可在这里单独指定发布封面,并先写回文章。'
}}
</p>
<div v-if="effectiveCoverEnabled" class="publish-modal__cover-body">
<CoverSourceSelector
:mode="coverMode"
:random-scope="coverRandomScope"
:random-folder-id="coverRandomFolderId"
@update:mode="handleCoverModeChange"
@update:random-scope="coverRandomScope = $event"
@update:random-folder-id="coverRandomFolderId = $event"
>
<template #specific-preview>
<div v-if="coverAssetUrl" class="publish-modal__cover-preview-card animate-fade-in">
<img :src="coverAssetUrl" alt="cover preview" class="publish-modal__cover-img" />
<div class="publish-modal__cover-overlay">
<button
type="button"
class="publish-modal__overlay-btn"
@click="coverPickerOpen = true"
>
更换
</button>
<button
type="button"
class="publish-modal__overlay-btn publish-modal__overlay-btn--danger"
@click.stop="handleRemoveCover"
>
移除
</button>
</div>
</div>
<button
v-else
type="button"
class="publish-modal__cover-upload-placeholder animate-fade-in"
@click="coverPickerOpen = true"
>
<div class="publish-modal__upload-icon">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
<span class="publish-modal__upload-text">{{ t('media.publish.coverUpload') }}</span>
</button>
</template>
</CoverSourceSelector>
</div>
</section>
</div>
</a-modal>
<CoverPickerModal
v-model:open="coverPickerOpen"
:article-id="detailQuery.data.value?.id ?? null"
:platform-ids="selectedPlatformIds"
:current-url="coverAssetUrl"
:current-file-name="coverFileName"
:current-asset-id="coverImageAssetId"
@confirmed="handleCoverPicked"
/>
</template>
<style scoped>
.publish-modal {
display: flex;
flex-direction: column;
gap: 24px;
}
.publish-modal__hero {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 24px;
padding: 24px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #ffffff;
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.02),
0 4px 8px -2px rgba(0, 0, 0, 0.02);
}
.publish-modal__eyebrow {
display: inline-block;
margin-bottom: 12px;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
color: #4f46e5;
background: #e0e7ff;
border-radius: 6px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.publish-modal__hero h3 {
margin: 0;
color: #111827;
font-size: 18px;
font-weight: 600;
line-height: 1.5;
}
.publish-modal__hero p {
margin: 8px 0 0;
color: #6b7280;
font-size: 14px;
line-height: 1.6;
}
.publish-modal__hero-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(96px, 1fr));
gap: 12px;
}
.publish-modal__metric {
padding: 16px;
border-radius: 8px;
background: #f9fafb;
border: 1px solid #f3f4f6;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.publish-modal__metric strong {
display: block;
color: #111827;
font-size: 24px;
font-weight: 600;
line-height: 1;
}
.publish-modal__metric span {
display: block;
margin-top: 6px;
color: #6b7280;
font-size: 13px;
font-weight: 500;
}
.publish-modal__section {
display: flex;
flex-direction: column;
}
.publish-modal__compliance {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 16px 20px;
border-radius: 12px;
border: 1px solid transparent;
transition: all 0.3s ease;
}
.publish-modal__compliance-icon {
font-size: 24px;
line-height: 1;
}
.publish-modal__compliance-content {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.publish-modal__compliance-title {
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 24px;
}
.publish-modal__compliance-desc {
margin: 0;
font-size: 13px;
line-height: 1.5;
opacity: 0.9;
}
.publish-modal__compliance--error {
background: linear-gradient(145deg, #fef2f2, #fff5f5);
border-color: #fee2e2;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.06);
}
.publish-modal__compliance--error .publish-modal__compliance-icon {
color: #ef4444;
}
.publish-modal__compliance--error .publish-modal__compliance-title {
color: #991b1b;
}
.publish-modal__compliance--error .publish-modal__compliance-desc {
color: #991b1b;
}
.publish-modal__compliance--warning {
background: linear-gradient(145deg, #fffbeb, #fffde7);
border-color: #fef3c7;
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.06);
}
.publish-modal__compliance--warning .publish-modal__compliance-icon {
color: #f59e0b;
}
.publish-modal__compliance--warning .publish-modal__compliance-title {
color: #92400e;
}
.publish-modal__compliance--warning .publish-modal__compliance-desc {
color: #92400e;
}
.publish-modal__compliance--info {
background: linear-gradient(145deg, #eff6ff, #f5f8ff);
border-color: #dbeafe;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.06);
}
.publish-modal__compliance--info .publish-modal__compliance-icon {
color: #3b82f6;
}
.publish-modal__compliance--info .publish-modal__compliance-title {
color: #1e40af;
}
.publish-modal__compliance--info .publish-modal__compliance-desc {
color: #1e40af;
}
.publish-modal__compliance--success {
background: linear-gradient(145deg, #f0fdf4, #f5fdf7);
border-color: #dcfce7;
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.06);
}
.publish-modal__compliance--success .publish-modal__compliance-icon {
color: #22c55e;
}
.publish-modal__compliance--success .publish-modal__compliance-title {
color: #166534;
}
.publish-modal__compliance--success .publish-modal__compliance-desc {
color: #166534;
}
.publish-modal__section-header {
margin-bottom: 12px;
}
.publish-modal__section-header--inline {
display: flex;
align-items: center;
gap: 12px;
}
.publish-modal__section-header h3 {
margin: 0;
color: #1f2937;
font-size: 16px;
font-weight: 600;
}
.publish-modal__title-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.publish-modal__section-header--inline h3 {
margin: 0;
}
.required-star {
color: #ef4444;
margin-right: 4px;
}
.muted {
margin: 0;
color: #6b7280;
font-size: 14px;
line-height: 1.6;
}
.publish-modal__loading {
padding: 12px 0;
}
.publish-modal__target-tabs {
margin-top: 14px;
}
.publish-modal__target-tabs :deep(.ant-tabs-nav) {
margin-bottom: 16px;
}
.publish-modal__target-tabs :deep(.ant-tabs-tab) {
padding: 8px 0;
font-weight: 600;
}
.publish-modal__enterprise-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
}
.publish-modal__enterprise-toolbar span {
color: #374151;
font-size: 13px;
font-weight: 600;
}
.publish-modal__account-lock {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
max-width: 100%;
padding: 10px 14px;
border: 1px solid #ffedd5;
border-radius: 8px;
background: #fff7ed;
color: #9a3412;
font-size: 13px;
line-height: 1.5;
}
.publish-modal__account-lock--inline {
padding: 6px 12px;
border-radius: 6px;
}
.publish-modal__account-lock--inline span {
white-space: pre-wrap;
line-height: 1.5;
}
.publish-modal__account-lock--error {
background: #fef2f2;
border-color: #fee2e2;
color: #991b1b;
}
.publish-modal__account-lock--error .publish-modal__account-lock-icon {
color: #ef4444;
}
.publish-modal__account-lock-icon {
color: #ea580c;
font-size: 15px;
}
.publish-modal__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
gap: 16px;
max-height: 400px;
overflow-y: auto;
padding-right: 6px;
padding-bottom: 4px;
}
.publish-modal__list--enterprise {
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
align-items: start;
max-height: 520px;
}
/* Custom scrollbar for list */
.publish-modal__list::-webkit-scrollbar {
width: 6px;
}
.publish-modal__list::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 999px;
}
.publish-modal__card {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.02);
}
.publish-modal__card:hover:not(.publish-modal__card--disabled) {
border-color: #d1d5db;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.08),
0 2px 4px -1px rgba(0, 0, 0, 0.04);
transform: translateY(-2px);
}
.publish-modal__card--active {
border-color: #3b82f6;
background: #eff6ff;
box-shadow: 0 0 0 1px #3b82f6;
}
.publish-modal__card--active:hover:not(.publish-modal__card--disabled) {
box-shadow:
0 4px 6px -1px rgba(59, 130, 246, 0.15),
0 0 0 1px #3b82f6;
}
.publish-modal__card--disabled {
cursor: not-allowed;
opacity: 0.6;
background: #f9fafb;
box-shadow: none;
}
.publish-modal__card--enterprise {
cursor: default;
padding: 0;
gap: 0;
overflow: visible;
min-width: 0;
}
.publish-modal__card--enterprise:hover:not(.publish-modal__card--disabled):not(
.publish-modal__card--active
) {
transform: translateY(-2px);
border-color: #d1d5db;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.08),
0 2px 4px -1px rgba(0, 0, 0, 0.04);
}
.publish-modal__card--enterprise.publish-modal__card--active:hover:not(
.publish-modal__card--disabled
) {
transform: none;
}
.publish-modal__enterprise-card-main {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
padding: 16px;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.publish-modal__card--disabled .publish-modal__enterprise-card-main {
cursor: not-allowed;
}
.publish-modal__enterprise-config {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
margin: 0 16px 16px;
padding: 12px 14px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #e2e8f0;
z-index: 2;
transition: all 0.2s ease;
}
.publish-modal__card--active .publish-modal__enterprise-config {
background: #ffffff;
border-color: #bfdbfe;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.02);
}
.publish-modal__config-title {
display: flex;
align-items: center;
gap: 6px;
color: #475569;
font-size: 12px;
font-weight: 600;
}
.publish-modal__config-icon {
color: #64748b;
flex-shrink: 0;
}
.publish-modal__config-body {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
width: 100%;
min-width: 0;
}
.publish-modal__enterprise-select {
width: 100%;
}
.publish-modal__sync-btn {
width: 100%;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
border-color: #d9d9d9;
font-size: 13px;
font-weight: 500;
transition: all 0.2s ease;
}
.publish-modal__sync-btn:hover {
color: #3b82f6;
border-color: #3b82f6;
}
:global(.publish-modal__enterprise-select-dropdown) {
z-index: 3200;
}
:global(.publish-modal__enterprise-select-dropdown .ant-select-item-option) {
padding: 6px 12px;
}
:global(.publish-modal__enterprise-select-dropdown .ant-select-item-option-content) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.publish-modal__select-option-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.publish-modal__site-warning {
margin: -4px 16px 14px;
color: #b45309;
font-size: 12px;
line-height: 1.5;
}
.publish-modal__card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.publish-modal__card-identity {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.publish-modal__card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
gap: 8px;
transition: border-color 0.2s;
}
/* Custom premium status badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
line-height: 14px;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
transition: all 0.2s ease;
}
.status-badge__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #94a3b8;
display: inline-block;
flex-shrink: 0;
}
/* Success */
.status-badge--success {
background: #f0fdf4;
color: #166534;
border-color: #bbf7d0;
}
.status-badge--success .status-badge__dot {
background: #22c55e;
}
/* Error */
.status-badge--error {
background: #fef2f2;
color: #991b1b;
border-color: #fee2e2;
}
.status-badge--error .status-badge__dot {
background: #ef4444;
}
/* Warning */
.status-badge--warning {
background: #fffbeb;
color: #92400e;
border-color: #fde68a;
}
.status-badge--warning .status-badge__dot {
background: #f59e0b;
}
/* Neutral */
.status-badge--neutral {
background: #f8fafc;
color: #475569;
border-color: #e2e8f0;
}
.status-badge--neutral .status-badge__dot {
background: #94a3b8;
}
/* Processing */
.status-badge--processing {
background: #eff6ff;
color: #1e40af;
border-color: #bfdbfe;
}
.status-badge--processing .status-badge__dot {
background: #3b82f6;
animation: badgePulse 2s infinite ease-in-out;
}
/* Published */
.status-badge--published {
background: #f0fdf4;
color: #166534;
border-color: #bbf7d0;
}
.status-badge--published .status-badge__dot {
background: #10b981;
}
/* Info */
.status-badge--info {
background: #f0fdfa;
color: #0f766e;
border-color: #ccfbf1;
}
@keyframes badgePulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(0.8);
}
}
.publish-modal__card--active .publish-modal__card-footer {
border-top-color: #bfdbfe;
}
.publish-modal__card--enterprise .publish-modal__card-identity {
align-items: flex-start;
flex: 1;
}
.publish-modal__card--enterprise .publish-modal__identity-copy {
gap: 4px;
}
.publish-modal__card--enterprise .publish-modal__identity-copy strong {
white-space: normal;
overflow-wrap: anywhere;
}
.publish-modal__card--enterprise .publish-modal__identity-copy span {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.publish-modal__card--enterprise .publish-modal__platform-line {
align-items: flex-start;
width: 100%;
}
.publish-modal__card--enterprise .publish-modal__platform-text {
overflow: visible;
text-overflow: clip;
overflow-wrap: anywhere;
}
.publish-modal__check {
width: 18px;
height: 18px;
border: 1px solid #d1d5db;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #fff;
flex-shrink: 0;
transition: all 0.2s;
margin-top: 2px;
}
.publish-modal__card:hover:not(.publish-modal__card--disabled) .publish-modal__check {
border-color: #3b82f6;
}
.publish-modal__card--active .publish-modal__check {
background: #3b82f6;
border-color: #3b82f6;
}
.publish-modal__check-inner {
width: 6px;
height: 10px;
border-right: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(45deg) translate(-1px, -2px);
opacity: 0;
transition: opacity 0.2s;
}
.publish-modal__card--active .publish-modal__check-inner {
opacity: 1;
}
.publish-modal__avatar {
width: 40px;
height: 40px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 15px;
font-weight: 600;
overflow: hidden;
flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.05);
}
.publish-modal__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.publish-modal__avatar--site {
background: linear-gradient(135deg, #0ea5e9, #0f766e);
box-shadow: 0 2px 6px rgba(15, 118, 110, 0.15);
transition: all 0.3s ease;
}
.publish-modal__card:hover:not(.publish-modal__card--disabled) .publish-modal__avatar--site {
transform: scale(1.05);
box-shadow: 0 4px 10px rgba(15, 118, 110, 0.25);
}
.publish-modal__identity-copy {
display: flex;
flex-direction: column;
min-width: 0;
}
.publish-modal__account-name-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.publish-modal__identity-copy strong {
min-width: 0;
color: #111827;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.publish-modal__identity-copy span {
margin-top: 2px;
color: #6b7280;
font-size: 13px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.publish-modal__platform-line {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.publish-modal__platform-logo {
width: 14px;
height: 14px;
border-radius: 3px;
object-fit: contain;
flex-shrink: 0;
background: #fff;
}
.publish-modal__platform-logo--fallback {
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff !important;
font-size: 9px !important;
font-weight: 600;
line-height: 1 !important;
margin-top: 0;
}
.publish-modal__platform-logo--cms {
background: #0f766e;
font-size: 8px !important;
width: auto;
padding: 0 4px;
height: 14px;
line-height: 14px !important;
border-radius: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: #fff !important;
}
.publish-modal__platform-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.publish-modal__state-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
height: 24px;
padding: 0 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.publish-modal__state-pill--compact {
min-width: 0;
height: 22px;
padding: 0 9px;
font-size: 12px;
}
.publish-modal__state-pill--immediate {
background: #ecfdf5;
color: #059669;
border: 1px solid #a7f3d0;
}
.publish-modal__state-pill--queued {
background: #fffbeb;
color: #d97706;
border: 1px solid #fde68a;
}
.publish-modal__state-pill--unavailable {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
.publish-modal__state-pill--published {
background: #dbeafe;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
.publish-modal__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.publish-modal__tags .ant-tag {
margin: 0;
border-radius: 4px;
}
.publish-modal__client-status-tag {
display: inline-block;
max-width: 118px;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
white-space: nowrap;
}
.publish-modal__cover-body {
margin-top: 16px;
}
.publish-modal__cover-preview-card {
position: relative;
width: 200px;
height: 112px;
border-radius: 10px;
overflow: hidden;
border: 1px solid #e2e8f0;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
}
.publish-modal__cover-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.publish-modal__cover-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.6);
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
opacity: 0;
transition: opacity 0.2s ease-in-out;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.publish-modal__cover-preview-card:hover .publish-modal__cover-overlay {
opacity: 1;
}
.publish-modal__overlay-btn {
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
color: #0f172a;
background: #ffffff;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.publish-modal__overlay-btn:hover {
background: #f1f5f9;
transform: scale(1.03);
}
.publish-modal__overlay-btn--danger {
color: #ffffff;
background: #ef4444;
}
.publish-modal__overlay-btn--danger:hover {
background: #dc2626;
}
.publish-modal__cover-upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 200px;
height: 112px;
padding: 12px;
border: 1.5px dashed #cbd5e1;
border-radius: 10px;
background: #f8fafc;
color: #64748b;
cursor: pointer;
transition: all 0.2s ease;
}
.publish-modal__cover-upload-placeholder:hover {
border-color: #3b82f6;
color: #3b82f6;
background: #f0f6ff;
}
.publish-modal__upload-icon {
margin-bottom: 6px;
color: #94a3b8;
transition: color 0.2s;
display: inline-flex;
}
.publish-modal__cover-upload-placeholder:hover .publish-modal__upload-icon {
color: #3b82f6;
}
.publish-modal__upload-text {
font-size: 12px;
font-weight: 600;
}
.animate-fade-in {
animation: fadeIn 0.2s ease-out forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 900px) {
.publish-modal__hero {
grid-template-columns: minmax(0, 1fr);
}
.publish-modal__hero-metrics {
min-width: 0;
}
}
</style>