Update: Js 4 Log.html 80%
This commit is contained in:
@@ -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;
|
||||
/* --- 核心结构样式 --- */
|
||||
|
||||
@@ -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 = `
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">${this.title}</div>
|
||||
<div class="space-y-1 p-1">
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
148
frontend/js/pages/logs/logSettingsModal.js
Normal file
148
frontend/js/pages/logs/logSettingsModal.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 != ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
360
web/static/js/chunk-SHK62ZJN.js
Normal file
360
web/static/js/chunk-SHK62ZJN.js
Normal file
@@ -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 = '<i class="fas fa-check-circle text-success-500"></i>';
|
||||
iconElement.className = "text-6xl mb-3 text-success-500";
|
||||
} else {
|
||||
iconElement.innerHTML = '<i class="fas fa-times-circle text-danger-500"></i>';
|
||||
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
|
||||
};
|
||||
@@ -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 = '<i class="fas fa-check-circle text-success-500"></i>';
|
||||
iconElement.className = "text-6xl mb-3 text-success-500";
|
||||
} else {
|
||||
iconElement.innerHTML = '<i class="fas fa-times-circle text-danger-500"></i>';
|
||||
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
|
||||
};
|
||||
@@ -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
|
||||
@@ -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 = `
|
||||
<div class="px-2 py-1.5 text-sm font-semibold">${this.title}</div>
|
||||
<div class="space-y-1 p-1">
|
||||
@@ -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 {
|
||||
@@ -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会自动处理
|
||||
};
|
||||
|
||||
@@ -210,7 +210,106 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block modals %}
|
||||
<!-- 日志详情模态框将在此处定义 -->
|
||||
<!-- [MODIFIED] 日志系统参数管理模态框 -->
|
||||
<div id="log-settings-modal" class="modal-overlay hidden">
|
||||
<div class="modal-panel max-w-2xl max-h-[90vh]">
|
||||
<!-- Header -->
|
||||
<div class="modal-header shrink-0">
|
||||
<h2 id="log-settings-modal-title" class="modal-title">日志系统参数管理</h2>
|
||||
<button data-modal-close="log-settings-modal" class="modal-close-btn">
|
||||
<i class="fas fa-times text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form Body -->
|
||||
<div class="modal-body flex-grow overflow-y-auto pr-4 -mr-4">
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- 1. 系统日志级别 (修改为单行) -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label for="log-level-select" class="flex items-center modal-label font-semibold">
|
||||
<span>系统日志级别</span>
|
||||
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="设置后台服务的日志输出详细程度。DEBUG最详细,ERROR最精简。更改后实时生效。"></i>
|
||||
</label>
|
||||
<p class="modal-label-description">控制后台系统日志的输出级别。</p>
|
||||
</div>
|
||||
<select id="log-level-select" class="modal-input w-40">
|
||||
<option value="DEBUG">DEBUG (调试)</option>
|
||||
<option value="INFO">INFO (信息)</option>
|
||||
<option value="WARNING">WARNING (警告)</option>
|
||||
<option value="ERROR">ERROR (错误)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 2. 请求日志自动清理 (修改为单行和新组件) -->
|
||||
<div class="border-t border-border pt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label for="log-cleanup-enable" class="modal-label flex-grow flex items-center font-semibold">
|
||||
<span>开启请求日志自动清理</span>
|
||||
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="启用后,系统将按计划自动删除旧的请求日志,以节省存储空间。"></i>
|
||||
</label>
|
||||
<p class="modal-label-description">定期清理超时的请求日志记录。</p>
|
||||
</div>
|
||||
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" name="log-cleanup-enable" id="log-cleanup-enable" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer">
|
||||
<label for="log-cleanup-enable" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible settings for cleanup -->
|
||||
<div id="log-cleanup-settings" class="mt-4 hidden">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center modal-label">
|
||||
<span>日志保留天数</span>
|
||||
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="只保留最近 N 天的日志,早于此时间的日志将被删除。"></i>
|
||||
</label>
|
||||
<div id="retention-days-group" class="flex items-center space-x-2">
|
||||
<button type="button" data-days="1" class="modal-btn modal-btn-secondary px-3 py-1 text-sm">1天</button>
|
||||
<button type="button" data-days="7" class="modal-btn modal-btn-secondary px-3 py-1 text-sm">7天</button>
|
||||
<button type="button" data-days="30" class="modal-btn modal-btn-secondary px-3 py-1 text-sm">30天</button>
|
||||
<input type="number" id="log-cleanup-retention-days" class="modal-input w-28" placeholder="自定义天数">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- [NEW] 每日清理执行时间 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center modal-label">
|
||||
<span>每日清理执行时间</span>
|
||||
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="自动清理任务在每天的这个时间点执行(基于服务器的UTC时间)。"></i>
|
||||
</label>
|
||||
<input type="time" id="log-cleanup-exec-time" class="modal-input w-28">
|
||||
</div>
|
||||
|
||||
<!-- 3. 日志备份 (待定) -->
|
||||
<div class="border-t border-border pt-6 opacity-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label for="log-backup-enable" class="modal-label flex-grow flex items-center font-semibold">
|
||||
<span>开启日志自动备份 (功能待定)</span>
|
||||
<i class="fas fa-question-circle tooltip-icon" data-tooltip-text="此功能正在开发中。启用后,系统可将日志备份到指定位置。"></i>
|
||||
</label>
|
||||
<p class="modal-label-description">将系统和请求日志备份到指定路径。</p>
|
||||
</div>
|
||||
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" name="log-backup-enable" id="log-backup-enable" class="toggle-checkbox" disabled>
|
||||
<label for="log-backup-enable" class="toggle-label"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-footer shrink-0">
|
||||
<button data-modal-close="log-settings-modal" class="modal-btn modal-btn-secondary">取消</button>
|
||||
<button id="log-settings-save-btn" class="modal-btn modal-btn-primary">保存设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock modals %}
|
||||
|
||||
{% block page_scripts %}
|
||||
|
||||
Reference in New Issue
Block a user