feat(channel-monitor): add feature switch settings + fix extra_models save

Settings:
- New "功能开关" tab between 通用设置 and 安全与认证
- ChannelMonitorEnabled toggle: runner skips scheduling when false,
  user-facing list returns empty
- ChannelMonitorDefaultIntervalSeconds (15-3600): pre-fills interval
  when creating a new monitor; each monitor can still override

Bug fix:
- ModelTagInput now commits pending input on blur, not just Enter/Tab.
  Previously clicking "save" with an un-Enter'd extra model would drop
  the value (DB stored extra_models=[] even when user typed entries).

Backend:
- domain_constants: SettingKeyChannelMonitor{Enabled,DefaultIntervalSeconds}
- SettingService.GetChannelMonitorRuntime: lightweight getter used by
  runner tick + user handler per-request (fail-open on DB error)
- Runner tickDueChecks: bails early when feature disabled
- ChannelMonitorUserHandler: checks feature flag before serving
- Comment on runner doc: scheduler state is implicit (every tick re-reads
  ListEnabled from DB), so CRUD ops on monitors self-maintain the schedule

Bump VERSION to 0.1.114.25
This commit is contained in:
erio
2026-04-21 00:21:29 +08:00
parent a1425b457d
commit 7da5124067
18 changed files with 283 additions and 14 deletions

View File

@@ -217,8 +217,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
channelMonitorRepository := repository.NewChannelMonitorRepository(client, sqlDB)
channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor)
channelMonitorHandler := admin.NewChannelMonitorHandler(channelMonitorService)
channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService)
channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
_ = channelMonitorRunner
registry := payment.ProvideRegistry()
encryptionKey, err := payment.ProvideEncryptionKey(configConfig)

View File

@@ -235,6 +235,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PaymentCancelRateLimitWindow: paymentCfg.CancelRateLimitWindow,
PaymentCancelRateLimitUnit: paymentCfg.CancelRateLimitUnit,
PaymentCancelRateLimitMode: paymentCfg.CancelRateLimitMode,
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
}
response.Success(c, systemSettingsResponseData(payload, authSourceDefaults))
}
@@ -425,6 +428,10 @@ type UpdateSettingsRequest struct {
PaymentCancelRateLimitWindow *int `json:"payment_cancel_rate_limit_window"`
PaymentCancelRateLimitUnit *string `json:"payment_cancel_rate_limit_unit"`
PaymentCancelRateLimitMode *string `json:"payment_cancel_rate_limit_window_mode"`
// Channel Monitor feature switch
ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
}
// UpdateSettings 更新系统设置
@@ -1219,6 +1226,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.AccountQuotaNotifyEmails
}(),
ChannelMonitorEnabled: func() bool {
if req.ChannelMonitorEnabled != nil {
return *req.ChannelMonitorEnabled
}
return previousSettings.ChannelMonitorEnabled
}(),
ChannelMonitorDefaultIntervalSeconds: func() int {
if req.ChannelMonitorDefaultIntervalSeconds != nil {
return *req.ChannelMonitorDefaultIntervalSeconds
}
return previousSettings.ChannelMonitorDefaultIntervalSeconds
}(),
}
authSourceDefaults := &service.AuthSourceDefaultSettings{
@@ -1449,6 +1468,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PaymentCancelRateLimitWindow: updatedPaymentCfg.CancelRateLimitWindow,
PaymentCancelRateLimitUnit: updatedPaymentCfg.CancelRateLimitUnit,
PaymentCancelRateLimitMode: updatedPaymentCfg.CancelRateLimitMode,
ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
}
response.Success(c, systemSettingsResponseData(payload, updatedAuthSourceDefaults))
}
@@ -1805,6 +1827,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if !equalNotifyEmailEntries(before.AccountQuotaNotifyEmails, after.AccountQuotaNotifyEmails) {
changed = append(changed, "account_quota_notify_emails")
}
if before.ChannelMonitorEnabled != after.ChannelMonitorEnabled {
changed = append(changed, "channel_monitor_enabled")
}
if before.ChannelMonitorDefaultIntervalSeconds != after.ChannelMonitorDefaultIntervalSeconds {
changed = append(changed, "channel_monitor_default_interval_seconds")
}
changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults)
return changed
}

View File

@@ -14,11 +14,28 @@ import (
// ChannelMonitorUserHandler 渠道监控用户只读 handler。
type ChannelMonitorUserHandler struct {
monitorService *service.ChannelMonitorService
settingService *service.SettingService
}
// NewChannelMonitorUserHandler 创建 handler。
func NewChannelMonitorUserHandler(monitorService *service.ChannelMonitorService) *ChannelMonitorUserHandler {
return &ChannelMonitorUserHandler{monitorService: monitorService}
// settingService 用于每次请求前读取功能开关;关闭时 List/GetStatus 直接返回空/404。
func NewChannelMonitorUserHandler(
monitorService *service.ChannelMonitorService,
settingService *service.SettingService,
) *ChannelMonitorUserHandler {
return &ChannelMonitorUserHandler{
monitorService: monitorService,
settingService: settingService,
}
}
// featureEnabled 返回当前渠道监控功能是否开启。
// settingService 为 nil测试场景视为启用。
func (h *ChannelMonitorUserHandler) featureEnabled(c *gin.Context) bool {
if h.settingService == nil {
return true
}
return h.settingService.GetChannelMonitorRuntime(c.Request.Context()).Enabled
}
// --- Response ---
@@ -123,6 +140,10 @@ func userMonitorDetailToResponse(d *service.UserMonitorDetail) *channelMonitorUs
// List GET /api/v1/channel-monitors
func (h *ChannelMonitorUserHandler) List(c *gin.Context) {
if !h.featureEnabled(c) {
response.Success(c, gin.H{"items": []channelMonitorUserListItem{}})
return
}
views, err := h.monitorService.ListUserView(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
@@ -137,6 +158,10 @@ func (h *ChannelMonitorUserHandler) List(c *gin.Context) {
// GetStatus GET /api/v1/channel-monitors/:id/status
func (h *ChannelMonitorUserHandler) GetStatus(c *gin.Context) {
if !h.featureEnabled(c) {
response.ErrorFrom(c, service.ErrChannelMonitorNotFound)
return
}
// 复用 admin.ParseChannelMonitorID 保持错误码与日志一致。
id, ok := admin.ParseChannelMonitorID(c)
if !ok {

View File

@@ -183,6 +183,10 @@ type SystemSettings struct {
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"`
// Channel Monitor feature switch
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
}
type DefaultSubscriptionSetting struct {
@@ -230,6 +234,9 @@ type PublicSettings struct {
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
}
// OverloadCooldownSettings 529过载冷却配置 DTO

View File

@@ -70,5 +70,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
})
}

View File

@@ -18,8 +18,13 @@ import (
// - Stop 时优雅关闭:池 drain + ticker.Stop + wg.Wait
//
// 不引入 cron 库;清理调度通过"每小时检查时间"实现,足够 MVP。
//
// 定时任务维护:删除/创建/编辑 monitor 无需显式 reload每个 tick 都会重新查 DB
// ListEnabled + listDueForCheck新 monitor 的 LastCheckedAt 为 nil 天然立即到期,
// 被删除的 monitor 自然不再返回interval 变化下次 tick 自动按新值判定。
type ChannelMonitorRunner struct {
svc *ChannelMonitorService
svc *ChannelMonitorService
settingService *SettingService
pool pond.Pool
stopCh chan struct{}
@@ -37,11 +42,13 @@ type ChannelMonitorRunner struct {
}
// NewChannelMonitorRunner 构造调度器。Start 在 wire 中调用。
func NewChannelMonitorRunner(svc *ChannelMonitorService) *ChannelMonitorRunner {
// settingService 用于在每次 tick 前读取功能开关;传 nil 时视为总是启用(兼容测试)。
func NewChannelMonitorRunner(svc *ChannelMonitorService, settingService *SettingService) *ChannelMonitorRunner {
return &ChannelMonitorRunner{
svc: svc,
stopCh: make(chan struct{}),
inFlight: make(map[int64]struct{}),
svc: svc,
settingService: settingService,
stopCh: make(chan struct{}),
inFlight: make(map[int64]struct{}),
}
}
@@ -93,10 +100,15 @@ func (r *ChannelMonitorRunner) dueCheckLoop() {
// tickDueChecks 一次扫描:查询到期监控并逐个提交到池。
// 已在执行的 monitor 会被跳过(防止单次检测耗时 > interval 时重复调度)。
// 池满时使用 TrySubmit 跳过(不能阻塞 ticker同时立即释放已占用的 inFlight 槽。
// 当功能开关关闭时直接返回——管理员可以动态禁用模块runner 不会拉取 DB。
func (r *ChannelMonitorRunner) tickDueChecks() {
ctx, cancel := context.WithTimeout(context.Background(), monitorListDueTimeout)
defer cancel()
if r.settingService != nil && !r.settingService.GetChannelMonitorRuntime(ctx).Enabled {
return
}
due, err := r.svc.listDueForCheck(ctx)
if err != nil {
slog.Warn("channel_monitor: list due failed", "error", err)

View File

@@ -242,6 +242,18 @@ const (
// SettingKeyOpsRuntimeLogConfig stores JSON config for runtime log settings.
SettingKeyOpsRuntimeLogConfig = "ops_runtime_log_config"
// =========================
// Channel Monitor (渠道监控)
// =========================
// SettingKeyChannelMonitorEnabled is a DB-backed soft switch for the channel monitor feature.
// When false: runner skips scheduling and user-facing endpoints return an empty list.
SettingKeyChannelMonitorEnabled = "channel_monitor_enabled"
// SettingKeyChannelMonitorDefaultIntervalSeconds controls the default interval (seconds)
// pre-filled when creating a new channel monitor from the admin UI. Range: [15, 3600].
SettingKeyChannelMonitorDefaultIntervalSeconds = "channel_monitor_default_interval_seconds"
// =========================
// Overload Cooldown (529)
// =========================

View File

@@ -450,6 +450,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyBalanceLowNotifyThreshold,
SettingKeyBalanceLowNotifyRechargeURL,
SettingKeyAccountQuotaNotifyEnabled,
SettingKeyChannelMonitorEnabled,
SettingKeyChannelMonitorDefaultIntervalSeconds,
}
settings, err := s.settingRepo.GetMultiple(ctx, keys)
@@ -532,9 +534,67 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL],
ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]),
ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
}, nil
}
// channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval
// (mirrors the monitor-level constraint but lives here so setting_service stays decoupled).
const (
channelMonitorIntervalMin = 15
channelMonitorIntervalMax = 3600
channelMonitorIntervalFallback = 60
)
// parseChannelMonitorInterval parses the stored string and clamps to [15, 3600].
// Empty / invalid input falls back to channelMonitorIntervalFallback.
func parseChannelMonitorInterval(raw string) int {
v, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return channelMonitorIntervalFallback
}
return clampChannelMonitorInterval(v)
}
// clampChannelMonitorInterval clamps v to the allowed range. 0 means "not provided".
func clampChannelMonitorInterval(v int) int {
if v <= 0 {
return 0
}
if v < channelMonitorIntervalMin {
return channelMonitorIntervalMin
}
if v > channelMonitorIntervalMax {
return channelMonitorIntervalMax
}
return v
}
// ChannelMonitorRuntime is the lightweight view of the channel monitor feature
// consumed by the runner and user-facing handlers.
type ChannelMonitorRuntime struct {
Enabled bool
DefaultIntervalSeconds int
}
// GetChannelMonitorRuntime reads the channel monitor feature flags directly from
// the settings store. Fail-open: on error returns Enabled=true with the default interval.
func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime {
vals, err := s.settingRepo.GetMultiple(ctx, []string{
SettingKeyChannelMonitorEnabled,
SettingKeyChannelMonitorDefaultIntervalSeconds,
})
if err != nil {
return ChannelMonitorRuntime{Enabled: true, DefaultIntervalSeconds: channelMonitorIntervalFallback}
}
return ChannelMonitorRuntime{
Enabled: !isFalseSettingValue(vals[SettingKeyChannelMonitorEnabled]),
DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]),
}
}
// SetOnUpdateCallback sets a callback function to be called when settings are updated
// This is used for cache invalidation (e.g., HTML cache in frontend server)
func (s *SettingService) SetOnUpdateCallback(callback func()) {
@@ -1085,6 +1145,12 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyOpsMetricsIntervalSeconds] = strconv.Itoa(settings.OpsMetricsIntervalSeconds)
}
// Channel monitor feature switch
updates[SettingKeyChannelMonitorEnabled] = strconv.FormatBool(settings.ChannelMonitorEnabled)
if v := clampChannelMonitorInterval(settings.ChannelMonitorDefaultIntervalSeconds); v > 0 {
updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v)
}
// Claude Code version check
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion
@@ -1630,6 +1696,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyOpsQueryModeDefault: "auto",
SettingKeyOpsMetricsIntervalSeconds: "60",
// Channel monitor defaults (enabled, 60s)
SettingKeyChannelMonitorEnabled: "true",
SettingKeyChannelMonitorDefaultIntervalSeconds: "60",
// Claude Code version check (default: empty = disabled)
SettingKeyMinClaudeCodeVersion: "",
SettingKeyMaxClaudeCodeVersion: "",
@@ -1932,6 +2002,12 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
}
}
// Channel monitor feature (default: enabled, 60s)
result.ChannelMonitorEnabled = !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled])
result.ChannelMonitorDefaultIntervalSeconds = parseChannelMonitorInterval(
settings[SettingKeyChannelMonitorDefaultIntervalSeconds],
)
// Claude Code version check
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion]

View File

@@ -125,6 +125,10 @@ type SystemSettings struct {
OpsQueryModeDefault string
OpsMetricsIntervalSeconds int
// Channel Monitor feature
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
// Claude Code version check
MinClaudeCodeVersion string
MaxClaudeCodeVersion string
@@ -209,6 +213,10 @@ type PublicSettings struct {
AccountQuotaNotifyEnabled bool
BalanceLowNotifyThreshold float64
BalanceLowNotifyRechargeURL string
// Channel Monitor feature
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
}
type WeChatConnectOAuthConfig struct {

View File

@@ -500,8 +500,9 @@ func ProvideChannelMonitorService(
// ProvideChannelMonitorRunner 创建并启动渠道监控调度器。
// Runner.Stop 由 cleanup function 调用。
func ProvideChannelMonitorRunner(svc *ChannelMonitorService) *ChannelMonitorRunner {
r := NewChannelMonitorRunner(svc)
// settingService 用于 runner 每个 tick 读取功能开关。
func ProvideChannelMonitorRunner(svc *ChannelMonitorService, settingService *SettingService) *ChannelMonitorRunner {
r := NewChannelMonitorRunner(svc, settingService)
r.Start()
return r
}

View File

@@ -469,6 +469,10 @@ export interface SystemSettings {
balance_low_notify_recharge_url: string;
account_quota_notify_enabled: boolean;
account_quota_notify_emails: NotifyEmailEntry[];
// Channel Monitor feature switch
channel_monitor_enabled: boolean;
channel_monitor_default_interval_seconds: number;
}
export interface UpdateSettingsRequest {
@@ -618,6 +622,10 @@ export interface UpdateSettingsRequest {
balance_low_notify_recharge_url?: string;
account_quota_notify_enabled?: boolean;
account_quota_notify_emails?: NotifyEmailEntry[];
// Channel Monitor feature switch
channel_monitor_enabled?: boolean;
channel_monitor_default_interval_seconds?: number;
}
/**

View File

@@ -27,6 +27,7 @@
@keydown.tab.prevent="addModel"
@keydown.delete="handleBackspace"
@paste="handlePaste"
@blur="addModel"
/>
</div>
<p class="mt-1 text-xs text-gray-400">

View File

@@ -143,6 +143,13 @@ const emit = defineEmits<{
const { t } = useI18n()
const appStore = useAppStore()
// System-configured default interval for new monitors. Falls back to the static
// constant when public settings haven't loaded yet or store the legacy 0 value.
const systemDefaultInterval = computed<number>(() => {
const configured = appStore.cachedPublicSettings?.channel_monitor_default_interval_seconds
return configured && configured > 0 ? configured : DEFAULT_INTERVAL_SECONDS
})
// editing is true when we have an existing monitor
const editing = computed<ChannelMonitor | null>(() => props.monitor)
@@ -173,7 +180,7 @@ const form = reactive<MonitorForm>({
primary_model: '',
extra_models: [],
group_name: '',
interval_seconds: DEFAULT_INTERVAL_SECONDS,
interval_seconds: systemDefaultInterval.value,
enabled: true,
})
@@ -191,7 +198,7 @@ function resetForm() {
form.primary_model = ''
form.extra_models = []
form.group_name = ''
form.interval_seconds = DEFAULT_INTERVAL_SECONDS
form.interval_seconds = systemDefaultInterval.value
form.enabled = true
}
@@ -203,7 +210,7 @@ function loadFromMonitor(m: ChannelMonitor) {
form.primary_model = m.primary_model
form.extra_models = [...(m.extra_models || [])]
form.group_name = m.group_name || ''
form.interval_seconds = m.interval_seconds || DEFAULT_INTERVAL_SECONDS
form.interval_seconds = m.interval_seconds || systemDefaultInterval.value
form.enabled = m.enabled
}

View File

@@ -4530,6 +4530,7 @@ export default {
description: 'Manage registration, email verification, default values, and SMTP settings',
tabs: {
general: 'General',
features: 'Feature Switches',
security: 'Security',
users: 'Users',
gateway: 'Gateway',
@@ -4537,6 +4538,16 @@ export default {
backup: 'Backup',
payment: 'Payment',
},
features: {
channelMonitor: {
title: 'Channel Monitor',
description: 'Periodically probe configured channels and surface availability / latency to users. Turning it off stops the scheduler and returns an empty list on the user page.',
enabled: 'Enable Channel Monitor',
enabledHint: 'Disabling stops background checks; existing history is preserved.',
defaultInterval: 'Default check interval (seconds)',
defaultIntervalHint: 'Pre-fills the interval when creating a new monitor; each monitor can override it. Range 15 3600.',
},
},
emailTabDisabledTitle: 'Email Verification Not Enabled',
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',
registration: {

View File

@@ -4695,6 +4695,7 @@ export default {
description: '管理注册、邮箱验证、默认值和 SMTP 设置',
tabs: {
general: '通用设置',
features: '功能开关',
security: '安全与认证',
users: '用户默认值',
gateway: '网关服务',
@@ -4702,6 +4703,16 @@ export default {
backup: '数据备份',
payment: '支付设置',
},
features: {
channelMonitor: {
title: '渠道监控',
description: '定期对配置的渠道发起健康检查,向用户展示可用性与延迟。关闭后调度器停止扫描,用户端列表为空。',
enabled: '启用渠道监控',
enabledHint: '关闭后后台不再执行定时检测,已有数据保留。',
defaultInterval: '默认检测间隔(秒)',
defaultIntervalHint: '新建渠道监控时表单的默认值,可被单个渠道覆盖。范围 15 3600 秒。',
},
},
emailTabDisabledTitle: '邮箱验证未启用',
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',
registration: {

View File

@@ -352,6 +352,8 @@ export const useAppStore = defineStore('app', () => {
balance_low_notify_enabled: false,
account_quota_notify_enabled: false,
balance_low_notify_threshold: 0,
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
}
}

View File

@@ -185,6 +185,8 @@ export interface PublicSettings {
balance_low_notify_enabled: boolean
account_quota_notify_enabled: boolean
balance_low_notify_threshold: number
channel_monitor_enabled: boolean
channel_monitor_default_interval_seconds: number
}
export interface AuthResponse {

View File

@@ -3749,6 +3749,52 @@
</div>
<!-- /Tab: General -->
<!-- Tab: Features (功能开关) -->
<div v-show="activeTab === 'features'" class="space-y-6">
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.channelMonitor.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.channelMonitor.description') }}
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.channelMonitor.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.channelMonitor.enabledHint') }}
</p>
</div>
<Toggle v-model="form.channel_monitor_enabled" />
</div>
<div v-if="form.channel_monitor_enabled">
<label class="input-label">
{{ t('admin.settings.features.channelMonitor.defaultInterval') }}
<span class="text-red-500">*</span>
</label>
<input
v-model.number="form.channel_monitor_default_interval_seconds"
type="number"
min="15"
max="3600"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.channelMonitor.defaultIntervalHint') }}
</p>
</div>
</div>
</div>
</div><!-- /Tab: Features -->
<!-- Tab: Email -->
<!-- Tab: Payment -->
<div v-show="activeTab === 'payment'" class="space-y-6">
@@ -4737,6 +4783,7 @@ const paymentMethodsHref = computed(() =>
type SettingsTab =
| "general"
| "features"
| "security"
| "users"
| "gateway"
@@ -4746,6 +4793,7 @@ type SettingsTab =
const activeTab = ref<SettingsTab>("general");
const settingsTabs = [
{ key: "general" as SettingsTab, icon: "home" as const },
{ key: "features" as SettingsTab, icon: "bolt" as const },
{ key: "security" as SettingsTab, icon: "shield" as const },
{ key: "users" as SettingsTab, icon: "user" as const },
{ key: "gateway" as SettingsTab, icon: "server" as const },
@@ -5005,6 +5053,9 @@ const form = reactive<SettingsForm>({
balance_low_notify_recharge_url: "",
account_quota_notify_enabled: false,
account_quota_notify_emails: [] as NotifyEmailEntry[],
// Channel Monitor feature switch
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
});
const authSourceDefaults = reactive<AuthSourceDefaultsState>(
@@ -5912,6 +5963,10 @@ async function saveSettings() {
account_quota_notify_emails: (
form.account_quota_notify_emails || []
).filter((e) => e.email.trim() !== ""),
// Channel Monitor feature switch
channel_monitor_enabled: form.channel_monitor_enabled,
channel_monitor_default_interval_seconds:
Number(form.channel_monitor_default_interval_seconds) || 60,
};
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);