Files
geo/apps/admin-web/src/components/PublishArticleModal.vue
T
root 337bb6f9ac
Deployment Config CI / Deployment Config (push) Successful in 28s
Backend CI / Backend (push) Successful in 14m8s
Frontend CI / Frontend (push) Failing after 2m1s
feat(publish): enhance compliance feedback and UI elements in PublishArticleModal
2026-05-05 21:01:11 +08:00

1515 lines
42 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 {
CheckCircleFilled,
CloseCircleFilled,
ExclamationCircleFilled,
InfoCircleFilled,
MinusCircleFilled,
} from '@ant-design/icons-vue'
import { ApiClientError } from '@geo/http-client'
import type {
ArticleDetail,
ComplianceCheckResult,
ComplianceRuntimeStatus,
GateDecisionLiteral,
} 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 {
articlesApi,
complianceApi,
mediaApi,
publishJobsApi,
resolveApiURL,
tenantAccountsApi,
} from '@/lib/api'
import { coverUploadRequired, deriveCoverFileName } from '@/lib/cover-requirements'
import { formatError } from '@/lib/errors'
import {
accountInitial,
buildPublishAccountCards,
buildPublishPlatformMap,
clientStatusColor,
clientStatusLabel,
healthColor,
healthLabel,
publishStateLabel,
type PublishAccountCard,
} from '@/lib/publish-account-cards'
const props = defineProps<{
open: boolean
articleId: number | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
published: []
}>()
const { t } = useI18n()
const queryClient = useQueryClient()
const selectedAccountIds = ref<string[]>([])
const coverEnabled = ref(false)
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', props.articleId, 'publish-modal']),
enabled: computed(() => props.open && Boolean(props.articleId)),
queryFn: () => articlesApi.detail(props.articleId as number),
})
const accountsQuery = useQuery({
queryKey: ['tenant', 'desktop-accounts', 'publish-modal'],
enabled: computed(() => props.open),
queryFn: () => tenantAccountsApi.list(),
})
const platformsQuery = useQuery({
queryKey: ['media', 'platforms', 'publish-modal'],
enabled: computed(() => props.open),
queryFn: () => mediaApi.platforms(),
})
const complianceStatusQuery = useQuery({
queryKey: ['compliance', 'runtime-status', 'publish-modal'],
enabled: computed(() => props.open),
queryFn: () => complianceApi.runtimeStatus(),
})
watch(
() => props.open,
async (open) => {
if (!open) {
selectedAccountIds.value = []
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(),
accountsQuery.refetch(),
platformsQuery.refetch(),
complianceStatusQuery.refetch(),
])
},
{ immediate: true },
)
const platformMap = computed(() => buildPublishPlatformMap(platformsQuery.data.value ?? []))
const accountCards = computed<PublishAccountCard[]>(() =>
buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value),
)
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 },
)
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 ||
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 modalTitle = computed(() => detailQuery.data.value?.title || t('article.untitled'))
const selectedPlatformIds = computed(() =>
Array.from(new Set(selectedCards.value.map((card) => card.platformId))),
)
watch(selectedPlatformIds, () => {
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 publishModalVisible = computed(() => props.open && !coverPickerOpen.value)
const coverRequired = computed(() => coverUploadRequired(selectedPlatformIds.value))
const effectiveCoverEnabled = computed(() => coverRequired.value || coverEnabled.value)
const normalizedCoverValue = computed(() =>
effectiveCoverEnabled.value ? coverAssetUrl.value.trim() : '',
)
const publishMutation = useMutation({
mutationFn: () => runPublishFlow(),
onSuccess: handlePublishSuccess,
onError: handlePublishError,
})
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',
)
const mandatoryAdmissionBlocking = computed(
() => publishComplianceMandatory.value && !publishAdmissionPassed.value,
)
const accountSelectionLocked = computed(
() =>
publishMutation.isPending.value || ackInProgress.value || mandatoryAdmissionBlocking.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 (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 (!selectedAccountIds.value.length) {
throw new Error('no_accounts_selected')
}
if (coverRequired.value && !normalizedCoverValue.value) {
throw new Error('cover_required_for_selected_platforms')
}
const article = await persistCoverIfNeeded(detailQuery.data.value)
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 handlePublishSuccess() {
notification.success({
message: '已提交发布任务',
description: successDescription(),
placement: 'topRight',
duration: 5,
})
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['articles'] }),
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
queryClient.invalidateQueries({ queryKey: ['tenant', 'desktop-accounts'] }),
queryClient.invalidateQueries({ queryKey: ['articles', 'detail', props.articleId] }),
queryClient.invalidateQueries({
queryKey: ['articles', 'detail', props.articleId, 'publish-modal'],
}),
queryClient.invalidateQueries({ queryKey: ['articles', 'publish-records', props.articleId] }),
])
emit('published')
emit('update:open', false)
}
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 === 'cover_required_for_selected_platforms') {
message.warning(t('media.publish.messages.coverRequired'))
return
}
if (normalized === 'compliance_needs_ack' || normalized === 'compliance_block') {
return
}
message.error(formatError(error))
}
async function persistCoverIfNeeded(detail: ArticleDetail): Promise<ArticleDetail> {
const currentUrl = resolveApiURL(detail.cover_asset_url).trim()
const nextUrl = normalizedCoverValue.value.trim()
const currentAssetId = detail.cover_image_asset_id ?? null
if (currentUrl === nextUrl && currentAssetId === coverImageAssetId.value) {
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: coverImageAssetId.value,
})
}
function successDescription(): string {
const total = selectedCards.value.length
if (total === 0) {
return '任务已进入发布队列。'
}
if (selectedQueuedCount.value === 0) {
return `${total} 个账号,在线客户端会立即开始消费。`
}
if (selectedImmediateCount.value === 0) {
return `${total} 个账号已进入离线队列,客户端上线后会自动继续发布。`
}
return `${total} 个账号:${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 isSelected(accountId: string): boolean {
return selectedAccountIds.value.includes(accountId)
}
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
}
function handleRemoveCover(): void {
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,
})
await runPublishFlow(ack.id)
await handlePublishSuccess()
} 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="publishMutation.isPending.value || ackInProgress"
: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>
在线账号会立即消费离线账号会自动排队数据库保留任务真相RabbitMQ
负责唤醒在线客户端
</p>
</div>
<div class="publish-modal__hero-metrics">
<div class="publish-modal__metric">
<strong>{{ selectedCards.length }}</strong>
<span>已选账号</span>
</div>
<div class="publish-modal__metric">
<strong>{{ selectedImmediateCount }}</strong>
<span>立即发布</span>
</div>
<div class="publish-modal__metric">
<strong>{{ selectedQueuedCount }}</strong>
<span>离线排队</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"
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">
优先预选文章里已经勾选的平台账号支持同平台多账号授权异常或未绑定桌面客户端的账号不可选
</p>
</div>
<div
v-if="accountsQuery.isPending.value || platformsQuery.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,
}"
: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">
<strong :title="account.displayName">{{ account.displayName }}</strong>
<span
class="publish-modal__platform-line"
:title="`${account.platformName} · ${account.platformUid}`"
>
<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>
</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">
<a-tag :color="healthColor(account.health)">
{{ healthLabel(account.health) }}
</a-tag>
<a-tag :color="clientStatusColor(account)">{{ clientStatusLabel(account) }}</a-tag>
</div>
<a-tooltip :title="account.statusText" placement="top">
<span
class="publish-modal__state-pill"
:class="`publish-modal__state-pill--${account.publishState}`"
>
{{ publishStateLabel(account.publishState) }}
</span>
</a-tooltip>
</div>
</button>
</div>
<a-empty v-else description="当前还没有可用的桌面媒体账号,请先在桌面端绑定。" />
</template>
</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">
<div class="publish-modal__cover-preview-wrap">
<button
type="button"
class="publish-modal__cover-preview"
@click="coverPickerOpen = true"
>
<template v-if="coverAssetUrl">
<img :src="coverAssetUrl" alt="cover preview" />
</template>
<template v-else>
<span class="publish-modal__cover-plus">+</span>
<span>{{ t('media.publish.coverUpload') }}</span>
</template>
</button>
<button
v-if="coverAssetUrl"
type="button"
class="publish-modal__cover-remove"
:aria-label="t('article.editor.coverRemove')"
:title="t('article.editor.coverRemove')"
@click.stop="handleRemoveCover"
>
<MinusCircleFilled />
</button>
</div>
</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__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;
}
/* 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: 12px;
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-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 #e5e7eb;
gap: 8px;
transition: border-color 0.2s;
}
.publish-modal__card--active .publish-modal__card-footer {
border-top-color: #bfdbfe;
}
.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--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__identity-copy {
display: flex;
flex-direction: column;
min-width: 0;
}
.publish-modal__identity-copy strong {
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;
font-size: 9px;
font-weight: 600;
line-height: 1;
margin-top: 0;
}
.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--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__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.publish-modal__tags .ant-tag {
margin: 0;
border-radius: 4px;
}
.publish-modal__cover-body {
display: flex;
margin-top: 12px;
}
.publish-modal__cover-preview-wrap {
position: relative;
width: 176px;
flex: 0 0 176px;
}
.publish-modal__cover-preview {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 124px;
padding: 0;
border: 1px dashed #d1d5db;
border-radius: 8px;
background: #f9fafb;
color: #6b7280;
font-size: 13px;
gap: 8px;
cursor: pointer;
overflow: hidden;
transition: all 0.2s ease;
}
.publish-modal__cover-preview:hover {
border-color: #6366f1;
color: #6366f1;
background: #f5f3ff;
}
.publish-modal__cover-preview img {
display: block;
width: 100%;
height: 124px;
object-fit: cover;
}
.publish-modal__cover-plus {
font-size: 20px;
line-height: 1;
}
.publish-modal__cover-remove {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
background: rgba(17, 24, 39, 0.7);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}
.publish-modal__cover-remove:hover {
background: rgba(17, 24, 39, 0.9);
}
@media (max-width: 900px) {
.publish-modal__hero {
grid-template-columns: minmax(0, 1fr);
}
.publish-modal__hero-metrics {
min-width: 0;
}
}
</style>