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>
This commit is contained in:
@@ -16,6 +16,8 @@ import type {
|
||||
EnterpriseSitePublishResponse,
|
||||
GateDecisionLiteral,
|
||||
PublishRecord,
|
||||
ScheduleCoverMode,
|
||||
ScheduleCoverRandomScope,
|
||||
} from '@geo/shared-types'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { message, notification } from 'ant-design-vue'
|
||||
@@ -23,10 +25,12 @@ 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,
|
||||
@@ -86,6 +90,9 @@ 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)
|
||||
@@ -161,6 +168,9 @@ watch(
|
||||
enterpriseCategoriesBySiteId.value = {}
|
||||
enterpriseSiteCategoryIds.value = {}
|
||||
enterprisePublishType.value = 'publish'
|
||||
coverMode.value = 'specific'
|
||||
coverRandomScope.value = 'all'
|
||||
coverRandomFolderId.value = null
|
||||
coverAssetUrl.value = ''
|
||||
coverFileName.value = ''
|
||||
coverImageAssetId.value = null
|
||||
@@ -418,7 +428,10 @@ 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() : '',
|
||||
effectiveCoverEnabled.value && coverMode.value === 'specific' ? coverAssetUrl.value.trim() : '',
|
||||
)
|
||||
const randomCoverEnabled = computed(
|
||||
() => effectiveCoverEnabled.value && coverMode.value === 'random',
|
||||
)
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: () => runPublishFlow(),
|
||||
@@ -524,7 +537,7 @@ const okDisabled = computed(() => {
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (coverRequired.value && !normalizedCoverValue.value) {
|
||||
if (coverRequired.value && !normalizedCoverValue.value && !randomCoverEnabled.value) {
|
||||
return true
|
||||
}
|
||||
if (mandatoryAdmissionBlocking.value) {
|
||||
@@ -681,12 +694,12 @@ async function runPublishFlow(ackRecordId?: number) {
|
||||
if (!selectedAccountIds.value.length) {
|
||||
throw new Error('no_accounts_selected')
|
||||
}
|
||||
if (coverRequired.value && !normalizedCoverValue.value) {
|
||||
if (coverRequired.value && !normalizedCoverValue.value && !randomCoverEnabled.value) {
|
||||
throw new Error('cover_required_for_selected_platforms')
|
||||
}
|
||||
|
||||
const freshDetail = await loadLatestPublishArticleDetail()
|
||||
const article = await persistCoverIfNeeded(freshDetail)
|
||||
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')
|
||||
@@ -735,7 +748,7 @@ async function runEnterpriseSitePublishFlow(
|
||||
}
|
||||
|
||||
const freshDetail = await loadLatestPublishArticleDetail()
|
||||
const article = await persistCoverIfNeeded(freshDetail)
|
||||
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')
|
||||
@@ -860,6 +873,14 @@ function handlePublishError(error: unknown) {
|
||||
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
|
||||
}
|
||||
@@ -911,12 +932,21 @@ async function handleEnterprisePublishSuccess(
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
type PublishCoverOverride = {
|
||||
url: string
|
||||
assetId: number | null
|
||||
}
|
||||
|
||||
if (currentUrl === nextUrl && currentAssetId === coverImageAssetId.value) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -924,10 +954,47 @@ async function persistCoverIfNeeded(detail: ArticleDetail): Promise<ArticleDetai
|
||||
title: detail.title?.trim() || t('article.untitled'),
|
||||
markdown_content: detail.markdown_content ?? '',
|
||||
cover_asset_url: nextUrl || null,
|
||||
cover_image_asset_id: coverImageAssetId.value,
|
||||
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
|
||||
@@ -1144,7 +1211,9 @@ function cmsTypeLabel(cmsType: string): string {
|
||||
}
|
||||
|
||||
function normalizeEnterprisePlatformId(cmsType: string): string {
|
||||
const normalized = String(cmsType ?? '').trim().toLowerCase()
|
||||
const normalized = String(cmsType ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
switch (normalized) {
|
||||
case 'pbootcms':
|
||||
return 'pbootcms'
|
||||
@@ -1246,6 +1315,7 @@ function handleCoverPicked(payload: {
|
||||
coverFileName.value = payload.fileName
|
||||
coverImageAssetId.value = payload.assetId ?? null
|
||||
coverEnabled.value = true
|
||||
coverMode.value = 'specific'
|
||||
}
|
||||
|
||||
function handleRemoveCover(): void {
|
||||
@@ -1254,6 +1324,15 @@ function handleRemoveCover(): void {
|
||||
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
|
||||
@@ -1838,32 +1917,52 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
||||
</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>
|
||||
<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-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>
|
||||
<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>
|
||||
@@ -2758,71 +2857,125 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
||||
}
|
||||
|
||||
.publish-modal__cover-body {
|
||||
display: flex;
|
||||
margin-top: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.publish-modal__cover-preview-wrap {
|
||||
.publish-modal__cover-preview-card {
|
||||
position: relative;
|
||||
width: 176px;
|
||||
flex: 0 0 176px;
|
||||
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-preview {
|
||||
.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;
|
||||
width: 100%;
|
||||
min-height: 124px;
|
||||
padding: 0;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
gap: 8px;
|
||||
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;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.publish-modal__cover-preview:hover {
|
||||
border-color: #6366f1;
|
||||
color: #6366f1;
|
||||
background: #f5f3ff;
|
||||
.publish-modal__cover-upload-placeholder:hover {
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
background: #f0f6ff;
|
||||
}
|
||||
|
||||
.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;
|
||||
.publish-modal__upload-icon {
|
||||
margin-bottom: 6px;
|
||||
color: #94a3b8;
|
||||
transition: color 0.2s;
|
||||
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);
|
||||
.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) {
|
||||
|
||||
Reference in New Issue
Block a user