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));
+44
View File
@@ -1612,6 +1612,20 @@ const enUS = {
coverUpload: 'Upload cover image',
coverUploadHint: 'Local preview only for now',
scheduleCoverUploadHint: 'Choose from local files or the asset library',
source: 'Generation source',
sourcePromptRule: 'Custom Prompt',
sourceKolTemplate: 'Refined Template',
kolTemplate: 'Refined template',
kolTemplatePlaceholder: 'Select a subscribed refined template',
kolTemplateEmptyTitle: 'No refined templates available',
kolTemplateEmptyHint:
'This account has no active subscribed refined template package yet. Subscribe in the marketplace first.',
scheduleSection: 'Schedule Policy',
kolInputSection: 'Refined Variables',
kolWebSearch: 'Web search',
kolWebSearchHint: 'Enable it when this refined template allows web search.',
kolKnowledge: 'Knowledge base',
kolKnowledgePlaceholder: 'Optional. Select your knowledge groups for generation',
scheduleTime: 'Schedule article generation',
scheduleEveryDay: 'Every day',
enableWebSearch: 'Enable web search for generation',
@@ -1629,11 +1643,29 @@ const enUS = {
schedule: {
name: 'Task name',
rule: 'Prompt rule',
target: 'Target',
plan: 'Schedule',
cron: 'Frequency',
frequency: 'Frequency',
kindDaily: 'Every day',
kindWeekly: 'Weekdays',
weekdaysLabel: 'Run days',
executionTime: 'Run time',
executionTimeHint:
'New tasks default to a random time between 01:00 and 05:00 to smooth overnight load. You can edit it before saving.',
executionTimeDefault: 'Default staggered time',
to: 'to',
autoPublishPlatforms: 'Auto publish platforms',
autoPublish: 'Auto publish',
manualPublish: 'Manual publish',
nextRun: 'Next run',
dispatchStatus: 'Last dispatch',
dispatchSuccess: 'Success',
dispatchFailed: 'Failed',
dispatchPartialSuccess: 'Partial success',
notDispatched: 'Not dispatched',
footerHint:
'New tasks start with a random time between 01:00 and 05:00, then run at the saved time.',
startAt: 'Start time',
endAt: 'End time',
brand: 'Brand',
@@ -1650,6 +1682,15 @@ const enUS = {
weekly: 'Once weekly',
custom: 'Custom Cron',
},
weekdays: {
mon: 'Mon',
tue: 'Tue',
wed: 'Wed',
thu: 'Thu',
fri: 'Fri',
sat: 'Sat',
sun: 'Sun',
},
},
instant: {
title: 'Instant Generate',
@@ -1700,8 +1741,11 @@ const enUS = {
generateSubmitted: 'Generation task submitted.',
missingTaskName: 'Enter a task name first.',
missingPromptRule: 'Choose a prompt first.',
missingKolTemplate: 'Choose a subscribed refined template first.',
invalidGenerateCount: 'Article count must be at least 1.',
invalidScheduleTime: 'Choose a valid schedule time.',
invalidRandomWindow: 'The schedule time configuration is invalid.',
missingScheduleDays: 'Select at least one run day.',
missingPublishAccounts: 'Choose at least one media account when auto publish is enabled.',
missingScheduleCover: 'A selected media account platform requires a cover image.',
missingPromptName: 'Enter a prompt name first.',
+42
View File
@@ -1520,6 +1520,19 @@ const zhCN = {
coverUpload: "上传封面图",
coverUploadHint: "当前仅做本地预览",
scheduleCoverUploadHint: "从本地或素材库选择",
source: "生成来源",
sourcePromptRule: "自定义 Prompt",
sourceKolTemplate: "精调模版",
kolTemplate: "精调模版",
kolTemplatePlaceholder: "请选择已订阅的精调模版",
kolTemplateEmptyTitle: "暂无可用精调模版",
kolTemplateEmptyHint: "当前账号还没有订阅可用的精调模版包,请先到精调模版市场完成订阅。",
scheduleSection: "调度策略",
kolInputSection: "精调变量",
kolWebSearch: "联网搜索",
kolWebSearchHint: "该精调模版允许联网搜索时可开启。",
kolKnowledge: "知识库引用",
kolKnowledgePlaceholder: "可选,选择你的知识库分组辅助生成",
scheduleTime: "定时生成文章",
scheduleEveryDay: "每天",
enableWebSearch: "生成文章联网搜索",
@@ -1537,11 +1550,28 @@ const zhCN = {
schedule: {
name: "任务名称",
rule: "关联规则",
target: "生成目标",
plan: "调度计划",
cron: "执行频率",
frequency: "频率",
kindDaily: "每天",
kindWeekly: "按星期",
weekdaysLabel: "执行日",
executionTime: "执行时间",
executionTimeHint:
"新建任务会默认随机落在 01:00-05:00,避免凌晨任务集中;你也可以手动修改为固定执行时间。",
executionTimeDefault: "默认错峰生成",
to: "至",
autoPublishPlatforms: "自动发布平台",
autoPublish: "自动发布",
manualPublish: "手动发布",
nextRun: "下次执行",
dispatchStatus: "最近调度",
dispatchSuccess: "成功",
dispatchFailed: "失败",
dispatchPartialSuccess: "部分成功",
notDispatched: "未调度",
footerHint: "新建任务默认给出凌晨 1 点到 5 点之间的随机时间,保存后按所选时间执行。",
startAt: "开始时间",
endAt: "结束时间",
brand: "关联品牌",
@@ -1558,6 +1588,15 @@ const zhCN = {
weekly: "每周一次",
custom: "自定义 Cron",
},
weekdays: {
mon: "周一",
tue: "周二",
wed: "周三",
thu: "周四",
fri: "周五",
sat: "周六",
sun: "周日",
},
},
instant: {
title: "即时生成",
@@ -1608,8 +1647,11 @@ const zhCN = {
generateSubmitted: "生成任务已提交",
missingTaskName: "请先填写任务名称",
missingPromptRule: "请先选择 Prompt",
missingKolTemplate: "请先选择已订阅的精调模版",
invalidGenerateCount: "生成篇数至少为 1",
invalidScheduleTime: "请选择有效的定时时间",
invalidRandomWindow: "执行时间配置不正确",
missingScheduleDays: "请至少选择一个执行日",
missingPublishAccounts: "开启自动发布后,请至少选择一个媒体账号",
missingScheduleCover: "已选媒体账号的平台要求封面图,请先设置封面",
missingPromptName: "请先填写 Prompt 名称",