From c28c4f1419c4881fc0c7ca8ea1943fe37b8f964b Mon Sep 17 00:00:00 2001 From: liangxu Date: Fri, 22 May 2026 23:51:45 +0800 Subject: [PATCH] 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) --- .gitignore | 1 + .../src/components/GenerateTaskDrawer.vue | 813 ++++++++++++++++-- .../src/components/ScheduleTaskTab.vue | 229 ++++- apps/admin-web/src/i18n/messages/en-US.ts | 44 + apps/admin-web/src/i18n/messages/zh-CN.ts | 42 + packages/shared-types/src/index.ts | 36 +- server/cmd/scheduler/main.go | 14 +- .../scheduler/schedule_dispatch_worker.go | 397 +++++++-- .../schedule_dispatch_worker_test.go | 30 + server/internal/shared/schedule/policy.go | 177 ++++ .../internal/shared/schedule/policy_test.go | 164 ++++ .../tenant/app/instant_task_service.go | 13 +- .../tenant/app/kol_generation_service.go | 174 +++- .../tenant/app/schedule_task_policy.go | 327 +++++++ .../tenant/app/schedule_task_policy_test.go | 62 ++ .../tenant/app/schedule_task_service.go | 645 +++++++++++--- .../internal/tenant/domain/schedule_task.go | 52 +- .../tenant/transport/schedule_task_handler.go | 8 + ...schedule_tasks_for_random_and_kol.down.sql | 26 + ...d_schedule_tasks_for_random_and_kol.up.sql | 109 +++ 20 files changed, 3034 insertions(+), 329 deletions(-) create mode 100644 server/internal/scheduler/schedule_dispatch_worker_test.go create mode 100644 server/internal/shared/schedule/policy.go create mode 100644 server/internal/shared/schedule/policy_test.go create mode 100644 server/internal/tenant/app/schedule_task_policy.go create mode 100644 server/internal/tenant/app/schedule_task_policy_test.go create mode 100644 server/migrations/20260522100000_extend_schedule_tasks_for_random_and_kol.down.sql create mode 100644 server/migrations/20260522100000_extend_schedule_tasks_for_random_and_kol.up.sql diff --git a/.gitignore b/.gitignore index 76f81d0..98d4d3e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ .pnpm-store/ coverage/ dist/ +output/ .env .env.local .env.signing diff --git a/apps/admin-web/src/components/GenerateTaskDrawer.vue b/apps/admin-web/src/components/GenerateTaskDrawer.vue index 1557393..2f8da34 100644 --- a/apps/admin-web/src/components/GenerateTaskDrawer.vue +++ b/apps/admin-web/src/components/GenerateTaskDrawer.vue @@ -1,16 +1,33 @@ @@ -418,7 +754,22 @@ function padTime(value: string): string { /> -
+
+ + + + {{ option.label }} + + +
+ +
@@ -444,6 +795,161 @@ function padTime(value: string): string {
+ +
+ + + + +
+
+ +
+
+ {{ selectedSubscriptionPrompt.package_name }} + + {{ selectedSubscriptionPrompt.prompt_name }} · + {{ selectedSubscriptionPrompt.kol_display_name }} + +
+ + {{ selectedSubscriptionPrompt.platform_hint }} + +
+ +
+ {{ t('custom.task.kolTemplateEmptyTitle') }} +

{{ t('custom.task.kolTemplateEmptyHint') }}

+
+
+ + +
+
+
+

{{ t('custom.task.scheduleSection') }}

+
+ +
+
+ + + + {{ option.label }} + + +
+ +
+ +
+ +
+
+
+ +
+ +

{{ t('custom.schedule.executionTimeHint') }}

+
+ + {{ t('custom.schedule.executionTimeDefault') }} +
+
+
+ +
+
+
+

{{ t('custom.task.kolInputSection') }}

+
+ + +
{{ t('custom.task.advancedSection') }} -
- -
- {{ t('custom.task.scheduleEveryDay') }} - -
-
- -
+
@@ -626,12 +1121,18 @@ function padTime(value: string): string { @@ -756,8 +1257,9 @@ function padTime(value: string): string { align-items: center; } -.generate-task-drawer__prompt-select { - flex: 0 1 320px; +.generate-task-drawer__prompt-select, +.generate-task-drawer__kol-select { + width: min(520px, 100%); } .generate-task-drawer__prompt-create { @@ -766,6 +1268,123 @@ function padTime(value: string): string { color: #355dff; } +.generate-task-drawer__target-card { + display: grid; + grid-template-columns: 40px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + max-width: 640px; + margin-top: 12px; + padding: 14px 16px; + border: 1px solid #e6edf5; + border-radius: 8px; + background: #fbfcff; +} + +.generate-task-drawer__target-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 8px; + background: #eef4ff; + color: #355dff; + font-size: 18px; +} + +.generate-task-drawer__target-card strong, +.generate-task-drawer__target-card span { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.generate-task-drawer__target-card strong { + color: #111827; + font-size: 14px; + font-weight: 700; +} + +.generate-task-drawer__target-card span { + margin-top: 4px; + color: #667085; + font-size: 13px; +} + +.generate-task-drawer__schedule-grid { + display: grid; + grid-template-columns: minmax(220px, 280px) minmax(360px, 1fr); + gap: 24px; + align-items: start; +} + +.generate-task-drawer__weekday-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.generate-task-drawer__weekday { + min-width: 48px; + height: 36px; + border: 1px solid #d9e2ef; + border-radius: 8px; + background: #fff; + color: #344054; + font-weight: 700; + cursor: pointer; + transition: + border-color 0.2s ease, + background 0.2s ease, + color 0.2s ease; +} + +.generate-task-drawer__weekday--active { + border-color: #355dff; + background: #eef4ff; + color: #2445d4; +} + +.generate-task-drawer__capability { + padding: 2px 0 8px; +} + +.generate-task-drawer__capability-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 20px; + padding: 16px 18px; + border: 1px solid #e6edf5; + border-radius: 8px; + background: #fbfcff; +} + +.generate-task-drawer__capability-row strong, +.generate-task-drawer__capability-row span { + display: block; +} + +.generate-task-drawer__capability-row strong { + color: #111827; + font-size: 14px; + font-weight: 700; +} + +.generate-task-drawer__capability-row span { + margin-top: 4px; + color: #667085; + font-size: 13px; +} + +.generate-task-drawer__kol-form { + margin-top: 20px; +} + .generate-task-drawer__account-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); @@ -914,6 +1533,7 @@ function padTime(value: string): string { } .generate-task-drawer__empty { + margin-top: 12px; padding: 18px 20px; border: 1px dashed #cfd9e8; border-radius: 8px; @@ -1005,6 +1625,7 @@ function padTime(value: string): string { .generate-task-drawer__time-wrapper { display: flex; align-items: center; + flex-wrap: wrap; gap: 12px; } @@ -1015,19 +1636,47 @@ function padTime(value: string): string { } .generate-task-drawer__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.generate-task-drawer__footer-note { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + color: #667085; + font-size: 13px; +} + +.generate-task-drawer__footer-actions { display: flex; justify-content: flex-end; gap: 12px; } @media (max-width: 1024px) { - .generate-task-drawer__prompt-row { + .generate-task-drawer__prompt-row, + .generate-task-drawer__schedule-grid { + grid-template-columns: 1fr; flex-direction: column; align-items: stretch; } - .generate-task-drawer__prompt-select { - flex-basis: auto; + .generate-task-drawer__prompt-select, + .generate-task-drawer__kol-select { + width: 100%; + } + + .generate-task-drawer__footer { + flex-direction: column; + align-items: stretch; + } + + .generate-task-drawer__footer-actions { + justify-content: flex-end; } } diff --git a/apps/admin-web/src/components/ScheduleTaskTab.vue b/apps/admin-web/src/components/ScheduleTaskTab.vue index 09ab0c4..be300da 100644 --- a/apps/admin-web/src/components/ScheduleTaskTab.vue +++ b/apps/admin-web/src/components/ScheduleTaskTab.vue @@ -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(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(() => { 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>(() => [ { 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>(() => [ 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' } +}