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:
2026-06-14 21:02:39 +08:00
parent f27c910c93
commit c6b7090536
11 changed files with 1032 additions and 178 deletions
@@ -0,0 +1,343 @@
<script setup lang="ts">
import { imagesApi } from '@/lib/api'
import type { ScheduleCoverMode, ScheduleCoverRandomScope } from '@geo/shared-types'
import { useQuery } from '@tanstack/vue-query'
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const props = withDefaults(
defineProps<{
mode: ScheduleCoverMode
randomScope: ScheduleCoverRandomScope
randomFolderId: number | null
disabled?: boolean
}>(),
{
disabled: false,
},
)
const emit = defineEmits<{
'update:mode': [value: ScheduleCoverMode]
'update:randomScope': [value: ScheduleCoverRandomScope]
'update:randomFolderId': [value: number | null]
}>()
const { t } = useI18n()
const foldersQuery = useQuery({
queryKey: ['images', 'folders', 'cover-source'],
enabled: computed(() => !props.disabled),
queryFn: () => imagesApi.listFolders(),
})
const folderOptions = computed(() =>
(foldersQuery.data.value ?? []).map((folder) => ({
label: `${folder.name} (${folder.image_count})`,
value: folder.id,
})),
)
watch(
() => props.mode,
(mode) => {
if (mode === 'specific') {
emit('update:randomFolderId', null)
}
},
)
watch(
() => props.randomScope,
(scope) => {
if (scope === 'all') {
emit('update:randomFolderId', null)
}
},
)
function updateMode(value: string | number): void {
emit('update:mode', value === 'random' ? 'random' : 'specific')
}
function updateScope(value: string | number): void {
emit('update:randomScope', value === 'folder' ? 'folder' : 'all')
}
function updateFolder(value: number | null): void {
emit('update:randomFolderId', value ?? null)
}
</script>
<template>
<div
class="cover-source-selector-panel"
:class="{ 'cover-source-selector-panel--disabled': disabled }"
>
<!-- Top part: Compact Low-profile controls stacked vertically with comfortable spacing -->
<div class="cover-source-selector__controls">
<!-- Mode selection row -->
<div class="control-group">
<span class="control-label">封面模式</span>
<div class="custom-segmented">
<button
type="button"
class="custom-segmented__item"
:class="{ 'custom-segmented__item--active': mode === 'specific' }"
:disabled="disabled"
@click="updateMode('specific')"
>
{{ t('coverSource.specific') }}
</button>
<button
type="button"
class="custom-segmented__item"
:class="{ 'custom-segmented__item--active': mode === 'random' }"
:disabled="disabled"
@click="updateMode('random')"
>
{{ t('coverSource.random') }}
</button>
</div>
</div>
<!-- Scope selection row (Only for Random Mode) -->
<div v-if="mode === 'random'" class="control-group anim-fade-in">
<span class="control-label">图片来源</span>
<div class="custom-segmented">
<button
type="button"
class="custom-segmented__item"
:class="{ 'custom-segmented__item--active': randomScope === 'all' }"
:disabled="disabled"
@click="updateScope('all')"
>
{{ t('coverSource.randomAll') }}
</button>
<button
type="button"
class="custom-segmented__item"
:class="{ 'custom-segmented__item--active': randomScope === 'folder' }"
:disabled="disabled"
@click="updateScope('folder')"
>
{{ t('coverSource.randomFolder') }}
</button>
</div>
</div>
<!-- Folder select row (Only for Random + Folder Scope) -->
<div v-if="mode === 'random' && randomScope === 'folder'" class="control-group anim-fade-in">
<span class="control-label">选择目录</span>
<a-select
class="cover-source-selector__folder"
size="default"
:value="randomFolderId"
:options="folderOptions"
:loading="foldersQuery.isPending.value"
:disabled="disabled"
:placeholder="t('coverSource.folderPlaceholder')"
allow-clear
@change="updateFolder"
/>
</div>
</div>
<!-- Bottom part: Visual Preview or Gradient Placeholder shown below controls -->
<div class="cover-source-selector__preview-area anim-fade-in">
<div v-if="mode === 'random'" class="random-gradient-card">
<div class="random-gradient-card__badge">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 3 21 3 21 8" />
<line x1="4" y1="20" x2="21" y2="3" />
<polyline points="21 16 21 21 16 21" />
<line x1="15" y1="15" x2="21" y2="21" />
<line x1="4" y1="4" x2="9" y2="9" />
</svg>
</div>
<span class="random-gradient-card__text">随机抽取中</span>
</div>
<div v-else class="specific-preview-container">
<slot name="specific-preview"></slot>
</div>
<!-- Hint text for Random Mode -->
<div v-if="mode === 'random'" class="control-hint anim-fade-in">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="control-hint__icon">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
<span>{{ t('coverSource.randomHint') }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.cover-source-selector-panel {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
}
.cover-source-selector-panel--disabled {
opacity: 0.6;
pointer-events: none;
}
.cover-source-selector__controls {
display: flex;
flex-direction: column;
gap: 16px;
}
.cover-source-selector__preview-area {
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
margin-top: 8px;
}
.random-gradient-card {
position: relative;
width: 200px;
height: 112px;
border-radius: 10px;
background: linear-gradient(135deg, #4f46e5, #06b6d4);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.15);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.random-gradient-card__badge {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: #ffffff;
margin-bottom: 6px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08);
animation: float 3s ease-in-out infinite;
}
.random-gradient-card__text {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
letter-spacing: 0.05em;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.specific-preview-container {
width: 200px;
height: 112px;
}
.control-group {
display: flex;
align-items: center;
gap: 20px;
}
.control-label {
font-size: 13px;
font-weight: 500;
color: #64748b;
width: 72px;
flex-shrink: 0;
}
.custom-segmented {
display: inline-flex;
padding: 3px;
background: #f1f5f9;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.custom-segmented__item {
padding: 6px 16px;
border: none;
border-radius: 6px;
background: transparent;
font-size: 13px;
font-weight: 500;
color: #475569;
cursor: pointer;
transition: all 0.15s ease;
outline: none;
}
.custom-segmented__item:hover:not(:disabled) {
color: #0f172a;
}
.custom-segmented__item--active {
background: #ffffff;
color: #0f172a;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.cover-source-selector__folder {
width: 260px;
max-width: 100%;
}
.control-hint {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #64748b;
margin-top: 4px;
}
.control-hint__icon {
color: #3b82f6;
flex-shrink: 0;
}
.anim-fade-in {
animation: fadeIn 0.2s ease-out forwards;
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 640px) {
.random-gradient-card, .specific-preview-container {
width: 100%;
}
}
</style>
@@ -13,6 +13,8 @@ import type {
KolSubscriptionPromptCard,
KolVariableDefinition,
PromptRuleSimple,
ScheduleCoverMode,
ScheduleCoverRandomScope,
ScheduleEnterpriseSiteTarget,
ScheduleKind,
ScheduleTargetType,
@@ -24,6 +26,7 @@ import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import CoverPickerModal from '@/components/CoverPickerModal.vue'
import CoverSourceSelector from '@/components/CoverSourceSelector.vue'
import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
import KolDynamicForm from '@/components/kol/KolDynamicForm.vue'
import KolPlatformTags from '@/components/kol/KolPlatformTags.vue'
@@ -113,6 +116,9 @@ const form = reactive({
enterpriseSiteCategoryIds: {} as Record<number, string>,
enterprisePublishType: 'publish' as SchedulePublishType,
coverEnabled: false,
coverMode: 'specific' as ScheduleCoverMode,
coverRandomScope: 'all' as ScheduleCoverRandomScope,
coverRandomFolderId: null as number | null,
coverAssetUrl: '',
coverFileName: '',
coverImageAssetId: null as number | null,
@@ -469,10 +475,14 @@ function hydrateForm(): void {
enterpriseTargets.forEach((target) => {
void loadEnterpriseCategories(target.site_id)
})
form.coverAssetUrl = isSchedule.value ? resolveApiURL(task?.cover_asset_url) : ''
form.coverMode = isSchedule.value ? (task?.cover_mode ?? 'specific') : 'specific'
form.coverRandomScope = isSchedule.value ? (task?.cover_random_scope ?? 'all') : 'all'
form.coverRandomFolderId = isSchedule.value ? (task?.cover_random_folder_id ?? null) : null
form.coverAssetUrl =
isSchedule.value && form.coverMode === 'specific' ? resolveApiURL(task?.cover_asset_url) : ''
form.coverFileName = deriveCoverFileName(form.coverAssetUrl)
form.coverImageAssetId = isSchedule.value ? (task?.cover_image_asset_id ?? null) : null
form.coverEnabled = Boolean(form.coverAssetUrl)
form.coverEnabled = form.coverMode === 'random' || Boolean(form.coverAssetUrl)
form.enableWebSearch = task?.enable_web_search ?? false
form.generateCount = task?.generate_count ?? 1
form.scheduleKind = task?.schedule_kind ?? 'daily'
@@ -505,7 +515,8 @@ function selectDefaultSubscriptionPrompt(): void {
function buildSchedulePayload() {
const coverEnabled = form.autoPublish && effectiveScheduleCoverEnabled.value
const coverAssetUrl = coverEnabled ? form.coverAssetUrl.trim() : ''
const coverMode = coverEnabled ? form.coverMode : 'specific'
const coverAssetUrl = coverEnabled && coverMode === 'specific' ? form.coverAssetUrl.trim() : ''
const publishAccountIds =
form.autoPublish && form.publishTargetTab === 'media_accounts' ? form.publishAccountIds : []
const publishEnterpriseSiteTargets =
@@ -530,6 +541,12 @@ function buildSchedulePayload() {
publish_enterprise_site_targets: publishEnterpriseSiteTargets,
cover_asset_url: coverAssetUrl || null,
cover_image_asset_id: coverAssetUrl ? form.coverImageAssetId : null,
cover_mode: coverMode,
cover_random_scope: coverEnabled && coverMode === 'random' ? form.coverRandomScope : 'all',
cover_random_folder_id:
coverEnabled && coverMode === 'random' && form.coverRandomScope === 'folder'
? form.coverRandomFolderId
: null,
enable_web_search: form.targetType === 'prompt_rule' ? form.enableWebSearch : false,
generate_count: form.generateCount,
}
@@ -691,6 +708,7 @@ function handleScheduleCoverPicked(payload: {
form.coverFileName = payload.fileName
form.coverImageAssetId = payload.assetId ?? null
form.coverEnabled = true
form.coverMode = 'specific'
}
function handleRemoveScheduleCover(): void {
@@ -699,6 +717,15 @@ function handleRemoveScheduleCover(): void {
form.coverImageAssetId = null
}
function handleScheduleCoverModeChange(value: ScheduleCoverMode): void {
form.coverMode = value
if (value === 'random') {
form.coverAssetUrl = ''
form.coverFileName = ''
form.coverImageAssetId = null
}
}
function handleScheduleCoverToggle(checked: boolean): void {
if (scheduleCoverRequired.value) {
form.coverEnabled = true
@@ -783,11 +810,21 @@ function validateForm(): boolean {
if (
form.publishTargetTab === 'media_accounts' &&
scheduleCoverRequired.value &&
form.coverMode === 'specific' &&
!form.coverAssetUrl.trim()
) {
message.warning(t('custom.messages.missingScheduleCover'))
return false
}
if (
effectiveScheduleCoverEnabled.value &&
form.coverMode === 'random' &&
form.coverRandomScope === 'folder' &&
!form.coverRandomFolderId
) {
message.warning(t('coverSource.randomEmpty'))
return false
}
}
return true
@@ -1454,32 +1491,52 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
</p>
<div v-if="effectiveScheduleCoverEnabled" class="generate-task-drawer__cover-body">
<div class="generate-task-drawer__cover-preview-wrap">
<button
type="button"
class="generate-task-drawer__cover-preview"
@click="coverPickerOpen = true"
>
<template v-if="form.coverAssetUrl">
<img :src="form.coverAssetUrl" alt="cover preview" />
</template>
<template v-else>
<span class="generate-task-drawer__cover-plus">+</span>
<span>{{ t('custom.task.coverUpload') }}</span>
</template>
</button>
<CoverSourceSelector
:mode="form.coverMode"
:random-scope="form.coverRandomScope"
:random-folder-id="form.coverRandomFolderId"
@update:mode="handleScheduleCoverModeChange"
@update:random-scope="form.coverRandomScope = $event"
@update:random-folder-id="form.coverRandomFolderId = $event"
>
<template #specific-preview>
<div v-if="form.coverAssetUrl" class="generate-task-drawer__cover-preview-card animate-fade-in">
<img :src="form.coverAssetUrl" alt="cover preview" class="generate-task-drawer__cover-img" />
<div class="generate-task-drawer__cover-overlay">
<button
type="button"
class="generate-task-drawer__overlay-btn"
@click="coverPickerOpen = true"
>
更换
</button>
<button
type="button"
class="generate-task-drawer__overlay-btn generate-task-drawer__overlay-btn--danger"
@click.stop="handleRemoveScheduleCover"
>
移除
</button>
</div>
</div>
<button
v-if="form.coverAssetUrl"
type="button"
class="generate-task-drawer__cover-remove"
:aria-label="t('article.editor.coverRemove')"
:title="t('article.editor.coverRemove')"
@click.stop="handleRemoveScheduleCover"
>
<MinusCircleFilled />
</button>
</div>
<button
v-else
type="button"
class="generate-task-drawer__cover-upload-placeholder animate-fade-in"
@click="coverPickerOpen = true"
>
<div class="generate-task-drawer__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="generate-task-drawer__upload-text">{{ t('custom.task.coverUpload') }}</span>
</button>
</template>
</CoverSourceSelector>
</div>
</div>
</template>
@@ -2075,71 +2132,125 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
}
.generate-task-drawer__cover-body {
display: flex;
margin-top: 12px;
margin-top: 16px;
}
.generate-task-drawer__cover-preview-wrap {
.generate-task-drawer__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);
}
.generate-task-drawer__cover-preview {
.generate-task-drawer__cover-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.generate-task-drawer__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: 8px;
width: 100%;
min-height: 124px;
padding: 0;
border: 1px dashed #d1d5db;
border-radius: 8px;
background: #f9fafb;
color: #6b7280;
font-size: 13px;
gap: 10px;
opacity: 0;
transition: opacity 0.2s ease-in-out;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.generate-task-drawer__cover-preview-card:hover .generate-task-drawer__cover-overlay {
opacity: 1;
}
.generate-task-drawer__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);
}
.generate-task-drawer__overlay-btn:hover {
background: #f1f5f9;
transform: scale(1.03);
}
.generate-task-drawer__overlay-btn--danger {
color: #ffffff;
background: #ef4444;
}
.generate-task-drawer__overlay-btn--danger:hover {
background: #dc2626;
}
.generate-task-drawer__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;
}
.generate-task-drawer__cover-preview:hover {
border-color: #6366f1;
background: #f5f3ff;
color: #6366f1;
.generate-task-drawer__cover-upload-placeholder:hover {
border-color: #3b82f6;
color: #3b82f6;
background: #f0f6ff;
}
.generate-task-drawer__cover-preview img {
display: block;
width: 100%;
height: 124px;
object-fit: cover;
}
.generate-task-drawer__cover-plus {
font-size: 20px;
line-height: 1;
}
.generate-task-drawer__cover-remove {
position: absolute;
top: 8px;
right: 8px;
.generate-task-drawer__upload-icon {
margin-bottom: 6px;
color: #94a3b8;
transition: color 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
background: rgba(17, 24, 39, 0.7);
color: #fff;
cursor: pointer;
transition: background 0.2s ease;
}
.generate-task-drawer__cover-remove:hover {
background: rgba(17, 24, 39, 0.9);
.generate-task-drawer__cover-upload-placeholder:hover .generate-task-drawer__upload-icon {
color: #3b82f6;
}
.generate-task-drawer__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);
}
}
.generate-task-drawer__time-wrapper {
@@ -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) {
@@ -69,6 +69,15 @@ const zhCN = {
},
},
},
coverSource: {
specific: '指定图片',
random: '随机图片',
randomAll: '全部图片',
randomFolder: '指定文件夹',
folderPlaceholder: '选择图片文件夹',
randomHint: '发布时会从所选范围随机抽取一张封面图。',
randomEmpty: '请先选择随机图片文件夹',
},
nav: {
workspace: '工作台',
articleCreation: '文章创作',