From c86e7a7ba4d8d463e37abf71598bfaca7cbd17b3 Mon Sep 17 00:00:00 2001 From: xofine Date: Wed, 26 Nov 2025 20:36:25 +0800 Subject: [PATCH] Update: Js 4 Log.html 80% --- frontend/input.css | 2 +- frontend/js/components/filterPopover.js | 2 +- frontend/js/pages/logs/index.js | 85 ++++- frontend/js/pages/logs/logSettingsModal.js | 148 +++++++ internal/handlers/proxy_handler.go | 3 + internal/handlers/setting_handler.go | 30 +- internal/models/runtime.go | 4 + internal/scheduler/scheduler.go | 160 +++++++- internal/settings/settings.go | 8 +- web/static/css/output.css | 104 ++--- web/static/js/chunk-PLQL6WIO.js | 83 ---- web/static/js/chunk-SHK62ZJN.js | 360 ++++++++++++++++++ .../{chunk-EZAP7GR4.js => chunk-U67KAGZP.js} | 277 -------------- .../js/{keys-2IUHJHHE.js => keys-KZSAIVIM.js} | 8 +- .../js/{logs-YPEOUZVC.js => logs-WRHCX7IP.js} | 202 +++++++++- web/static/js/main.js | 16 +- web/templates/logs.html | 101 ++++- 17 files changed, 1120 insertions(+), 473 deletions(-) create mode 100644 frontend/js/pages/logs/logSettingsModal.js delete mode 100644 web/static/js/chunk-PLQL6WIO.js create mode 100644 web/static/js/chunk-SHK62ZJN.js rename web/static/js/{chunk-EZAP7GR4.js => chunk-U67KAGZP.js} (63%) rename web/static/js/{keys-2IUHJHHE.js => keys-KZSAIVIM.js} (99%) rename web/static/js/{logs-YPEOUZVC.js => logs-WRHCX7IP.js} (94%) diff --git a/frontend/input.css b/frontend/input.css index 83ea72f..e49011d 100644 --- a/frontend/input.css +++ b/frontend/input.css @@ -104,7 +104,7 @@ .flatpickr-calendar { /* --- 主题样式 --- */ - @apply bg-background text-foreground rounded-lg shadow-lg border border-border w-auto font-sans; + @apply bg-background text-foreground rounded-lg shadow-lg border border-border border-zinc-500/30 w-auto font-sans; animation: var(--animation-panel-in); width: 200px; /* --- 核心结构样式 --- */ diff --git a/frontend/js/components/filterPopover.js b/frontend/js/components/filterPopover.js index d40da3e..2fd790d 100644 --- a/frontend/js/components/filterPopover.js +++ b/frontend/js/components/filterPopover.js @@ -24,7 +24,7 @@ export default class FilterPopover { _createPopoverHTML() { this.popoverElement = document.createElement('div'); - this.popoverElement.className = 'hidden z-50 min-w-[12rem] rounded-md border bg-popover bg-white dark:bg-zinc-800 p-2 text-popover-foreground shadow-md'; + this.popoverElement.className = 'hidden z-50 min-w-[12rem] rounded-md border-1 border-zinc-500/30 bg-popover bg-white dark:bg-zinc-900 p-2 text-popover-foreground shadow-md'; this.popoverElement.innerHTML = `
${this.title}
diff --git a/frontend/js/pages/logs/index.js b/frontend/js/pages/logs/index.js index b82cdc4..d44aa6e 100644 --- a/frontend/js/pages/logs/index.js +++ b/frontend/js/pages/logs/index.js @@ -1,4 +1,3 @@ -// Filename: frontend/js/pages/logs/index.js import { apiFetchJson } from '../../services/api.js'; import LogList from './logList.js'; import CustomSelectV2 from '../../components/customSelectV2.js'; @@ -8,11 +7,13 @@ import { STATIC_ERROR_MAP, STATUS_CODE_MAP } from './logList.js'; import SystemLogTerminal from './systemLog.js'; import { initBatchActions } from './batchActions.js'; import flatpickr from '../../vendor/flatpickr.js'; +import LogSettingsModal from './logSettingsModal.js'; const dataStore = { groups: new Map(), keys: new Map(), }; + class LogsPage { constructor() { this.state = { @@ -40,6 +41,7 @@ class LogsPage { systemControls: document.getElementById('system-logs-controls'), errorTemplate: document.getElementById('error-logs-template'), systemTemplate: document.getElementById('system-logs-template'), + settingsBtn: document.querySelector('button[aria-label="日志设置"]'), }; this.initialized = !!this.elements.contentContainer; if (this.initialized) { @@ -48,15 +50,87 @@ class LogsPage { this.debouncedLoadAndRender = debounce(() => this.loadAndRenderLogs(), 300); this.fp = null; this.themeObserver = null; + this.settingsModal = null; + this.currentSettings = {}; } } + async init() { if (!this.initialized) return; this._initPermanentEventListeners(); + await this.loadCurrentSettings(); + this._initSettingsModal(); await this.loadGroupsOnce(); this.state.currentView = null; this.switchToView('error'); } + + _initSettingsModal() { + if (!this.elements.settingsBtn) return; + this.settingsModal = new LogSettingsModal({ + onSave: this.handleSaveSettings.bind(this) + }); + this.elements.settingsBtn.addEventListener('click', () => { + + const settingsForModal = { + log_level: this.currentSettings.log_level, + auto_cleanup: { + enabled: this.currentSettings.log_auto_cleanup_enabled, + retention_days: this.currentSettings.log_auto_cleanup_retention_days, + exec_time: this.currentSettings.log_auto_cleanup_time, + interval: 'daily', + } + }; + this.settingsModal.open(settingsForModal); + }); + } + + async loadCurrentSettings() { + try { + const { success, data } = await apiFetchJson('/admin/settings'); + if (success) { + this.currentSettings = data; + } else { + console.error('Failed to load settings from server.'); + this.currentSettings = { log_auto_cleanup_time: '04:05' }; + } + } catch (error) { + console.error('Failed to load log settings:', error); + this.currentSettings = { log_auto_cleanup_time: '04:05' }; + } + } + + async handleSaveSettings(settingsData) { + const partialPayload = { + "log_level": settingsData.log_level, + "log_auto_cleanup_enabled": settingsData.auto_cleanup.enabled, + "log_auto_cleanup_time": settingsData.auto_cleanup.exec_time, + }; + if (settingsData.auto_cleanup.enabled) { + let retentionDays = settingsData.auto_cleanup.retention_days; + if (retentionDays === null || retentionDays <= 0) { + retentionDays = 30; + } + partialPayload.log_auto_cleanup_retention_days = retentionDays; + } + + console.log('Sending PARTIAL settings update to /admin/settings:', partialPayload); + try { + const { success, message } = await apiFetchJson('/admin/settings', { + method: 'PUT', + body: JSON.stringify(partialPayload) + }); + if (!success) { + throw new Error(message || 'Failed to save settings'); + } + + Object.assign(this.currentSettings, partialPayload); + } catch (error) { + console.error('Error saving log settings:', error); + throw error; + } + } + _initPermanentEventListeners() { this.elements.tabsContainer.addEventListener('click', (event) => { const tabItem = event.target.closest('[data-tab-target]'); @@ -68,6 +142,7 @@ class LogsPage { } }); } + switchToView(viewName) { if (this.state.currentView === viewName && this.elements.contentContainer.innerHTML !== '') return; if (this.systemLogTerminal) { @@ -147,7 +222,6 @@ class LogsPage { }); this.themeObserver.observe(document.documentElement, { attributes: true }); - // 确保初始状态正确 applyTheme(); } @@ -218,7 +292,6 @@ class LogsPage { } }, onReady: (selectedDates, dateStr, instance) => { - // 暗黑模式和清除按钮的现有逻辑保持不变 if (document.documentElement.classList.contains('dark')) { instance.calendarContainer.classList.add('dark'); } @@ -431,6 +504,7 @@ class LogsPage { console.error("Failed to load key groups:", error); } } + async loadAndRenderLogs() { this.state.isLoading = true; this.state.selectedLogIds.clear(); @@ -500,7 +574,7 @@ class LogsPage { } async enrichLogsWithKeyNames(logs) { const missingKeyIds = [...new Set( - logs.filter(log => log.KeyID && !dataStore.keys.has(log.KeyID)).map(log => log.ID) + logs.filter(log => log.KeyID && !dataStore.keys.has(log.KeyID)).map(log => log.KeyID) )]; if (missingKeyIds.length === 0) return; try { @@ -514,7 +588,8 @@ class LogsPage { } } } + export default function() { const page = new LogsPage(); page.init(); -} \ No newline at end of file +} diff --git a/frontend/js/pages/logs/logSettingsModal.js b/frontend/js/pages/logs/logSettingsModal.js new file mode 100644 index 0000000..54ae796 --- /dev/null +++ b/frontend/js/pages/logs/logSettingsModal.js @@ -0,0 +1,148 @@ +// Filename: frontend/js/pages/logs/logList.js +import { modalManager } from '../../components/ui.js'; + +export default class LogSettingsModal { + constructor({ onSave }) { + this.modalId = 'log-settings-modal'; + this.onSave = onSave; + const modal = document.getElementById(this.modalId); + if (!modal) { + throw new Error(`Modal with id "${this.modalId}" not found.`); + } + + this.elements = { + modal: modal, + title: document.getElementById('log-settings-modal-title'), + saveBtn: document.getElementById('log-settings-save-btn'), + + logLevelSelect: document.getElementById('log-level-select'), + + cleanupEnableToggle: document.getElementById('log-cleanup-enable'), + cleanupSettingsPanel: document.getElementById('log-cleanup-settings'), + cleanupRetentionInput: document.getElementById('log-cleanup-retention-days'), + retentionDaysGroup: document.getElementById('retention-days-group'), + retentionPresetBtns: document.querySelectorAll('#retention-days-group button[data-days]'), + cleanupExecTimeInput: document.getElementById('log-cleanup-exec-time'), // [NEW] 添加时间选择器元素 + }; + + this.activePresetClasses = ['!bg-primary', '!text-primary-foreground', '!border-primary', 'hover:!bg-primary/90']; + this.inactivePresetClasses = ['modal-btn-secondary']; + this._initEventListeners(); + } + + open(settingsData = {}) { + this._populateForm(settingsData); + modalManager.show(this.modalId); + } + + close() { + modalManager.hide(this.modalId); + } + + _initEventListeners() { + this.elements.saveBtn.addEventListener('click', this._handleSave.bind(this)); + + this.elements.cleanupEnableToggle.addEventListener('change', (e) => { + this.elements.cleanupSettingsPanel.classList.toggle('hidden', !e.target.checked); + }); + + this._initRetentionPresets(); + + const closeAction = () => this.close(); + const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`); + closeTriggers.forEach(trigger => trigger.addEventListener('click', closeAction)); + this.elements.modal.addEventListener('click', (event) => { + if (event.target === this.elements.modal) closeAction(); + }); + } + + _initRetentionPresets() { + this.elements.retentionPresetBtns.forEach(btn => { + btn.addEventListener('click', () => { + const days = btn.dataset.days; + this.elements.cleanupRetentionInput.value = days; + this._updateActivePresetButton(days); + }); + }); + + this.elements.cleanupRetentionInput.addEventListener('input', (e) => { + this._updateActivePresetButton(e.target.value); + }); + } + + _updateActivePresetButton(currentValue) { + this.elements.retentionPresetBtns.forEach(btn => { + if (btn.dataset.days === currentValue) { + btn.classList.remove(...this.inactivePresetClasses); + btn.classList.add(...this.activePresetClasses); + } else { + btn.classList.remove(...this.activePresetClasses); + btn.classList.add(...this.inactivePresetClasses); + } + }); + } + + async _handleSave() { + const data = this._collectFormData(); + if (data.auto_cleanup.enabled && (!data.auto_cleanup.retention_days || data.auto_cleanup.retention_days <= 0)) { + alert('启用自动清理时,保留天数必须是大于0的数字。'); + return; + } + + if (this.onSave) { + this.elements.saveBtn.disabled = true; + this.elements.saveBtn.textContent = '保存中...'; + try { + await this.onSave(data); + this.close(); + } catch (error) { + console.error("Failed to save log settings:", error); + // 可以添加一个UI提示,比如 toast + } finally { + this.elements.saveBtn.disabled = false; + this.elements.saveBtn.textContent = '保存设置'; + } + } + } + + // [MODIFIED] - 更新此方法以填充新的时间选择器 + _populateForm(data) { + this.elements.logLevelSelect.value = data.log_level || 'INFO'; + + const cleanup = data.auto_cleanup || {}; + const isCleanupEnabled = cleanup.enabled || false; + this.elements.cleanupEnableToggle.checked = isCleanupEnabled; + this.elements.cleanupSettingsPanel.classList.toggle('hidden', !isCleanupEnabled); + + const retentionDays = cleanup.retention_days || ''; + this.elements.cleanupRetentionInput.value = retentionDays; + this._updateActivePresetButton(retentionDays.toString()); + + // [NEW] 填充执行时间,提供一个安全的默认值 + this.elements.cleanupExecTimeInput.value = cleanup.exec_time || '04:05'; + } + + // [MODIFIED] - 更新此方法以收集新的时间数据 + _collectFormData() { + const parseIntOrNull = (value) => { + const trimmed = value.trim(); + if (trimmed === '') return null; + const num = parseInt(trimmed, 10); + return isNaN(num) ? null : num; + }; + + const isCleanupEnabled = this.elements.cleanupEnableToggle.checked; + + const formData = { + log_level: this.elements.logLevelSelect.value, + auto_cleanup: { + enabled: isCleanupEnabled, + interval: isCleanupEnabled ? 'daily' : null, + retention_days: isCleanupEnabled ? parseIntOrNull(this.elements.cleanupRetentionInput.value) : null, + exec_time: isCleanupEnabled ? this.elements.cleanupExecTimeInput.value : '04:05', // [NEW] 收集时间数据 + }, + }; + + return formData; + } +} diff --git a/internal/handlers/proxy_handler.go b/internal/handlers/proxy_handler.go index 9a57d70..39d18ec 100644 --- a/internal/handlers/proxy_handler.go +++ b/internal/handlers/proxy_handler.go @@ -87,6 +87,9 @@ func (h *ProxyHandler) HandleProxy(c *gin.Context) { return } + c.Request.Body = io.NopCloser(bytes.NewReader(requestBody)) + c.Request.ContentLength = int64(len(requestBody)) + modelName := h.channel.ExtractModel(c, requestBody) groupName := c.Param("group_name") isPreciseRouting := groupName != "" diff --git a/internal/handlers/setting_handler.go b/internal/handlers/setting_handler.go index ae918af..1ce4394 100644 --- a/internal/handlers/setting_handler.go +++ b/internal/handlers/setting_handler.go @@ -7,6 +7,7 @@ import ( "gemini-balancer/internal/settings" "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" ) type SettingHandler struct { @@ -23,16 +24,35 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { func (h *SettingHandler) UpdateSettings(c *gin.Context) { var newSettingsMap map[string]interface{} if err := c.ShouldBindJSON(&newSettingsMap); err != nil { - response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error())) + logrus.WithError(err).Error("Failed to bind JSON in UpdateSettings") + response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, "Invalid JSON: "+err.Error())) return } - if err := h.settingsManager.UpdateSettings(newSettingsMap); err != nil { - // TODO 可以根据错误类型返回更具体的错误 + + logrus.Debugf("Received settings update: %+v", newSettingsMap) + + validKeys := make(map[string]interface{}) + for key, value := range newSettingsMap { + if _, exists := h.settingsManager.IsValidKey(key); exists { + validKeys[key] = value + } else { + logrus.Warnf("Invalid key received: %s", key) + } + } + + if len(validKeys) == 0 { + response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, "No valid settings keys provided")) + return + } + + logrus.Debugf("Valid keys to update: %+v", validKeys) + + if err := h.settingsManager.UpdateSettings(validKeys); err != nil { + logrus.WithError(err).Error("Failed to update settings") response.Error(c, errors.NewAPIError(errors.ErrInternalServer, err.Error())) return } - response.Success(c, gin.H{"message": "Settings update request processed successfully."}) - + response.Success(c, gin.H{"message": "Settings updated successfully"}) } // ResetSettingsToDefaults resets all settings to their default values diff --git a/internal/models/runtime.go b/internal/models/runtime.go index ac6bcdc..d83f7fe 100644 --- a/internal/models/runtime.go +++ b/internal/models/runtime.go @@ -71,6 +71,10 @@ type SystemSettings struct { LogBufferCapacity int `json:"log_buffer_capacity" default:"1000" name:"日志缓冲区容量" category:"日志设置" desc:"内存中日志缓冲区的最大容量,超过则可能丢弃日志。"` LogFlushBatchSize int `json:"log_flush_batch_size" default:"100" name:"日志刷新批次大小" category:"日志设置" desc:"每次向数据库批量写入日志的最大数量。"` + LogAutoCleanupEnabled bool `json:"log_auto_cleanup_enabled" default:"false" name:"开启请求日志自动清理" category:"日志配置" desc:"启用后,系统将每日定时删除旧的请求日志。"` + LogAutoCleanupRetentionDays int `json:"log_auto_cleanup_retention_days" default:"30" name:"日志保留天数" category:"日志配置" desc:"自动清理任务将保留最近 N 天的日志。"` + LogAutoCleanupTime string `json:"log_auto_cleanup_time" default:"04:05" name:"每日清理执行时间" category:"日志配置" desc:"自动清理任务执行的时间点(24小时制,例如 04:05)。"` + // --- API配置 --- CustomHeaders map[string]string `json:"custom_headers" name:"自定义Headers" category:"API配置" ` // 默认为nil diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index fd058f9..fa9e72d 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -1,42 +1,66 @@ -// Filename: internal/scheduler/scheduler.go +// [REVISED] - 用这个更智能的版本完整替换 package scheduler import ( "context" + "fmt" // [NEW] 导入 fmt "gemini-balancer/internal/repository" "gemini-balancer/internal/service" + "gemini-balancer/internal/settings" + "gemini-balancer/internal/store" + "strconv" // [NEW] 导入 strconv + "strings" // [NEW] 导入 strings + "sync" "time" "github.com/go-co-op/gocron" "github.com/sirupsen/logrus" ) +// ... (Scheduler struct 和 NewScheduler 保持不变) ... +const LogCleanupTaskTag = "log-cleanup-task" + type Scheduler struct { gocronScheduler *gocron.Scheduler logger *logrus.Entry statsService *service.StatsService + logService *service.LogService + settingsManager *settings.SettingsManager keyRepo repository.KeyRepository + store store.Store + + stopChan chan struct{} + wg sync.WaitGroup } -func NewScheduler(statsSvc *service.StatsService, keyRepo repository.KeyRepository, logger *logrus.Logger) *Scheduler { +func NewScheduler( + statsSvc *service.StatsService, + logSvc *service.LogService, + keyRepo repository.KeyRepository, + settingsMgr *settings.SettingsManager, + store store.Store, + logger *logrus.Logger, +) *Scheduler { s := gocron.NewScheduler(time.UTC) s.TagsUnique() return &Scheduler{ gocronScheduler: s, logger: logger.WithField("component", "Scheduler📆"), statsService: statsSvc, + logService: logSvc, + settingsManager: settingsMgr, keyRepo: keyRepo, + store: store, + stopChan: make(chan struct{}), } } +// ... (Start 和 listenForSettingsUpdates 保持不变) ... func (s *Scheduler) Start() { s.logger.Info("Starting scheduler and registering jobs...") - - // 任务一:每小时执行一次的统计聚合 - // 使用CRON表达式,精确定义“每小时的第5分钟”执行 + // --- 注册静态定时任务 --- _, err := s.gocronScheduler.Cron("5 * * * *").Tag("stats-aggregation").Do(func() { s.logger.Info("Executing hourly request stats aggregation...") - // 为后台定时任务创建一个新的、空的 context ctx := context.Background() if err := s.statsService.AggregateHourlyStats(ctx); err != nil { s.logger.WithError(err).Error("Hourly stats aggregation failed.") @@ -47,17 +71,9 @@ func (s *Scheduler) Start() { if err != nil { s.logger.Errorf("Failed to schedule [stats-aggregation]: %v", err) } - - // 任务二:(预留) 自动健康检查 - - // 任务三:每日执行一次的软删除Key清理 - // Executes once daily at 3:15 AM UTC. _, err = s.gocronScheduler.Cron("15 3 * * *").Tag("cleanup-soft-deleted-keys").Do(func() { s.logger.Info("Executing daily cleanup of soft-deleted API keys...") - - // [假设保留7天,实际应来自配置 const retentionDays = 7 - count, err := s.keyRepo.HardDeleteSoftDeletedBefore(time.Now().AddDate(0, 0, -retentionDays)) if err != nil { s.logger.WithError(err).Error("Daily cleanup of soft-deleted keys failed.") @@ -70,13 +86,125 @@ func (s *Scheduler) Start() { if err != nil { s.logger.Errorf("Failed to schedule [cleanup-soft-deleted-keys]: %v", err) } - + // --- 动态任务初始化 --- + if err := s.UpdateLogCleanupTask(); err != nil { + s.logger.WithError(err).Error("Failed to initialize log cleanup task on startup.") + } + // --- 启动后台监听器和调度器 --- + s.wg.Add(1) + go s.listenForSettingsUpdates() s.gocronScheduler.StartAsync() s.logger.Info("Scheduler started.") } +func (s *Scheduler) listenForSettingsUpdates() { + defer s.wg.Done() + s.logger.Info("Starting listener for system settings updates...") + for { + select { + case <-s.stopChan: + s.logger.Info("Stopping settings update listener.") + return + default: + } + ctx, cancel := context.WithCancel(context.Background()) + subscription, err := s.store.Subscribe(ctx, settings.SettingsUpdateChannel) + if err != nil { + s.logger.WithError(err).Warnf("Failed to subscribe to settings channel, retrying in 5s...") + cancel() + time.Sleep(5 * time.Second) + continue + } + s.logger.Infof("Successfully subscribed to channel '%s'.", settings.SettingsUpdateChannel) + listenLoop: + for { + select { + case msg, ok := <-subscription.Channel(): + if !ok { + s.logger.Warn("Subscription channel closed by publisher. Re-subscribing...") + break listenLoop + } + s.logger.Infof("Received settings update notification: %s", string(msg.Payload)) + if err := s.UpdateLogCleanupTask(); err != nil { + s.logger.WithError(err).Error("Failed to update log cleanup task after notification.") + } + case <-s.stopChan: + s.logger.Info("Stopping settings update listener.") + subscription.Close() + cancel() + return + } + } + subscription.Close() + cancel() + } +} +// [MODIFIED] - UpdateLogCleanupTask 现在会动态生成 cron 表达式 +func (s *Scheduler) UpdateLogCleanupTask() error { + if err := s.gocronScheduler.RemoveByTag(LogCleanupTaskTag); err != nil { + // This is not an error, just means the job didn't exist + } + + settings := s.settingsManager.GetSettings() + if !settings.LogAutoCleanupEnabled || settings.LogAutoCleanupRetentionDays <= 0 { + s.logger.Info("Log auto-cleanup is disabled. Task removed or not scheduled.") + return nil + } + + days := settings.LogAutoCleanupRetentionDays + + // [NEW] 解析时间并生成 cron 表达式 + cronSpec, err := parseTimeToCron(settings.LogAutoCleanupTime) + if err != nil { + s.logger.WithError(err).Warnf("Invalid cleanup time format '%s'. Falling back to default '04:05'.", settings.LogAutoCleanupTime) + cronSpec = "5 4 * * *" // 安全回退 + } + + s.logger.Infof("Scheduling/updating daily log cleanup task to retain last %d days of logs, using cron spec: '%s'", days, cronSpec) + + _, err = s.gocronScheduler.Cron(cronSpec).Tag(LogCleanupTaskTag).Do(func() { + s.logger.Infof("Executing daily log cleanup, deleting logs older than %d days...", days) + ctx := context.Background() + deletedCount, err := s.logService.DeleteOldLogs(ctx, days) + if err != nil { + s.logger.WithError(err).Error("Daily log cleanup task failed.") + } else { + s.logger.Infof("Daily log cleanup task completed. Deleted %d old logs.", deletedCount) + } + }) + + if err != nil { + s.logger.WithError(err).Error("Failed to schedule new log cleanup task.") + return err + } + + s.logger.Info("Log cleanup task updated successfully.") + return nil +} + +// [NEW] - 用于解析 "HH:mm" 格式时间为 cron 表达式的辅助函数 +func parseTimeToCron(timeStr string) (string, error) { + parts := strings.Split(timeStr, ":") + if len(parts) != 2 { + return "", fmt.Errorf("invalid time format, expected HH:mm") + } + + hour, err := strconv.Atoi(parts[0]) + if err != nil || hour < 0 || hour > 23 { + return "", fmt.Errorf("invalid hour value: %s", parts[0]) + } + + minute, err := strconv.Atoi(parts[1]) + if err != nil || minute < 0 || minute > 59 { + return "", fmt.Errorf("invalid minute value: %s", parts[1]) + } + + return fmt.Sprintf("%d %d * * *", minute, hour), nil +} func (s *Scheduler) Stop() { s.logger.Info("Stopping scheduler...") + close(s.stopChan) s.gocronScheduler.Stop() - s.logger.Info("Scheduler stopped.") + s.wg.Wait() + s.logger.Info("Scheduler stopped gracefully.") } diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 092f0b4..5e45887 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -87,7 +87,7 @@ func NewSettingsManager(db *gorm.DB, store store.Store, logger *logrus.Logger) ( return settings, nil } - s, err := syncer.NewCacheSyncer(settingsLoader, store, SettingsUpdateChannel, logger,) + s, err := syncer.NewCacheSyncer(settingsLoader, store, SettingsUpdateChannel, logger) if err != nil { return nil, fmt.Errorf("failed to create system settings syncer: %w", err) } @@ -250,3 +250,9 @@ func (sm *SettingsManager) convertToDBValue(_ string, value interface{}, fieldTy return fmt.Sprintf("%v", value), nil } } + +// IsValidKey 检查给定的 JSON key 是否是有效的设置字段 +func (sm *SettingsManager) IsValidKey(key string) (reflect.Type, bool) { + fieldType, ok := sm.jsonToFieldType[key] + return fieldType, ok +} diff --git a/web/static/css/output.css b/web/static/css/output.css index 053f61b..528ce26 100644 --- a/web/static/css/output.css +++ b/web/static/css/output.css @@ -488,9 +488,6 @@ .m-0 { margin: calc(var(--spacing) * 0); } - .m-2 { - margin: calc(var(--spacing) * 2); - } .mx-1 { margin-inline: calc(var(--spacing) * 1); } @@ -506,9 +503,6 @@ .my-2 { margin-block: calc(var(--spacing) * 2); } - .mt-0 { - margin-top: calc(var(--spacing) * 0); - } .mt-0\.5 { margin-top: calc(var(--spacing) * 0.5); } @@ -630,9 +624,6 @@ width: calc(var(--spacing) * 6); height: calc(var(--spacing) * 6); } - .h-0 { - height: calc(var(--spacing) * 0); - } .h-0\.5 { height: calc(var(--spacing) * 0.5); } @@ -717,9 +708,6 @@ .w-0 { width: calc(var(--spacing) * 0); } - .w-1 { - width: calc(var(--spacing) * 1); - } .w-1\/4 { width: calc(1/4 * 100%); } @@ -771,6 +759,9 @@ .w-32 { width: calc(var(--spacing) * 32); } + .w-40 { + width: calc(var(--spacing) * 40); + } .w-48 { width: calc(var(--spacing) * 48); } @@ -831,9 +822,6 @@ .flex-1 { flex: 1; } - .flex-shrink { - flex-shrink: 1; - } .shrink-0 { flex-shrink: 0; } @@ -846,9 +834,6 @@ .caption-bottom { caption-side: bottom; } - .border-collapse { - border-collapse: collapse; - } .origin-center { transform-origin: center; } @@ -875,10 +860,6 @@ --tw-translate-x: 100%; translate: var(--tw-translate-x) var(--tw-translate-y); } - .-translate-y-1 { - --tw-translate-y: calc(var(--spacing) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); - } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -1047,9 +1028,6 @@ margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse))); } } - .gap-x-1 { - column-gap: calc(var(--spacing) * 1); - } .gap-x-1\.5 { column-gap: calc(var(--spacing) * 1.5); } @@ -1163,6 +1141,10 @@ border-style: var(--tw-border-style); border-width: 1px; } + .border-1 { + border-style: var(--tw-border-style); + border-width: 1px; + } .border-2 { border-style: var(--tw-border-style); border-width: 2px; @@ -1198,9 +1180,6 @@ .\!border-primary { border-color: var(--color-primary) !important; } - .border-black { - border-color: var(--color-black); - } .border-black\/10 { border-color: color-mix(in srgb, #000 10%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -1228,9 +1207,6 @@ .border-green-200 { border-color: var(--color-green-200); } - .border-primary { - border-color: var(--color-primary); - } .border-primary\/20 { border-color: var(--color-primary); @supports (color: color-mix(in lab, red, red)) { @@ -1267,8 +1243,11 @@ .border-zinc-300 { border-color: var(--color-zinc-300); } - .border-zinc-700 { - border-color: var(--color-zinc-700); + .border-zinc-500\/30 { + border-color: color-mix(in srgb, oklch(55.2% 0.016 285.938) 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-zinc-500) 30%, transparent); + } } .border-zinc-700\/50 { border-color: color-mix(in srgb, oklch(37% 0.013 285.805) 50%, transparent); @@ -1282,6 +1261,9 @@ .border-b-border { border-bottom-color: var(--color-border); } + .\!bg-primary { + background-color: var(--color-primary) !important; + } .bg-accent { background-color: var(--color-accent); } @@ -1342,9 +1324,6 @@ .bg-gray-500 { background-color: var(--color-gray-500); } - .bg-gray-950 { - background-color: var(--color-gray-950); - } .bg-gray-950\/5 { background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 5%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -1559,10 +1538,6 @@ --tw-gradient-position: to right in oklab; background-image: linear-gradient(var(--tw-gradient-stops)); } - .from-blue-500 { - --tw-gradient-from: var(--color-blue-500); - --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); - } .from-blue-500\/30 { --tw-gradient-from: color-mix(in srgb, oklch(62.3% 0.214 259.815) 30%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -1633,9 +1608,6 @@ .px-8 { padding-inline: calc(var(--spacing) * 8); } - .py-0 { - padding-block: calc(var(--spacing) * 0); - } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } @@ -1684,9 +1656,6 @@ .pr-20 { padding-right: calc(var(--spacing) * 20); } - .pb-1 { - padding-bottom: calc(var(--spacing) * 1); - } .pb-1\.5 { padding-bottom: calc(var(--spacing) * 1.5); } @@ -1808,6 +1777,9 @@ .\!text-primary { color: var(--color-primary) !important; } + .\!text-primary-foreground { + color: var(--color-primary-foreground) !important; + } .text-amber-300 { color: var(--color-amber-300); } @@ -1977,9 +1949,6 @@ --tw-ordinal: ordinal; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); } - .underline { - text-decoration-line: underline; - } .opacity-0 { opacity: 0%; } @@ -2041,10 +2010,6 @@ --tw-inset-shadow: inset 0 2px 4px var(--tw-inset-shadow-color, oklab(from rgb(0 0 0 / 0.05) l a b / 25%)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } - .inset-shadow-sm { - --tw-inset-shadow: inset 0 2px 4px var(--tw-inset-shadow-color, rgb(0 0 0 / 0.05)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } .ring-black { --tw-ring-color: var(--color-black); } @@ -2069,19 +2034,12 @@ .ring-input { --tw-ring-color: var(--color-input); } - .ring-zinc-500 { - --tw-ring-color: var(--color-zinc-500); - } .ring-zinc-500\/30 { --tw-ring-color: color-mix(in srgb, oklch(55.2% 0.016 285.938) 30%, transparent); @supports (color: color-mix(in lab, red, red)) { --tw-ring-color: color-mix(in oklab, var(--color-zinc-500) 30%, transparent); } } - .outline { - outline-style: var(--tw-outline-style); - outline-width: 1px; - } .blur { --tw-blur: blur(8px); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); @@ -2253,6 +2211,16 @@ } } } + .hover\:\!bg-primary\/90 { + &:hover { + @media (hover: hover) { + background-color: var(--color-primary) !important; + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-primary) 90%, transparent) !important; + } + } + } + } .hover\:bg-accent { &:hover { @media (hover: hover) { @@ -3481,6 +3449,10 @@ border-style: var(--tw-border-style); border-width: 1px; border-color: var(--color-border); + border-color: color-mix(in srgb, oklch(55.2% 0.016 285.938) 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-zinc-500) 30%, transparent); + } background-color: var(--color-background); font-family: var(--font-sans); color: var(--color-foreground); @@ -5610,11 +5582,6 @@ inherits: false; initial-value: 0 0 #0000; } -@property --tw-outline-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} @property --tw-blur { syntax: "*"; inherits: false; @@ -5712,6 +5679,11 @@ syntax: "*"; inherits: false; } +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @keyframes spin { to { transform: rotate(360deg); @@ -5777,7 +5749,6 @@ --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; - --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; @@ -5802,6 +5773,7 @@ --tw-backdrop-sepia: initial; --tw-duration: initial; --tw-ease: initial; + --tw-outline-style: solid; } } } diff --git a/web/static/js/chunk-PLQL6WIO.js b/web/static/js/chunk-PLQL6WIO.js deleted file mode 100644 index 24ca5f3..0000000 --- a/web/static/js/chunk-PLQL6WIO.js +++ /dev/null @@ -1,83 +0,0 @@ -// frontend/js/services/api.js -var APIClientError = class extends Error { - constructor(message, status, code, rawMessageFromServer) { - super(message); - this.name = "APIClientError"; - this.status = status; - this.code = code; - this.rawMessageFromServer = rawMessageFromServer; - } -}; -var apiPromiseCache = /* @__PURE__ */ new Map(); -async function apiFetch(url, options = {}) { - const isGetRequest = !options.method || options.method.toUpperCase() === "GET"; - const cacheKey = isGetRequest && !options.noCache ? url : null; - if (cacheKey && apiPromiseCache.has(cacheKey)) { - return apiPromiseCache.get(cacheKey); - } - const token = localStorage.getItem("bearerToken"); - const headers = { - "Content-Type": "application/json", - ...options.headers - }; - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - const requestPromise = (async () => { - try { - const response = await fetch(url, { ...options, headers }); - if (response.status === 401) { - if (cacheKey) apiPromiseCache.delete(cacheKey); - localStorage.removeItem("bearerToken"); - if (window.location.pathname !== "/login") { - window.location.href = "/login?error=\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u3002"; - } - throw new APIClientError("Unauthorized", 401, "UNAUTHORIZED", "Session expired or token is invalid."); - } - if (!response.ok) { - let errorData = null; - let rawMessage = ""; - try { - rawMessage = await response.text(); - if (rawMessage) { - errorData = JSON.parse(rawMessage); - } - } catch (e) { - errorData = { error: { code: "UNKNOWN_FORMAT", message: rawMessage || response.statusText } }; - } - const code = errorData?.error?.code || "UNKNOWN_ERROR"; - const messageFromServer = errorData?.error?.message || rawMessage || "No message provided by server."; - const error = new APIClientError( - `API request failed: ${response.status}`, - response.status, - code, - messageFromServer - ); - throw error; - } - return response; - } catch (error) { - if (cacheKey) apiPromiseCache.delete(cacheKey); - throw error; - } - })(); - if (cacheKey) { - apiPromiseCache.set(cacheKey, requestPromise); - } - return requestPromise; -} -async function apiFetchJson(url, options = {}) { - try { - const response = await apiFetch(url, options); - const clonedResponse = response.clone(); - const jsonData = await clonedResponse.json(); - return jsonData; - } catch (error) { - throw error; - } -} - -export { - apiFetch, - apiFetchJson -}; diff --git a/web/static/js/chunk-SHK62ZJN.js b/web/static/js/chunk-SHK62ZJN.js new file mode 100644 index 0000000..49befa4 --- /dev/null +++ b/web/static/js/chunk-SHK62ZJN.js @@ -0,0 +1,360 @@ +// frontend/js/components/ui.js +var ModalManager = class { + /** + * Shows a generic modal by its ID. + * @param {string} modalId The ID of the modal element to show. + */ + show(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.classList.remove("hidden"); + } else { + console.error(`Modal with ID "${modalId}" not found.`); + } + } + /** + * Hides a generic modal by its ID. + * @param {string} modalId The ID of the modal element to hide. + */ + hide(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.classList.add("hidden"); + } else { + console.error(`Modal with ID "${modalId}" not found.`); + } + } + /** + * Shows a confirmation dialog. This is a versatile method for 'Are you sure?' style prompts. + * It dynamically sets the title, message, and confirm action for a generic confirmation modal. + * @param {object} options - The options for the confirmation modal. + * @param {string} options.modalId - The ID of the confirmation modal element (e.g., 'resetModal', 'deleteConfirmModal'). + * @param {string} options.title - The title to display in the modal header. + * @param {string} options.message - The message to display in the modal body. Can contain HTML. + * @param {function} options.onConfirm - The callback function to execute when the confirm button is clicked. + * @param {boolean} [options.disableConfirm=false] - Whether the confirm button should be initially disabled. + */ + showConfirm({ modalId, title, message, onConfirm, disableConfirm = false }) { + const modalElement = document.getElementById(modalId); + if (!modalElement) { + console.error(`Confirmation modal with ID "${modalId}" not found.`); + return; + } + const titleElement = modalElement.querySelector('[id$="ModalTitle"]'); + const messageElement = modalElement.querySelector('[id$="ModalMessage"]'); + const confirmButton = modalElement.querySelector('[id^="confirm"]'); + if (!titleElement || !messageElement || !confirmButton) { + console.error(`Modal "${modalId}" is missing required child elements (title, message, or confirm button).`); + return; + } + titleElement.textContent = title; + messageElement.innerHTML = message; + confirmButton.disabled = disableConfirm; + const newConfirmButton = confirmButton.cloneNode(true); + confirmButton.parentNode.replaceChild(newConfirmButton, confirmButton); + newConfirmButton.onclick = () => onConfirm(); + this.show(modalId); + } + /** + * Shows a result modal to indicate the outcome of an operation (success or failure). + * @param {boolean} success - If true, displays a success icon and title; otherwise, shows failure indicators. + * @param {string|Node} message - The message to display. Can be a simple string or a complex DOM Node for rich content. + * @param {boolean} [autoReload=false] - If true, the page will automatically reload when the modal is closed. + */ + showResult(success, message, autoReload = false) { + const modalElement = document.getElementById("resultModal"); + if (!modalElement) { + console.error("Result modal with ID 'resultModal' not found."); + return; + } + const titleElement = document.getElementById("resultModalTitle"); + const messageElement = document.getElementById("resultModalMessage"); + const iconElement = document.getElementById("resultIcon"); + const confirmButton = document.getElementById("resultModalConfirmBtn"); + if (!titleElement || !messageElement || !iconElement || !confirmButton) { + console.error("Result modal is missing required child elements."); + return; + } + titleElement.textContent = success ? "\u64CD\u4F5C\u6210\u529F" : "\u64CD\u4F5C\u5931\u8D25"; + if (success) { + iconElement.innerHTML = ''; + iconElement.className = "text-6xl mb-3 text-success-500"; + } else { + iconElement.innerHTML = ''; + iconElement.className = "text-6xl mb-3 text-danger-500"; + } + messageElement.innerHTML = ""; + if (typeof message === "string") { + const messageDiv = document.createElement("div"); + messageDiv.innerText = message; + messageElement.appendChild(messageDiv); + } else if (message instanceof Node) { + messageElement.appendChild(message); + } else { + const messageDiv = document.createElement("div"); + messageDiv.innerText = String(message); + messageElement.appendChild(messageDiv); + } + confirmButton.onclick = () => this.closeResult(autoReload); + this.show("resultModal"); + } + /** + * Closes the result modal. + * @param {boolean} [reload=false] - If true, reloads the page after closing the modal. + */ + closeResult(reload = false) { + this.hide("resultModal"); + if (reload) { + location.reload(); + } + } + /** + * Shows and initializes the progress modal for long-running operations. + * @param {string} title - The title to display for the progress modal. + */ + showProgress(title) { + const modal = document.getElementById("progressModal"); + if (!modal) { + console.error("Progress modal with ID 'progressModal' not found."); + return; + } + const titleElement = document.getElementById("progressModalTitle"); + const statusText = document.getElementById("progressStatusText"); + const progressBar = document.getElementById("progressBar"); + const progressPercentage = document.getElementById("progressPercentage"); + const progressLog = document.getElementById("progressLog"); + const closeButton = document.getElementById("progressModalCloseBtn"); + const closeIcon = document.getElementById("closeProgressModalBtn"); + if (!titleElement || !statusText || !progressBar || !progressPercentage || !progressLog || !closeButton || !closeIcon) { + console.error("Progress modal is missing required child elements."); + return; + } + titleElement.textContent = title; + statusText.textContent = "\u51C6\u5907\u5F00\u59CB..."; + progressBar.style.width = "0%"; + progressPercentage.textContent = "0%"; + progressLog.innerHTML = ""; + closeButton.disabled = true; + closeIcon.disabled = true; + this.show("progressModal"); + } + /** + * Updates the progress bar and status text within the progress modal. + * @param {number} processed - The number of items that have been processed. + * @param {number} total - The total number of items to process. + * @param {string} status - The current status message to display. + */ + updateProgress(processed, total, status) { + const modal = document.getElementById("progressModal"); + if (!modal || modal.classList.contains("hidden")) return; + const progressBar = document.getElementById("progressBar"); + const progressPercentage = document.getElementById("progressPercentage"); + const statusText = document.getElementById("progressStatusText"); + const closeButton = document.getElementById("progressModalCloseBtn"); + const closeIcon = document.getElementById("closeProgressModalBtn"); + const percentage = total > 0 ? Math.round(processed / total * 100) : 0; + progressBar.style.width = `${percentage}%`; + progressPercentage.textContent = `${percentage}%`; + statusText.textContent = status; + if (processed === total) { + closeButton.disabled = false; + closeIcon.disabled = false; + } + } + /** + * Adds a log entry to the progress modal's log area. + * @param {string} message - The log message to append. + * @param {boolean} [isError=false] - If true, styles the log entry as an error. + */ + addProgressLog(message, isError = false) { + const progressLog = document.getElementById("progressLog"); + if (!progressLog) return; + const logEntry = document.createElement("div"); + logEntry.textContent = message; + logEntry.className = isError ? "text-danger-600" : "text-gray-700"; + progressLog.appendChild(logEntry); + progressLog.scrollTop = progressLog.scrollHeight; + } + /** + * Closes the progress modal. + * @param {boolean} [reload=false] - If true, reloads the page after closing. + */ + closeProgress(reload = false) { + this.hide("progressModal"); + if (reload) { + location.reload(); + } + } +}; +var UIPatterns = class { + /** + * Animates numerical values in elements from 0 to their target number. + * The target number is read from the element's text content. + * @param {string} selector - The CSS selector for the elements to animate (e.g., '.stat-value'). + * @param {number} [duration=1500] - The duration of the animation in milliseconds. + */ + animateCounters(selector = ".stat-value", duration = 1500) { + const statValues = document.querySelectorAll(selector); + statValues.forEach((valueElement) => { + const finalValue = parseInt(valueElement.textContent, 10); + if (isNaN(finalValue)) return; + if (!valueElement.dataset.originalValue) { + valueElement.dataset.originalValue = valueElement.textContent; + } + let startValue = 0; + const startTime = performance.now(); + const updateCounter = (currentTime) => { + const elapsedTime = currentTime - startTime; + if (elapsedTime < duration) { + const progress = elapsedTime / duration; + const easeOutValue = 1 - Math.pow(1 - progress, 3); + const currentValue = Math.floor(easeOutValue * finalValue); + valueElement.textContent = currentValue; + requestAnimationFrame(updateCounter); + } else { + valueElement.textContent = valueElement.dataset.originalValue; + } + }; + requestAnimationFrame(updateCounter); + }); + } + /** + * Toggles the visibility of a content section with a smooth height animation. + * It expects a specific HTML structure where the header and content are within a common parent (e.g., a card). + * The content element should have a `collapsed` class when hidden. + * @param {HTMLElement} header - The header element that was clicked to trigger the toggle. + */ + toggleSection(header) { + const card = header.closest(".stats-card"); + if (!card) return; + const content = card.querySelector(".key-content"); + const toggleIcon = header.querySelector(".toggle-icon"); + if (!content || !toggleIcon) { + console.error("Toggle section failed: Content or icon element not found.", { header }); + return; + } + const isCollapsed = content.classList.contains("collapsed"); + toggleIcon.classList.toggle("collapsed", !isCollapsed); + if (isCollapsed) { + content.classList.remove("collapsed"); + content.style.maxHeight = null; + content.style.opacity = null; + content.style.paddingTop = null; + content.style.paddingBottom = null; + content.style.overflow = "hidden"; + requestAnimationFrame(() => { + const targetHeight = content.scrollHeight; + content.style.maxHeight = `${targetHeight}px`; + content.style.opacity = "1"; + content.style.paddingTop = "1rem"; + content.style.paddingBottom = "1rem"; + content.addEventListener("transitionend", function onExpansionEnd() { + content.removeEventListener("transitionend", onExpansionEnd); + if (!content.classList.contains("collapsed")) { + content.style.maxHeight = ""; + content.style.overflow = "visible"; + } + }, { once: true }); + }); + } else { + const currentHeight = content.scrollHeight; + content.style.maxHeight = `${currentHeight}px`; + content.style.overflow = "hidden"; + requestAnimationFrame(() => { + content.style.maxHeight = "0px"; + content.style.opacity = "0"; + content.style.paddingTop = "0"; + content.style.paddingBottom = "0"; + content.classList.add("collapsed"); + }); + } + } +}; +var modalManager = new ModalManager(); +var uiPatterns = new UIPatterns(); + +// frontend/js/services/api.js +var APIClientError = class extends Error { + constructor(message, status, code, rawMessageFromServer) { + super(message); + this.name = "APIClientError"; + this.status = status; + this.code = code; + this.rawMessageFromServer = rawMessageFromServer; + } +}; +var apiPromiseCache = /* @__PURE__ */ new Map(); +async function apiFetch(url, options = {}) { + const isGetRequest = !options.method || options.method.toUpperCase() === "GET"; + const cacheKey = isGetRequest && !options.noCache ? url : null; + if (cacheKey && apiPromiseCache.has(cacheKey)) { + return apiPromiseCache.get(cacheKey); + } + const token = localStorage.getItem("bearerToken"); + const headers = { + "Content-Type": "application/json", + ...options.headers + }; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + const requestPromise = (async () => { + try { + const response = await fetch(url, { ...options, headers }); + if (response.status === 401) { + if (cacheKey) apiPromiseCache.delete(cacheKey); + localStorage.removeItem("bearerToken"); + if (window.location.pathname !== "/login") { + window.location.href = "/login?error=\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u3002"; + } + throw new APIClientError("Unauthorized", 401, "UNAUTHORIZED", "Session expired or token is invalid."); + } + if (!response.ok) { + let errorData = null; + let rawMessage = ""; + try { + rawMessage = await response.text(); + if (rawMessage) { + errorData = JSON.parse(rawMessage); + } + } catch (e) { + errorData = { error: { code: "UNKNOWN_FORMAT", message: rawMessage || response.statusText } }; + } + const code = errorData?.error?.code || "UNKNOWN_ERROR"; + const messageFromServer = errorData?.error?.message || rawMessage || "No message provided by server."; + const error = new APIClientError( + `API request failed: ${response.status}`, + response.status, + code, + messageFromServer + ); + throw error; + } + return response; + } catch (error) { + if (cacheKey) apiPromiseCache.delete(cacheKey); + throw error; + } + })(); + if (cacheKey) { + apiPromiseCache.set(cacheKey, requestPromise); + } + return requestPromise; +} +async function apiFetchJson(url, options = {}) { + try { + const response = await apiFetch(url, options); + const clonedResponse = response.clone(); + const jsonData = await clonedResponse.json(); + return jsonData; + } catch (error) { + throw error; + } +} + +export { + modalManager, + uiPatterns, + apiFetch, + apiFetchJson +}; diff --git a/web/static/js/chunk-EZAP7GR4.js b/web/static/js/chunk-U67KAGZP.js similarity index 63% rename from web/static/js/chunk-EZAP7GR4.js rename to web/static/js/chunk-U67KAGZP.js index 509869a..54f0bcd 100644 --- a/web/static/js/chunk-EZAP7GR4.js +++ b/web/static/js/chunk-U67KAGZP.js @@ -111,281 +111,6 @@ var CustomSelect = class _CustomSelect { } }; -// frontend/js/components/ui.js -var ModalManager = class { - /** - * Shows a generic modal by its ID. - * @param {string} modalId The ID of the modal element to show. - */ - show(modalId) { - const modal = document.getElementById(modalId); - if (modal) { - modal.classList.remove("hidden"); - } else { - console.error(`Modal with ID "${modalId}" not found.`); - } - } - /** - * Hides a generic modal by its ID. - * @param {string} modalId The ID of the modal element to hide. - */ - hide(modalId) { - const modal = document.getElementById(modalId); - if (modal) { - modal.classList.add("hidden"); - } else { - console.error(`Modal with ID "${modalId}" not found.`); - } - } - /** - * Shows a confirmation dialog. This is a versatile method for 'Are you sure?' style prompts. - * It dynamically sets the title, message, and confirm action for a generic confirmation modal. - * @param {object} options - The options for the confirmation modal. - * @param {string} options.modalId - The ID of the confirmation modal element (e.g., 'resetModal', 'deleteConfirmModal'). - * @param {string} options.title - The title to display in the modal header. - * @param {string} options.message - The message to display in the modal body. Can contain HTML. - * @param {function} options.onConfirm - The callback function to execute when the confirm button is clicked. - * @param {boolean} [options.disableConfirm=false] - Whether the confirm button should be initially disabled. - */ - showConfirm({ modalId, title, message, onConfirm, disableConfirm = false }) { - const modalElement = document.getElementById(modalId); - if (!modalElement) { - console.error(`Confirmation modal with ID "${modalId}" not found.`); - return; - } - const titleElement = modalElement.querySelector('[id$="ModalTitle"]'); - const messageElement = modalElement.querySelector('[id$="ModalMessage"]'); - const confirmButton = modalElement.querySelector('[id^="confirm"]'); - if (!titleElement || !messageElement || !confirmButton) { - console.error(`Modal "${modalId}" is missing required child elements (title, message, or confirm button).`); - return; - } - titleElement.textContent = title; - messageElement.innerHTML = message; - confirmButton.disabled = disableConfirm; - const newConfirmButton = confirmButton.cloneNode(true); - confirmButton.parentNode.replaceChild(newConfirmButton, confirmButton); - newConfirmButton.onclick = () => onConfirm(); - this.show(modalId); - } - /** - * Shows a result modal to indicate the outcome of an operation (success or failure). - * @param {boolean} success - If true, displays a success icon and title; otherwise, shows failure indicators. - * @param {string|Node} message - The message to display. Can be a simple string or a complex DOM Node for rich content. - * @param {boolean} [autoReload=false] - If true, the page will automatically reload when the modal is closed. - */ - showResult(success, message, autoReload = false) { - const modalElement = document.getElementById("resultModal"); - if (!modalElement) { - console.error("Result modal with ID 'resultModal' not found."); - return; - } - const titleElement = document.getElementById("resultModalTitle"); - const messageElement = document.getElementById("resultModalMessage"); - const iconElement = document.getElementById("resultIcon"); - const confirmButton = document.getElementById("resultModalConfirmBtn"); - if (!titleElement || !messageElement || !iconElement || !confirmButton) { - console.error("Result modal is missing required child elements."); - return; - } - titleElement.textContent = success ? "\u64CD\u4F5C\u6210\u529F" : "\u64CD\u4F5C\u5931\u8D25"; - if (success) { - iconElement.innerHTML = ''; - iconElement.className = "text-6xl mb-3 text-success-500"; - } else { - iconElement.innerHTML = ''; - iconElement.className = "text-6xl mb-3 text-danger-500"; - } - messageElement.innerHTML = ""; - if (typeof message === "string") { - const messageDiv = document.createElement("div"); - messageDiv.innerText = message; - messageElement.appendChild(messageDiv); - } else if (message instanceof Node) { - messageElement.appendChild(message); - } else { - const messageDiv = document.createElement("div"); - messageDiv.innerText = String(message); - messageElement.appendChild(messageDiv); - } - confirmButton.onclick = () => this.closeResult(autoReload); - this.show("resultModal"); - } - /** - * Closes the result modal. - * @param {boolean} [reload=false] - If true, reloads the page after closing the modal. - */ - closeResult(reload = false) { - this.hide("resultModal"); - if (reload) { - location.reload(); - } - } - /** - * Shows and initializes the progress modal for long-running operations. - * @param {string} title - The title to display for the progress modal. - */ - showProgress(title) { - const modal = document.getElementById("progressModal"); - if (!modal) { - console.error("Progress modal with ID 'progressModal' not found."); - return; - } - const titleElement = document.getElementById("progressModalTitle"); - const statusText = document.getElementById("progressStatusText"); - const progressBar = document.getElementById("progressBar"); - const progressPercentage = document.getElementById("progressPercentage"); - const progressLog = document.getElementById("progressLog"); - const closeButton = document.getElementById("progressModalCloseBtn"); - const closeIcon = document.getElementById("closeProgressModalBtn"); - if (!titleElement || !statusText || !progressBar || !progressPercentage || !progressLog || !closeButton || !closeIcon) { - console.error("Progress modal is missing required child elements."); - return; - } - titleElement.textContent = title; - statusText.textContent = "\u51C6\u5907\u5F00\u59CB..."; - progressBar.style.width = "0%"; - progressPercentage.textContent = "0%"; - progressLog.innerHTML = ""; - closeButton.disabled = true; - closeIcon.disabled = true; - this.show("progressModal"); - } - /** - * Updates the progress bar and status text within the progress modal. - * @param {number} processed - The number of items that have been processed. - * @param {number} total - The total number of items to process. - * @param {string} status - The current status message to display. - */ - updateProgress(processed, total, status) { - const modal = document.getElementById("progressModal"); - if (!modal || modal.classList.contains("hidden")) return; - const progressBar = document.getElementById("progressBar"); - const progressPercentage = document.getElementById("progressPercentage"); - const statusText = document.getElementById("progressStatusText"); - const closeButton = document.getElementById("progressModalCloseBtn"); - const closeIcon = document.getElementById("closeProgressModalBtn"); - const percentage = total > 0 ? Math.round(processed / total * 100) : 0; - progressBar.style.width = `${percentage}%`; - progressPercentage.textContent = `${percentage}%`; - statusText.textContent = status; - if (processed === total) { - closeButton.disabled = false; - closeIcon.disabled = false; - } - } - /** - * Adds a log entry to the progress modal's log area. - * @param {string} message - The log message to append. - * @param {boolean} [isError=false] - If true, styles the log entry as an error. - */ - addProgressLog(message, isError = false) { - const progressLog = document.getElementById("progressLog"); - if (!progressLog) return; - const logEntry = document.createElement("div"); - logEntry.textContent = message; - logEntry.className = isError ? "text-danger-600" : "text-gray-700"; - progressLog.appendChild(logEntry); - progressLog.scrollTop = progressLog.scrollHeight; - } - /** - * Closes the progress modal. - * @param {boolean} [reload=false] - If true, reloads the page after closing. - */ - closeProgress(reload = false) { - this.hide("progressModal"); - if (reload) { - location.reload(); - } - } -}; -var UIPatterns = class { - /** - * Animates numerical values in elements from 0 to their target number. - * The target number is read from the element's text content. - * @param {string} selector - The CSS selector for the elements to animate (e.g., '.stat-value'). - * @param {number} [duration=1500] - The duration of the animation in milliseconds. - */ - animateCounters(selector = ".stat-value", duration = 1500) { - const statValues = document.querySelectorAll(selector); - statValues.forEach((valueElement) => { - const finalValue = parseInt(valueElement.textContent, 10); - if (isNaN(finalValue)) return; - if (!valueElement.dataset.originalValue) { - valueElement.dataset.originalValue = valueElement.textContent; - } - let startValue = 0; - const startTime = performance.now(); - const updateCounter = (currentTime) => { - const elapsedTime = currentTime - startTime; - if (elapsedTime < duration) { - const progress = elapsedTime / duration; - const easeOutValue = 1 - Math.pow(1 - progress, 3); - const currentValue = Math.floor(easeOutValue * finalValue); - valueElement.textContent = currentValue; - requestAnimationFrame(updateCounter); - } else { - valueElement.textContent = valueElement.dataset.originalValue; - } - }; - requestAnimationFrame(updateCounter); - }); - } - /** - * Toggles the visibility of a content section with a smooth height animation. - * It expects a specific HTML structure where the header and content are within a common parent (e.g., a card). - * The content element should have a `collapsed` class when hidden. - * @param {HTMLElement} header - The header element that was clicked to trigger the toggle. - */ - toggleSection(header) { - const card = header.closest(".stats-card"); - if (!card) return; - const content = card.querySelector(".key-content"); - const toggleIcon = header.querySelector(".toggle-icon"); - if (!content || !toggleIcon) { - console.error("Toggle section failed: Content or icon element not found.", { header }); - return; - } - const isCollapsed = content.classList.contains("collapsed"); - toggleIcon.classList.toggle("collapsed", !isCollapsed); - if (isCollapsed) { - content.classList.remove("collapsed"); - content.style.maxHeight = null; - content.style.opacity = null; - content.style.paddingTop = null; - content.style.paddingBottom = null; - content.style.overflow = "hidden"; - requestAnimationFrame(() => { - const targetHeight = content.scrollHeight; - content.style.maxHeight = `${targetHeight}px`; - content.style.opacity = "1"; - content.style.paddingTop = "1rem"; - content.style.paddingBottom = "1rem"; - content.addEventListener("transitionend", function onExpansionEnd() { - content.removeEventListener("transitionend", onExpansionEnd); - if (!content.classList.contains("collapsed")) { - content.style.maxHeight = ""; - content.style.overflow = "visible"; - } - }, { once: true }); - }); - } else { - const currentHeight = content.scrollHeight; - content.style.maxHeight = `${currentHeight}px`; - content.style.overflow = "hidden"; - requestAnimationFrame(() => { - content.style.maxHeight = "0px"; - content.style.opacity = "0"; - content.style.paddingTop = "0"; - content.style.paddingBottom = "0"; - content.classList.add("collapsed"); - }); - } - } -}; -var modalManager = new ModalManager(); -var uiPatterns = new UIPatterns(); - // frontend/js/components/taskCenter.js var TaskCenterManager = class { constructor() { @@ -810,8 +535,6 @@ var toastManager = new ToastManager(); export { CustomSelect, - modalManager, - uiPatterns, taskCenterManager, toastManager }; diff --git a/web/static/js/keys-2IUHJHHE.js b/web/static/js/keys-KZSAIVIM.js similarity index 99% rename from web/static/js/keys-2IUHJHHE.js rename to web/static/js/keys-KZSAIVIM.js index 245f6c3..1940925 100644 --- a/web/static/js/keys-2IUHJHHE.js +++ b/web/static/js/keys-KZSAIVIM.js @@ -1,9 +1,8 @@ import { CustomSelect, - modalManager, taskCenterManager, toastManager -} from "./chunk-EZAP7GR4.js"; +} from "./chunk-U67KAGZP.js"; import { debounce, escapeHTML, @@ -11,8 +10,9 @@ import { } from "./chunk-A4OOMLXK.js"; import { apiFetch, - apiFetchJson -} from "./chunk-PLQL6WIO.js"; + apiFetchJson, + modalManager +} from "./chunk-SHK62ZJN.js"; import "./chunk-JSBRDJBE.js"; // frontend/js/components/tagInput.js diff --git a/web/static/js/logs-YPEOUZVC.js b/web/static/js/logs-WRHCX7IP.js similarity index 94% rename from web/static/js/logs-YPEOUZVC.js rename to web/static/js/logs-WRHCX7IP.js index 1fe39f9..76e37c4 100644 --- a/web/static/js/logs-YPEOUZVC.js +++ b/web/static/js/logs-WRHCX7IP.js @@ -3,8 +3,9 @@ import { escapeHTML } from "./chunk-A4OOMLXK.js"; import { - apiFetchJson -} from "./chunk-PLQL6WIO.js"; + apiFetchJson, + modalManager +} from "./chunk-SHK62ZJN.js"; import { __commonJS, __toESM @@ -1767,7 +1768,7 @@ var FilterPopover = class { } _createPopoverHTML() { this.popoverElement = document.createElement("div"); - this.popoverElement.className = "hidden z-50 min-w-[12rem] rounded-md border bg-popover bg-white dark:bg-zinc-800 p-2 text-popover-foreground shadow-md"; + this.popoverElement.className = "hidden z-50 min-w-[12rem] rounded-md border-1 border-zinc-500/30 bg-popover bg-white dark:bg-zinc-900 p-2 text-popover-foreground shadow-md"; this.popoverElement.innerHTML = `
${this.title}
@@ -2103,6 +2104,132 @@ function initBatchActions(logsPage) { // frontend/js/pages/logs/index.js var import_flatpickr = __toESM(require_flatpickr()); + +// frontend/js/pages/logs/logSettingsModal.js +var LogSettingsModal = class { + constructor({ onSave }) { + this.modalId = "log-settings-modal"; + this.onSave = onSave; + const modal = document.getElementById(this.modalId); + if (!modal) { + throw new Error(`Modal with id "${this.modalId}" not found.`); + } + this.elements = { + modal, + title: document.getElementById("log-settings-modal-title"), + saveBtn: document.getElementById("log-settings-save-btn"), + logLevelSelect: document.getElementById("log-level-select"), + cleanupEnableToggle: document.getElementById("log-cleanup-enable"), + cleanupSettingsPanel: document.getElementById("log-cleanup-settings"), + cleanupRetentionInput: document.getElementById("log-cleanup-retention-days"), + retentionDaysGroup: document.getElementById("retention-days-group"), + retentionPresetBtns: document.querySelectorAll("#retention-days-group button[data-days]"), + cleanupExecTimeInput: document.getElementById("log-cleanup-exec-time") + // [NEW] 添加时间选择器元素 + }; + this.activePresetClasses = ["!bg-primary", "!text-primary-foreground", "!border-primary", "hover:!bg-primary/90"]; + this.inactivePresetClasses = ["modal-btn-secondary"]; + this._initEventListeners(); + } + open(settingsData = {}) { + this._populateForm(settingsData); + modalManager.show(this.modalId); + } + close() { + modalManager.hide(this.modalId); + } + _initEventListeners() { + this.elements.saveBtn.addEventListener("click", this._handleSave.bind(this)); + this.elements.cleanupEnableToggle.addEventListener("change", (e) => { + this.elements.cleanupSettingsPanel.classList.toggle("hidden", !e.target.checked); + }); + this._initRetentionPresets(); + const closeAction = () => this.close(); + const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`); + closeTriggers.forEach((trigger) => trigger.addEventListener("click", closeAction)); + this.elements.modal.addEventListener("click", (event) => { + if (event.target === this.elements.modal) closeAction(); + }); + } + _initRetentionPresets() { + this.elements.retentionPresetBtns.forEach((btn) => { + btn.addEventListener("click", () => { + const days = btn.dataset.days; + this.elements.cleanupRetentionInput.value = days; + this._updateActivePresetButton(days); + }); + }); + this.elements.cleanupRetentionInput.addEventListener("input", (e) => { + this._updateActivePresetButton(e.target.value); + }); + } + _updateActivePresetButton(currentValue) { + this.elements.retentionPresetBtns.forEach((btn) => { + if (btn.dataset.days === currentValue) { + btn.classList.remove(...this.inactivePresetClasses); + btn.classList.add(...this.activePresetClasses); + } else { + btn.classList.remove(...this.activePresetClasses); + btn.classList.add(...this.inactivePresetClasses); + } + }); + } + async _handleSave() { + const data = this._collectFormData(); + if (data.auto_cleanup.enabled && (!data.auto_cleanup.retention_days || data.auto_cleanup.retention_days <= 0)) { + alert("\u542F\u7528\u81EA\u52A8\u6E05\u7406\u65F6\uFF0C\u4FDD\u7559\u5929\u6570\u5FC5\u987B\u662F\u5927\u4E8E0\u7684\u6570\u5B57\u3002"); + return; + } + if (this.onSave) { + this.elements.saveBtn.disabled = true; + this.elements.saveBtn.textContent = "\u4FDD\u5B58\u4E2D..."; + try { + await this.onSave(data); + this.close(); + } catch (error) { + console.error("Failed to save log settings:", error); + } finally { + this.elements.saveBtn.disabled = false; + this.elements.saveBtn.textContent = "\u4FDD\u5B58\u8BBE\u7F6E"; + } + } + } + // [MODIFIED] - 更新此方法以填充新的时间选择器 + _populateForm(data) { + this.elements.logLevelSelect.value = data.log_level || "INFO"; + const cleanup = data.auto_cleanup || {}; + const isCleanupEnabled = cleanup.enabled || false; + this.elements.cleanupEnableToggle.checked = isCleanupEnabled; + this.elements.cleanupSettingsPanel.classList.toggle("hidden", !isCleanupEnabled); + const retentionDays = cleanup.retention_days || ""; + this.elements.cleanupRetentionInput.value = retentionDays; + this._updateActivePresetButton(retentionDays.toString()); + this.elements.cleanupExecTimeInput.value = cleanup.exec_time || "04:05"; + } + // [MODIFIED] - 更新此方法以收集新的时间数据 + _collectFormData() { + const parseIntOrNull = (value) => { + const trimmed = value.trim(); + if (trimmed === "") return null; + const num = parseInt(trimmed, 10); + return isNaN(num) ? null : num; + }; + const isCleanupEnabled = this.elements.cleanupEnableToggle.checked; + const formData = { + log_level: this.elements.logLevelSelect.value, + auto_cleanup: { + enabled: isCleanupEnabled, + interval: isCleanupEnabled ? "daily" : null, + retention_days: isCleanupEnabled ? parseIntOrNull(this.elements.cleanupRetentionInput.value) : null, + exec_time: isCleanupEnabled ? this.elements.cleanupExecTimeInput.value : "04:05" + // [NEW] 收集时间数据 + } + }; + return formData; + } +}; + +// frontend/js/pages/logs/index.js var dataStore = { groups: /* @__PURE__ */ new Map(), keys: /* @__PURE__ */ new Map() @@ -2133,7 +2260,8 @@ var LogsPage = class { errorFilters: document.getElementById("error-logs-filters"), systemControls: document.getElementById("system-logs-controls"), errorTemplate: document.getElementById("error-logs-template"), - systemTemplate: document.getElementById("system-logs-template") + systemTemplate: document.getElementById("system-logs-template"), + settingsBtn: document.querySelector('button[aria-label="\u65E5\u5FD7\u8BBE\u7F6E"]') }; this.initialized = !!this.elements.contentContainer; if (this.initialized) { @@ -2142,15 +2270,79 @@ var LogsPage = class { this.debouncedLoadAndRender = debounce(() => this.loadAndRenderLogs(), 300); this.fp = null; this.themeObserver = null; + this.settingsModal = null; + this.currentSettings = {}; } } async init() { if (!this.initialized) return; this._initPermanentEventListeners(); + await this.loadCurrentSettings(); + this._initSettingsModal(); await this.loadGroupsOnce(); this.state.currentView = null; this.switchToView("error"); } + _initSettingsModal() { + if (!this.elements.settingsBtn) return; + this.settingsModal = new LogSettingsModal({ + onSave: this.handleSaveSettings.bind(this) + }); + this.elements.settingsBtn.addEventListener("click", () => { + const settingsForModal = { + log_level: this.currentSettings.log_level, + auto_cleanup: { + enabled: this.currentSettings.log_auto_cleanup_enabled, + retention_days: this.currentSettings.log_auto_cleanup_retention_days, + exec_time: this.currentSettings.log_auto_cleanup_time, + interval: "daily" + } + }; + this.settingsModal.open(settingsForModal); + }); + } + async loadCurrentSettings() { + try { + const { success, data } = await apiFetchJson("/admin/settings"); + if (success) { + this.currentSettings = data; + } else { + console.error("Failed to load settings from server."); + this.currentSettings = { log_auto_cleanup_time: "04:05" }; + } + } catch (error) { + console.error("Failed to load log settings:", error); + this.currentSettings = { log_auto_cleanup_time: "04:05" }; + } + } + async handleSaveSettings(settingsData) { + const partialPayload = { + "log_level": settingsData.log_level, + "log_auto_cleanup_enabled": settingsData.auto_cleanup.enabled, + "log_auto_cleanup_time": settingsData.auto_cleanup.exec_time + }; + if (settingsData.auto_cleanup.enabled) { + let retentionDays = settingsData.auto_cleanup.retention_days; + if (retentionDays === null || retentionDays <= 0) { + retentionDays = 30; + } + partialPayload.log_auto_cleanup_retention_days = retentionDays; + } + console.log("Sending PARTIAL settings update to /admin/settings:", partialPayload); + try { + const { success, message } = await apiFetchJson("/admin/settings", { + method: "PUT", + body: JSON.stringify(partialPayload) + }); + if (!success) { + throw new Error(message || "Failed to save settings"); + } + Object.assign(this.currentSettings, partialPayload); + } catch (error) { + console.error("Error saving log settings:", error); + throw error; + } + } _initPermanentEventListeners() { this.elements.tabsContainer.addEventListener("click", (event) => { const tabItem = event.target.closest("[data-tab-target]"); @@ -2574,7 +2766,7 @@ var LogsPage = class { } async enrichLogsWithKeyNames(logs) { const missingKeyIds = [...new Set( - logs.filter((log) => log.KeyID && !dataStore.keys.has(log.KeyID)).map((log) => log.ID) + logs.filter((log) => log.KeyID && !dataStore.keys.has(log.KeyID)).map((log) => log.KeyID) )]; if (missingKeyIds.length === 0) return; try { diff --git a/web/static/js/main.js b/web/static/js/main.js index 5ff4b58..268252e 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -1,14 +1,14 @@ import { CustomSelect, - modalManager, taskCenterManager, - toastManager, - uiPatterns -} from "./chunk-EZAP7GR4.js"; + toastManager +} from "./chunk-U67KAGZP.js"; import { apiFetch, - apiFetchJson -} from "./chunk-PLQL6WIO.js"; + apiFetchJson, + modalManager, + uiPatterns +} from "./chunk-SHK62ZJN.js"; import "./chunk-JSBRDJBE.js"; // frontend/js/components/slidingTabs.js @@ -181,8 +181,8 @@ var pageModules = { // 键 'dashboard' 对应一个函数,该函数调用 import() 返回一个 Promise // esbuild 看到这个 import() 语法,就会自动将 dashboard.js 及其依赖打包成一个独立的 chunk 文件 "dashboard": () => import("./dashboard-XFUWX3IN.js"), - "keys": () => import("./keys-2IUHJHHE.js"), - "logs": () => import("./logs-YPEOUZVC.js") + "keys": () => import("./keys-KZSAIVIM.js"), + "logs": () => import("./logs-WRHCX7IP.js") // 'settings': () => import('./pages/settings.js'), // 未来启用 settings 页面 // 未来新增的页面,只需在这里添加一行映射,esbuild会自动处理 }; diff --git a/web/templates/logs.html b/web/templates/logs.html index cf1f20b..def6b82 100644 --- a/web/templates/logs.html +++ b/web/templates/logs.html @@ -210,7 +210,106 @@ {% endblock %} {% block modals %} - + + + {% endblock modals %} {% block page_scripts %}