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:
@@ -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,
|
KolSubscriptionPromptCard,
|
||||||
KolVariableDefinition,
|
KolVariableDefinition,
|
||||||
PromptRuleSimple,
|
PromptRuleSimple,
|
||||||
|
ScheduleCoverMode,
|
||||||
|
ScheduleCoverRandomScope,
|
||||||
ScheduleEnterpriseSiteTarget,
|
ScheduleEnterpriseSiteTarget,
|
||||||
ScheduleKind,
|
ScheduleKind,
|
||||||
ScheduleTargetType,
|
ScheduleTargetType,
|
||||||
@@ -24,6 +26,7 @@ import { computed, reactive, ref, watch } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import CoverPickerModal from '@/components/CoverPickerModal.vue'
|
import CoverPickerModal from '@/components/CoverPickerModal.vue'
|
||||||
|
import CoverSourceSelector from '@/components/CoverSourceSelector.vue'
|
||||||
import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
|
import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
|
||||||
import KolDynamicForm from '@/components/kol/KolDynamicForm.vue'
|
import KolDynamicForm from '@/components/kol/KolDynamicForm.vue'
|
||||||
import KolPlatformTags from '@/components/kol/KolPlatformTags.vue'
|
import KolPlatformTags from '@/components/kol/KolPlatformTags.vue'
|
||||||
@@ -113,6 +116,9 @@ const form = reactive({
|
|||||||
enterpriseSiteCategoryIds: {} as Record<number, string>,
|
enterpriseSiteCategoryIds: {} as Record<number, string>,
|
||||||
enterprisePublishType: 'publish' as SchedulePublishType,
|
enterprisePublishType: 'publish' as SchedulePublishType,
|
||||||
coverEnabled: false,
|
coverEnabled: false,
|
||||||
|
coverMode: 'specific' as ScheduleCoverMode,
|
||||||
|
coverRandomScope: 'all' as ScheduleCoverRandomScope,
|
||||||
|
coverRandomFolderId: null as number | null,
|
||||||
coverAssetUrl: '',
|
coverAssetUrl: '',
|
||||||
coverFileName: '',
|
coverFileName: '',
|
||||||
coverImageAssetId: null as number | null,
|
coverImageAssetId: null as number | null,
|
||||||
@@ -469,10 +475,14 @@ function hydrateForm(): void {
|
|||||||
enterpriseTargets.forEach((target) => {
|
enterpriseTargets.forEach((target) => {
|
||||||
void loadEnterpriseCategories(target.site_id)
|
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.coverFileName = deriveCoverFileName(form.coverAssetUrl)
|
||||||
form.coverImageAssetId = isSchedule.value ? (task?.cover_image_asset_id ?? null) : null
|
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.enableWebSearch = task?.enable_web_search ?? false
|
||||||
form.generateCount = task?.generate_count ?? 1
|
form.generateCount = task?.generate_count ?? 1
|
||||||
form.scheduleKind = task?.schedule_kind ?? 'daily'
|
form.scheduleKind = task?.schedule_kind ?? 'daily'
|
||||||
@@ -505,7 +515,8 @@ function selectDefaultSubscriptionPrompt(): void {
|
|||||||
|
|
||||||
function buildSchedulePayload() {
|
function buildSchedulePayload() {
|
||||||
const coverEnabled = form.autoPublish && effectiveScheduleCoverEnabled.value
|
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 =
|
const publishAccountIds =
|
||||||
form.autoPublish && form.publishTargetTab === 'media_accounts' ? form.publishAccountIds : []
|
form.autoPublish && form.publishTargetTab === 'media_accounts' ? form.publishAccountIds : []
|
||||||
const publishEnterpriseSiteTargets =
|
const publishEnterpriseSiteTargets =
|
||||||
@@ -530,6 +541,12 @@ function buildSchedulePayload() {
|
|||||||
publish_enterprise_site_targets: publishEnterpriseSiteTargets,
|
publish_enterprise_site_targets: publishEnterpriseSiteTargets,
|
||||||
cover_asset_url: coverAssetUrl || null,
|
cover_asset_url: coverAssetUrl || null,
|
||||||
cover_image_asset_id: coverAssetUrl ? form.coverImageAssetId : 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,
|
enable_web_search: form.targetType === 'prompt_rule' ? form.enableWebSearch : false,
|
||||||
generate_count: form.generateCount,
|
generate_count: form.generateCount,
|
||||||
}
|
}
|
||||||
@@ -691,6 +708,7 @@ function handleScheduleCoverPicked(payload: {
|
|||||||
form.coverFileName = payload.fileName
|
form.coverFileName = payload.fileName
|
||||||
form.coverImageAssetId = payload.assetId ?? null
|
form.coverImageAssetId = payload.assetId ?? null
|
||||||
form.coverEnabled = true
|
form.coverEnabled = true
|
||||||
|
form.coverMode = 'specific'
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemoveScheduleCover(): void {
|
function handleRemoveScheduleCover(): void {
|
||||||
@@ -699,6 +717,15 @@ function handleRemoveScheduleCover(): void {
|
|||||||
form.coverImageAssetId = null
|
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 {
|
function handleScheduleCoverToggle(checked: boolean): void {
|
||||||
if (scheduleCoverRequired.value) {
|
if (scheduleCoverRequired.value) {
|
||||||
form.coverEnabled = true
|
form.coverEnabled = true
|
||||||
@@ -783,11 +810,21 @@ function validateForm(): boolean {
|
|||||||
if (
|
if (
|
||||||
form.publishTargetTab === 'media_accounts' &&
|
form.publishTargetTab === 'media_accounts' &&
|
||||||
scheduleCoverRequired.value &&
|
scheduleCoverRequired.value &&
|
||||||
|
form.coverMode === 'specific' &&
|
||||||
!form.coverAssetUrl.trim()
|
!form.coverAssetUrl.trim()
|
||||||
) {
|
) {
|
||||||
message.warning(t('custom.messages.missingScheduleCover'))
|
message.warning(t('custom.messages.missingScheduleCover'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
effectiveScheduleCoverEnabled.value &&
|
||||||
|
form.coverMode === 'random' &&
|
||||||
|
form.coverRandomScope === 'folder' &&
|
||||||
|
!form.coverRandomFolderId
|
||||||
|
) {
|
||||||
|
message.warning(t('coverSource.randomEmpty'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -1454,32 +1491,52 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="effectiveScheduleCoverEnabled" class="generate-task-drawer__cover-body">
|
<div v-if="effectiveScheduleCoverEnabled" class="generate-task-drawer__cover-body">
|
||||||
<div class="generate-task-drawer__cover-preview-wrap">
|
<CoverSourceSelector
|
||||||
<button
|
:mode="form.coverMode"
|
||||||
type="button"
|
:random-scope="form.coverRandomScope"
|
||||||
class="generate-task-drawer__cover-preview"
|
:random-folder-id="form.coverRandomFolderId"
|
||||||
@click="coverPickerOpen = true"
|
@update:mode="handleScheduleCoverModeChange"
|
||||||
>
|
@update:random-scope="form.coverRandomScope = $event"
|
||||||
<template v-if="form.coverAssetUrl">
|
@update:random-folder-id="form.coverRandomFolderId = $event"
|
||||||
<img :src="form.coverAssetUrl" alt="cover preview" />
|
>
|
||||||
</template>
|
<template #specific-preview>
|
||||||
<template v-else>
|
<div v-if="form.coverAssetUrl" class="generate-task-drawer__cover-preview-card animate-fade-in">
|
||||||
<span class="generate-task-drawer__cover-plus">+</span>
|
<img :src="form.coverAssetUrl" alt="cover preview" class="generate-task-drawer__cover-img" />
|
||||||
<span>{{ t('custom.task.coverUpload') }}</span>
|
<div class="generate-task-drawer__cover-overlay">
|
||||||
</template>
|
<button
|
||||||
</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
|
<button
|
||||||
v-if="form.coverAssetUrl"
|
v-else
|
||||||
type="button"
|
type="button"
|
||||||
class="generate-task-drawer__cover-remove"
|
class="generate-task-drawer__cover-upload-placeholder animate-fade-in"
|
||||||
:aria-label="t('article.editor.coverRemove')"
|
@click="coverPickerOpen = true"
|
||||||
:title="t('article.editor.coverRemove')"
|
>
|
||||||
@click.stop="handleRemoveScheduleCover"
|
<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">
|
||||||
<MinusCircleFilled />
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
</button>
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -2075,71 +2132,125 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
|
|||||||
}
|
}
|
||||||
|
|
||||||
.generate-task-drawer__cover-body {
|
.generate-task-drawer__cover-body {
|
||||||
display: flex;
|
margin-top: 16px;
|
||||||
margin-top: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-task-drawer__cover-preview-wrap {
|
.generate-task-drawer__cover-preview-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 176px;
|
width: 200px;
|
||||||
flex: 0 0 176px;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
width: 100%;
|
opacity: 0;
|
||||||
min-height: 124px;
|
transition: opacity 0.2s ease-in-out;
|
||||||
padding: 0;
|
backdrop-filter: blur(4px);
|
||||||
border: 1px dashed #d1d5db;
|
-webkit-backdrop-filter: blur(4px);
|
||||||
border-radius: 8px;
|
}
|
||||||
background: #f9fafb;
|
|
||||||
color: #6b7280;
|
.generate-task-drawer__cover-preview-card:hover .generate-task-drawer__cover-overlay {
|
||||||
font-size: 13px;
|
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;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-task-drawer__cover-preview:hover {
|
.generate-task-drawer__cover-upload-placeholder:hover {
|
||||||
border-color: #6366f1;
|
border-color: #3b82f6;
|
||||||
background: #f5f3ff;
|
color: #3b82f6;
|
||||||
color: #6366f1;
|
background: #f0f6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-task-drawer__cover-preview img {
|
.generate-task-drawer__upload-icon {
|
||||||
display: block;
|
margin-bottom: 6px;
|
||||||
width: 100%;
|
color: #94a3b8;
|
||||||
height: 124px;
|
transition: color 0.2s;
|
||||||
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;
|
|
||||||
display: inline-flex;
|
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 {
|
.generate-task-drawer__cover-upload-placeholder:hover .generate-task-drawer__upload-icon {
|
||||||
background: rgba(17, 24, 39, 0.9);
|
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 {
|
.generate-task-drawer__time-wrapper {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import type {
|
|||||||
EnterpriseSitePublishResponse,
|
EnterpriseSitePublishResponse,
|
||||||
GateDecisionLiteral,
|
GateDecisionLiteral,
|
||||||
PublishRecord,
|
PublishRecord,
|
||||||
|
ScheduleCoverMode,
|
||||||
|
ScheduleCoverRandomScope,
|
||||||
} from '@geo/shared-types'
|
} from '@geo/shared-types'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { message, notification } from 'ant-design-vue'
|
import { message, notification } from 'ant-design-vue'
|
||||||
@@ -23,10 +25,12 @@ import { computed, ref, watch, watchEffect } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import CoverPickerModal from '@/components/CoverPickerModal.vue'
|
import CoverPickerModal from '@/components/CoverPickerModal.vue'
|
||||||
|
import CoverSourceSelector from '@/components/CoverSourceSelector.vue'
|
||||||
import {
|
import {
|
||||||
articlesApi,
|
articlesApi,
|
||||||
complianceApi,
|
complianceApi,
|
||||||
enterpriseSitesApi,
|
enterpriseSitesApi,
|
||||||
|
imagesApi,
|
||||||
mediaApi,
|
mediaApi,
|
||||||
publishJobsApi,
|
publishJobsApi,
|
||||||
resolveApiURL,
|
resolveApiURL,
|
||||||
@@ -86,6 +90,9 @@ const enterpriseCategoriesBySiteId = ref<Record<number, EnterpriseSiteCategory[]
|
|||||||
const enterpriseSiteCategoryIds = ref<Record<number, string>>({})
|
const enterpriseSiteCategoryIds = ref<Record<number, string>>({})
|
||||||
const enterprisePublishType = ref<PublishType>('publish')
|
const enterprisePublishType = ref<PublishType>('publish')
|
||||||
const coverEnabled = ref(false)
|
const coverEnabled = ref(false)
|
||||||
|
const coverMode = ref<ScheduleCoverMode>('specific')
|
||||||
|
const coverRandomScope = ref<ScheduleCoverRandomScope>('all')
|
||||||
|
const coverRandomFolderId = ref<number | null>(null)
|
||||||
const coverAssetUrl = ref('')
|
const coverAssetUrl = ref('')
|
||||||
const coverFileName = ref('')
|
const coverFileName = ref('')
|
||||||
const coverImageAssetId = ref<number | null>(null)
|
const coverImageAssetId = ref<number | null>(null)
|
||||||
@@ -161,6 +168,9 @@ watch(
|
|||||||
enterpriseCategoriesBySiteId.value = {}
|
enterpriseCategoriesBySiteId.value = {}
|
||||||
enterpriseSiteCategoryIds.value = {}
|
enterpriseSiteCategoryIds.value = {}
|
||||||
enterprisePublishType.value = 'publish'
|
enterprisePublishType.value = 'publish'
|
||||||
|
coverMode.value = 'specific'
|
||||||
|
coverRandomScope.value = 'all'
|
||||||
|
coverRandomFolderId.value = null
|
||||||
coverAssetUrl.value = ''
|
coverAssetUrl.value = ''
|
||||||
coverFileName.value = ''
|
coverFileName.value = ''
|
||||||
coverImageAssetId.value = null
|
coverImageAssetId.value = null
|
||||||
@@ -418,7 +428,10 @@ const publishModalVisible = computed(() => props.open && !coverPickerOpen.value)
|
|||||||
const coverRequired = computed(() => coverUploadRequired(selectedPlatformIds.value))
|
const coverRequired = computed(() => coverUploadRequired(selectedPlatformIds.value))
|
||||||
const effectiveCoverEnabled = computed(() => coverRequired.value || coverEnabled.value)
|
const effectiveCoverEnabled = computed(() => coverRequired.value || coverEnabled.value)
|
||||||
const normalizedCoverValue = computed(() =>
|
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({
|
const publishMutation = useMutation({
|
||||||
mutationFn: () => runPublishFlow(),
|
mutationFn: () => runPublishFlow(),
|
||||||
@@ -524,7 +537,7 @@ const okDisabled = computed(() => {
|
|||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (coverRequired.value && !normalizedCoverValue.value) {
|
if (coverRequired.value && !normalizedCoverValue.value && !randomCoverEnabled.value) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (mandatoryAdmissionBlocking.value) {
|
if (mandatoryAdmissionBlocking.value) {
|
||||||
@@ -681,12 +694,12 @@ async function runPublishFlow(ackRecordId?: number) {
|
|||||||
if (!selectedAccountIds.value.length) {
|
if (!selectedAccountIds.value.length) {
|
||||||
throw new Error('no_accounts_selected')
|
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')
|
throw new Error('cover_required_for_selected_platforms')
|
||||||
}
|
}
|
||||||
|
|
||||||
const freshDetail = await loadLatestPublishArticleDetail()
|
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
|
const articleVersionId = article.current_version_id ?? detailQuery.data.value.current_version_id
|
||||||
if (!articleVersionId) {
|
if (!articleVersionId) {
|
||||||
throw new Error('invalid_article_version')
|
throw new Error('invalid_article_version')
|
||||||
@@ -735,7 +748,7 @@ async function runEnterpriseSitePublishFlow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const freshDetail = await loadLatestPublishArticleDetail()
|
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
|
const articleVersionId = article.current_version_id ?? detailQuery.data.value.current_version_id
|
||||||
if (!articleVersionId) {
|
if (!articleVersionId) {
|
||||||
throw new Error('invalid_article_version')
|
throw new Error('invalid_article_version')
|
||||||
@@ -860,6 +873,14 @@ function handlePublishError(error: unknown) {
|
|||||||
message.warning(t('media.publish.messages.coverRequired'))
|
message.warning(t('media.publish.messages.coverRequired'))
|
||||||
return
|
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') {
|
if (normalized === 'compliance_needs_ack' || normalized === 'compliance_block') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -911,12 +932,21 @@ async function handleEnterprisePublishSuccess(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistCoverIfNeeded(detail: ArticleDetail): Promise<ArticleDetail> {
|
type PublishCoverOverride = {
|
||||||
const currentUrl = resolveApiURL(detail.cover_asset_url).trim()
|
url: string
|
||||||
const nextUrl = normalizedCoverValue.value.trim()
|
assetId: number | null
|
||||||
const currentAssetId = detail.cover_image_asset_id ?? 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
|
return detail
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -924,10 +954,47 @@ async function persistCoverIfNeeded(detail: ArticleDetail): Promise<ArticleDetai
|
|||||||
title: detail.title?.trim() || t('article.untitled'),
|
title: detail.title?.trim() || t('article.untitled'),
|
||||||
markdown_content: detail.markdown_content ?? '',
|
markdown_content: detail.markdown_content ?? '',
|
||||||
cover_asset_url: nextUrl || null,
|
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 {
|
function successDescription(result: CreatePublishJobResponse): string {
|
||||||
const created = result.created_task_ids.length
|
const created = result.created_task_ids.length
|
||||||
const existing = result.existing_task_ids.length
|
const existing = result.existing_task_ids.length
|
||||||
@@ -1144,7 +1211,9 @@ function cmsTypeLabel(cmsType: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEnterprisePlatformId(cmsType: string): string {
|
function normalizeEnterprisePlatformId(cmsType: string): string {
|
||||||
const normalized = String(cmsType ?? '').trim().toLowerCase()
|
const normalized = String(cmsType ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
switch (normalized) {
|
switch (normalized) {
|
||||||
case 'pbootcms':
|
case 'pbootcms':
|
||||||
return 'pbootcms'
|
return 'pbootcms'
|
||||||
@@ -1246,6 +1315,7 @@ function handleCoverPicked(payload: {
|
|||||||
coverFileName.value = payload.fileName
|
coverFileName.value = payload.fileName
|
||||||
coverImageAssetId.value = payload.assetId ?? null
|
coverImageAssetId.value = payload.assetId ?? null
|
||||||
coverEnabled.value = true
|
coverEnabled.value = true
|
||||||
|
coverMode.value = 'specific'
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemoveCover(): void {
|
function handleRemoveCover(): void {
|
||||||
@@ -1254,6 +1324,15 @@ function handleRemoveCover(): void {
|
|||||||
coverImageAssetId.value = null
|
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> {
|
async function handleModalOk(): Promise<void> {
|
||||||
if (okDisabled.value) {
|
if (okDisabled.value) {
|
||||||
return
|
return
|
||||||
@@ -1838,32 +1917,52 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="effectiveCoverEnabled" class="publish-modal__cover-body">
|
<div v-if="effectiveCoverEnabled" class="publish-modal__cover-body">
|
||||||
<div class="publish-modal__cover-preview-wrap">
|
<CoverSourceSelector
|
||||||
<button
|
:mode="coverMode"
|
||||||
type="button"
|
:random-scope="coverRandomScope"
|
||||||
class="publish-modal__cover-preview"
|
:random-folder-id="coverRandomFolderId"
|
||||||
@click="coverPickerOpen = true"
|
@update:mode="handleCoverModeChange"
|
||||||
>
|
@update:random-scope="coverRandomScope = $event"
|
||||||
<template v-if="coverAssetUrl">
|
@update:random-folder-id="coverRandomFolderId = $event"
|
||||||
<img :src="coverAssetUrl" alt="cover preview" />
|
>
|
||||||
</template>
|
<template #specific-preview>
|
||||||
<template v-else>
|
<div v-if="coverAssetUrl" class="publish-modal__cover-preview-card animate-fade-in">
|
||||||
<span class="publish-modal__cover-plus">+</span>
|
<img :src="coverAssetUrl" alt="cover preview" class="publish-modal__cover-img" />
|
||||||
<span>{{ t('media.publish.coverUpload') }}</span>
|
<div class="publish-modal__cover-overlay">
|
||||||
</template>
|
<button
|
||||||
</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
|
<button
|
||||||
v-if="coverAssetUrl"
|
v-else
|
||||||
type="button"
|
type="button"
|
||||||
class="publish-modal__cover-remove"
|
class="publish-modal__cover-upload-placeholder animate-fade-in"
|
||||||
:aria-label="t('article.editor.coverRemove')"
|
@click="coverPickerOpen = true"
|
||||||
:title="t('article.editor.coverRemove')"
|
>
|
||||||
@click.stop="handleRemoveCover"
|
<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">
|
||||||
<MinusCircleFilled />
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
</button>
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -2758,71 +2857,125 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__cover-body {
|
.publish-modal__cover-body {
|
||||||
display: flex;
|
margin-top: 16px;
|
||||||
margin-top: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__cover-preview-wrap {
|
.publish-modal__cover-preview-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 176px;
|
width: 200px;
|
||||||
flex: 0 0 176px;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
gap: 10px;
|
||||||
min-height: 124px;
|
opacity: 0;
|
||||||
padding: 0;
|
transition: opacity 0.2s ease-in-out;
|
||||||
border: 1px dashed #d1d5db;
|
backdrop-filter: blur(4px);
|
||||||
border-radius: 8px;
|
-webkit-backdrop-filter: blur(4px);
|
||||||
background: #f9fafb;
|
}
|
||||||
color: #6b7280;
|
|
||||||
font-size: 13px;
|
.publish-modal__cover-preview-card:hover .publish-modal__cover-overlay {
|
||||||
gap: 8px;
|
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;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__cover-preview:hover {
|
.publish-modal__cover-upload-placeholder:hover {
|
||||||
border-color: #6366f1;
|
border-color: #3b82f6;
|
||||||
color: #6366f1;
|
color: #3b82f6;
|
||||||
background: #f5f3ff;
|
background: #f0f6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__cover-preview img {
|
.publish-modal__upload-icon {
|
||||||
display: block;
|
margin-bottom: 6px;
|
||||||
width: 100%;
|
color: #94a3b8;
|
||||||
height: 124px;
|
transition: color 0.2s;
|
||||||
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;
|
display: inline-flex;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__cover-remove:hover {
|
.publish-modal__cover-upload-placeholder:hover .publish-modal__upload-icon {
|
||||||
background: rgba(17, 24, 39, 0.9);
|
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) {
|
@media (max-width: 900px) {
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ const zhCN = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
coverSource: {
|
||||||
|
specific: '指定图片',
|
||||||
|
random: '随机图片',
|
||||||
|
randomAll: '全部图片',
|
||||||
|
randomFolder: '指定文件夹',
|
||||||
|
folderPlaceholder: '选择图片文件夹',
|
||||||
|
randomHint: '发布时会从所选范围随机抽取一张封面图。',
|
||||||
|
randomEmpty: '请先选择随机图片文件夹',
|
||||||
|
},
|
||||||
nav: {
|
nav: {
|
||||||
workspace: '工作台',
|
workspace: '工作台',
|
||||||
articleCreation: '文章创作',
|
articleCreation: '文章创作',
|
||||||
|
|||||||
@@ -2132,6 +2132,8 @@ export type ScheduleTargetType = 'prompt_rule' | 'kol_subscription_prompt'
|
|||||||
export type ScheduleKind = 'daily' | 'weekly'
|
export type ScheduleKind = 'daily' | 'weekly'
|
||||||
export type ScheduleTimeMode = 'fixed' | 'random_window'
|
export type ScheduleTimeMode = 'fixed' | 'random_window'
|
||||||
export type ScheduleDispatchStatus = 'success' | 'failed' | string
|
export type ScheduleDispatchStatus = 'success' | 'failed' | string
|
||||||
|
export type ScheduleCoverMode = 'specific' | 'random'
|
||||||
|
export type ScheduleCoverRandomScope = 'all' | 'folder'
|
||||||
|
|
||||||
export interface ScheduleTask {
|
export interface ScheduleTask {
|
||||||
id: number
|
id: number
|
||||||
@@ -2159,6 +2161,9 @@ export interface ScheduleTask {
|
|||||||
auto_publish_platforms: string[]
|
auto_publish_platforms: string[]
|
||||||
cover_asset_url: string | null
|
cover_asset_url: string | null
|
||||||
cover_image_asset_id: number | null
|
cover_image_asset_id: number | null
|
||||||
|
cover_mode: ScheduleCoverMode
|
||||||
|
cover_random_scope: ScheduleCoverRandomScope
|
||||||
|
cover_random_folder_id: number | null
|
||||||
enable_web_search: boolean
|
enable_web_search: boolean
|
||||||
generate_count: number
|
generate_count: number
|
||||||
start_at: string | null
|
start_at: string | null
|
||||||
@@ -2194,6 +2199,9 @@ export interface ScheduleTaskRequest {
|
|||||||
publish_enterprise_site_targets?: ScheduleEnterpriseSiteTarget[]
|
publish_enterprise_site_targets?: ScheduleEnterpriseSiteTarget[]
|
||||||
cover_asset_url?: string | null
|
cover_asset_url?: string | null
|
||||||
cover_image_asset_id?: number | null
|
cover_image_asset_id?: number | null
|
||||||
|
cover_mode?: ScheduleCoverMode
|
||||||
|
cover_random_scope?: ScheduleCoverRandomScope
|
||||||
|
cover_random_folder_id?: number | null
|
||||||
enable_web_search?: boolean
|
enable_web_search?: boolean
|
||||||
generate_count?: number
|
generate_count?: number
|
||||||
start_at?: string
|
start_at?: string
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/publicasset"
|
||||||
sharedschedule "github.com/geo-platform/tenant-api/internal/shared/schedule"
|
sharedschedule "github.com/geo-platform/tenant-api/internal/shared/schedule"
|
||||||
tenantapp "github.com/geo-platform/tenant-api/internal/tenant/app"
|
tenantapp "github.com/geo-platform/tenant-api/internal/tenant/app"
|
||||||
)
|
)
|
||||||
@@ -60,6 +61,9 @@ type dueScheduleTask struct {
|
|||||||
PublishEnterpriseSiteTargets []tenantapp.ScheduleEnterpriseSiteTarget
|
PublishEnterpriseSiteTargets []tenantapp.ScheduleEnterpriseSiteTarget
|
||||||
CoverAssetURL *string
|
CoverAssetURL *string
|
||||||
CoverImageAssetID *int64
|
CoverImageAssetID *int64
|
||||||
|
CoverMode string
|
||||||
|
CoverRandomScope string
|
||||||
|
CoverRandomFolderID *int64
|
||||||
EnableWebSearch bool
|
EnableWebSearch bool
|
||||||
GenerateCount int
|
GenerateCount int
|
||||||
StartAt *time.Time
|
StartAt *time.Time
|
||||||
@@ -378,6 +382,7 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
|
|||||||
subscription_prompt_id, brand_id, name, cron_expr, schedule_days, schedule_time_mode,
|
subscription_prompt_id, brand_id, name, cron_expr, schedule_days, schedule_time_mode,
|
||||||
random_window_start::text, random_window_end::text, generation_input_json,
|
random_window_start::text, random_window_end::text, generation_input_json,
|
||||||
auto_publish, publish_account_ids, publish_enterprise_site_targets, cover_asset_url, cover_image_asset_id,
|
auto_publish, publish_account_ids, publish_enterprise_site_targets, cover_asset_url, cover_image_asset_id,
|
||||||
|
cover_mode, cover_random_scope, cover_random_folder_id,
|
||||||
enable_web_search, generate_count, start_at, end_at, next_run_at
|
enable_web_search, generate_count, start_at, end_at, next_run_at
|
||||||
FROM schedule_tasks
|
FROM schedule_tasks
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
@@ -410,6 +415,7 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
|
|||||||
scheduleDaysJSON []byte
|
scheduleDaysJSON []byte
|
||||||
publishAccountIDsJSON []byte
|
publishAccountIDsJSON []byte
|
||||||
publishEnterpriseSiteTargetsJSON []byte
|
publishEnterpriseSiteTargetsJSON []byte
|
||||||
|
coverRandomFolderID pgtype.Int8
|
||||||
startAt pgtype.Timestamptz
|
startAt pgtype.Timestamptz
|
||||||
endAt pgtype.Timestamptz
|
endAt pgtype.Timestamptz
|
||||||
nextRunAt pgtype.Timestamptz
|
nextRunAt pgtype.Timestamptz
|
||||||
@@ -435,6 +441,9 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
|
|||||||
&publishEnterpriseSiteTargetsJSON,
|
&publishEnterpriseSiteTargetsJSON,
|
||||||
&task.CoverAssetURL,
|
&task.CoverAssetURL,
|
||||||
&task.CoverImageAssetID,
|
&task.CoverImageAssetID,
|
||||||
|
&task.CoverMode,
|
||||||
|
&task.CoverRandomScope,
|
||||||
|
&coverRandomFolderID,
|
||||||
&task.EnableWebSearch,
|
&task.EnableWebSearch,
|
||||||
&task.GenerateCount,
|
&task.GenerateCount,
|
||||||
&startAt,
|
&startAt,
|
||||||
@@ -460,6 +469,13 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
|
|||||||
}
|
}
|
||||||
task.PublishAccountIDs = decodeDueSchedulePublishAccountIDs(publishAccountIDsJSON)
|
task.PublishAccountIDs = decodeDueSchedulePublishAccountIDs(publishAccountIDsJSON)
|
||||||
task.PublishEnterpriseSiteTargets = tenantapp.DecodeScheduleEnterpriseSiteTargets(publishEnterpriseSiteTargetsJSON)
|
task.PublishEnterpriseSiteTargets = tenantapp.DecodeScheduleEnterpriseSiteTargets(publishEnterpriseSiteTargetsJSON)
|
||||||
|
task.CoverRandomFolderID = int64PtrFromInt8(coverRandomFolderID)
|
||||||
|
if task.CoverMode == "" {
|
||||||
|
task.CoverMode = tenantapp.ScheduleCoverModeSpecific
|
||||||
|
}
|
||||||
|
if task.CoverRandomScope == "" {
|
||||||
|
task.CoverRandomScope = tenantapp.ScheduleCoverRandomScopeAll
|
||||||
|
}
|
||||||
if task.GenerateCount <= 0 {
|
if task.GenerateCount <= 0 {
|
||||||
task.GenerateCount = 1
|
task.GenerateCount = 1
|
||||||
}
|
}
|
||||||
@@ -587,6 +603,10 @@ func (w *ScheduleDispatchWorker) dispatch(parent context.Context, task dueSchedu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *ScheduleDispatchWorker) enqueueDueGeneration(ctx context.Context, task dueScheduleTask, generateCount int) (*tenantapp.GenerateFromRuleResponse, error) {
|
func (w *ScheduleDispatchWorker) enqueueDueGeneration(ctx context.Context, task dueScheduleTask, generateCount int) (*tenantapp.GenerateFromRuleResponse, error) {
|
||||||
|
coverAssetURL, coverImageAssetID, err := w.resolveDueScheduleCover(ctx, task)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
switch task.TargetType {
|
switch task.TargetType {
|
||||||
case "", tenantapp.ScheduleTargetPromptRule:
|
case "", tenantapp.ScheduleTargetPromptRule:
|
||||||
if task.PromptRuleID == nil || *task.PromptRuleID <= 0 {
|
if task.PromptRuleID == nil || *task.PromptRuleID <= 0 {
|
||||||
@@ -603,8 +623,8 @@ func (w *ScheduleDispatchWorker) enqueueDueGeneration(ctx context.Context, task
|
|||||||
AutoPublish: task.AutoPublish,
|
AutoPublish: task.AutoPublish,
|
||||||
PublishAccountIDs: task.PublishAccountIDs,
|
PublishAccountIDs: task.PublishAccountIDs,
|
||||||
PublishEnterpriseSiteTargets: task.PublishEnterpriseSiteTargets,
|
PublishEnterpriseSiteTargets: task.PublishEnterpriseSiteTargets,
|
||||||
CoverAssetURL: task.CoverAssetURL,
|
CoverAssetURL: coverAssetURL,
|
||||||
CoverImageAssetID: task.CoverImageAssetID,
|
CoverImageAssetID: coverImageAssetID,
|
||||||
EnableWebSearch: task.EnableWebSearch,
|
EnableWebSearch: task.EnableWebSearch,
|
||||||
GenerateCount: generateCount,
|
GenerateCount: generateCount,
|
||||||
ScheduledFor: task.ScheduledFor,
|
ScheduledFor: task.ScheduledFor,
|
||||||
@@ -631,8 +651,8 @@ func (w *ScheduleDispatchWorker) enqueueDueGeneration(ctx context.Context, task
|
|||||||
AutoPublish: task.AutoPublish,
|
AutoPublish: task.AutoPublish,
|
||||||
PublishAccountIDs: task.PublishAccountIDs,
|
PublishAccountIDs: task.PublishAccountIDs,
|
||||||
PublishEnterpriseSiteTargets: task.PublishEnterpriseSiteTargets,
|
PublishEnterpriseSiteTargets: task.PublishEnterpriseSiteTargets,
|
||||||
CoverAssetURL: task.CoverAssetURL,
|
CoverAssetURL: coverAssetURL,
|
||||||
CoverImageAssetID: task.CoverImageAssetID,
|
CoverImageAssetID: coverImageAssetID,
|
||||||
GenerateCount: generateCount,
|
GenerateCount: generateCount,
|
||||||
ScheduledFor: task.ScheduledFor,
|
ScheduledFor: task.ScheduledFor,
|
||||||
})
|
})
|
||||||
@@ -648,6 +668,82 @@ func (w *ScheduleDispatchWorker) enqueueDueGeneration(ctx context.Context, task
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *ScheduleDispatchWorker) resolveDueScheduleCover(ctx context.Context, task dueScheduleTask) (*string, *int64, error) {
|
||||||
|
if !task.AutoPublish {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
if task.CoverMode != tenantapp.ScheduleCoverModeRandom {
|
||||||
|
return task.CoverAssetURL, task.CoverImageAssetID, nil
|
||||||
|
}
|
||||||
|
image, err := w.pickRandomScheduleCoverImage(ctx, task)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
coverURL := buildScheduleCoverAssetURL(image.ObjectKey, w.assetTokenSecret())
|
||||||
|
return &coverURL, &image.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type scheduleCoverImage struct {
|
||||||
|
ID int64
|
||||||
|
ObjectKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ScheduleDispatchWorker) pickRandomScheduleCoverImage(ctx context.Context, task dueScheduleTask) (scheduleCoverImage, error) {
|
||||||
|
if w == nil || w.pool == nil {
|
||||||
|
return scheduleCoverImage{}, errors.New("schedule random cover lookup unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
var row pgxRow
|
||||||
|
if task.CoverRandomScope == tenantapp.ScheduleCoverRandomScopeFolder {
|
||||||
|
if task.CoverRandomFolderID == nil || *task.CoverRandomFolderID <= 0 {
|
||||||
|
return scheduleCoverImage{}, errors.New("schedule random cover folder missing")
|
||||||
|
}
|
||||||
|
row = w.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, object_key
|
||||||
|
FROM image_assets
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND folder_id = $2
|
||||||
|
AND status = 'active'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY random()
|
||||||
|
LIMIT 1
|
||||||
|
`, task.TenantID, *task.CoverRandomFolderID)
|
||||||
|
} else {
|
||||||
|
row = w.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, object_key
|
||||||
|
FROM image_assets
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND status = 'active'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY random()
|
||||||
|
LIMIT 1
|
||||||
|
`, task.TenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var image scheduleCoverImage
|
||||||
|
if err := row.Scan(&image.ID, &image.ObjectKey); err != nil {
|
||||||
|
return scheduleCoverImage{}, errors.New("schedule random cover image not found")
|
||||||
|
}
|
||||||
|
return image, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type pgxRow interface {
|
||||||
|
Scan(dest ...interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ScheduleDispatchWorker) assetTokenSecret() string {
|
||||||
|
if w != nil && w.configProvider != nil {
|
||||||
|
if cfg := w.configProvider.Current(); cfg != nil {
|
||||||
|
return strings.TrimSpace(cfg.JWT.Secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildScheduleCoverAssetURL(objectKey string, secret string) string {
|
||||||
|
return "/api/public/assets/" + publicasset.SignObjectKey(objectKey, secret)
|
||||||
|
}
|
||||||
|
|
||||||
func (w *ScheduleDispatchWorker) recordDispatchResult(ctx context.Context, task dueScheduleTask, generateCount int, failures []error) {
|
func (w *ScheduleDispatchWorker) recordDispatchResult(ctx context.Context, task dueScheduleTask, generateCount int, failures []error) {
|
||||||
if w == nil || w.pool == nil {
|
if w == nil || w.pool == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import (
|
|||||||
const (
|
const (
|
||||||
ScheduleTargetPromptRule = "prompt_rule"
|
ScheduleTargetPromptRule = "prompt_rule"
|
||||||
ScheduleTargetKolSubscriptionPrompt = "kol_subscription_prompt"
|
ScheduleTargetKolSubscriptionPrompt = "kol_subscription_prompt"
|
||||||
|
ScheduleCoverModeSpecific = "specific"
|
||||||
|
ScheduleCoverModeRandom = "random"
|
||||||
|
ScheduleCoverRandomScopeAll = "all"
|
||||||
|
ScheduleCoverRandomScopeFolder = "folder"
|
||||||
|
|
||||||
scheduleKindDaily = "daily"
|
scheduleKindDaily = "daily"
|
||||||
scheduleKindWeekly = "weekly"
|
scheduleKindWeekly = "weekly"
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ type ScheduleTaskRequest struct {
|
|||||||
PublishEnterpriseSiteTargets []ScheduleEnterpriseSiteTarget `json:"publish_enterprise_site_targets"`
|
PublishEnterpriseSiteTargets []ScheduleEnterpriseSiteTarget `json:"publish_enterprise_site_targets"`
|
||||||
CoverAssetURL *string `json:"cover_asset_url"`
|
CoverAssetURL *string `json:"cover_asset_url"`
|
||||||
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
||||||
|
CoverMode string `json:"cover_mode"`
|
||||||
|
CoverRandomScope string `json:"cover_random_scope"`
|
||||||
|
CoverRandomFolderID *int64 `json:"cover_random_folder_id"`
|
||||||
EnableWebSearch *bool `json:"enable_web_search"`
|
EnableWebSearch *bool `json:"enable_web_search"`
|
||||||
GenerateCount *int `json:"generate_count"`
|
GenerateCount *int `json:"generate_count"`
|
||||||
StartAt *string `json:"start_at"`
|
StartAt *string `json:"start_at"`
|
||||||
@@ -90,6 +93,9 @@ type ScheduleTaskResponse struct {
|
|||||||
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
|
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
|
||||||
CoverAssetURL *string `json:"cover_asset_url"`
|
CoverAssetURL *string `json:"cover_asset_url"`
|
||||||
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
||||||
|
CoverMode string `json:"cover_mode"`
|
||||||
|
CoverRandomScope string `json:"cover_random_scope"`
|
||||||
|
CoverRandomFolderID *int64 `json:"cover_random_folder_id"`
|
||||||
EnableWebSearch bool `json:"enable_web_search"`
|
EnableWebSearch bool `json:"enable_web_search"`
|
||||||
GenerateCount int `json:"generate_count"`
|
GenerateCount int `json:"generate_count"`
|
||||||
StartAt *string `json:"start_at"`
|
StartAt *string `json:"start_at"`
|
||||||
@@ -212,21 +218,27 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
|
|||||||
brand_id, name, cron_expr, schedule_kind, schedule_days, schedule_time_mode,
|
brand_id, name, cron_expr, schedule_kind, schedule_days, schedule_time_mode,
|
||||||
random_window_start, random_window_end, generation_input_json,
|
random_window_start, random_window_end, generation_input_json,
|
||||||
enable_web_search, generate_count, auto_publish, publish_account_ids,
|
enable_web_search, generate_count, auto_publish, publish_account_ids,
|
||||||
publish_enterprise_site_targets, cover_asset_url, cover_image_asset_id, start_at, end_at
|
publish_enterprise_site_targets, cover_asset_url, cover_image_asset_id,
|
||||||
|
cover_mode, cover_random_scope, cover_random_folder_id,
|
||||||
|
start_at, end_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1, $2, $3, $4, $5, $6,
|
$1, $2, $3, $4, $5, $6,
|
||||||
$7, $8, $9, $10, $11::jsonb, $12,
|
$7, $8, $9, $10, $11::jsonb, $12,
|
||||||
$13::time, $14::time, $15::jsonb,
|
$13::time, $14::time, $15::jsonb,
|
||||||
$16, $17, $18, $19::jsonb,
|
$16, $17, $18, $19::jsonb,
|
||||||
$20::jsonb, $21, $22, $23::timestamptz, $24::timestamptz
|
$20::jsonb, $21, $22,
|
||||||
|
$23, $24, $25,
|
||||||
|
$26::timestamptz, $27::timestamptz
|
||||||
)
|
)
|
||||||
RETURNING id, created_at, start_at, end_at, status
|
RETURNING id, created_at, start_at, end_at, status
|
||||||
`, actor.TenantID, actor.PrimaryWorkspaceID, actor.UserID, targetType, promptRuleID, subscriptionPromptID,
|
`, actor.TenantID, actor.PrimaryWorkspaceID, actor.UserID, targetType, promptRuleID, subscriptionPromptID,
|
||||||
req.BrandID, req.Name, plan.CronExpr, plan.ScheduleKind, encodeScheduleDayKeys(plan.ScheduleDays), plan.ScheduleTimeMode,
|
req.BrandID, req.Name, plan.CronExpr, plan.ScheduleKind, encodeScheduleDayKeys(plan.ScheduleDays), plan.ScheduleTimeMode,
|
||||||
plan.RandomWindowStart, plan.RandomWindowEnd, encodeScheduleGenerationInput(generationInput),
|
plan.RandomWindowStart, plan.RandomWindowEnd, encodeScheduleGenerationInput(generationInput),
|
||||||
enableWebSearch, generateCount, publishConfig.AutoPublish, publishConfig.AccountIDsJSON,
|
enableWebSearch, generateCount, publishConfig.AutoPublish, publishConfig.AccountIDsJSON,
|
||||||
publishConfig.EnterpriseTargetsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID, req.StartAt, req.EndAt).
|
publishConfig.EnterpriseTargetsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID,
|
||||||
|
publishConfig.CoverMode, publishConfig.CoverRandomScope, publishConfig.CoverRandomFolderID,
|
||||||
|
req.StartAt, req.EndAt).
|
||||||
Scan(&id, &createdAt, &startAt, &endAt, &status)
|
Scan(&id, &createdAt, &startAt, &endAt, &status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50010, "create_failed", "failed to create schedule task")
|
return nil, response.ErrInternal(50010, "create_failed", "failed to create schedule task")
|
||||||
@@ -286,9 +298,11 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
|
|||||||
RandomWindowEnd: plan.RandomWindowEnd, GenerationInput: generationInput, AutoPublish: publishConfig.AutoPublish,
|
RandomWindowEnd: plan.RandomWindowEnd, GenerationInput: generationInput, AutoPublish: publishConfig.AutoPublish,
|
||||||
PublishAccountIDs: publishConfig.AccountIDs, PublishEnterpriseSiteTargets: publishConfig.EnterpriseTargets,
|
PublishAccountIDs: publishConfig.AccountIDs, PublishEnterpriseSiteTargets: publishConfig.EnterpriseTargets,
|
||||||
AutoPublishPlatforms: publishConfig.PlatformIDs, CoverAssetURL: publishConfig.CoverAssetURL,
|
AutoPublishPlatforms: publishConfig.PlatformIDs, CoverAssetURL: publishConfig.CoverAssetURL,
|
||||||
CoverImageAssetID: publishConfig.CoverImageAssetID, EnableWebSearch: enableWebSearch,
|
CoverImageAssetID: publishConfig.CoverImageAssetID, CoverMode: publishConfig.CoverMode,
|
||||||
GenerateCount: generateCount,
|
CoverRandomScope: publishConfig.CoverRandomScope, CoverRandomFolderID: publishConfig.CoverRandomFolderID,
|
||||||
Status: status, CreatedAt: timeStringFromDBValue(createdAt),
|
EnableWebSearch: enableWebSearch,
|
||||||
|
GenerateCount: generateCount,
|
||||||
|
Status: status, CreatedAt: timeStringFromDBValue(createdAt),
|
||||||
NextRunAt: timeStringPtr(nextRunAt),
|
NextRunAt: timeStringPtr(nextRunAt),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -350,17 +364,19 @@ func (s *ScheduleTaskService) Update(ctx context.Context, id int64, req Schedule
|
|||||||
workspace_id = $15, auto_publish = $16, publish_account_ids = $17::jsonb,
|
workspace_id = $15, auto_publish = $16, publish_account_ids = $17::jsonb,
|
||||||
publish_enterprise_site_targets = $18::jsonb,
|
publish_enterprise_site_targets = $18::jsonb,
|
||||||
cover_asset_url = $19, cover_image_asset_id = $20,
|
cover_asset_url = $19, cover_image_asset_id = $20,
|
||||||
start_at = $21::timestamptz, end_at = $22::timestamptz, operator_id = $23,
|
cover_mode = $21, cover_random_scope = $22, cover_random_folder_id = $23,
|
||||||
|
start_at = $24::timestamptz, end_at = $25::timestamptz, operator_id = $26,
|
||||||
last_dispatch_status = NULL, last_dispatch_error = NULL,
|
last_dispatch_status = NULL, last_dispatch_error = NULL,
|
||||||
consecutive_failures = 0,
|
consecutive_failures = 0,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $24 AND tenant_id = $25 AND brand_id = $26 AND deleted_at IS NULL
|
WHERE id = $27 AND tenant_id = $28 AND brand_id = $29 AND deleted_at IS NULL
|
||||||
RETURNING status, start_at, end_at
|
RETURNING status, start_at, end_at
|
||||||
`, targetType, promptRuleID, subscriptionPromptID, req.BrandID, req.Name, plan.CronExpr,
|
`, targetType, promptRuleID, subscriptionPromptID, req.BrandID, req.Name, plan.CronExpr,
|
||||||
plan.ScheduleKind, encodeScheduleDayKeys(plan.ScheduleDays), plan.ScheduleTimeMode,
|
plan.ScheduleKind, encodeScheduleDayKeys(plan.ScheduleDays), plan.ScheduleTimeMode,
|
||||||
plan.RandomWindowStart, plan.RandomWindowEnd, encodeScheduleGenerationInput(generationInput),
|
plan.RandomWindowStart, plan.RandomWindowEnd, encodeScheduleGenerationInput(generationInput),
|
||||||
enableWebSearch, generateCount, actor.PrimaryWorkspaceID, publishConfig.AutoPublish,
|
enableWebSearch, generateCount, actor.PrimaryWorkspaceID, publishConfig.AutoPublish,
|
||||||
publishConfig.AccountIDsJSON, publishConfig.EnterpriseTargetsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID,
|
publishConfig.AccountIDsJSON, publishConfig.EnterpriseTargetsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID,
|
||||||
|
publishConfig.CoverMode, publishConfig.CoverRandomScope, publishConfig.CoverRandomFolderID,
|
||||||
req.StartAt, req.EndAt, actor.UserID, id, actor.TenantID, brandID).
|
req.StartAt, req.EndAt, actor.UserID, id, actor.TenantID, brandID).
|
||||||
Scan(&status, &startAt, &endAt)
|
Scan(&status, &startAt, &endAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -503,7 +519,9 @@ func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID, b
|
|||||||
st.cron_expr, st.schedule_kind, st.schedule_days, st.schedule_time_mode,
|
st.cron_expr, st.schedule_kind, st.schedule_days, st.schedule_time_mode,
|
||||||
st.random_window_start::text, st.random_window_end::text, st.generation_input_json,
|
st.random_window_start::text, st.random_window_end::text, st.generation_input_json,
|
||||||
st.auto_publish, st.publish_account_ids, st.publish_enterprise_site_targets,
|
st.auto_publish, st.publish_account_ids, st.publish_enterprise_site_targets,
|
||||||
st.cover_asset_url, st.cover_image_asset_id, st.enable_web_search, st.generate_count, st.start_at, st.end_at,
|
st.cover_asset_url, st.cover_image_asset_id,
|
||||||
|
st.cover_mode, st.cover_random_scope, st.cover_random_folder_id,
|
||||||
|
st.enable_web_search, st.generate_count, st.start_at, st.end_at,
|
||||||
st.next_run_at, st.status, st.last_run_at, st.last_dispatch_status,
|
st.next_run_at, st.status, st.last_run_at, st.last_dispatch_status,
|
||||||
st.last_dispatch_error, st.consecutive_failures, st.created_at, st.updated_at,
|
st.last_dispatch_error, st.consecutive_failures, st.created_at, st.updated_at,
|
||||||
pr.name AS prompt_rule_name,
|
pr.name AS prompt_rule_name,
|
||||||
@@ -558,7 +576,9 @@ func (s *ScheduleTaskService) loadScheduleTaskDetail(ctx context.Context, tenant
|
|||||||
st.cron_expr, st.schedule_kind, st.schedule_days, st.schedule_time_mode,
|
st.cron_expr, st.schedule_kind, st.schedule_days, st.schedule_time_mode,
|
||||||
st.random_window_start::text, st.random_window_end::text, st.generation_input_json,
|
st.random_window_start::text, st.random_window_end::text, st.generation_input_json,
|
||||||
st.auto_publish, st.publish_account_ids, st.publish_enterprise_site_targets,
|
st.auto_publish, st.publish_account_ids, st.publish_enterprise_site_targets,
|
||||||
st.cover_asset_url, st.cover_image_asset_id, st.enable_web_search, st.generate_count, st.start_at, st.end_at,
|
st.cover_asset_url, st.cover_image_asset_id,
|
||||||
|
st.cover_mode, st.cover_random_scope, st.cover_random_folder_id,
|
||||||
|
st.enable_web_search, st.generate_count, st.start_at, st.end_at,
|
||||||
st.next_run_at, st.status, st.last_run_at, st.last_dispatch_status,
|
st.next_run_at, st.status, st.last_run_at, st.last_dispatch_status,
|
||||||
st.last_dispatch_error, st.consecutive_failures, st.created_at, st.updated_at,
|
st.last_dispatch_error, st.consecutive_failures, st.created_at, st.updated_at,
|
||||||
pr.name AS prompt_rule_name,
|
pr.name AS prompt_rule_name,
|
||||||
@@ -936,7 +956,9 @@ func scanScheduleTaskResponse(scanner interface {
|
|||||||
&item.CronExpr, &item.ScheduleKind, &scheduleDaysJSON, &item.ScheduleTimeMode,
|
&item.CronExpr, &item.ScheduleKind, &scheduleDaysJSON, &item.ScheduleTimeMode,
|
||||||
&item.RandomWindowStart, &item.RandomWindowEnd, &generationInputJSON,
|
&item.RandomWindowStart, &item.RandomWindowEnd, &generationInputJSON,
|
||||||
&item.AutoPublish, &publishAccountIDsJSON, &publishEnterpriseSiteTargetsJSON,
|
&item.AutoPublish, &publishAccountIDsJSON, &publishEnterpriseSiteTargetsJSON,
|
||||||
&item.CoverAssetURL, &item.CoverImageAssetID, &item.EnableWebSearch, &item.GenerateCount, &startAt, &endAt,
|
&item.CoverAssetURL, &item.CoverImageAssetID,
|
||||||
|
&item.CoverMode, &item.CoverRandomScope, &item.CoverRandomFolderID,
|
||||||
|
&item.EnableWebSearch, &item.GenerateCount, &startAt, &endAt,
|
||||||
&nextRunAt, &item.Status, &lastRunAt, &item.LastDispatchStatus,
|
&nextRunAt, &item.Status, &lastRunAt, &item.LastDispatchStatus,
|
||||||
&item.LastDispatchError, &item.ConsecutiveFailures, &createdAt, &updatedAt,
|
&item.LastDispatchError, &item.ConsecutiveFailures, &createdAt, &updatedAt,
|
||||||
&item.PromptRuleName, &item.SubscriptionPromptName, &item.SubscriptionPackageName,
|
&item.PromptRuleName, &item.SubscriptionPromptName, &item.SubscriptionPackageName,
|
||||||
@@ -961,6 +983,12 @@ func scanScheduleTaskResponse(scanner interface {
|
|||||||
if item.RandomWindowEnd == "" {
|
if item.RandomWindowEnd == "" {
|
||||||
item.RandomWindowEnd = defaultRandomWindowEnd
|
item.RandomWindowEnd = defaultRandomWindowEnd
|
||||||
}
|
}
|
||||||
|
if item.CoverMode == "" {
|
||||||
|
item.CoverMode = ScheduleCoverModeSpecific
|
||||||
|
}
|
||||||
|
if item.CoverRandomScope == "" {
|
||||||
|
item.CoverRandomScope = ScheduleCoverRandomScopeAll
|
||||||
|
}
|
||||||
item.ScheduleDays = decodeScheduleDayKeys(scheduleDaysJSON)
|
item.ScheduleDays = decodeScheduleDayKeys(scheduleDaysJSON)
|
||||||
item.GenerationInput = decodeScheduleGenerationInput(generationInputJSON)
|
item.GenerationInput = decodeScheduleGenerationInput(generationInputJSON)
|
||||||
item.PublishAccountIDs = decodeSchedulePublishAccountIDs(publishAccountIDsJSON)
|
item.PublishAccountIDs = decodeSchedulePublishAccountIDs(publishAccountIDsJSON)
|
||||||
@@ -1075,6 +1103,9 @@ type scheduleAutoPublishConfig struct {
|
|||||||
EnterpriseTargetsJSON []byte
|
EnterpriseTargetsJSON []byte
|
||||||
CoverAssetURL *string
|
CoverAssetURL *string
|
||||||
CoverImageAssetID *int64
|
CoverImageAssetID *int64
|
||||||
|
CoverMode string
|
||||||
|
CoverRandomScope string
|
||||||
|
CoverRandomFolderID *int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, actor auth.Actor, req ScheduleTaskRequest) (scheduleAutoPublishConfig, error) {
|
func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, actor auth.Actor, req ScheduleTaskRequest) (scheduleAutoPublishConfig, error) {
|
||||||
@@ -1086,6 +1117,8 @@ func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, ac
|
|||||||
AutoPublish: autoPublish,
|
AutoPublish: autoPublish,
|
||||||
AccountIDs: accountIDs,
|
AccountIDs: accountIDs,
|
||||||
EnterpriseTargets: enterpriseTargets,
|
EnterpriseTargets: enterpriseTargets,
|
||||||
|
CoverMode: ScheduleCoverModeSpecific,
|
||||||
|
CoverRandomScope: ScheduleCoverRandomScopeAll,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !autoPublish {
|
if !autoPublish {
|
||||||
@@ -1105,6 +1138,18 @@ func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, ac
|
|||||||
if err := validateOptionalPositiveInt64(req.CoverImageAssetID, "cover_image_asset_id"); err != nil {
|
if err := validateOptionalPositiveInt64(req.CoverImageAssetID, "cover_image_asset_id"); err != nil {
|
||||||
return config, err
|
return config, err
|
||||||
}
|
}
|
||||||
|
coverMode, err := normalizeScheduleCoverMode(req.CoverMode)
|
||||||
|
if err != nil {
|
||||||
|
return config, err
|
||||||
|
}
|
||||||
|
randomScope := ScheduleCoverRandomScopeAll
|
||||||
|
var randomFolderID *int64
|
||||||
|
if coverMode == ScheduleCoverModeRandom {
|
||||||
|
randomScope, randomFolderID, err = s.normalizeScheduleCoverRandomSource(ctx, actor.TenantID, req.CoverRandomScope, req.CoverRandomFolderID)
|
||||||
|
if err != nil {
|
||||||
|
return config, err
|
||||||
|
}
|
||||||
|
}
|
||||||
coverURL := strings.TrimSpace(stringPointerValue(req.CoverAssetURL))
|
coverURL := strings.TrimSpace(stringPointerValue(req.CoverAssetURL))
|
||||||
if len(accountIDs) > 0 {
|
if len(accountIDs) > 0 {
|
||||||
platformIDs, err := s.loadPublishAccountPlatformIDs(ctx, actor.TenantID, actor.PrimaryWorkspaceID, accountIDs)
|
platformIDs, err := s.loadPublishAccountPlatformIDs(ctx, actor.TenantID, actor.PrimaryWorkspaceID, accountIDs)
|
||||||
@@ -1112,7 +1157,7 @@ func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, ac
|
|||||||
return config, err
|
return config, err
|
||||||
}
|
}
|
||||||
config.PlatformIDs = normalizePlatformIDs(platformIDs)
|
config.PlatformIDs = normalizePlatformIDs(platformIDs)
|
||||||
if schedulePublishPlatformsRequireCover(platformIDs) && coverURL == "" {
|
if schedulePublishPlatformsRequireCover(platformIDs) && coverMode == ScheduleCoverModeSpecific && coverURL == "" {
|
||||||
return config, response.ErrBadRequest(40025, "cover_required", "已选媒体账号的平台要求封面图,请先设置封面")
|
return config, response.ErrBadRequest(40025, "cover_required", "已选媒体账号的平台要求封面图,请先设置封面")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1133,13 +1178,67 @@ func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, ac
|
|||||||
|
|
||||||
config.AccountIDsJSON = accountIDsJSON
|
config.AccountIDsJSON = accountIDsJSON
|
||||||
config.EnterpriseTargetsJSON = enterpriseTargetsJSON
|
config.EnterpriseTargetsJSON = enterpriseTargetsJSON
|
||||||
if coverURL != "" {
|
config.CoverMode = coverMode
|
||||||
|
config.CoverRandomScope = randomScope
|
||||||
|
config.CoverRandomFolderID = randomFolderID
|
||||||
|
if coverMode == ScheduleCoverModeSpecific && coverURL != "" {
|
||||||
config.CoverAssetURL = &coverURL
|
config.CoverAssetURL = &coverURL
|
||||||
config.CoverImageAssetID = req.CoverImageAssetID
|
config.CoverImageAssetID = req.CoverImageAssetID
|
||||||
}
|
}
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeScheduleCoverMode(value string) (string, error) {
|
||||||
|
switch strings.TrimSpace(value) {
|
||||||
|
case "", ScheduleCoverModeSpecific:
|
||||||
|
return ScheduleCoverModeSpecific, nil
|
||||||
|
case ScheduleCoverModeRandom:
|
||||||
|
return ScheduleCoverModeRandom, nil
|
||||||
|
default:
|
||||||
|
return "", response.ErrBadRequest(40076, "invalid_cover_mode", "封面图模式不正确")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScheduleTaskService) normalizeScheduleCoverRandomSource(ctx context.Context, tenantID int64, scope string, folderID *int64) (string, *int64, error) {
|
||||||
|
normalizedScope := strings.TrimSpace(scope)
|
||||||
|
if normalizedScope == "" {
|
||||||
|
normalizedScope = ScheduleCoverRandomScopeAll
|
||||||
|
}
|
||||||
|
switch normalizedScope {
|
||||||
|
case ScheduleCoverRandomScopeAll:
|
||||||
|
return ScheduleCoverRandomScopeAll, nil, nil
|
||||||
|
case ScheduleCoverRandomScopeFolder:
|
||||||
|
if folderID == nil || *folderID <= 0 {
|
||||||
|
return "", nil, response.ErrBadRequest(40077, "cover_random_folder_required", "请选择随机封面图片文件夹")
|
||||||
|
}
|
||||||
|
if err := s.ensureImageFolderExists(ctx, tenantID, *folderID); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return ScheduleCoverRandomScopeFolder, folderID, nil
|
||||||
|
default:
|
||||||
|
return "", nil, response.ErrBadRequest(40078, "invalid_cover_random_scope", "随机封面范围不正确")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ScheduleTaskService) ensureImageFolderExists(ctx context.Context, tenantID, folderID int64) error {
|
||||||
|
var exists bool
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM image_folders
|
||||||
|
WHERE id = $1
|
||||||
|
AND tenant_id = $2
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
)
|
||||||
|
`, folderID, tenantID).Scan(&exists); err != nil {
|
||||||
|
return response.ErrInternal(50010, "query_failed", "failed to validate image folder")
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return response.ErrBadRequest(40079, "image_folder_not_found", "随机封面图片文件夹不存在")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ScheduleTaskService) validateScheduleEnterpriseSiteTargets(ctx context.Context, tenantID, workspaceID int64, targets []ScheduleEnterpriseSiteTarget) error {
|
func (s *ScheduleTaskService) validateScheduleEnterpriseSiteTargets(ctx context.Context, tenantID, workspaceID int64, targets []ScheduleEnterpriseSiteTarget) error {
|
||||||
for _, target := range targets {
|
for _, target := range targets {
|
||||||
var status string
|
var status string
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ type ScheduleTask struct {
|
|||||||
PublishAccountIDs []string
|
PublishAccountIDs []string
|
||||||
CoverAssetURL *string
|
CoverAssetURL *string
|
||||||
CoverImageAssetID *int64
|
CoverImageAssetID *int64
|
||||||
|
CoverMode string
|
||||||
|
CoverRandomScope string
|
||||||
|
CoverRandomFolderID *int64
|
||||||
EnableWebSearch bool
|
EnableWebSearch bool
|
||||||
GenerateCount int
|
GenerateCount int
|
||||||
StartAt *time.Time
|
StartAt *time.Time
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE schedule_tasks
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_schedule_tasks_cover_random_folder_id,
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_schedule_tasks_cover_random_scope,
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_schedule_tasks_cover_mode;
|
||||||
|
|
||||||
|
ALTER TABLE schedule_tasks
|
||||||
|
DROP COLUMN IF EXISTS cover_random_folder_id,
|
||||||
|
DROP COLUMN IF EXISTS cover_random_scope,
|
||||||
|
DROP COLUMN IF EXISTS cover_mode;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
ALTER TABLE schedule_tasks
|
||||||
|
ADD COLUMN cover_mode VARCHAR(20) NOT NULL DEFAULT 'specific',
|
||||||
|
ADD COLUMN cover_random_scope VARCHAR(20) NOT NULL DEFAULT 'all',
|
||||||
|
ADD COLUMN cover_random_folder_id BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE schedule_tasks
|
||||||
|
ADD CONSTRAINT chk_schedule_tasks_cover_mode
|
||||||
|
CHECK (cover_mode IN ('specific', 'random'));
|
||||||
|
|
||||||
|
ALTER TABLE schedule_tasks
|
||||||
|
ADD CONSTRAINT chk_schedule_tasks_cover_random_scope
|
||||||
|
CHECK (cover_random_scope IN ('all', 'folder'));
|
||||||
|
|
||||||
|
ALTER TABLE schedule_tasks
|
||||||
|
ADD CONSTRAINT chk_schedule_tasks_cover_random_folder_id
|
||||||
|
CHECK (
|
||||||
|
cover_random_folder_id IS NULL
|
||||||
|
OR cover_random_folder_id > 0
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user