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

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

942 lines
24 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 { MinusCircleFilled } from '@ant-design/icons-vue'
import type { ArticleDetail } 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, 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 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(),
})
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
return
}
selectionHydrated.value = false
coverHydrated.value = false
await Promise.allSettled([
props.articleId ? detailQuery.refetch() : Promise.resolve(),
accountsQuery.refetch(),
platformsQuery.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))),
)
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() : '',
)
watch(
coverRequired,
(required) => {
if (required) {
coverEnabled.value = true
}
},
{ immediate: true },
)
const publishMutation = useMutation({
mutationFn: async () => {
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)
return publishJobsApi.create({
title: article.title?.trim() || t('article.untitled'),
content_ref: {
article_id: article.id,
},
accounts: selectedAccountIds.value.map((accountId) => ({
account_id: accountId,
})),
})
},
onSuccess: async () => {
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)
},
onError: (error) => {
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
}
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 || publishMutation.isPending.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
}
</script>
<template>
<a-modal
:open="publishModalVisible"
title="发布"
:width="980"
:confirm-loading="publishMutation.isPending.value"
ok-text="提交发布"
:cancel-text="t('common.cancel')"
@ok="publishMutation.mutate()"
@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">
<h3>
<span class="required-star">*</span>
目标账号
</h3>
<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>
<div v-else-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,
}"
@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="当前还没有可用的桌面媒体账号,请先在桌面端绑定。" />
</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__section-header {
margin-bottom: 12px;
}
.publish-modal__section-header--inline {
display: flex;
align-items: center;
gap: 12px;
}
.publish-modal__section-header h3 {
margin: 0 0 6px;
color: #1f2937;
font-size: 16px;
font-weight: 600;
}
.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__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>