feat(schedule): support KOL subscription targets and random time windows
Frontend CI / Frontend (push) Successful in 3m37s
Backend CI / Backend (push) Failing after 6m47s

Extend schedule tasks to dispatch KOL subscription prompts in addition
to prompt rules, add daily/weekly scheduling with fixed or random time
windows, and track dispatch outcomes (last run, status, consecutive
failures).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 23:51:45 +08:00
parent 9cc6537e68
commit c28c4f1419
20 changed files with 3034 additions and 329 deletions
File diff suppressed because it is too large Load Diff
+210 -19
View File
@@ -7,7 +7,7 @@ import {
PauseCircleOutlined,
PlayCircleOutlined,
} from '@ant-design/icons-vue'
import type { ScheduleTask, ScheduleTaskListParams } from '@geo/shared-types'
import type { ScheduleTargetType, ScheduleTask, ScheduleTaskListParams } from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message, type TableColumnsType } from 'ant-design-vue'
import type { Dayjs } from 'dayjs'
@@ -16,7 +16,7 @@ import { useI18n } from 'vue-i18n'
import GeneratedArticleLinksDrawer from '@/components/GeneratedArticleLinksDrawer.vue'
import ScheduleTaskModal from '@/components/ScheduleTaskModal.vue'
import { promptRulesApi, schedulesApi } from '@/lib/api'
import { kolMarketplaceApi, promptRulesApi, schedulesApi } from '@/lib/api'
import { formatDateTime, getGenerateStatusMeta, hasActiveGenerationStatus } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { formatPublishPlatformList } from '@/lib/publish-platforms'
@@ -35,7 +35,9 @@ const articleLinksOpen = ref(false)
const selectedTask = ref<ScheduleTask | null>(null)
const draftFilters = reactive<{
target_type?: ScheduleTargetType
prompt_rule_id?: number
subscription_prompt_id?: number
status?: string
keyword?: string
}>({
@@ -43,7 +45,9 @@ const draftFilters = reactive<{
})
const appliedFilters = reactive<{
target_type?: ScheduleTargetType
prompt_rule_id?: number
subscription_prompt_id?: number
status?: string
keyword?: string
created_from?: string
@@ -65,6 +69,23 @@ const promptOptions = computed(() =>
})),
)
const subscriptionPromptsQuery = useQuery({
queryKey: ['kol', 'subscription-prompts', 'schedule-task-filter'],
queryFn: () => kolMarketplaceApi.mySubscriptionPrompts(),
})
const subscriptionPromptOptions = computed(() =>
(subscriptionPromptsQuery.data.value ?? []).map((item) => ({
label: `${item.package_name} / ${item.prompt_name}`,
value: item.id,
})),
)
const sourceOptions = computed(() => [
{ label: t('custom.task.sourcePromptRule'), value: 'prompt_rule' },
{ label: t('custom.task.sourceKolTemplate'), value: 'kol_subscription_prompt' },
])
const statusOptions = computed(() => [
{ label: t('custom.schedule.enabled'), value: 'enabled' },
{ label: t('custom.schedule.disabled'), value: 'disabled' },
@@ -75,7 +96,11 @@ const listParams = computed<ScheduleTaskListParams>(() => {
page: page.value,
page_size: pageSize.value,
}
if (appliedFilters.target_type) params.target_type = appliedFilters.target_type
if (appliedFilters.prompt_rule_id) params.prompt_rule_id = appliedFilters.prompt_rule_id
if (appliedFilters.subscription_prompt_id) {
params.subscription_prompt_id = appliedFilters.subscription_prompt_id
}
if (appliedFilters.status) params.status = appliedFilters.status
if (appliedFilters.keyword?.trim()) params.keyword = appliedFilters.keyword.trim()
if (appliedFilters.created_from) params.created_from = appliedFilters.created_from
@@ -92,10 +117,28 @@ const listQuery = useQuery({
const columns = computed<TableColumnsType<ScheduleTask>>(() => [
{ title: t('custom.schedule.name'), dataIndex: 'name', key: 'name', width: 220 },
{
title: t('custom.schedule.rule'),
title: t('custom.task.source'),
dataIndex: 'target_type',
key: 'target_type',
width: 130,
},
{
title: t('custom.schedule.target'),
dataIndex: 'prompt_rule_name',
key: 'prompt_rule_name',
width: 180,
key: 'target_name',
width: 260,
},
{
title: t('custom.schedule.plan'),
dataIndex: 'schedule_kind',
key: 'schedule_plan',
width: 220,
},
{
title: t('custom.schedule.nextRun'),
dataIndex: 'next_run_at',
key: 'next_run_at',
width: 170,
},
{
title: t('custom.instant.executionStatus'),
@@ -104,9 +147,9 @@ const columns = computed<TableColumnsType<ScheduleTask>>(() => [
width: 140,
},
{
title: t('custom.instant.executionTime'),
dataIndex: 'execution_time',
key: 'execution_time',
title: t('custom.schedule.dispatchStatus'),
dataIndex: 'last_dispatch_status',
key: 'dispatch_status',
width: 170,
},
{
@@ -155,7 +198,9 @@ const toggleStatusMutation = useMutation({
function applyFilters(): void {
page.value = 1
appliedFilters.target_type = draftFilters.target_type
appliedFilters.prompt_rule_id = draftFilters.prompt_rule_id
appliedFilters.subscription_prompt_id = draftFilters.subscription_prompt_id
appliedFilters.status = draftFilters.status
appliedFilters.keyword = draftFilters.keyword?.trim() || ''
appliedFilters.created_from = createdRange.value?.[0]?.startOf('day').toISOString()
@@ -163,7 +208,9 @@ function applyFilters(): void {
}
function resetFilters(): void {
draftFilters.target_type = undefined
draftFilters.prompt_rule_id = undefined
draftFilters.subscription_prompt_id = undefined
draftFilters.status = undefined
draftFilters.keyword = ''
createdRange.value = null
@@ -218,8 +265,12 @@ watch(
watch(
() => companyStore.currentBrandId,
() => {
draftFilters.target_type = undefined
draftFilters.prompt_rule_id = undefined
draftFilters.subscription_prompt_id = undefined
appliedFilters.target_type = undefined
appliedFilters.prompt_rule_id = undefined
appliedFilters.subscription_prompt_id = undefined
editingTask.value = null
modalOpen.value = false
page.value = 1
@@ -229,11 +280,96 @@ watch(
onBeforeUnmount(() => {
stopPolling()
})
function targetTypeLabel(targetType?: string | null): string {
if (targetType === 'kol_subscription_prompt') {
return t('custom.task.sourceKolTemplate')
}
return t('custom.task.sourcePromptRule')
}
function scheduleTargetName(record: ScheduleTask): string {
if (record.target_type === 'kol_subscription_prompt') {
const parts = [
record.subscription_package_name,
record.subscription_prompt_name,
record.subscription_kol_name,
].filter(Boolean)
return parts.length ? parts.join(' / ') : '--'
}
return record.prompt_rule_name || '--'
}
function schedulePlanText(record: ScheduleTask): string {
const days =
record.schedule_kind === 'daily'
? t('custom.schedule.kindDaily')
: (record.schedule_days ?? []).map((day) => t(`custom.schedule.weekdays.${day}`)).join('、')
return `${days} · ${scheduleTimeText(record)}`
}
function scheduleTimeText(record: ScheduleTask): string {
if (record.schedule_time_mode === 'random_window') {
return `${formatClock(record.random_window_start)}-${formatClock(record.random_window_end)}`
}
return parseCronClock(record.cron_expr) ?? '--'
}
function formatClock(value?: string | null): string {
const text = String(value ?? '').trim()
return text ? text.slice(0, 5) : '--'
}
function parseCronClock(value?: string | null): string | null {
const parts = String(value ?? '')
.trim()
.split(/\s+/)
.filter(Boolean)
if (parts.length !== 5 && parts.length !== 6) {
return null
}
const minute = parts.length === 5 ? parts[0] : parts[1]
const hour = parts.length === 5 ? parts[1] : parts[2]
if (!/^\d+$/.test(hour) || !/^\d+$/.test(minute)) {
return null
}
const hourValue = Number(hour)
const minuteValue = Number(minute)
if (hourValue > 23 || minuteValue > 59) {
return null
}
return `${String(hourValue).padStart(2, '0')}:${String(minuteValue).padStart(2, '0')}`
}
function dispatchStatusMeta(status?: string | null): { label: string; color: string } {
if (status === 'success') {
return { label: t('custom.schedule.dispatchSuccess'), color: 'success' }
}
if (status === 'failed') {
return { label: t('custom.schedule.dispatchFailed'), color: 'error' }
}
if (status === 'partial') {
return { label: t('custom.schedule.dispatchPartialSuccess'), color: 'warning' }
}
return { label: t('custom.schedule.notDispatched'), color: 'default' }
}
</script>
<template>
<div class="task-tab">
<div class="task-tab__filters">
<div class="task-tab__filter">
<label>{{ t('custom.task.source') }}:</label>
<a-select
v-model:value="draftFilters.target_type"
allow-clear
size="large"
:options="sourceOptions"
:placeholder="t('common.selectPlease')"
@change="applyFilters"
/>
</div>
<div class="task-tab__filter">
<label>{{ t('custom.filters.promptRule') }}:</label>
<a-select
@@ -247,6 +383,21 @@ onBeforeUnmount(() => {
/>
</div>
<div class="task-tab__filter">
<label>{{ t('custom.task.kolTemplate') }}:</label>
<a-select
v-model:value="draftFilters.subscription_prompt_id"
allow-clear
show-search
size="large"
:options="subscriptionPromptOptions"
:placeholder="t('custom.task.kolTemplatePlaceholder')"
:loading="subscriptionPromptsQuery.isPending.value"
option-filter-prop="label"
@change="applyFilters"
/>
</div>
<div class="task-tab__filter">
<label>{{ t('custom.filters.status') }}:</label>
<a-select
@@ -297,12 +448,28 @@ onBeforeUnmount(() => {
showSizeChanger: true,
}"
row-key="id"
:scroll="{ x: 1360 }"
:scroll="{ x: 1620 }"
@change="(p: any) => handleTableChange(p.current, p.pageSize)"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'prompt_rule_name'">
{{ record.prompt_rule_name || '--' }}
<template v-if="column.key === 'target_type'">
<a-tag :color="record.target_type === 'kol_subscription_prompt' ? 'cyan' : 'blue'">
{{ targetTypeLabel(record.target_type) }}
</a-tag>
</template>
<template v-else-if="column.key === 'target_name'">
<div class="task-tab__target">
<strong>{{ scheduleTargetName(record) }}</strong>
<span v-if="record.target_type === 'kol_subscription_prompt'">
{{ record.subscription_kol_name || '--' }}
</span>
</div>
</template>
<template v-else-if="column.key === 'schedule_plan'">
<span class="task-tab__plan">{{ schedulePlanText(record) }}</span>
</template>
<template v-else-if="column.key === 'next_run_at'">
{{ formatDateTime(record.next_run_at) }}
</template>
<template v-else-if="column.key === 'generation_status'">
<a-tag :color="getGenerateStatusMeta(record.generation_status).color">
@@ -312,8 +479,12 @@ onBeforeUnmount(() => {
</span>
</a-tag>
</template>
<template v-else-if="column.key === 'execution_time'">
{{ formatDateTime(record.execution_time) }}
<template v-else-if="column.key === 'dispatch_status'">
<a-tooltip :title="record.last_dispatch_error || formatDateTime(record.last_run_at)">
<a-tag :color="dispatchStatusMeta(record.last_dispatch_status).color">
{{ dispatchStatusMeta(record.last_dispatch_status).label }}
</a-tag>
</a-tooltip>
</template>
<template v-else-if="column.key === 'auto_publish_platforms'">
<a-tag :color="record.auto_publish ? 'blue' : 'default'">
@@ -402,10 +573,7 @@ onBeforeUnmount(() => {
</a-table>
<ScheduleTaskModal v-model:open="modalOpen" :task="editingTask" />
<GeneratedArticleLinksDrawer
v-model:open="articleLinksOpen"
:articles="selectedTaskArticles"
/>
<GeneratedArticleLinksDrawer v-model:open="articleLinksOpen" :articles="selectedTaskArticles" />
</div>
</template>
@@ -413,8 +581,8 @@ onBeforeUnmount(() => {
.task-tab__filters {
display: grid;
grid-template-columns:
minmax(180px, 240px) minmax(180px, 240px) minmax(240px, 320px) minmax(240px, 1fr)
auto;
repeat(3, minmax(180px, 240px)) minmax(180px, 220px) minmax(240px, 320px)
minmax(240px, 1fr) auto;
gap: 16px;
margin-bottom: 18px;
}
@@ -448,6 +616,29 @@ onBeforeUnmount(() => {
gap: 4px;
}
.task-tab__target {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.task-tab__target strong {
overflow: hidden;
color: #1f2937;
font-size: 13px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-tab__target span,
.task-tab__plan {
color: #667085;
font-size: 12px;
line-height: 1.5;
}
@media (max-width: 1400px) {
.task-tab__filters {
grid-template-columns: repeat(2, minmax(260px, 1fr));