feat(schedule): support KOL subscription targets and random time windows
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:
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user