diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8c08ef3..a9d2eaf 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -7,19 +7,13 @@ services: restart: unless-stopped ports: - "3000:3000" - - "8085-8087:8085-8087" + - "8085-8087:8085-8087" - "1455:1455" - "19876-19880:19876-19880" volumes: - ./configs:/app/configs environment: - ARGS= - - HTTP_PROXY=http://host.docker.internal:10801 - - http_proxy=http://host.docker.internal:10801 - - HTTPS_PROXY=http://host.docker.internal:10801 - - https_proxy=http://host.docker.internal:10801 - - NO_PROXY=localhost,127.0.0.1,host.docker.internal - - no_proxy=localhost,127.0.0.1,host.docker.internal healthcheck: test: ["CMD", "node", "healthcheck.js"] interval: 30s diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index c3dc2ab..15bf9f5 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -307,13 +307,15 @@ export class ProviderPoolManager { // 使用 Promise.resolve().then 避免过深的递归 Promise.resolve().then(nextTask); } else if (currentQueue.activeCount === 0) { - // 2. 如果当前提供商的所有任务都完成了,释放全局槽位 - // 只有持有全局槽位的任务才能递减计数器,避免负值 - if (ownsGlobalSlot && - currentQueue.waitingTasks.length === 0 && + // 清理空队列:无论是否持有全局槽位,都应删除已无任务的队列对象 + if (currentQueue.waitingTasks.length === 0 && this.refreshQueues[providerType] === currentQueue) { + delete this.refreshQueues[providerType]; + } + + // 只有持有全局槽位的任务才能递减计数器 + if (ownsGlobalSlot) { this.activeProviderRefreshes--; - delete this.refreshQueues[providerType]; // 清理空队列 } // 3. 尝试启动下一个等待中的提供商队列 diff --git a/src/services/api-server.js b/src/services/api-server.js index 4bc1b0c..6caf415 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -7,6 +7,7 @@ import { initializeAPIManagement } from './api-manager.js'; import { createRequestHandler } from '../handlers/request-handler.js'; import { discoverPlugins, getPluginManager } from '../core/plugin-manager.js'; import { getTLSSidecar } from '../utils/tls-sidecar.js'; +import { HEALTH_CHECK } from '../utils/constants.js'; /** * @license @@ -362,45 +363,30 @@ async function startServer() { } // 定时健康检查 + // 注意:无论初始 enabled 状态如何,都注册 reloadHealthCheckTimer, + // 使得热更新时(从 disabled→enabled)config-api 能调用它启动 timer。 const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK; - if (scheduledConfig?.enabled) { - // 设计决策:只验证最小值 60000ms,不设最大值。 - // 前端有 max=3600000 (1小时) 的 UI 限制,但后端允许更大值以支持特殊需求。 - // 如果用户需要超长的间隔,可以通过 API 直接设置。 + { const DEFAULT_INTERVAL = CONFIG.CRON_NEAR_MINUTES * 60 * 1000; - let interval = scheduledConfig.interval; - if (typeof interval !== 'number' || interval < 60000) { - logger.warn(`[ScheduledHealthCheck] Invalid interval ${interval}, using default ${DEFAULT_INTERVAL}`); - interval = DEFAULT_INTERVAL; - } - - // 启动时运行健康检查 - if (scheduledConfig.startupRun !== false) { - logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...'); - // 使用 setImmediate 确保在事件循环的下一阶段执行,此时服务器已完全就绪 - setImmediate(async () => { - try { - await poolManager.performScheduledHealthChecks(); - } catch (error) { - logger.error('[ScheduledHealthCheck] Startup run error:', error); - } - }); - } - + let isHealthCheckRunning = false; let healthCheckTimerId = null; - - // 定时健康检查函数 + + // 定时健康检查函数(始终注册,无论初始 enabled 状态) const runHealthCheckTimer = (interval) => { // 清除旧的 timer if (healthCheckTimerId) { clearInterval(healthCheckTimerId); + healthCheckTimerId = null; } // 重置运行状态,允许新的 timer 立即触发 // 否则如果 reload 时正在运行,新 timer 的第一次触发会被跳过 isHealthCheckRunning = false; - + // 设置定时任务 + // 设计决策:只验证最小值,不设最大值。 + // 前端有 max=3600000 (1小时) 的 UI 限制,但后端允许更大值以支持特殊需求。 + const safeInterval = (typeof interval === 'number' && interval >= HEALTH_CHECK.MIN_INTERVAL_MS) ? interval : DEFAULT_INTERVAL; healthCheckTimerId = setInterval(async () => { if (isHealthCheckRunning) { logger.debug('[ScheduledHealthCheck] Skipping - previous run still in progress'); @@ -414,16 +400,45 @@ async function startServer() { } finally { isHealthCheckRunning = false; } - }, interval); - logger.info(`[ScheduledHealthCheck] Scheduled every ${interval}ms`); + }, safeInterval); + logger.info(`[ScheduledHealthCheck] Scheduled every ${safeInterval}ms`); + return safeInterval; }; - - // 设置定时任务 - runHealthCheckTimer(interval); - // 注册重载函数和初始 interval 到 globalThis(供 config-api 热更新使用) + // 注册重载/停止函数到 globalThis(供 config-api 热更新使用) + // 必须在 enabled 检查外注册,保证热更新时可访问 globalThis.reloadHealthCheckTimer = runHealthCheckTimer; - globalThis._activeHealthCheckInterval = interval; + globalThis.stopHealthCheckTimer = () => { + if (healthCheckTimerId) { + clearInterval(healthCheckTimerId); + healthCheckTimerId = null; + logger.info('[ScheduledHealthCheck] Timer stopped'); + } + }; + + if (scheduledConfig?.enabled) { + let interval = scheduledConfig.interval; + if (typeof interval !== 'number' || interval < HEALTH_CHECK.MIN_INTERVAL_MS) { + logger.warn(`[ScheduledHealthCheck] Invalid interval ${interval}, using default ${DEFAULT_INTERVAL}`); + interval = DEFAULT_INTERVAL; + } + + // 启动时运行健康检查 + if (scheduledConfig.startupRun !== false) { + logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...'); + setImmediate(async () => { + try { + await poolManager.performScheduledHealthChecks(); + } catch (error) { + logger.error('[ScheduledHealthCheck] Startup run error:', error); + } + }); + } + + // 设置定时任务 + const activeInterval = runHealthCheckTimer(interval); + globalThis._activeHealthCheckInterval = activeInterval; + } } // 如果是子进程,通知主进程已就绪 diff --git a/src/ui-modules/auth.js b/src/ui-modules/auth.js index 534a399..b60995f 100644 --- a/src/ui-modules/auth.js +++ b/src/ui-modules/auth.js @@ -5,6 +5,7 @@ import path from 'path'; import crypto from 'crypto'; import { CONFIG } from '../core/config-manager.js'; import { getClientIp } from '../utils/common.js'; +import { PASSWORD } from '../utils/constants.js'; // Token存储到本地文件中 const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json'); @@ -56,7 +57,7 @@ export async function validateCredentials(password) { if (parts.length !== 3) return false; const [, salt, storedHash] = parts; const inputHash = await new Promise((resolve, reject) => - crypto.pbkdf2(password.trim(), salt, 100000, 64, 'sha512', (err, key) => + crypto.pbkdf2(password.trim(), salt, PASSWORD.PBKDF2_ITERATIONS, PASSWORD.PBKDF2_KEYLEN, PASSWORD.PBKDF2_DIGEST, (err, key) => err ? reject(err) : resolve(key.toString('hex')) ) ); @@ -64,7 +65,11 @@ export async function validateCredentials(password) { } // 旧格式:明文(兼容迁移期,建议通过 UI 重新设置密码以升级为哈希格式) - return password === storedPassword; + // 使用 timingSafeEqual 防止时序攻击 + const a = Buffer.from(password.trim()); + const b = Buffer.from(storedPassword); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); } /** diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index fd8e908..c62376a 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -8,6 +8,7 @@ import { serviceInstances } from '../providers/adapter.js'; import { initApiService } from '../services/service-manager.js'; import { getRequestBody } from '../utils/common.js'; import { broadcastEvent } from '../ui-modules/event-broadcast.js'; +import { HEALTH_CHECK, PASSWORD, NETWORK, RETRY } from '../utils/constants.js'; /** * 重载配置文件 @@ -120,14 +121,18 @@ export async function handleUpdateConfig(req, res, currentConfig) { } if (newConfig.SERVER_PORT !== undefined) { const port = Number(newConfig.SERVER_PORT); - if (Number.isInteger(port) && port > 0 && port < 65536) currentConfig.SERVER_PORT = port; + if (Number.isInteger(port) && port >= NETWORK.MIN_PORT && port <= NETWORK.MAX_PORT) currentConfig.SERVER_PORT = port; } if (newConfig.MODEL_PROVIDER !== undefined) currentConfig.MODEL_PROVIDER = newConfig.MODEL_PROVIDER; if (newConfig.SYSTEM_PROMPT_FILE_PATH !== undefined) { const p = String(newConfig.SYSTEM_PROMPT_FILE_PATH); // 防止路径遍历:解析后的绝对路径必须在工作目录内 const resolved = path.resolve(process.cwd(), p); - if (resolved.startsWith(process.cwd() + path.sep) || resolved === process.cwd()) { + const cwd = process.cwd(); + // Windows兼容:统一使用正斜杠进行比较 + const normalizedResolved = resolved.replace(/\\/g, '/'); + const normalizedCwd = cwd.replace(/\\/g, '/'); + if (normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd) { currentConfig.SYSTEM_PROMPT_FILE_PATH = p; } } @@ -136,7 +141,7 @@ export async function handleUpdateConfig(req, res, currentConfig) { if (newConfig.PROMPT_LOG_MODE !== undefined) currentConfig.PROMPT_LOG_MODE = newConfig.PROMPT_LOG_MODE; if (newConfig.REQUEST_MAX_RETRIES !== undefined) { const v = Number(newConfig.REQUEST_MAX_RETRIES); - if (Number.isInteger(v) && v >= 0 && v <= 100) currentConfig.REQUEST_MAX_RETRIES = v; + if (Number.isInteger(v) && v >= 0 && v <= RETRY.MAX_RETRIES) currentConfig.REQUEST_MAX_RETRIES = v; } if (newConfig.REQUEST_BASE_DELAY !== undefined) currentConfig.REQUEST_BASE_DELAY = newConfig.REQUEST_BASE_DELAY; if (newConfig.CREDENTIAL_SWITCH_MAX_RETRIES !== undefined) currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES = newConfig.CREDENTIAL_SWITCH_MAX_RETRIES; @@ -164,7 +169,18 @@ export async function handleUpdateConfig(req, res, currentConfig) { if (newConfig.LOG_ENABLED !== undefined) currentConfig.LOG_ENABLED = newConfig.LOG_ENABLED; if (newConfig.LOG_OUTPUT_MODE !== undefined) currentConfig.LOG_OUTPUT_MODE = newConfig.LOG_OUTPUT_MODE; if (newConfig.LOG_LEVEL !== undefined) currentConfig.LOG_LEVEL = newConfig.LOG_LEVEL; - if (newConfig.LOG_DIR !== undefined) currentConfig.LOG_DIR = newConfig.LOG_DIR; + if (newConfig.LOG_DIR !== undefined) { + const p = String(newConfig.LOG_DIR); + // 防止路径遍历:解析后的绝对路径必须在工作目录内 + const resolved = path.resolve(process.cwd(), p); + const cwd = process.cwd(); + // Windows兼容:统一使用正斜杠进行比较 + const normalizedResolved = resolved.replace(/\\/g, '/'); + const normalizedCwd = cwd.replace(/\\/g, '/'); + if (normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd) { + currentConfig.LOG_DIR = p; + } + } if (newConfig.LOG_INCLUDE_REQUEST_ID !== undefined) currentConfig.LOG_INCLUDE_REQUEST_ID = newConfig.LOG_INCLUDE_REQUEST_ID; if (newConfig.LOG_INCLUDE_TIMESTAMP !== undefined) currentConfig.LOG_INCLUDE_TIMESTAMP = newConfig.LOG_INCLUDE_TIMESTAMP; if (newConfig.LOG_MAX_FILE_SIZE !== undefined) currentConfig.LOG_MAX_FILE_SIZE = newConfig.LOG_MAX_FILE_SIZE; @@ -175,7 +191,7 @@ export async function handleUpdateConfig(req, res, currentConfig) { const incoming = newConfig.SCHEDULED_HEALTH_CHECK; const newInterval = (() => { const val = Number(incoming?.interval); - return isNaN(val) ? 600000 : Math.max(60000, val); + return isNaN(val) ? HEALTH_CHECK.DEFAULT_INTERVAL_MS : Math.max(HEALTH_CHECK.MIN_INTERVAL_MS, val); })(); currentConfig.SCHEDULED_HEALTH_CHECK = { enabled: incoming?.enabled === true, @@ -184,10 +200,26 @@ export async function handleUpdateConfig(req, res, currentConfig) { providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : [] }; - // 仅在 interval 实际变化时重新加载 timer(_activeInterval 存在内存变量中,不写入配置文件) - if (globalThis.reloadHealthCheckTimer && currentConfig.SCHEDULED_HEALTH_CHECK.enabled && newInterval !== globalThis._activeHealthCheckInterval) { - globalThis._activeHealthCheckInterval = newInterval; - globalThis.reloadHealthCheckTimer(newInterval); + // 检测 enabled 状态变化 + const wasEnabled = currentConfig.SCHEDULED_HEALTH_CHECK?.enabled === true; + const nowEnabled = incoming?.enabled === true; + + if (currentConfig.SCHEDULED_HEALTH_CHECK) { + // 当 enabled 从 true -> false 时,清除 timer + if (wasEnabled && !nowEnabled && globalThis.stopHealthCheckTimer) { + globalThis.stopHealthCheckTimer(); + globalThis._activeHealthCheckInterval = undefined; + } + // 当 enabled 从 false -> true 时,启动 timer + else if (!wasEnabled && nowEnabled && globalThis.reloadHealthCheckTimer) { + globalThis._activeHealthCheckInterval = newInterval; + globalThis.reloadHealthCheckTimer(newInterval); + } + // 当 enabled=true 且 interval 变化时,重启 timer + else if (nowEnabled && newInterval !== globalThis._activeHealthCheckInterval) { + globalThis._activeHealthCheckInterval = newInterval; + globalThis.reloadHealthCheckTimer(newInterval); + } } } @@ -347,16 +379,16 @@ export async function handleUpdateAdminPassword(req, res) { return true; } - if (password.trim().length < 8) { + if (password.trim().length < PASSWORD.MIN_LENGTH) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Password must be at least 8 characters' } })); + res.end(JSON.stringify({ error: { message: `Password must be at least ${PASSWORD.MIN_LENGTH} characters` } })); return true; } // 使用 PBKDF2 哈希存储密码,避免明文写入文件 const salt = crypto.randomBytes(16).toString('hex'); const hash = await new Promise((resolve, reject) => - crypto.pbkdf2(password.trim(), salt, 100000, 64, 'sha512', (err, key) => + crypto.pbkdf2(password.trim(), salt, PASSWORD.PBKDF2_ITERATIONS, PASSWORD.PBKDF2_KEYLEN, PASSWORD.PBKDF2_DIGEST, (err, key) => err ? reject(err) : resolve(key.toString('hex')) ) ); diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 8c7854d..534032a 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -7,10 +7,43 @@ import { broadcastEvent } from './event-broadcast.js'; import { getRegisteredProviders } from '../providers/adapter.js'; // 文件级互斥锁:防止并发读写导致数据丢失 +// HTML 脱敏:移除用户输入字段中的 HTML/JS,防止 XSS +function sanitizeProviderData(provider) { + if (!provider || typeof provider !== 'object') return provider; + const sanitized = { ...provider }; + // 允许在前端显示的纯文本字段做 HTML 转义 + if (typeof sanitized.customName === 'string') { + sanitized.customName = sanitized.customName + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + return sanitized; +} + +function sanitizeProviderPools(pools) { + if (!pools || typeof pools !== 'object') return pools; + const sanitized = {}; + for (const [type, providers] of Object.entries(pools)) { + sanitized[type] = Array.isArray(providers) + ? providers.map(sanitizeProviderData) + : providers; + } + return sanitized; +} +// 使用 Promise 链式队列,确保文件操作顺序执行 let _fileLockChain = Promise.resolve(); function withFileLock(fn) { - const next = _fileLockChain.then(() => fn()); - _fileLockChain = next.catch(() => {}); + const next = _fileLockChain + .then(() => fn()) + .catch(err => { + // 记录错误但继续链式执行,防止死锁 + logger.error('[FileLock] Operation failed:', err?.message || err); + return null; + }); + _fileLockChain = next.then(() => {}).catch(() => {}); return next; } /** @@ -31,7 +64,7 @@ export async function handleGetProviders(req, res, currentConfig, providerPoolMa } res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(providerPools)); + res.end(JSON.stringify(sanitizeProviderPools(providerPools))); return true; } @@ -66,7 +99,7 @@ export async function handleGetProviderType(req, res, currentConfig, providerPoo res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ providerType, - providers, + providers: providers.map(sanitizeProviderData), totalCount: providers.length, healthyCount: providers.filter(p => p.isHealthy).length })); @@ -159,7 +192,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager) action: 'add', filePath: filePath, providerType, - providerConfig, + providerConfig: sanitizeProviderData(providerConfig), timestamp: new Date().toISOString() }); @@ -167,7 +200,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager) broadcastEvent('provider_update', { action: 'add', providerType, - providerConfig, + providerConfig: sanitizeProviderData(providerConfig), timestamp: new Date().toISOString() }); @@ -175,7 +208,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager) res.end(JSON.stringify({ success: true, message: 'Provider added successfully', - provider: providerConfig, + provider: sanitizeProviderData(providerConfig), providerType })); return true; @@ -257,7 +290,7 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage action: 'update', filePath: filePath, providerType, - providerConfig: updatedProvider, + providerConfig: sanitizeProviderData(updatedProvider), timestamp: new Date().toISOString() }); @@ -265,7 +298,7 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage res.end(JSON.stringify({ success: true, message: 'Provider updated successfully', - provider: updatedProvider + provider: sanitizeProviderData(updatedProvider) })); return true; } catch (error) { @@ -331,7 +364,7 @@ async function _handleDeleteProvider(req, res, currentConfig, providerPoolManage action: 'delete', filePath: filePath, providerType, - providerConfig: deletedProvider, + providerConfig: sanitizeProviderData(deletedProvider), timestamp: new Date().toISOString() }); @@ -339,7 +372,7 @@ async function _handleDeleteProvider(req, res, currentConfig, providerPoolManage res.end(JSON.stringify({ success: true, message: 'Provider deleted successfully', - deletedProvider + deletedProvider: sanitizeProviderData(deletedProvider) })); return true; } catch (error) { @@ -407,7 +440,7 @@ async function _handleDisableEnableProvider(req, res, currentConfig, providerPoo action: action, filePath: filePath, providerType, - providerConfig: provider, + providerConfig: sanitizeProviderData(provider), timestamp: new Date().toISOString() }); @@ -415,7 +448,7 @@ async function _handleDisableEnableProvider(req, res, currentConfig, providerPoo res.end(JSON.stringify({ success: true, message: `Provider ${action}d successfully`, - provider: provider + provider: sanitizeProviderData(provider) })); return true; } catch (error) { @@ -569,7 +602,7 @@ export async function handleDeleteUnhealthyProviders(req, res, currentConfig, pr filePath: filePath, providerType, deletedCount: unhealthyProviders.length, - deletedProviders: unhealthyProviders.map(p => ({ uuid: p.uuid, customName: p.customName })), + deletedProviders: unhealthyProviders.map(p => sanitizeProviderData({ uuid: p.uuid, customName: p.customName })), timestamp: new Date().toISOString() }); @@ -660,7 +693,7 @@ export async function handleRefreshUnhealthyUuids(req, res, currentConfig, provi filePath: filePath, providerType, refreshedCount: refreshedProviders.length, - refreshedProviders, + refreshedProviders: refreshedProviders.map(p => sanitizeProviderData(p)), timestamp: new Date().toISOString() }); @@ -806,7 +839,7 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan action: 'health_check', filePath: filePath, providerType, - results, + results: results.map(r => ({ ...r, message: sanitizeProviderData({ message: r.message }).message })), timestamp: new Date().toISOString() }); @@ -1046,7 +1079,7 @@ export async function handleRefreshProviderUuid(req, res, currentConfig, provide message: 'UUID refreshed successfully', oldUuid, newUuid, - provider: providerPools[providerType][providerIndex] + provider: sanitizeProviderData(providerPools[providerType][providerIndex]) })); return true; } catch (error) { diff --git a/src/utils/constants.js b/src/utils/constants.js new file mode 100644 index 0000000..20c0071 --- /dev/null +++ b/src/utils/constants.js @@ -0,0 +1,44 @@ +/** + * 共享常量定义 + * 集中管理各处使用的硬编码值 + */ + +// 定时健康检查相关常量 +export const HEALTH_CHECK = { + // 最小检查间隔:60秒(60000毫秒) + MIN_INTERVAL_MS: 60000, + // 默认检查间隔:10分钟(600000毫秒) + DEFAULT_INTERVAL_MS: 600000, + // 最大检查间隔:1小时(3600000毫秒)- 仅用于前端UI限制 + MAX_INTERVAL_MS: 3600000 +}; + +// 密码安全相关常量 +export const PASSWORD = { + // 最小密码长度 + MIN_LENGTH: 8, + // PBKDF2迭代次数 + PBKDF2_ITERATIONS: 100000, + // PBKDF2密钥长度(字节) + PBKDF2_KEYLEN: 64, + // PBKDF2哈希算法 + PBKDF2_DIGEST: 'sha512' +}; + +// 网络相关常量 +export const NETWORK = { + // 最小端口号 + MIN_PORT: 1, + // 最大端口号 + MAX_PORT: 65535, + // 默认服务器端口 + DEFAULT_PORT: 3000 +}; + +// 请求重试相关常量 +export const RETRY = { + // 最大重试次数 + MAX_RETRIES: 100, + // 默认重试次数 + DEFAULT_RETRIES: 3 +}; diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 36ac1cb..8d942e8 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -53,7 +53,7 @@ function renderProviderTags(container, configs, isRequired) { // 过滤掉不可见的提供商 const visibleConfigs = configs.filter(c => c.visible !== false); - const escHtml = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + const escHtml = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); container.innerHTML = visibleConfigs.map(c => ` - - - - - - - - - + 选择需要进行定时健康检查的供应商类型