Update: Js 4 Log.html 80%

This commit is contained in:
XOF
2025-11-26 20:36:25 +08:00
parent 01c9b34600
commit c86e7a7ba4
17 changed files with 1120 additions and 473 deletions

View File

@@ -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;
/* --- 核心结构样式 --- */

View File

@@ -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">

View File

@@ -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();
}
}

View 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;
}
}

View File

@@ -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 != ""

View File

@@ -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

View File

@@ -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

View File

@@ -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.")
}

View File

@@ -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
}

View File

@@ -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;
}
}
}

View File

@@ -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
};

View 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
};

View File

@@ -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
};

View File

@@ -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

View File

@@ -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 {

View File

@@ -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会自动处理
};

View File

@@ -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 %}