简单的定时任务--ok
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
"baseUrl": "https://123.207.217.176",
|
"baseUrl": "https://123.207.217.176",
|
||||||
"username": "17665234375",
|
"username": "17665234375",
|
||||||
"phone": "17665234375",
|
"phone": "17665234375",
|
||||||
"loggedInAt": "2026-05-14T05:47:42.507Z",
|
"loggedInAt": "2026-05-14T09:29:51.511Z",
|
||||||
"deviceId": "-",
|
"deviceId": "-",
|
||||||
"deviceType": 0
|
"deviceType": 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"jobs": [
|
||||||
|
{
|
||||||
|
"id": "cloudtentacles-health",
|
||||||
|
"type": "cloudtentacles_health",
|
||||||
|
"enabled": true,
|
||||||
|
"intervalSeconds": 18000,
|
||||||
|
"cooldownSeconds": 1800,
|
||||||
|
"config": {
|
||||||
|
"assetThreshold": 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import open91Router from './routes/open-91.js'
|
|||||||
import webhooksRouter from './routes/webhooks.js'
|
import webhooksRouter from './routes/webhooks.js'
|
||||||
import { ensureAdminUsersBootstrapped } from './services/admin/admin-auth-service.js'
|
import { ensureAdminUsersBootstrapped } from './services/admin/admin-auth-service.js'
|
||||||
import { ensureFulfillmentCatalogBootstrapped } from './services/bootstrap/fulfillment-bootstrap-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 { closeLocalOcrWorker, warmupLocalOcrWorker } from './services/session/ocr.js'
|
||||||
import { closeAllTencentBrowserSessions, warmupTencentBrowser } from './services/session/session.js'
|
import { closeAllTencentBrowserSessions, warmupTencentBrowser } from './services/session/session.js'
|
||||||
import { buildSuccessPayload } from './utils/http.js'
|
import { buildSuccessPayload } from './utils/http.js'
|
||||||
@@ -162,6 +163,7 @@ async function bootstrapCoreServices() {
|
|||||||
attempt: startupState.core.attemptCount,
|
attempt: startupState.core.attemptCount,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
startScheduledJobs()
|
||||||
void bootstrapBrowser()
|
void bootstrapBrowser()
|
||||||
void bootstrapOcr()
|
void bootstrapOcr()
|
||||||
break
|
break
|
||||||
@@ -358,6 +360,8 @@ async function shutdown(signal) {
|
|||||||
startupState.phase = 'shutting_down'
|
startupState.phase = 'shutting_down'
|
||||||
logInfo('[shutdown]', `received ${signal}, closing browser sessions and HTTP server`)
|
logInfo('[shutdown]', `received ${signal}, closing browser sessions and HTTP server`)
|
||||||
|
|
||||||
|
stopScheduledJobs()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await closeAllTencentBrowserSessions({ markClosed: true })
|
await closeAllTencentBrowserSessions({ markClosed: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getAdminCloudtentaclesBindUrl,
|
getAdminCloudtentaclesBindUrl,
|
||||||
getAdminCloudtentaclesCategories,
|
getAdminCloudtentaclesCategories,
|
||||||
getAdminNotificationConfig,
|
getAdminNotificationConfig,
|
||||||
|
getAdminScheduledJobsConfig,
|
||||||
getAdminKuaishouCloudFulfillmentConfig,
|
getAdminKuaishouCloudFulfillmentConfig,
|
||||||
getAdminKuaishouEticketSourceConfig,
|
getAdminKuaishouEticketSourceConfig,
|
||||||
getAdminCloudtentaclesKnapsack,
|
getAdminCloudtentaclesKnapsack,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
queryAdminKuaishouEticketDetail,
|
queryAdminKuaishouEticketDetail,
|
||||||
retryAdminNinetyoneOrder,
|
retryAdminNinetyoneOrder,
|
||||||
runAdminCloudtentaclesFullFlow,
|
runAdminCloudtentaclesFullFlow,
|
||||||
|
runAdminScheduledJobNow,
|
||||||
sendAdminCloudtentaclesSmsCode,
|
sendAdminCloudtentaclesSmsCode,
|
||||||
testAdminNotification,
|
testAdminNotification,
|
||||||
testAdminCloudtentaclesLogin,
|
testAdminCloudtentaclesLogin,
|
||||||
@@ -36,6 +38,7 @@ import {
|
|||||||
updateAdminKuaishouEticketSourceConfig,
|
updateAdminKuaishouEticketSourceConfig,
|
||||||
updateAdminCloudtentaclesSourceConfig,
|
updateAdminCloudtentaclesSourceConfig,
|
||||||
updateAdminNotificationConfig,
|
updateAdminNotificationConfig,
|
||||||
|
updateAdminScheduledJobsConfig,
|
||||||
updateAdminAgisoShopConfigs,
|
updateAdminAgisoShopConfigs,
|
||||||
updateAdminFulfillmentBindingConfigs,
|
updateAdminFulfillmentBindingConfigs,
|
||||||
verifyAdminCloudtentaclesLoginCode,
|
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').AdminKuaishouEticketSourceConfigRouteBody} AdminKuaishouEticketSourceConfigRouteBody */
|
||||||
/** @typedef {import('../../types/admin-route-inputs.js').AdminNotificationConfigRouteBody} AdminNotificationConfigRouteBody */
|
/** @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').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').AdminKuaishouEticketDetailQueryRouteBody} AdminKuaishouEticketDetailQueryRouteBody */
|
||||||
/** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketConsumeRouteBody} AdminKuaishouEticketConsumeRouteBody */
|
/** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketConsumeRouteBody} AdminKuaishouEticketConsumeRouteBody */
|
||||||
/** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketShopInfoRouteBody} AdminKuaishouEticketShopInfoRouteBody */
|
/** @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(
|
router.get('/platform-config/kuaishou-eticket-source', createJsonHandler(
|
||||||
() => getAdminKuaishouEticketSourceConfig(),
|
() => getAdminKuaishouEticketSourceConfig(),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,10 +6,21 @@ import {
|
|||||||
saveNotificationConfig,
|
saveNotificationConfig,
|
||||||
} from '../../notification/config-service.js'
|
} from '../../notification/config-service.js'
|
||||||
import { sendInternalNotification } from '../../notification/notification-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'
|
import { maskSecret } from './mappers.js'
|
||||||
|
|
||||||
/** @typedef {import('../../../types/admin-write-inputs.js').AdminNotificationConfigInput} AdminNotificationConfigInput */
|
/** @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').AdminNotificationTestInput} AdminNotificationTestInput */
|
||||||
|
/** @typedef {import('../../../types/admin-write-inputs.js').AdminScheduledJobsConfigInput} AdminScheduledJobsConfigInput */
|
||||||
|
|
||||||
export function getAdminNotificationConfig() {
|
export function getAdminNotificationConfig() {
|
||||||
const config = getNotificationConfig()
|
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 = {}) {
|
function mapAdminNotificationConfig(config = {}) {
|
||||||
const bark = config.channels?.bark || {}
|
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),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,4 +54,7 @@ export {
|
|||||||
getAdminNotificationConfig,
|
getAdminNotificationConfig,
|
||||||
updateAdminNotificationConfig,
|
updateAdminNotificationConfig,
|
||||||
testAdminNotification,
|
testAdminNotification,
|
||||||
|
getAdminScheduledJobsConfig,
|
||||||
|
updateAdminScheduledJobsConfig,
|
||||||
|
runAdminScheduledJobNow,
|
||||||
} from './notification-service.js'
|
} from './notification-service.js'
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function notifyCloudtentaclesAuthExpired({
|
|||||||
pathname = '',
|
pathname = '',
|
||||||
errorCode = '',
|
errorCode = '',
|
||||||
message = '',
|
message = '',
|
||||||
|
cooldownSeconds = 600,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return notifyInternalSafely({
|
return notifyInternalSafely({
|
||||||
title: 'cloudtentacles 登录已过期',
|
title: 'cloudtentacles 登录已过期',
|
||||||
@@ -92,6 +93,25 @@ export function notifyCloudtentaclesAuthExpired({
|
|||||||
].join('\n'),
|
].join('\n'),
|
||||||
category: 'cloudtentacles_auth_expired',
|
category: 'cloudtentacles_auth_expired',
|
||||||
cooldownKey: ['cloudtentacles_auth_expired', pathname, errorCode].join(':'),
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]'
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ export {}
|
|||||||
* @typedef {import('./admin-write-inputs.js').AdminNotificationTestInput} AdminNotificationTestRouteBody
|
* @typedef {import('./admin-write-inputs.js').AdminNotificationTestInput} AdminNotificationTestRouteBody
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('./admin-write-inputs.js').AdminScheduledJobsConfigInput} AdminScheduledJobsConfigRouteBody
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import('./admin-write-inputs.js').AdminAgisoShopConfigSaveInput} AdminAgisoShopConfigRouteBody
|
* @typedef {import('./admin-write-inputs.js').AdminAgisoShopConfigSaveInput} AdminAgisoShopConfigRouteBody
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -150,6 +150,26 @@ export {}
|
|||||||
* }} AdminNotificationTestInput
|
* }} 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 {{
|
* @typedef {{
|
||||||
* baseUrl?: string
|
* baseUrl?: string
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AdminNotificationTestResult } from '@/types/admin'
|
import { computed } from 'vue'
|
||||||
import type { EditableNotificationRecipient } from '@/composables/admin/platform-shops/types'
|
import type { AdminNotificationTestResult, AdminScheduledJobRuntimeState } from '@/types/admin'
|
||||||
|
import type { EditableNotificationRecipient, EditableScheduledJob } from '@/composables/admin/platform-shops/types'
|
||||||
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
notificationFilePath: string
|
notificationFilePath: string
|
||||||
@@ -17,20 +19,103 @@ type Props = {
|
|||||||
notificationTesting: boolean
|
notificationTesting: boolean
|
||||||
notificationResultError: string
|
notificationResultError: string
|
||||||
notificationTestResult: AdminNotificationTestResult | null
|
notificationTestResult: AdminNotificationTestResult | null
|
||||||
|
scheduledJobsFilePath: string
|
||||||
|
scheduledJobsForm: {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
scheduledJobs: EditableScheduledJob[]
|
||||||
|
scheduledJobRuntime: AdminScheduledJobRuntimeState[]
|
||||||
|
scheduledJobsSaving: boolean
|
||||||
|
scheduledJobRunningId: string
|
||||||
notificationStats: {
|
notificationStats: {
|
||||||
configuredRecipientCount: number
|
configuredRecipientCount: number
|
||||||
enabledRecipientCount: number
|
enabledRecipientCount: number
|
||||||
barkReady: boolean
|
barkReady: boolean
|
||||||
lastSuccessCount: number
|
lastSuccessCount: number
|
||||||
lastFailedCount: number
|
lastFailedCount: number
|
||||||
|
scheduledJobEnabledCount: number
|
||||||
}
|
}
|
||||||
addNotificationRecipient: () => void
|
addNotificationRecipient: () => void
|
||||||
removeNotificationRecipient: (id: string) => void
|
removeNotificationRecipient: (id: string) => void
|
||||||
handleNotificationSaveConfig: () => void | Promise<void>
|
handleNotificationSaveConfig: () => void | Promise<void>
|
||||||
handleNotificationTest: () => void | Promise<void>
|
handleNotificationTest: () => void | Promise<void>
|
||||||
|
handleScheduledJobsSaveConfig: () => void | Promise<void>
|
||||||
|
handleScheduledJobRunNow: (jobId: string) => void | Promise<void>
|
||||||
|
getScheduledJobRuntime: (jobId: string) => AdminScheduledJobRuntimeState | null
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const timeUnitOptions = [
|
||||||
|
{ label: '秒', value: 1 },
|
||||||
|
{ label: '分钟', value: 60 },
|
||||||
|
{ label: '小时', value: 3600 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatJobType(type: string) {
|
||||||
|
if (type === 'cloudtentacles_health') {
|
||||||
|
return 'cloudtentacles 健康检查'
|
||||||
|
}
|
||||||
|
|
||||||
|
return type || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTimeValue(seconds: number) {
|
||||||
|
const normalized = Math.max(0, Number(seconds || 0))
|
||||||
|
if (normalized >= 3600 && normalized % 3600 === 0) {
|
||||||
|
return { amount: normalized / 3600, unit: 3600 }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized >= 60 && normalized % 60 === 0) {
|
||||||
|
return { amount: normalized / 60, unit: 60 }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { amount: normalized || 1, unit: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number) {
|
||||||
|
const value = resolveTimeValue(seconds)
|
||||||
|
const unit = timeUnitOptions.find((item) => item.value === value.unit)?.label || '秒'
|
||||||
|
return `${value.amount} ${unit}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindDuration(job: EditableScheduledJob, key: 'intervalSeconds' | 'cooldownSeconds') {
|
||||||
|
const amountKey = `${key}Amount` as keyof EditableScheduledJob
|
||||||
|
const unitKey = `${key}Unit` as keyof EditableScheduledJob
|
||||||
|
|
||||||
|
if (!Number(job[amountKey])) {
|
||||||
|
const resolved = resolveTimeValue(Number(job[key] || 0))
|
||||||
|
;(job[amountKey] as number) = resolved.amount
|
||||||
|
;(job[unitKey] as number) = resolved.unit
|
||||||
|
}
|
||||||
|
|
||||||
|
return computed({
|
||||||
|
get: () => Number(job[amountKey] || 1),
|
||||||
|
set: (value: number) => {
|
||||||
|
;(job[amountKey] as number) = Math.max(1, Number(value || 1))
|
||||||
|
job[key] = Number(job[amountKey] || 1) * Number(job[unitKey] || 1)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindDurationUnit(job: EditableScheduledJob, key: 'intervalSeconds' | 'cooldownSeconds') {
|
||||||
|
const amountKey = `${key}Amount` as keyof EditableScheduledJob
|
||||||
|
const unitKey = `${key}Unit` as keyof EditableScheduledJob
|
||||||
|
|
||||||
|
if (!Number(job[unitKey])) {
|
||||||
|
const resolved = resolveTimeValue(Number(job[key] || 0))
|
||||||
|
;(job[amountKey] as number) = resolved.amount
|
||||||
|
;(job[unitKey] as number) = resolved.unit
|
||||||
|
}
|
||||||
|
|
||||||
|
return computed({
|
||||||
|
get: () => Number(job[unitKey] || 1),
|
||||||
|
set: (value: number) => {
|
||||||
|
;(job[unitKey] as number) = Math.max(1, Number(value || 1))
|
||||||
|
job[key] = Number(job[amountKey] || 1) * Number(job[unitKey] || 1)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -43,6 +128,10 @@ defineProps<Props>()
|
|||||||
<span class="meta-label">配置文件</span>
|
<span class="meta-label">配置文件</span>
|
||||||
<span>{{ notificationFilePath || '-' }}</span>
|
<span>{{ notificationFilePath || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="meta-line">
|
||||||
|
<span class="meta-label">监控任务</span>
|
||||||
|
<span>{{ scheduledJobsFilePath || '-' }}</span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="table-card">
|
<section class="table-card">
|
||||||
@@ -108,6 +197,99 @@ defineProps<Props>()
|
|||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="table-card">
|
||||||
|
<div class="section-title-row">
|
||||||
|
<div>
|
||||||
|
<h3>监控任务</h3>
|
||||||
|
<p>间隔表示多久检查一次;冷却表示同一个异常在这段时间内最多通知一次。</p>
|
||||||
|
</div>
|
||||||
|
<div class="section-actions">
|
||||||
|
<el-button :loading="scheduledJobsSaving" round type="primary" @click="handleScheduledJobsSaveConfig">保存任务</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="checkbox-line">
|
||||||
|
<input v-model="scheduledJobsForm.enabled" class="checkbox-box" type="checkbox" />
|
||||||
|
<span>启用监控任务系统</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>任务</th>
|
||||||
|
<th>参数</th>
|
||||||
|
<th>最近状态</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="job in scheduledJobs" :key="job.id">
|
||||||
|
<td>
|
||||||
|
<div class="cell-stack">
|
||||||
|
<strong>{{ formatJobType(job.type) }}</strong>
|
||||||
|
<label class="checkbox-line">
|
||||||
|
<input v-model="job.enabled" class="checkbox-box" type="checkbox" />
|
||||||
|
<span>{{ job.enabled ? '启用' : '停用' }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field-block">
|
||||||
|
<span>检查间隔</span>
|
||||||
|
<div class="time-input-row">
|
||||||
|
<input v-model.number="bindDuration(job, 'intervalSeconds').value" class="text-input" type="number" min="1" />
|
||||||
|
<select v-model.number="bindDurationUnit(job, 'intervalSeconds').value" class="text-input time-unit-select">
|
||||||
|
<option v-for="unit in timeUnitOptions" :key="unit.value" :value="unit.value">{{ unit.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<span class="cell-subtle">每 {{ formatDuration(job.intervalSeconds) }} 检查一次</span>
|
||||||
|
</label>
|
||||||
|
<label class="field-block">
|
||||||
|
<span>通知冷却</span>
|
||||||
|
<div class="time-input-row">
|
||||||
|
<input v-model.number="bindDuration(job, 'cooldownSeconds').value" class="text-input" type="number" min="1" />
|
||||||
|
<select v-model.number="bindDurationUnit(job, 'cooldownSeconds').value" class="text-input time-unit-select">
|
||||||
|
<option v-for="unit in timeUnitOptions" :key="unit.value" :value="unit.value">{{ unit.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<span class="cell-subtle">同类异常 {{ formatDuration(job.cooldownSeconds) }} 内只通知一次</span>
|
||||||
|
</label>
|
||||||
|
<label class="field-block">
|
||||||
|
<span>余额阈值</span>
|
||||||
|
<input v-model.number="job.assetThreshold" class="text-input" type="number" min="0" />
|
||||||
|
<span class="cell-subtle">低于该值时通知</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="cell-stack">
|
||||||
|
<strong>{{ getScheduledJobRuntime(job.id)?.lastStatus || 'pending' }}</strong>
|
||||||
|
<span class="cell-subtle">{{ getScheduledJobRuntime(job.id)?.lastMessage || '-' }}</span>
|
||||||
|
<span class="cell-subtle">余额:{{ getScheduledJobRuntime(job.id)?.lastAsset ?? '-' }}</span>
|
||||||
|
<span class="cell-subtle">上次:{{ formatAdminDateTime(getScheduledJobRuntime(job.id)?.lastRunAt || '') }}</span>
|
||||||
|
<span class="cell-subtle">下次:{{ formatAdminDateTime(getScheduledJobRuntime(job.id)?.nextRunAt || '') }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<el-button
|
||||||
|
:loading="scheduledJobRunningId === job.id"
|
||||||
|
round
|
||||||
|
@click="handleScheduledJobRunNow(job.id)"
|
||||||
|
>
|
||||||
|
立即检查
|
||||||
|
</el-button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="scheduledJobs.length === 0">
|
||||||
|
<td colspan="4" class="empty-inline">暂无可配置的监控任务。</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="table-card">
|
<section class="table-card">
|
||||||
<div class="section-title-row">
|
<div class="section-title-row">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -30,3 +30,16 @@ export type EditableNotificationRecipient = {
|
|||||||
deviceKey: string
|
deviceKey: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EditableScheduledJob = {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
enabled: boolean
|
||||||
|
intervalSeconds: number
|
||||||
|
intervalSecondsAmount: number
|
||||||
|
intervalSecondsUnit: number
|
||||||
|
cooldownSeconds: number
|
||||||
|
cooldownSecondsAmount: number
|
||||||
|
cooldownSecondsUnit: number
|
||||||
|
assetThreshold: number
|
||||||
|
}
|
||||||
|
|||||||
+111
-1
@@ -2,15 +2,20 @@ import { computed, ref } from 'vue'
|
|||||||
|
|
||||||
import { showError, showSuccess } from '@/lib/feedback'
|
import { showError, showSuccess } from '@/lib/feedback'
|
||||||
import {
|
import {
|
||||||
|
runAdminScheduledJob,
|
||||||
saveAdminNotificationConfig,
|
saveAdminNotificationConfig,
|
||||||
|
saveAdminScheduledJobsConfig,
|
||||||
testAdminNotification,
|
testAdminNotification,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
import type {
|
import type {
|
||||||
AdminNotificationConfig,
|
AdminNotificationConfig,
|
||||||
AdminNotificationTestResult,
|
AdminNotificationTestResult,
|
||||||
|
AdminScheduledJobRuntimeState,
|
||||||
|
AdminScheduledJobsConfig,
|
||||||
|
AdminScheduledJobsResponse,
|
||||||
} from '@/types/admin'
|
} from '@/types/admin'
|
||||||
|
|
||||||
import type { EditableNotificationRecipient } from './types'
|
import type { EditableNotificationRecipient, EditableScheduledJob } from './types'
|
||||||
|
|
||||||
export function useAdminNotificationPlatform() {
|
export function useAdminNotificationPlatform() {
|
||||||
const notificationFilePath = ref('')
|
const notificationFilePath = ref('')
|
||||||
@@ -27,6 +32,14 @@ export function useAdminNotificationPlatform() {
|
|||||||
const notificationTesting = ref(false)
|
const notificationTesting = ref(false)
|
||||||
const notificationResultError = ref('')
|
const notificationResultError = ref('')
|
||||||
const notificationTestResult = ref<AdminNotificationTestResult | null>(null)
|
const notificationTestResult = ref<AdminNotificationTestResult | null>(null)
|
||||||
|
const scheduledJobsFilePath = ref('')
|
||||||
|
const scheduledJobsForm = ref({
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
const scheduledJobs = ref<EditableScheduledJob[]>([])
|
||||||
|
const scheduledJobRuntime = ref<AdminScheduledJobRuntimeState[]>([])
|
||||||
|
const scheduledJobsSaving = ref(false)
|
||||||
|
const scheduledJobRunningId = ref('')
|
||||||
|
|
||||||
const notificationStats = computed(() => {
|
const notificationStats = computed(() => {
|
||||||
const enabledRecipients = notificationRecipients.value.filter((item) => item.enabled && item.deviceKey.trim())
|
const enabledRecipients = notificationRecipients.value.filter((item) => item.enabled && item.deviceKey.trim())
|
||||||
@@ -37,6 +50,7 @@ export function useAdminNotificationPlatform() {
|
|||||||
barkReady: notificationForm.value.enabled && notificationForm.value.barkEnabled && enabledRecipients.length > 0,
|
barkReady: notificationForm.value.enabled && notificationForm.value.barkEnabled && enabledRecipients.length > 0,
|
||||||
lastSuccessCount: notificationTestResult.value?.successCount || 0,
|
lastSuccessCount: notificationTestResult.value?.successCount || 0,
|
||||||
lastFailedCount: notificationTestResult.value?.failedCount || 0,
|
lastFailedCount: notificationTestResult.value?.failedCount || 0,
|
||||||
|
scheduledJobEnabledCount: scheduledJobs.value.filter((item) => item.enabled).length,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -56,6 +70,24 @@ export function useAdminNotificationPlatform() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hydrateScheduledJobsConfig(data: AdminScheduledJobsResponse) {
|
||||||
|
scheduledJobsFilePath.value = data.filePath
|
||||||
|
scheduledJobsForm.value.enabled = data.source.enabled !== false
|
||||||
|
scheduledJobs.value = data.source.jobs.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
type: item.type,
|
||||||
|
enabled: item.enabled === true,
|
||||||
|
intervalSeconds: Number(item.intervalSeconds || 300),
|
||||||
|
intervalSecondsAmount: resolveTimeValue(Number(item.intervalSeconds || 300)).amount,
|
||||||
|
intervalSecondsUnit: resolveTimeValue(Number(item.intervalSeconds || 300)).unit,
|
||||||
|
cooldownSeconds: Number(item.cooldownSeconds || 1800),
|
||||||
|
cooldownSecondsAmount: resolveTimeValue(Number(item.cooldownSeconds || 1800)).amount,
|
||||||
|
cooldownSecondsUnit: resolveTimeValue(Number(item.cooldownSeconds || 1800)).unit,
|
||||||
|
assetThreshold: Number(item.config?.assetThreshold || 500),
|
||||||
|
}))
|
||||||
|
scheduledJobRuntime.value = data.runtime || []
|
||||||
|
}
|
||||||
|
|
||||||
function addNotificationRecipient() {
|
function addNotificationRecipient() {
|
||||||
notificationRecipients.value.unshift({
|
notificationRecipients.value.unshift({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -88,6 +120,22 @@ export function useAdminNotificationPlatform() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildScheduledJobsConfigPayload(): AdminScheduledJobsConfig {
|
||||||
|
return {
|
||||||
|
enabled: scheduledJobsForm.value.enabled,
|
||||||
|
jobs: scheduledJobs.value.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
type: item.type,
|
||||||
|
enabled: item.enabled,
|
||||||
|
intervalSeconds: Number(item.intervalSeconds || 300),
|
||||||
|
cooldownSeconds: Number(item.cooldownSeconds || 1800),
|
||||||
|
config: {
|
||||||
|
assetThreshold: Number(item.assetThreshold || 500),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleNotificationSaveConfig() {
|
async function handleNotificationSaveConfig() {
|
||||||
notificationSaving.value = true
|
notificationSaving.value = true
|
||||||
notificationResultError.value = ''
|
notificationResultError.value = ''
|
||||||
@@ -134,6 +182,58 @@ export function useAdminNotificationPlatform() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleScheduledJobsSaveConfig() {
|
||||||
|
scheduledJobsSaving.value = true
|
||||||
|
notificationResultError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await saveAdminScheduledJobsConfig(buildScheduledJobsConfigPayload())
|
||||||
|
hydrateScheduledJobsConfig(response.data)
|
||||||
|
showSuccess('监控任务配置已保存')
|
||||||
|
} catch (error) {
|
||||||
|
notificationResultError.value = error instanceof Error ? error.message : '监控任务配置保存失败'
|
||||||
|
showError(notificationResultError.value)
|
||||||
|
} finally {
|
||||||
|
scheduledJobsSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleScheduledJobRunNow(jobId: string) {
|
||||||
|
scheduledJobRunningId.value = jobId
|
||||||
|
notificationResultError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = await saveAdminScheduledJobsConfig(buildScheduledJobsConfigPayload())
|
||||||
|
hydrateScheduledJobsConfig(saved.data)
|
||||||
|
const response = await runAdminScheduledJob(jobId)
|
||||||
|
scheduledJobRuntime.value = response.data.runtime || []
|
||||||
|
const message = String(response.data.result?.message || '监控任务已执行')
|
||||||
|
showSuccess(message)
|
||||||
|
} catch (error) {
|
||||||
|
notificationResultError.value = error instanceof Error ? error.message : '监控任务执行失败'
|
||||||
|
showError(notificationResultError.value)
|
||||||
|
} finally {
|
||||||
|
scheduledJobRunningId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScheduledJobRuntime(jobId: string) {
|
||||||
|
return scheduledJobRuntime.value.find((item) => item.id === jobId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTimeValue(seconds: number) {
|
||||||
|
const normalized = Math.max(0, Number(seconds || 0))
|
||||||
|
if (normalized >= 3600 && normalized % 3600 === 0) {
|
||||||
|
return { amount: normalized / 3600, unit: 3600 }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized >= 60 && normalized % 60 === 0) {
|
||||||
|
return { amount: normalized / 60, unit: 60 }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { amount: normalized || 1, unit: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notificationFilePath,
|
notificationFilePath,
|
||||||
notificationForm,
|
notificationForm,
|
||||||
@@ -142,11 +242,21 @@ export function useAdminNotificationPlatform() {
|
|||||||
notificationTesting,
|
notificationTesting,
|
||||||
notificationResultError,
|
notificationResultError,
|
||||||
notificationTestResult,
|
notificationTestResult,
|
||||||
|
scheduledJobsFilePath,
|
||||||
|
scheduledJobsForm,
|
||||||
|
scheduledJobs,
|
||||||
|
scheduledJobRuntime,
|
||||||
|
scheduledJobsSaving,
|
||||||
|
scheduledJobRunningId,
|
||||||
notificationStats,
|
notificationStats,
|
||||||
hydrateNotificationConfig,
|
hydrateNotificationConfig,
|
||||||
|
hydrateScheduledJobsConfig,
|
||||||
addNotificationRecipient,
|
addNotificationRecipient,
|
||||||
removeNotificationRecipient,
|
removeNotificationRecipient,
|
||||||
handleNotificationSaveConfig,
|
handleNotificationSaveConfig,
|
||||||
handleNotificationTest,
|
handleNotificationTest,
|
||||||
|
handleScheduledJobsSaveConfig,
|
||||||
|
handleScheduledJobRunNow,
|
||||||
|
getScheduledJobRuntime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import type {
|
|||||||
AdminNinetyoneOrderListResult,
|
AdminNinetyoneOrderListResult,
|
||||||
AdminNotificationConfig,
|
AdminNotificationConfig,
|
||||||
AdminNotificationTestResult,
|
AdminNotificationTestResult,
|
||||||
|
AdminScheduledJobsConfig,
|
||||||
|
AdminScheduledJobsResponse,
|
||||||
AdminKuaishouCloudFulfillmentConfig,
|
AdminKuaishouCloudFulfillmentConfig,
|
||||||
AdminKuaishouEticketConsumeResult,
|
AdminKuaishouEticketConsumeResult,
|
||||||
AdminKuaishouEticketDetailResult,
|
AdminKuaishouEticketDetailResult,
|
||||||
@@ -65,6 +67,24 @@ export function testAdminNotification(payload: {
|
|||||||
return apiPost<AdminNotificationTestResult>('/api/v1/admin/platform-config/notifications/test', payload)
|
return apiPost<AdminNotificationTestResult>('/api/v1/admin/platform-config/notifications/test', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchAdminScheduledJobsConfig() {
|
||||||
|
return apiGet<AdminScheduledJobsResponse>('/api/v1/admin/platform-config/scheduled-jobs')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAdminScheduledJobsConfig(payload: AdminScheduledJobsConfig) {
|
||||||
|
return apiPost<AdminScheduledJobsResponse>(
|
||||||
|
'/api/v1/admin/platform-config/scheduled-jobs',
|
||||||
|
payload as unknown as Record<string, unknown>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runAdminScheduledJob(jobId: string) {
|
||||||
|
return apiPost<{
|
||||||
|
result: Record<string, unknown>
|
||||||
|
runtime: AdminScheduledJobsResponse['runtime']
|
||||||
|
}>(`/api/v1/admin/platform-config/scheduled-jobs/${jobId}/run`, {})
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAdminNinetyoneOrders(params: {
|
export function fetchAdminNinetyoneOrders(params: {
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
|
|||||||
@@ -505,6 +505,17 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-platform-shops .time-input-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(96px, 1fr) 96px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-platform-shops .time-unit-select {
|
||||||
|
min-width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-platform-shops .empty-block,
|
.admin-platform-shops .empty-block,
|
||||||
.admin-platform-shops .empty-inline {
|
.admin-platform-shops .empty-inline {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
|||||||
@@ -202,6 +202,42 @@ export interface AdminNotificationTestResult {
|
|||||||
results: AdminNotificationSendResult[]
|
results: AdminNotificationSendResult[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminScheduledJobItem {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
enabled: boolean
|
||||||
|
intervalSeconds: number
|
||||||
|
cooldownSeconds: number
|
||||||
|
config: {
|
||||||
|
assetThreshold: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminScheduledJobsConfig {
|
||||||
|
enabled: boolean
|
||||||
|
jobs: AdminScheduledJobItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminScheduledJobRuntimeState {
|
||||||
|
id: string
|
||||||
|
enabled: boolean
|
||||||
|
running: boolean
|
||||||
|
lastRunAt: string
|
||||||
|
lastFinishedAt: string
|
||||||
|
nextRunAt: string
|
||||||
|
lastStatus: string
|
||||||
|
lastMessage: string
|
||||||
|
lastAsset: number | null
|
||||||
|
lastThreshold: number | null
|
||||||
|
lastManual: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminScheduledJobsResponse {
|
||||||
|
filePath: string
|
||||||
|
source: AdminScheduledJobsConfig
|
||||||
|
runtime: AdminScheduledJobRuntimeState[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminNinetyoneOrderItem {
|
export interface AdminNinetyoneOrderItem {
|
||||||
orderId: number
|
orderId: number
|
||||||
orderNo: string
|
orderNo: string
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
fetchAdminCloudtentaclesSourceConfig,
|
fetchAdminCloudtentaclesSourceConfig,
|
||||||
fetchAdminKuaishouEticketSourceConfig,
|
fetchAdminKuaishouEticketSourceConfig,
|
||||||
fetchAdminNotificationConfig,
|
fetchAdminNotificationConfig,
|
||||||
|
fetchAdminScheduledJobsConfig,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
import { hasAdminRole } from '@/utils/admin-auth'
|
import { hasAdminRole } from '@/utils/admin-auth'
|
||||||
import '@/styles/admin-platform-shops.css'
|
import '@/styles/admin-platform-shops.css'
|
||||||
@@ -66,12 +67,22 @@ const {
|
|||||||
notificationTesting,
|
notificationTesting,
|
||||||
notificationResultError,
|
notificationResultError,
|
||||||
notificationTestResult,
|
notificationTestResult,
|
||||||
|
scheduledJobsFilePath,
|
||||||
|
scheduledJobsForm,
|
||||||
|
scheduledJobs,
|
||||||
|
scheduledJobRuntime,
|
||||||
|
scheduledJobsSaving,
|
||||||
|
scheduledJobRunningId,
|
||||||
notificationStats,
|
notificationStats,
|
||||||
hydrateNotificationConfig,
|
hydrateNotificationConfig,
|
||||||
|
hydrateScheduledJobsConfig,
|
||||||
addNotificationRecipient,
|
addNotificationRecipient,
|
||||||
removeNotificationRecipient,
|
removeNotificationRecipient,
|
||||||
handleNotificationSaveConfig,
|
handleNotificationSaveConfig,
|
||||||
handleNotificationTest,
|
handleNotificationTest,
|
||||||
|
handleScheduledJobsSaveConfig,
|
||||||
|
handleScheduledJobRunNow,
|
||||||
|
getScheduledJobRuntime,
|
||||||
} = useAdminNotificationPlatform()
|
} = useAdminNotificationPlatform()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -151,15 +162,23 @@ async function loadConfigs() {
|
|||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [agisoResponse, notificationResponse, kuaishouEticketResponse, cloudtentaclesResponse] = await Promise.all([
|
const [
|
||||||
|
agisoResponse,
|
||||||
|
notificationResponse,
|
||||||
|
scheduledJobsResponse,
|
||||||
|
kuaishouEticketResponse,
|
||||||
|
cloudtentaclesResponse,
|
||||||
|
] = await Promise.all([
|
||||||
fetchAdminAgisoShopConfigs(),
|
fetchAdminAgisoShopConfigs(),
|
||||||
fetchAdminNotificationConfig(),
|
fetchAdminNotificationConfig(),
|
||||||
|
fetchAdminScheduledJobsConfig(),
|
||||||
fetchAdminKuaishouEticketSourceConfig(),
|
fetchAdminKuaishouEticketSourceConfig(),
|
||||||
fetchAdminCloudtentaclesSourceConfig(),
|
fetchAdminCloudtentaclesSourceConfig(),
|
||||||
])
|
])
|
||||||
|
|
||||||
hydrateAgisoConfig(agisoResponse.data)
|
hydrateAgisoConfig(agisoResponse.data)
|
||||||
hydrateNotificationConfig(notificationResponse.data)
|
hydrateNotificationConfig(notificationResponse.data)
|
||||||
|
hydrateScheduledJobsConfig(scheduledJobsResponse.data)
|
||||||
hydrateKuaishouEticketConfig(kuaishouEticketResponse.data)
|
hydrateKuaishouEticketConfig(kuaishouEticketResponse.data)
|
||||||
hydrateCloudtentaclesConfig(cloudtentaclesResponse.data)
|
hydrateCloudtentaclesConfig(cloudtentaclesResponse.data)
|
||||||
await loadNinetyoneOrders(1)
|
await loadNinetyoneOrders(1)
|
||||||
@@ -368,11 +387,20 @@ onMounted(loadConfigs)
|
|||||||
:notification-testing="notificationTesting"
|
:notification-testing="notificationTesting"
|
||||||
:notification-result-error="notificationResultError"
|
:notification-result-error="notificationResultError"
|
||||||
:notification-test-result="notificationTestResult"
|
:notification-test-result="notificationTestResult"
|
||||||
|
:scheduled-jobs-file-path="scheduledJobsFilePath"
|
||||||
|
:scheduled-jobs-form="scheduledJobsForm"
|
||||||
|
:scheduled-jobs="scheduledJobs"
|
||||||
|
:scheduled-job-runtime="scheduledJobRuntime"
|
||||||
|
:scheduled-jobs-saving="scheduledJobsSaving"
|
||||||
|
:scheduled-job-running-id="scheduledJobRunningId"
|
||||||
:notification-stats="notificationStats"
|
:notification-stats="notificationStats"
|
||||||
:add-notification-recipient="addNotificationRecipient"
|
:add-notification-recipient="addNotificationRecipient"
|
||||||
:remove-notification-recipient="removeNotificationRecipient"
|
:remove-notification-recipient="removeNotificationRecipient"
|
||||||
:handle-notification-save-config="handleNotificationSaveConfig"
|
:handle-notification-save-config="handleNotificationSaveConfig"
|
||||||
:handle-notification-test="handleNotificationTest"
|
:handle-notification-test="handleNotificationTest"
|
||||||
|
:handle-scheduled-jobs-save-config="handleScheduledJobsSaveConfig"
|
||||||
|
:handle-scheduled-job-run-now="handleScheduledJobRunNow"
|
||||||
|
:get-scheduled-job-runtime="getScheduledJobRuntime"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminPlatformKuaishouEticketSection
|
<AdminPlatformKuaishouEticketSection
|
||||||
|
|||||||
Reference in New Issue
Block a user