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 = `
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 %}