diff --git a/apps/backend/data/cloudtentacles-session.json b/apps/backend/data/cloudtentacles-session.json index 9d5fa043..d85efef2 100644 --- a/apps/backend/data/cloudtentacles-session.json +++ b/apps/backend/data/cloudtentacles-session.json @@ -3,7 +3,7 @@ "baseUrl": "https://123.207.217.176", "username": "17665234375", "phone": "17665234375", - "loggedInAt": "2026-05-14T05:47:42.507Z", + "loggedInAt": "2026-05-14T09:29:51.511Z", "deviceId": "-", "deviceType": 0 } diff --git a/apps/backend/data/scheduled-jobs.json b/apps/backend/data/scheduled-jobs.json new file mode 100644 index 00000000..24d83ca6 --- /dev/null +++ b/apps/backend/data/scheduled-jobs.json @@ -0,0 +1,15 @@ +{ + "enabled": true, + "jobs": [ + { + "id": "cloudtentacles-health", + "type": "cloudtentacles_health", + "enabled": true, + "intervalSeconds": 18000, + "cooldownSeconds": 1800, + "config": { + "assetThreshold": 500 + } + } + ] +} diff --git a/apps/backend/src/index.js b/apps/backend/src/index.js index f0b8c52a..48f58d7b 100644 --- a/apps/backend/src/index.js +++ b/apps/backend/src/index.js @@ -9,6 +9,7 @@ import open91Router from './routes/open-91.js' import webhooksRouter from './routes/webhooks.js' import { ensureAdminUsersBootstrapped } from './services/admin/admin-auth-service.js' import { ensureFulfillmentCatalogBootstrapped } from './services/bootstrap/fulfillment-bootstrap-service.js' +import { startScheduledJobs, stopScheduledJobs } from './services/scheduler/scheduler-service.js' import { closeLocalOcrWorker, warmupLocalOcrWorker } from './services/session/ocr.js' import { closeAllTencentBrowserSessions, warmupTencentBrowser } from './services/session/session.js' import { buildSuccessPayload } from './utils/http.js' @@ -162,6 +163,7 @@ async function bootstrapCoreServices() { attempt: startupState.core.attemptCount, }) + startScheduledJobs() void bootstrapBrowser() void bootstrapOcr() break @@ -358,6 +360,8 @@ async function shutdown(signal) { startupState.phase = 'shutting_down' logInfo('[shutdown]', `received ${signal}, closing browser sessions and HTTP server`) + stopScheduledJobs() + try { await closeAllTencentBrowserSessions({ markClosed: true }) } catch (error) { diff --git a/apps/backend/src/routes/admin/platform-config.js b/apps/backend/src/routes/admin/platform-config.js index 10717353..4bf4bd09 100644 --- a/apps/backend/src/routes/admin/platform-config.js +++ b/apps/backend/src/routes/admin/platform-config.js @@ -12,6 +12,7 @@ import { getAdminCloudtentaclesBindUrl, getAdminCloudtentaclesCategories, getAdminNotificationConfig, + getAdminScheduledJobsConfig, getAdminKuaishouCloudFulfillmentConfig, getAdminKuaishouEticketSourceConfig, getAdminCloudtentaclesKnapsack, @@ -29,6 +30,7 @@ import { queryAdminKuaishouEticketDetail, retryAdminNinetyoneOrder, runAdminCloudtentaclesFullFlow, + runAdminScheduledJobNow, sendAdminCloudtentaclesSmsCode, testAdminNotification, testAdminCloudtentaclesLogin, @@ -36,6 +38,7 @@ import { updateAdminKuaishouEticketSourceConfig, updateAdminCloudtentaclesSourceConfig, updateAdminNotificationConfig, + updateAdminScheduledJobsConfig, updateAdminAgisoShopConfigs, updateAdminFulfillmentBindingConfigs, verifyAdminCloudtentaclesLoginCode, @@ -50,6 +53,7 @@ import { createJsonHandler, requireAdminRoles } from './shared.js' /** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketSourceConfigRouteBody} AdminKuaishouEticketSourceConfigRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminNotificationConfigRouteBody} AdminNotificationConfigRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminNotificationTestRouteBody} AdminNotificationTestRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminScheduledJobsConfigRouteBody} AdminScheduledJobsConfigRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketDetailQueryRouteBody} AdminKuaishouEticketDetailQueryRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketConsumeRouteBody} AdminKuaishouEticketConsumeRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketShopInfoRouteBody} AdminKuaishouEticketShopInfoRouteBody */ @@ -154,6 +158,58 @@ router.post('/platform-config/notifications/test', createJsonHandler( }, )) +router.get('/platform-config/scheduled-jobs', createJsonHandler( + () => getAdminScheduledJobsConfig(), + { + successMessage: 'ok', + errorMessage: '读取定时任务配置失败', + scope: '[admin/platform-config/scheduled-jobs]', + }, +)) + +router.post('/platform-config/scheduled-jobs', createJsonHandler( + (req) => updateAdminScheduledJobsConfig(/** @type {AdminScheduledJobsConfigRouteBody} */ (req.body)), + { + successMessage: '定时任务配置已保存', + errorMessage: '保存定时任务配置失败', + scope: '[admin/platform-config/scheduled-jobs]', + audit: (_req, data) => { + const result = /** @type {{ filePath?: string, source?: { enabled?: boolean, jobs?: unknown[] } }} */ (data) + return { + action: 'platform_scheduled_jobs_updated', + targetType: 'platform_config', + targetId: 'scheduled_jobs', + data: { + filePath: String(result.filePath || '').trim(), + enabled: Boolean(result.source?.enabled), + jobCount: Array.isArray(result.source?.jobs) ? result.source.jobs.length : 0, + }, + } + }, + }, +)) + +router.post('/platform-config/scheduled-jobs/:id/run', createJsonHandler( + (req) => runAdminScheduledJobNow((/** @type {AdminEntityRouteParams} */ (req.params)).id), + { + successMessage: '定时任务已执行', + errorMessage: '执行定时任务失败', + scope: '[admin/platform-config/scheduled-jobs/:id/run]', + audit: (req, data) => { + const result = /** @type {{ result?: { status?: string, message?: string } }} */ (data) + return { + action: 'platform_scheduled_job_run', + targetType: 'platform_config', + targetId: String((/** @type {AdminEntityRouteParams} */ (req.params)).id || '').trim(), + data: { + status: String(result.result?.status || '').trim(), + message: String(result.result?.message || '').trim(), + }, + } + }, + }, +)) + router.get('/platform-config/kuaishou-eticket-source', createJsonHandler( () => getAdminKuaishouEticketSourceConfig(), { diff --git a/apps/backend/src/services/admin/platform-config/notification-service.js b/apps/backend/src/services/admin/platform-config/notification-service.js index 6b05946b..99b83586 100644 --- a/apps/backend/src/services/admin/platform-config/notification-service.js +++ b/apps/backend/src/services/admin/platform-config/notification-service.js @@ -6,10 +6,21 @@ import { saveNotificationConfig, } from '../../notification/config-service.js' import { sendInternalNotification } from '../../notification/notification-service.js' +import { + getScheduledJobsConfig, + getScheduledJobsFilePath, + saveScheduledJobsConfig, +} from '../../scheduler/config-service.js' +import { + getScheduledJobRuntimeStates, + reloadScheduledJobs, + runScheduledJobNow, +} from '../../scheduler/scheduler-service.js' import { maskSecret } from './mappers.js' /** @typedef {import('../../../types/admin-write-inputs.js').AdminNotificationConfigInput} AdminNotificationConfigInput */ /** @typedef {import('../../../types/admin-write-inputs.js').AdminNotificationTestInput} AdminNotificationTestInput */ +/** @typedef {import('../../../types/admin-write-inputs.js').AdminScheduledJobsConfigInput} AdminScheduledJobsConfigInput */ export function getAdminNotificationConfig() { const config = getNotificationConfig() @@ -40,6 +51,37 @@ export async function testAdminNotification(payload = /** @type {AdminNotificati }) } +export function getAdminScheduledJobsConfig() { + const config = getScheduledJobsConfig() + + return { + filePath: getScheduledJobsFilePath(), + source: mapAdminScheduledJobsConfig(config), + runtime: getScheduledJobRuntimeStates(), + } +} + +/** @param {AdminScheduledJobsConfigInput} [payload] */ +export function updateAdminScheduledJobsConfig(payload = /** @type {AdminScheduledJobsConfigInput} */ ({})) { + const saved = saveScheduledJobsConfig(payload) + reloadScheduledJobs() + + return { + filePath: getScheduledJobsFilePath(), + source: mapAdminScheduledJobsConfig(saved), + runtime: getScheduledJobRuntimeStates(), + } +} + +export async function runAdminScheduledJobNow(jobId) { + const result = await runScheduledJobNow(jobId) + + return { + result, + runtime: getScheduledJobRuntimeStates(), + } +} + function mapAdminNotificationConfig(config = {}) { const bark = config.channels?.bark || {} @@ -60,3 +102,19 @@ function mapAdminNotificationConfig(config = {}) { }, } } + +function mapAdminScheduledJobsConfig(config = {}) { + return { + enabled: config.enabled !== false, + jobs: (Array.isArray(config.jobs) ? config.jobs : []).map((item) => ({ + id: String(item.id || '').trim(), + type: String(item.type || '').trim(), + enabled: item.enabled === true, + intervalSeconds: Number(item.intervalSeconds || 300), + cooldownSeconds: Number(item.cooldownSeconds || 1800), + config: { + assetThreshold: Number(item.config?.assetThreshold || 500), + }, + })), + } +} diff --git a/apps/backend/src/services/admin/platform-config/service.js b/apps/backend/src/services/admin/platform-config/service.js index 51c7d037..4e5d12fe 100644 --- a/apps/backend/src/services/admin/platform-config/service.js +++ b/apps/backend/src/services/admin/platform-config/service.js @@ -54,4 +54,7 @@ export { getAdminNotificationConfig, updateAdminNotificationConfig, testAdminNotification, + getAdminScheduledJobsConfig, + updateAdminScheduledJobsConfig, + runAdminScheduledJobNow, } from './notification-service.js' diff --git a/apps/backend/src/services/notification/domain-notifications.js b/apps/backend/src/services/notification/domain-notifications.js index 8e2d0bbe..9ccae245 100644 --- a/apps/backend/src/services/notification/domain-notifications.js +++ b/apps/backend/src/services/notification/domain-notifications.js @@ -82,6 +82,7 @@ export function notifyCloudtentaclesAuthExpired({ pathname = '', errorCode = '', message = '', + cooldownSeconds = 600, } = {}) { return notifyInternalSafely({ title: 'cloudtentacles 登录已过期', @@ -92,6 +93,25 @@ export function notifyCloudtentaclesAuthExpired({ ].join('\n'), category: 'cloudtentacles_auth_expired', cooldownKey: ['cloudtentacles_auth_expired', pathname, errorCode].join(':'), + cooldownMs: Number(cooldownSeconds || 600) * 1000, + }) +} + +export function notifyCloudtentaclesAssetLow({ + asset = 0, + threshold = 500, + cooldownSeconds = 1800, +} = {}) { + return notifyInternalSafely({ + title: '快手 Cloud 余额低于阈值', + body: [ + `当前余额:${Number(asset || 0)}`, + `提醒阈值:${Number(threshold || 0)}`, + '请及时补充 cloudtentacles 余额,避免自动履约失败。', + ].join('\n'), + category: 'cloudtentacles_asset_low', + cooldownKey: ['cloudtentacles_asset_low', threshold].join(':'), + cooldownMs: Number(cooldownSeconds || 1800) * 1000, }) } diff --git a/apps/backend/src/services/scheduler/cloudtentacles-health-job.js b/apps/backend/src/services/scheduler/cloudtentacles-health-job.js new file mode 100644 index 00000000..ba14cb33 --- /dev/null +++ b/apps/backend/src/services/scheduler/cloudtentacles-health-job.js @@ -0,0 +1,58 @@ +// @ts-check + +import { getCloudtentaclesAsset } from '../platforms/cloudtentacles/catalog-service.js' +import { getCloudtentaclesSourceConfig } from '../platforms/cloudtentacles/source-config-service.js' +import { getCloudtentaclesSessionState } from '../platforms/cloudtentacles/session-state-service.js' +import { + notifyCloudtentaclesAssetLow, + notifyCloudtentaclesAuthExpired, +} from '../notification/domain-notifications.js' + +export async function runCloudtentaclesHealthJob(job) { + const source = getCloudtentaclesSourceConfig() + const session = getCloudtentaclesSessionState() + const token = String(session.token || '').trim() + const threshold = Number(job.config?.assetThreshold || 500) + const cooldownSeconds = Number(job.cooldownSeconds || 1800) + + if (!token) { + await notifyCloudtentaclesAuthExpired({ + pathname: '/user/get_asset', + errorCode: 'cloudtentacles_token_missing', + message: '当前 cloudtentacles 没有可用 token,请到后台重新登录', + cooldownSeconds, + }) + + return { + ok: false, + status: 'auth_missing', + message: 'cloudtentacles token 缺失', + asset: null, + threshold, + } + } + + const assetResult = await getCloudtentaclesAsset({ + baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176', + token, + deviceId: String(session.deviceId || source.deviceId || '-').trim() || '-', + deviceType: Number(session.deviceType ?? source.deviceType ?? 0), + }) + const asset = Number(assetResult.asset || 0) + + if (asset < threshold) { + await notifyCloudtentaclesAssetLow({ + asset, + threshold, + cooldownSeconds, + }) + } + + return { + ok: true, + status: asset < threshold ? 'asset_low' : 'ok', + message: asset < threshold ? `余额 ${asset} 低于阈值 ${threshold}` : `余额 ${asset} 正常`, + asset, + threshold, + } +} diff --git a/apps/backend/src/services/scheduler/config-service.js b/apps/backend/src/services/scheduler/config-service.js new file mode 100644 index 00000000..49aa3fd3 --- /dev/null +++ b/apps/backend/src/services/scheduler/config-service.js @@ -0,0 +1,122 @@ +// @ts-check + +import fs from 'node:fs' +import path from 'node:path' + +import { PROJECT_ROOT } from '../../config/runtime.js' + +const SCHEDULED_JOBS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'scheduled-jobs.json') +const CLOUDTENTACLES_HEALTH_JOB_ID = 'cloudtentacles-health' + +export function getScheduledJobsFilePath() { + return SCHEDULED_JOBS_FILE_PATH +} + +export function getScheduledJobsConfig() { + return loadScheduledJobsConfigFromFile() +} + +export function saveScheduledJobsConfig(rawValue) { + const normalized = normalizeScheduledJobsConfig(rawValue) + fs.mkdirSync(path.dirname(SCHEDULED_JOBS_FILE_PATH), { recursive: true }) + fs.writeFileSync(SCHEDULED_JOBS_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8') + return normalized +} + +export function getCloudtentaclesHealthJob(config = getScheduledJobsConfig()) { + return (Array.isArray(config.jobs) ? config.jobs : []) + .find((item) => String(item.id || '').trim() === CLOUDTENTACLES_HEALTH_JOB_ID) + || createDefaultCloudtentaclesHealthJob() +} + +function loadScheduledJobsConfigFromFile() { + if (!fs.existsSync(SCHEDULED_JOBS_FILE_PATH)) { + return createDefaultScheduledJobsConfig() + } + + try { + const rawText = fs.readFileSync(SCHEDULED_JOBS_FILE_PATH, 'utf8') + return normalizeScheduledJobsConfig(JSON.parse(rawText)) + } catch { + return createDefaultScheduledJobsConfig() + } +} + +function normalizeScheduledJobsConfig(rawValue) { + const source = isPlainObject(rawValue) ? rawValue : {} + const rawJobs = Array.isArray(source.jobs) ? source.jobs : [] + const jobs = rawJobs.map((item) => normalizeScheduledJob(item)).filter(Boolean) + const hasCloudtentaclesHealth = jobs.some((item) => item.id === CLOUDTENTACLES_HEALTH_JOB_ID) + + if (!hasCloudtentaclesHealth) { + jobs.push(createDefaultCloudtentaclesHealthJob()) + } + + return { + enabled: typeof source.enabled === 'boolean' ? source.enabled : true, + jobs, + } +} + +function normalizeScheduledJob(rawValue) { + if (!isPlainObject(rawValue)) { + return null + } + + const type = String(rawValue.type || '').trim() + if (type !== 'cloudtentacles_health') { + return null + } + + return normalizeCloudtentaclesHealthJob(rawValue) +} + +function normalizeCloudtentaclesHealthJob(rawValue) { + const config = isPlainObject(rawValue.config) ? rawValue.config : {} + + return { + id: CLOUDTENTACLES_HEALTH_JOB_ID, + type: 'cloudtentacles_health', + enabled: rawValue.enabled === true, + intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 86400), + cooldownSeconds: normalizeRangeInteger(rawValue.cooldownSeconds, 1800, 60, 86400), + config: { + assetThreshold: normalizeRangeInteger(config.assetThreshold, 500, 0, 999999), + }, + } +} + +function createDefaultScheduledJobsConfig() { + return { + enabled: true, + jobs: [ + createDefaultCloudtentaclesHealthJob(), + ], + } +} + +function createDefaultCloudtentaclesHealthJob() { + return { + id: CLOUDTENTACLES_HEALTH_JOB_ID, + type: 'cloudtentacles_health', + enabled: false, + intervalSeconds: 300, + cooldownSeconds: 1800, + config: { + assetThreshold: 500, + }, + } +} + +function normalizeRangeInteger(value, fallback, min, max) { + const parsed = Number(value) + if (!Number.isInteger(parsed)) { + return fallback + } + + return Math.min(max, Math.max(min, parsed)) +} + +function isPlainObject(value) { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/scheduler/scheduler-service.js b/apps/backend/src/services/scheduler/scheduler-service.js new file mode 100644 index 00000000..02ae3d5b --- /dev/null +++ b/apps/backend/src/services/scheduler/scheduler-service.js @@ -0,0 +1,189 @@ +// @ts-check + +import { logError, logInfo, logWarn } from '../../utils/logger.js' +import { + getCloudtentaclesHealthJob, + getScheduledJobsConfig, +} from './config-service.js' +import { runCloudtentaclesHealthJob } from './cloudtentacles-health-job.js' + +const timers = new Map() +const jobStates = new Map() + +export function startScheduledJobs() { + reloadScheduledJobs() +} + +export function stopScheduledJobs() { + for (const timer of timers.values()) { + clearTimeout(timer) + } + timers.clear() +} + +export function reloadScheduledJobs() { + stopScheduledJobs() + const config = getScheduledJobsConfig() + + if (config.enabled === false) { + logInfo('[scheduler]', '定时任务系统已停用') + return + } + + for (const job of config.jobs) { + if (job.enabled !== true) { + updateJobState(job.id, { + enabled: false, + running: false, + nextRunAt: '', + }) + continue + } + + scheduleJob(job, 1000) + } +} + +export function getScheduledJobRuntimeStates() { + return Array.from(jobStates.values()) +} + +export async function runScheduledJobNow(jobId) { + const config = getScheduledJobsConfig() + const job = config.jobs.find((item) => String(item.id || '').trim() === String(jobId || '').trim()) + + if (!job) { + throw new Error('定时任务不存在') + } + + const currentTimer = timers.get(job.id) + if (currentTimer) { + clearTimeout(currentTimer) + timers.delete(job.id) + } + + const result = await runJob(job, { manual: true }) + if (getScheduledJobsConfig().enabled !== false) { + const latestJob = getCloudtentaclesHealthJob(getScheduledJobsConfig()) + if (latestJob.enabled === true) { + scheduleJob(latestJob, Math.max(60, Number(latestJob.intervalSeconds || 300)) * 1000) + } + } + return result +} + +function scheduleJob(job, delayMs) { + const jobId = String(job.id || '').trim() + if (!jobId) { + return + } + + const nextRunAt = new Date(Date.now() + delayMs).toISOString() + updateJobState(jobId, { + enabled: true, + nextRunAt, + }) + + const timer = setTimeout(() => { + timers.delete(jobId) + void runJob(job).finally(() => { + const latestJob = getCloudtentaclesHealthJob(getScheduledJobsConfig()) + if (latestJob.enabled === true) { + scheduleJob(latestJob, Math.max(60, Number(latestJob.intervalSeconds || 300)) * 1000) + } + }) + }, delayMs) + + timers.set(jobId, timer) +} + +async function runJob(job, { manual = false } = {}) { + const jobId = String(job.id || '').trim() + const startedAt = new Date().toISOString() + + if (!jobId) { + return null + } + + if (jobStates.get(jobId)?.running) { + logWarn('[scheduler]', '定时任务仍在运行,跳过本次执行', { jobId }) + return { + skipped: true, + reason: 'running', + } + } + + updateJobState(jobId, { + enabled: job.enabled === true, + running: true, + lastRunAt: startedAt, + lastStatus: 'running', + lastMessage: '执行中', + }) + + try { + const result = await dispatchJob(job) + updateJobState(jobId, { + running: false, + lastFinishedAt: new Date().toISOString(), + lastStatus: result?.status || 'ok', + lastMessage: result?.message || '执行完成', + lastAsset: typeof result?.asset === 'number' ? result.asset : null, + lastThreshold: typeof result?.threshold === 'number' ? result.threshold : null, + lastManual: manual, + }) + logInfo('[scheduler]', '定时任务执行完成', { + jobId, + type: job.type, + status: result?.status || 'ok', + }) + return result + } catch (error) { + const message = error instanceof Error ? error.message : String(error || '定时任务执行失败') + updateJobState(jobId, { + running: false, + lastFinishedAt: new Date().toISOString(), + lastStatus: 'failed', + lastMessage: message, + lastManual: manual, + }) + logError('[scheduler]', '定时任务执行失败', { + jobId, + type: job.type, + error, + }) + return { + ok: false, + status: 'failed', + message, + } + } +} + +function dispatchJob(job) { + if (job.type === 'cloudtentacles_health') { + return runCloudtentaclesHealthJob(job) + } + + throw new Error(`不支持的定时任务类型:${job.type}`) +} + +function updateJobState(jobId, patch) { + const current = jobStates.get(jobId) || { + id: jobId, + enabled: false, + running: false, + lastRunAt: '', + lastFinishedAt: '', + nextRunAt: '', + lastStatus: 'pending', + lastMessage: '', + lastAsset: null, + lastThreshold: null, + lastManual: false, + } + jobStates.set(jobId, { + ...current, + ...patch, + }) +} diff --git a/apps/backend/src/types/admin-route-inputs.js b/apps/backend/src/types/admin-route-inputs.js index 02e30e2b..d2fcbfb9 100644 --- a/apps/backend/src/types/admin-route-inputs.js +++ b/apps/backend/src/types/admin-route-inputs.js @@ -42,6 +42,10 @@ export {} * @typedef {import('./admin-write-inputs.js').AdminNotificationTestInput} AdminNotificationTestRouteBody */ +/** + * @typedef {import('./admin-write-inputs.js').AdminScheduledJobsConfigInput} AdminScheduledJobsConfigRouteBody + */ + /** * @typedef {import('./admin-write-inputs.js').AdminAgisoShopConfigSaveInput} AdminAgisoShopConfigRouteBody */ diff --git a/apps/backend/src/types/admin-write-inputs.js b/apps/backend/src/types/admin-write-inputs.js index cf563933..c63bd9d0 100644 --- a/apps/backend/src/types/admin-write-inputs.js +++ b/apps/backend/src/types/admin-write-inputs.js @@ -150,6 +150,26 @@ export {} * }} AdminNotificationTestInput */ +/** + * @typedef {{ + * id?: string + * type?: string + * enabled?: boolean + * intervalSeconds?: number | string + * cooldownSeconds?: number | string + * config?: { + * assetThreshold?: number | string + * } + * }} AdminScheduledJobInput + */ + +/** + * @typedef {{ + * enabled?: boolean + * jobs?: AdminScheduledJobInput[] + * }} AdminScheduledJobsConfigInput + */ + /** * @typedef {{ * baseUrl?: string diff --git a/apps/frontend/src/components/admin/AdminPlatformNotificationSection.vue b/apps/frontend/src/components/admin/AdminPlatformNotificationSection.vue index e61c3580..1c06eac6 100644 --- a/apps/frontend/src/components/admin/AdminPlatformNotificationSection.vue +++ b/apps/frontend/src/components/admin/AdminPlatformNotificationSection.vue @@ -1,6 +1,8 @@