commit f28bdc751fa8e60b9b9114602a1756cf202ef96a Author: xofine Date: Thu Nov 20 12:24:05 2025 +0800 New diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..b61d0f9 --- /dev/null +++ b/.air.toml @@ -0,0 +1,37 @@ +# .air.toml + +# [_meta] 部分是为了让IDE(如VSCode)知道这是TOML文件,可选 +[_meta] + "version" = "v1.49.0" + +# 工作目录,"." 表示项目根目录 +root = "." +# 临时文件目录,air会在这里生成可执行文件 +tmp_dir = "tmp" + +[build] + # 编译的入口文件,请确保路径与您的项目结构一致 + cmd = "go build -o ./tmp/main.exe ./cmd/server" + # cmd = "cmd /c build.bat" + # 编译后生成的可执行文件 + bin = "tmp/main.exe" + # 监视以下后缀名的文件,一旦变动就触发重新编译 + # include_ext = ["go", "tpl", "tmpl", "html", "css", "js"] + include_ext = ["go", "tpl", "tmpl", "html"] + # 排除以下目录,避免不必要地重载 + exclude_dir = ["assets", "tmp", "vendor", "web/static/images"] + # 发生构建错误时,只打印日志而不退出 + stop_on_error = false + # 给构建日志加上一个漂亮的前缀,方便识别。 + log = "air-build.log" + # 增加一点延迟,防止文件系统在保存瞬间触发多次事件。 + delay = 1000 # ms + +[log] + # 日志输出带时间 + time = true + color = true + +[misc] + # 删除临时文件 + clean_on_exit = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a771705 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,26 @@ +# [文本统一规范] + +* text=auto + +*.go text +*.js text +*.css text +*.html text +*.json text +*.md text +*.xml text +*.yml text +*.yaml text +Dockerfile text +Makefile text +go.mod text +go.sum text + +*.sh text eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.wasm binary \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4832f39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.vscode/ +.idea/ + +*.code-workspace + +*.db +*.bat +.env \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..6a40af4 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "gemini-balancer/internal/app" + "gemini-balancer/internal/container" + "log" +) + +func main() { + cont, err := container.BuildContainer() + if err != nil { + log.Fatalf("FATAL: Failed to build dependency container: %v", err) + } + err = cont.Invoke(func(application *app.App) error { + return application.Run() + }) + if err != nil { + log.Fatalf("FATAL: Error during application execution: %v", err) + } +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..afa24c5 --- /dev/null +++ b/config.yaml @@ -0,0 +1,25 @@ +# 数据库配置 +database: + # 类型: sqlite, postgres, mysql + type: "sqlite" + # 数据源名称 (DSN) + # 对于 sqlite, 这只是一个文件路径 + dsn: "gemini-balancer.db" + +# 服务器配置 +server: + port: "9000" + +# 日志级别 +log: + level: "info" + +redis: + dsn: "redis://localhost:6379/0" + +session_secret: "a-very-long-and-super-secure-random-string-for-session-encryption-change-this" # [ADD] + +# [NEW] The master key for encrypting API keys. +# MUST be 32 bytes (64 hex characters). +# It is STRONGLY RECOMMENDED to set this via an environment variable instead (ENCRYPTION_KEY). +encryption_key: "c7e0b5f8a2d1c9e8b3a4f5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6" \ No newline at end of file diff --git a/frontend/input.css b/frontend/input.css new file mode 100644 index 0000000..59b9694 --- /dev/null +++ b/frontend/input.css @@ -0,0 +1,753 @@ +/* static/css/input.css */ +@import "tailwindcss"; + +/* =================================================================== */ +/* [核心] 定义 shadcn/ui 的设计系统变量 */ +/* =================================================================== */ +@layer base { + /* 亮色模式 */ + :root { + --background: theme(colors.white); + --foreground: theme(colors.zinc.900); + + --muted: theme(colors.zinc.100); + --muted-foreground: theme(colors.zinc.500); + + --primary: theme(colors.blue.600); + --primary-foreground: theme(colors.white); + + --secondary: theme(colors.zinc.200); + --secondary-foreground: theme(colors.zinc.900); + + --destructive: theme(colors.red.600); + --destructive-foreground: theme(colors.white); + --accent: theme(colors.zinc.100); + --accent-foreground: theme(colors.zinc.900); + + --border: theme(colors.zinc.300); /* 统一使用 zinc 以保持一致性 */ + --input: theme(colors.zinc.300); + --ring: theme(colors.blue.500); + --radius: 0.5rem; + } + + /* 暗色模式 */ + .dark { + --background: theme(colors.zinc.900); + --foreground: theme(colors.zinc.100); + + --muted: theme(colors.zinc.900 / 0.5); + --muted-foreground: theme(colors.zinc.400); + + --primary: theme(colors.blue.600); /* 亮色和暗色模式下的主色通常保持一致 */ + --primary-foreground: theme(colors.white); + + --secondary: theme(colors.zinc.900); + --secondary-foreground: theme(colors.zinc.100); + + --destructive: theme(colors.red.700); /* 暗色模式下可以稍暗一些以保持对比度 */ + --destructive-foreground: theme(colors.white); + + --accent: theme(colors.zinc.700 / 0.6); /* 示例: dark:border-zinc-700/60 */ + --accent-foreground: theme(colors.zinc.100); + --border: theme(colors.zinc.900); + --input: theme(colors.zinc.700); + --ring: theme(colors.blue.500); + } +} + +/* =================================================================== */ +/* [核心] shadcn/ui 组件层定义 */ +/* =================================================================== */ +@layer components { + /* --- 按钮 (Button) --- */ + .btn { + @apply inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors + focus-visible:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[(var(--ring))] + disabled:pointer-events-none disabled:opacity-50; + } + .btn-primary { + @apply bg-primary text-primary-foreground hover:bg-primary/90; + } + .btn-secondary { + @apply bg-secondary text-secondary-foreground hover:bg-secondary/80; + } + .btn-destructive { + @apply bg-destructive text-destructive-foreground hover:bg-destructive/90; + } + .btn-outline { + @apply border border-input bg-background hover:bg-accent hover:text-accent-foreground; + } + .btn-ghost { + @apply hover:bg-accent hover:text-accent-foreground; + } + .btn-link { + @apply text-primary underline-offset-4 hover:underline; + } + + /* 按钮尺寸变体 */ + .btn-lg { @apply h-11 rounded-md px-8; } + .btn-md { @apply h-10 px-4 py-2; } + .btn-sm { @apply h-9 rounded-md px-3; } + .btn-icon { @apply h-10 w-10; } + /* --- 输入框 (Input) --- */ + .input { + @apply flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm + file:border-0 file:bg-transparent file:text-sm file:font-medium + placeholder:text-muted-foreground + focus-visible:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[(var(--ring))] + disabled:cursor-not-allowed disabled:opacity-50; + } +} + +@custom-variant dark (&:where(.dark, .dark *)); + +/* =================================================================== */ +/* [核心] 主题定义层 (Theming Layer for Tailwind JIT) */ +/* =================================================================== */ +@theme { + /* 颜色: --color-KEY 会自动生成 bg-KEY, text-KEY, border-KEY 等 */ + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + /* 圆角: --radius-KEY 会生成 rounded-KEY */ + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + /* 动画 Keyframes */ + @keyframes toast-in { from { opacity: 0; transform: translateY(20px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } + @keyframes toast-out { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(20px); } } + @keyframes panel-in { from { opacity: 0; transform: translateY(-10px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } + @keyframes panel-out { from { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 0; transform: translateY(-10px) scale(0.95); } } + /* 动画工具类: --animation-KEY 生成 animate-KEY */ + --animation-toast-in: toast-in 0.4s cubic-bezier(0.21, 1.02, 0.73, 1) forwards; + --animation-toast-out: toast-out 0.3s ease-out forwards; + --animation-panel-in: panel-in 0.2s ease-out; + --animation-panel-out: panel-out 0.2s ease-out forwards; +} + +/* v4引擎已经为你生成了 animate-panel-in 等类,所以@apply可以找到它们 */ + +/* -------------------------*/ +/* ------ base.html ------- */ +/* ------------------------ */ + +/* [最终悬浮版] 细线轨道 + 宽滑块 */ +.main-content-scroll::-webkit-scrollbar { + width: 16px; /* 为轨道和滑块的交互留出足够的空间 */ +} +/* [核心] 使用渐变“画”出一条1px的细线作为轨道 */ +.main-content-scroll::-webkit-scrollbar-track { + background: transparent; /* 轨道区域完全透明 */ + background-image: linear-gradient( + to right, + transparent 7px, /* 7px 的透明空间 */ + rgba(108, 108, 108, 0.1) 7px, /* 线的开始 */ + rgba(100, 100, 100, 0.1) 8px, /* 线的结束 (1px宽) */ + transparent 8px /* 剩余的透明空间 */ + ); +} +.dark .main-content-scroll::-webkit-scrollbar-track { + background-image: linear-gradient( + to right, + transparent 7px, + rgba(0, 0, 0, 0.2) 7px, /* 暗色模式的细线 */ + rgba(0, 0, 0, 0.2) 8px, + transparent 8px + ); +} +/* 滑块比轨道线更宽,并带有光晕效果 */ +.main-content-scroll::-webkit-scrollbar-thumb { + height: 50px; /* 给滑块一个最小高度 */ + background-color: rgb(222, 222, 222); /* 浅色模式 slate-500 @ 40% */ + border-radius: 9999px; + + /* [核心] 使用透明边框让滑块宽度只有 8px (16px - 4px*2) */ + border: 5px solid transparent; + lg:border: 4px solid transparent; + background-clip: content-box; + /* 光晕效果 */ + box-shadow: inset 0 0 0 1px rgba(150, 150, 150, 0.1); + transition: all 0.2s ease-in-out; +} +.dark .main-content-scroll::-webkit-scrollbar-thumb { + background-color: #181818; /* 暗色模式 zinc-900 */ + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15); +} +/* 悬停时,滑块“变实”,颜色加深 */ +.main-content-scroll::-webkit-scrollbar-thumb:hover { + background-color: rgb(202, 202, 202); + border-width: 4px; /* [核心交互] 透明边框消失,滑块宽度变为完整的16px,产生吸附放大的效果 */ +} +.dark .main-content-scroll::-webkit-scrollbar-thumb:hover { + background-color: #151515; +} + +.shadow-main { box-shadow: 0 0 25px rgba(0, 0, 0, 0.08); } + + +@layer components { + /* 1. 父容器: group 统一写入html */ + .nav-item-wrapper { + @apply relative mx-4; /* 原始 margin: 0 1rem; -> mx-4 */ + } + /* 2. 链接本身 */ + .nav-link { + @apply flex items-baseline p-3 rounded-l-lg text-[#f4f4f5] justify-center lg:justify-start; + @apply transition-colors duration-200 ease-in-out; + /* 悬停状态 */ + @apply group-hover:bg-[rgba(63,63,70,0.8)] dark:group-hover:bg-white/10; + } + /* 3. 图标 */ + .nav-icon { + @apply w-[1.2rem] text-center; + @apply transition-all duration-300 ease-in-out; + /* 悬停和激活状态 */ + @apply group-hover:text-[#60a5fa] group-hover:[filter:drop-shadow(0_0_5px_rgba(59,130,246,0.5))]; + @apply group-data-[active='true']:text-[#60a5fa] group-data-[active='true']:[filter:drop-shadow(0_0_5px_rgba(59,130,246,0.7))]; + } + /* 4. 指示器 */ + .nav-indicator { + @apply absolute -left-4 top-0 h-full w-1 bg-[#f4f4f5] rounded-r-full pointer-events-none; + @apply opacity-0 transition-opacity duration-300 ease-in-out; + /* 激活状态 */ + @apply group-data-[active='true']:opacity-100; + } + /* 5. 像素装饰文本 */ + .pixel-decoration { + @apply font-['Pixelify_Sans'] text-[0.6rem] text-[#3ac06b] ml-2 relative top-px tracking-[0.5px]; + @apply opacity-0 [text-shadow:0_0_5px_rgba(74,222,128,0.5)]; + @apply transition-opacity duration-200 ease-in-out; + /* 悬停状态 */ + @apply group-data-[active='true']:opacity-100 group-hover:opacity-100; + } + + +/* =================================================================== */ +/* [融合版] 全局异步任务中心样式 */ +/* =================================================================== */ + +/* --- 组件 A: Toast 通知样式 (保持不变) --- */ +.toast-item { + @apply flex items-start p-3 w-full rounded-lg shadow-lg ring-1 ring-black/5 dark:ring-white/10 bg-white/80 dark:bg-zinc-800/80 backdrop-blur-md pointer-events-auto; +} +.toast-icon { + @apply flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-white mr-3; +} +.toast-icon-loading {@apply bg-blue-500;} +.toast-icon-success {@apply bg-green-500;} +.toast-icon-error {@apply bg-red-500;} +.toast-content { + @apply flex-grow; +} +.toast-title { + @apply font-semibold text-sm text-zinc-800 dark:text-zinc-100; +} +.toast-message { + @apply text-xs text-zinc-600 dark:text-zinc-400 mt-0.5; +} +.toast-close-btn { + @apply ml-4 text-zinc-400 hover:text-zinc-800 dark:hover:text-white transition-colors; +} + +/* --- [升级] 组件 B: 多阶段任务项样式 --- */ + +/* 1. 任务项主容器: 移除了 flex 布局,采用块级布局容纳复杂内容 */ +.task-list-item { + @apply flex justify-between items-start px-3 py-2 rounded-lg transition-colors duration-200 overflow-hidden border; + @apply hover:bg-black/5 dark:hover:bg-white/5; + @apply border-transparent dark:border-transparent; + @apply bg-zinc-50 dark:bg-zinc-800/50; +} + +/* --- 任务项主内容区 (左栏) --- */ +.task-item-main { + @apply flex items-center justify-between flex-grow gap-1; /* flex-grow 使其占据所有可用空间 */ +} +/* 2. 任务项头部: 包含标题和时间戳 */ +.task-item-header { + @apply flex justify-between items-center mb-2; +} +.task-item-title { + /* 融合了您原有的字体样式 */ + @apply font-medium text-sm text-zinc-700 dark:text-zinc-200; +} +.task-item-timestamp { + /* 融合了您原有的字体样式 */ + @apply text-xs self-start pt-1.5 pl-2 text-zinc-400 dark:text-zinc-500 flex-shrink-0; +} + +/* 3. [新增] 阶段动画的核心容器 */ +.task-stages-container { + @apply flex flex-col gap-1.5 overflow-hidden; +} + +/* 4. [新增] 单个阶段的样式 */ +.task-stage { + @apply flex items-center gap-2 p-1.5 rounded-md transition-all duration-300 ease-in-out relative; +} +.task-stage-icon { + @apply w-4 h-4 relative flex-shrink-0 text-zinc-400; +} +.task-stage-icon i { + @apply absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-200; +} +.task-stage-content { + @apply flex-grow flex justify-between items-baseline text-xs; +} +.task-stage-name { + @apply text-zinc-600 dark:text-zinc-400; +} +.task-stage-progress-text { + @apply font-mono text-zinc-500 dark:text-zinc-500; +} +.task-stage-progress-bg { + @apply absolute bottom-0 left-0 right-0 h-0.5 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden; +} +.task-stage-progress-bar { + @apply h-full w-0 rounded-full; +} + +/* 5. [新增] 各阶段的状态视觉 */ +/* Pending: 待处理 */ +.task-stage.stage-pending { @apply opacity-40; } +.task-stage.stage-pending .fa-circle { @apply opacity-100; } + +/* Active: 当前活动 */ +.task-stage.stage-active { + @apply opacity-100 bg-blue-500/10; +} +.task-stage.stage-active .fa-spinner { @apply opacity-100 text-blue-500; } +.task-stage.stage-active .task-stage-name { @apply font-semibold text-zinc-800 dark:text-zinc-100; } +.task-stage.stage-active .task-stage-progress-bar { @apply bg-blue-500; } + +/* Completed: 已完成 */ +.task-stage.stage-completed { @apply opacity-60; } +.task-stage.stage-completed .fa-check { @apply opacity-100 text-green-500; } +.task-stage.stage-completed .task-stage-name { @apply line-through text-zinc-500 dark:text-zinc-500; } +.task-stage.stage-completed .task-stage-progress-bar { @apply bg-green-500; } + +/* Error / Skipped: 错误或跳过 */ +.task-stage.stage-error, .task-stage.stage-skipped { @apply opacity-50 bg-red-500/5; } +.task-stage.stage-error .fa-times, .task-stage.stage-skipped .fa-times { @apply opacity-100 text-red-500; } +.task-stage.stage-error .task-stage-name, .task-stage.stage-skipped .task-stage-name { @apply line-through; } + +/* 6. [新增] 任务最终状态的容器 */ +.task-final-status { + @apply overflow-hidden text-sm; +} +.task-final-status i { + @apply text-lg; +} + +/* =================================================================== */ +/* 任务中心智能摘要卡片样式 (Tailwind @apply 统一版) */ +/* =================================================================== */ +/* --- 1. 摘要行的行内图标 --- */ +.task-item-icon-summary { + @apply text-lg mr-3; /* text-lg=18px, mr-3=12px, mt-px=1px */ +} +/* --- 2. 可折叠内容区 --- */ +.task-details-content { + @apply transition-all duration-300 ease-in-out overflow-hidden; +} +.task-details-content.collapsed { + @apply max-h-0 opacity-0 mt-0 pt-0 pb-0 border-t-0; +} +/* --- 3. 折叠区内的详情列表 --- */ +/* 注意: space-y-1 现在被移到HTML中,由父元素直接控制,更符合Tailwind用法 */ +.task-details-body { + @apply pt-2 mt-2 border-t border-black/5 text-xs text-zinc-600; + @apply dark:border-white/[.07] dark:text-zinc-400; +} +/* --- 4. 折叠/展开的雪佛兰图标 --- */ +.task-toggle-icon { + @apply transition-transform duration-300 ease-in-out text-zinc-400 flex-shrink-0 ml-2; +} +/* --- 5. 展开状态下的图标旋转 --- */ +/* + 使用 .expanded 类来控制旋转。 + 这个 .expanded 类由我们的JS代码在点击时添加到 [data-task-toggle] 元素上。 +*/ +[data-task-toggle].expanded .task-toggle-icon { + @apply rotate-180; +} + + /* -------------------------*/ + /* ------ dashboard ------- */ + /* ------------------------ */ + +/* [核心] dashboard tab 组件重构 */ + + /* 基础的标签项样式 (现在背景透明) */ + .tab-item { + /* [核心变化] 移除背景,添加 z-index 使其位于指示器之上 */ + @apply relative z-10 inline-flex items-center justify-center whitespace-nowrap rounded-md px-2 sm:px-5 py-1.5 text-sm font-medium transition-colors duration-200; + + /* 默认/非激活状态的文字颜色 */ + @apply text-zinc-200 dark:text-zinc-400; + + /* 悬停时的文字颜色 */ + @apply hover:text-zinc-900 hover:font-medium dark:hover:text-zinc-200 dark:hover:font-medium; + + /* 焦点样式 (无需改动) */ + @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500; + } + + /* 激活状态的标签 (只改变文字颜色) */ + .tab-active { + /* [核心变化] 激活状态只改变文字颜色,使其在白色指示器上可见 */ + @apply text-zinc-900 dark:text-zinc-50; + } + + + /* 自定义下拉选项的样式 */ + .custom-select-option { + @apply cursor-pointer select-none rounded-md px-3 py-1.5 text-sm text-zinc-800 dark:text-zinc-200; + @apply transition-colors duration-150; + } + + /* 选项的悬停和高亮状态 */ + .custom-select-option:hover, .custom-select-option.is-highlighted { + @apply bg-zinc-200 dark:bg-zinc-700 outline-none; + } + /* 定义下拉菜单面板的基础样式 */ + .dropdown-panel { + @apply absolute right-0 z-10 mt-1 w-32 rounded-md border border-zinc-200 bg-white shadow-lg dark:border-zinc-600 dark:bg-zinc-700; + } + /* 定义菜单项(按钮)的基础样式 */ + .menu-item { + @apply w-full text-left flex items-center gap-x-3 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-600 transition-colors; + } + /* 可选:为危险操作(如删除)创建一个变体 */ + .menu-item-danger { + @apply text-red-600; + } + .menu-item-icon-neutral { + @apply text-blue-500; + } + /* 定义菜单项内部图标的样式 */ + .menu-item-icon { + @apply w-4; + } + /* 定义菜单中的分隔线 */ + .menu-divider { + @apply my-1 h-px bg-zinc-200 dark:bg-zinc-700; + } + + /* + * ds-stats-card (Dashboard Statistics Card) + * 定义了仪表盘页面顶部核心指标卡片的统一外观。 + * 包含了内边距、圆角、边框、背景色、阴影以及深色模式下的样式。 + */ + .ds-stats-card { + @apply p-6 rounded-2xl border border-zinc-400 shadow-sm dark:bg-zinc-900/50 dark:border-zinc-800; + } + + /* -------------------------*/ + /* ------ keygroups ------- */ + /* ------------------------ */ + /** + * 1. 【新增】Group 卡片的通用基础样式 + * 抽离了 active 和 inactive 状态共享的所有样式。 + * 注意:它不会替换 .group-card-active/inactive,而是与之共存。 + */ + .group-card { + @apply cursor-pointer rounded-lg p-3 transition-all duration-200 h-16 flex flex-col justify-center; + } + /** + * 2. 【新增】移动端首屏的 "当前分组" 选择器样式 + */ + .mobile-group-selector { + @apply flex-grow flex items-center justify-between p-3 border border-zinc-200 dark:border-zinc-700 rounded-lg; + } + /* 移动端群组下拉列表样式 */ + .mobile-group-menu-active { + /* --- Positioning & Layering --- */ + @apply absolute z-20 top-full left-0 right-0; + /* --- Appearance --- */ + @apply bg-white dark:bg-zinc-800 shadow-lg rounded-b-lg border border-t-0 border-zinc-200 dark:border-zinc-700; + /* --- Spacing & Content Flow --- */ + @apply p-4; + + max-height: 14rem; + @apply overflow-y-auto; +} + /** + * 3. 【新增】卡片/选择器中的次要描述文本样式 + */ + .card-sub-text { + @apply text-xs text-zinc-500 dark:text-zinc-400 truncate; + } + /** + * 4. 【新增】桌面端 “添加分组” 按钮的特定样式 + */ + .add-group-btn-desktop { + /** + * [核心修正] + * 1. 新增 p-1: 增加一个4px的内边距,为背景裁剪创造空间。 + * 2. 新增 bg-clip-content: 让背景只在内容区(内边距内部)绘制。 + */ + @apply w-full h-16 border-zinc-300 dark:border-zinc-600 text-zinc-400 dark:text-zinc-500 + hover:border-blue-500 hover:text-blue-500 + p-1 bg-clip-content; + --stripe-color: theme('colors.zinc.200'); + .dark & { + --stripe-color: theme('colors.zinc.700'); + } + + /** + * [核心修正] + * 1. 角度改为 -45deg 实现镜像。 + * 2. 宽度和间距从 15px/30px 减半为 7.5px/15px。 + */ + background-image: repeating-linear-gradient( + -45deg, + var(--stripe-color), + var(--stripe-color) 6px, + transparent 6px, + transparent 12px + ); + } + /** + * 5. 【新增】移动端 “添加分组” 按钮的特定样式 + */ + .add-group-btn-mobile { + @apply w-15 h-15 border-blue-500/50 text-blue-500; + } + /** + * 6. 【新增】健康指示器的外环样式 + */ + .health-indicator-ring { + @apply w-5 h-5 flex items-center justify-center rounded-full shrink-0; + } + /** + * 7. 【新增】健康指示器的核心圆点样式 + */ + .health-indicator-dot { + @apply w-2 h-2 rounded-full; + } + /* + * 8. JS依赖 + */ + .group-card-active { + @apply cursor-pointer rounded-lg p-3 mr-3 bg-blue-500/10 border border-blue-500/30 text-zinc-800 dark:text-zinc-200 transition-all duration-200; + } + .group-card-inactive { + @apply cursor-pointer rounded-lg p-3 mr-3 bg-white dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700/60 text-zinc-800 dark:text-zinc-200 hover:border-blue-500/50 hover:bg-blue-500/5; + transition-property: background-color, border-color, transform; + transition-duration: 200ms; + } + + /* ========================================================================== + Modal Component Styles (单功能模态框组件) + ========================================================================== */ + /** + * 1. 模态框遮罩层 (Modal Overlay) + * 覆盖整个屏幕的半透明背景。 + */ + .modal-overlay { + @apply fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm; + } + /* ... (modal-panel, header, title, close-btn, body, label, footer 保持不变) ... */ + .modal-panel { + @apply w-full max-w-2xl rounded-lg bg-white p-8 shadow-2xl dark:bg-zinc-800/90 border dark:border-zinc-700 flex flex-col; + } + .modal-header { + @apply flex items-center justify-between pb-4 border-b dark:border-zinc-700; + } + .modal-title { + @apply text-xl font-semibold; + } + .modal-close-btn { + @apply text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors; + } + .modal-body { + @apply mt-6 p-1 pr-4 -mr-4; + } + .modal-label { + @apply text-sm font-medium text-zinc-700 dark:text-zinc-300; + } + .modal-footer { + @apply mt-2 flex justify-end gap-x-3 pt-3; + } + /** + * [修正] 将所有按钮的样式定义分离,避免 @apply 嵌套自定义类。 + * HTML中将同时使用 .modal-btn 和 .modal-btn-primary/secondary/danger。 + */ + /* 9. 模态框按钮基础样式 (Modal Button Base) */ + .modal-btn { + @apply rounded-md px-4 py-2 text-sm font-medium transition-colors; + } + + /* 10. 主要操作按钮 (如: 导入, 保存) */ + .modal-btn-primary { + @apply bg-blue-600 text-white hover:bg-blue-700; + } + + /* 11. 次要/取消按钮 */ + .modal-btn-secondary { + @apply bg-zinc-200 text-zinc-700 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600; + } + + /* 12. 危险操作按钮 (如: 删除) */ + .modal-btn-danger { + @apply bg-red-600 text-white hover:bg-red-700; + } + + /* Modal Inputs */ + .modal-input { + /*@apply mt-1 block w-full rounded-md border border-zinc-300 bg-white p-2 min-h-[40px] focus:border-blue-500 focus:ring-blue-500 dark:bg-zinc-700 dark:border-zinc-600 sm:text-sm;*/ + @apply mt-1 block w-full px-3 py-2 text-xs bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 rounded-md placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-transparent transition duration-150 ease-in-out; + } + /* + * 修正:确保textarea也使用相同的字体大小和行高 + * Tailwind forms 插件有时会覆盖这些,所以我们明确指定 + */ + textarea.modal-input { + @apply text-sm leading-6; + } + /* Tag Input Component */ + .tag-input-container { + + @apply flex flex-wrap items-center gap-2 mt-1 w-full rounded-md bg-white dark:bg-zinc-700 border border-zinc-300 dark:border-zinc-600 p-2 min-h-[40px] focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500; + } + .tag-item { + @apply flex items-center gap-x-1.5 bg-blue-100 dark:bg-blue-500/20 text-blue-800 dark:text-blue-200 text-sm font-medium rounded-full px-2.5 py-0.5; + } + .tag-delete { + @apply text-blue-500 dark:text-blue-300 hover:text-blue-700 dark:hover:text-blue-100 font-bold; + } + .tag-input-new { + /* 使其在容器内垂直居中,感觉更好 */ + @apply flex-grow bg-transparent focus:outline-none text-sm self-center; + } + + /* 为复制按钮提供基础样式 */ + .tag-copy-btn { + background: none; + border: none; + cursor: pointer; + font-size: 1.0rem; /* 调整图标大小 */ + padding: 0 8px; + opacity: 0.6; + transition: opacity 0.2s, transform 0.2s; + } + .tag-copy-btn:hover { + opacity: 1; + transform: scale(1.1); + } + /* 复制成功后的状态 */ + .tag-copy-btn.copied span { + color: #4CAF50; /* 绿色,表示成功 */ + font-size: 0.8rem; /* 可以让提示文字小一点 */ + font-weight: bold; + } + /* 复制成功后的状态 */ + .tag-copy-btn.none span { + color: #3766c3; /* 绿色,表示成功 */ + font-size: 0.8rem; /* 可以让提示文字小一点 */ + font-weight: bold; + } + + /* Advanced Request Settings Modal Specifics */ + details summary::-webkit-details-marker { + display: none; + } + details > summary { + list-style: none; + } + details > summary .fa-chevron-down { + transition: transform 0.2s ease-in-out; + } + details[open] > summary .fa-chevron-down { + transform: rotate(180deg); + } + + .dynamic-kv-item { + @apply flex items-center gap-x-2; + } + .dynamic-kv-key { + @apply w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-700/50 border border-zinc-300 dark:border-zinc-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-transparent transition duration-150 ease-in-out font-mono text-xs; + } + .dynamic-kv-value { + @apply w-full px-3 py-2 bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-transparent transition duration-150 ease-in-out font-mono text-xs; + } +} + +@layer components { + /* Toggle Switch Component */ + .toggle-checkbox:checked { + @apply right-0 border-blue-600; + right: 0; + } + .toggle-checkbox:checked + .toggle-label { + @apply bg-blue-600; + } + + /* Tooltip Component */ + .tooltip-icon { + @apply relative inline-flex items-center justify-center ml-2 text-zinc-400 cursor-pointer; + } + /* .tooltip-text is now dynamically generated by JS */ + .global-tooltip { + @apply fixed z-[9999] w-max max-w-xs whitespace-normal rounded-lg bg-zinc-800 px-3 py-2 text-sm font-medium text-white shadow-lg transition-opacity duration-200; + } +} + +@layer components { + /* SortableJS Drag Styles */ + .sortable-ghost { + @apply opacity-40 bg-blue-200 dark:bg-blue-900/50; + } + .sortable-drag { + @apply shadow-lg scale-105 cursor-grabbing; + } + + /* Fallback class for when forceFallback is true */ + .sortable-fallback { + @apply shadow-lg scale-105 cursor-grabbing; + } +} + +/* =================================================================== */ +/* 自定义 SweetAlert2 样式 (Custom SweetAlert2 Styles) */ +/* =================================================================== */ +.swal2-popup.swal2-custom-style { + @apply bg-zinc-300 dark:bg-zinc-800 text-sm; /* 为整个弹窗设置基础字体大小 */ +} +.swal2-popup.swal2-custom-style .swal2-title { + @apply text-xl font-semibold text-zinc-800 dark:text-zinc-100; /* 应用标题样式 */ +} +.swal2-popup.swal2-custom-style .swal2-html-container { + @apply text-sm text-zinc-600 dark:text-zinc-300; /* 应用正文样式 */ +} +.swal2-popup.swal2-custom-style .swal2-confirm-button, +.swal2-popup.swal2-custom-style .swal2-cancel-button { + @apply text-sm font-medium px-4 py-2 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2; /* 应用按钮样式 */ + @apply focus:ring-offset-white dark:focus:ring-offset-zinc-800; +} +.swal2-popup.swal2-custom-style .swal2-confirm-button { + @apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500; +} +.swal2-popup.swal2-custom-style .swal2-cancel-button { + @apply bg-transparent text-zinc-700 dark:text-zinc-200 border border-zinc-300 dark:border-zinc-600 hover:bg-zinc-50 dark:hover:bg-zinc-700 focus:ring-zinc-500; +} +/* 覆盖图标大小 */ +.swal2-popup.swal2-custom-style .swal2-icon { + @apply w-8 h-8 my-2; +} +.swal2-popup.swal2-custom-style .swal2-icon .swal2-icon-content { + @apply text-4xl; +} diff --git a/frontend/js/components/apiKeyManager.js b/frontend/js/components/apiKeyManager.js new file mode 100644 index 0000000..fdca336 --- /dev/null +++ b/frontend/js/components/apiKeyManager.js @@ -0,0 +1,312 @@ +// frontend/js/components/apiKeyManager.js + +//import { apiFetch } from "../main.js"; // Assuming apiFetch is exported from main.js +import { apiFetch, apiFetchJson } from '../services/api.js'; +import { modalManager } from "./ui.js"; + +/** + * Manages all API operations related to keys. + * This class provides a centralized interface for actions such as + * fetching, verifying, resetting, and deleting keys. + */ +class ApiKeyManager { + constructor() { + // The constructor can be used to initialize any properties, + // though for this static-like service class, it might be empty. + } + + // [新增] 开始一个向指定分组添加Keys的异步任务 + /** + * Starts a task to add multiple API keys to a specific group. + * @param {number} groupId - The ID of the group. + * @param {string} keysText - A string of keys, separated by newlines. + * @returns {Promise} A promise that resolves to the initial task status object. + */ + async addKeysToGroup(groupId, keysText, validate) { + // 后端期望的 Body 结构 + const payload = { + key_group_id: groupId, + keys: keysText, + validate_on_import: validate + }; + // POST 请求不应被缓存,使用原始的 apiFetch 并设置 noCache + const response = await apiFetch(`/admin/keygroups/${groupId}/apikeys/bulk`, { + method: 'POST', + body: JSON.stringify(payload), + noCache: true + }); + return response.json(); + } + // [新增] 查询一个指定任务的当前状态 + /** + * Gets the current status of a background task. + * @param {string} taskId - The ID of the task. + * @returns {Promise} A promise that resolves to the task status object. + */ + getTaskStatus(taskId, options = {}) { + return apiFetchJson(`/admin/tasks/${taskId}`, options); + } + + /** + * Fetches a paginated and filtered list of keys. + * @param {string} type - The type of keys to fetch ('valid' or 'invalid'). + * @param {number} [page=1] - The page number to retrieve. + * @param {number} [limit=10] - The number of keys per page. + * @param {string} [searchTerm=''] - A search term to filter keys. + * @param {number|null} [failCountThreshold=null] - A threshold for filtering by failure count. + * @returns {Promise} A promise that resolves to the API response data. + */ + async fetchKeys(type, page = 1, limit = 10, searchTerm = '', failCountThreshold = null) { + const params = new URLSearchParams({ + page: page, + limit: limit, + status: type, + }); + if (searchTerm) params.append('search', searchTerm); + if (failCountThreshold !== null) params.append('fail_count_threshold', failCountThreshold); + + return await apiFetch(`/api/keys?${params.toString()}`); + } + + + /** + * Starts a task to unlink multiple API keys from a specific group. + * @param {number} groupId - The ID of the group. + * @param {string} keysText - A string of keys, separated by newlines. + * @returns {Promise} A promise that resolves to the initial task status object. + */ + async unlinkKeysFromGroup(groupId, keysInput) { + let keysAsText; + if (Array.isArray(keysInput)) { + keysAsText = keysInput.join('\n'); + } else { + keysAsText = keysInput; + } + + const payload = { + key_group_id: groupId, + keys: keysAsText + }; + + const response = await apiFetch(`/admin/keygroups/${groupId}/apikeys/bulk`, { + method: 'DELETE', + body: JSON.stringify(payload), + noCache: true + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(errorData.message || `Request failed with status ${response.status}`); + } + return response.json(); + } + + /** + * 更新一个Key在特定分组中的状态 (e.g., 'ACTIVE', 'DISABLED'). + * @param {number} groupId - The ID of the group. + * @param {number} keyId - The ID of the API key (api_keys.id). + * @param {string} newStatus - The new operational status ('ACTIVE', 'DISABLED', etc.). + * @returns {Promise} A promise that resolves to the updated mapping object. + */ + async updateKeyStatusInGroup(groupId, keyId, newStatus) { + const endpoint = `/admin/keygroups/${groupId}/apikeys/${keyId}`; + const payload = { status: newStatus }; + return await apiFetchJson(endpoint, { + method: 'PUT', + body: JSON.stringify(payload), + noCache: true + }); + } + + /** + * [MODIFIED] Fetches a paginated and filtered list of API key details for a specific group. + * @param {number} groupId - The ID of the group. + * @param {object} [params={}] - An object containing pagination and filter parameters. + * @param {number} [params.page=1] - The page number to fetch. + * @param {number} [params.limit=20] - The number of items per page. + * @param {string} [params.status] - An optional status to filter the keys by. + * @returns {Promise} A promise that resolves to a pagination object. + */ + async getKeysForGroup(groupId, params = {}) { + // Step 1: Create a URLSearchParams object. This is the modern, safe way to build query strings. + const query = new URLSearchParams({ + page: params.page || 1, // Default to page 1 if not provided + limit: params.limit || 20, // Default to 20 per page if not provided + }); + // Step 2: Conditionally add the 'status' parameter IF it exists in the params object. + if (params.status) { + query.append('status', params.status); + } + if (params.keyword && params.keyword.trim() !== '') { + query.append('keyword', params.keyword.trim()); + } + // Step 3: Construct the final URL by converting the query object to a string. + const url = `/admin/keygroups/${groupId}/apikeys?${query.toString()}`; + + // The rest of the logic remains the same. + const responseData = await apiFetchJson(url, { noCache: true }); + + if (!responseData.success || typeof responseData.data !== 'object' || !Array.isArray(responseData.data.items)) { + throw new Error(responseData.message || 'Failed to fetch paginated keys for the group.'); + } + + return responseData.data; + } + + /** + * 启动一个重新验证一个或多个Key的异步任务。 + * @param {number} groupId - The ID of the group context for validation. + * @param {string[]} keyValues - An array of API key strings to revalidate. + * @returns {Promise} A promise that resolves to the initial task status object. + */ + async revalidateKeys(groupId, keyValues) { + const payload = { + keys: keyValues.join('\n') + }; + + const url = `/admin/keygroups/${groupId}/apikeys/test`; + const responseData = await apiFetchJson(url, { + method: 'POST', + body: JSON.stringify(payload), + noCache: true, + }); + if (!responseData.success || !responseData.data) { + throw new Error(responseData.message || "Failed to start revalidation task."); + } + return responseData.data; + } + + /** + * Starts a generic bulk action task for an entire group based on filters. + * This single function replaces the need for separate cleanup, revalidate, and restore functions. + * @param {number} groupId The group ID. + * @param {object} payload The body of the request, defining the action and filters. + * @returns {Promise} The initial task response with a task_id. + */ + async startGroupBulkActionTask(groupId, payload) { + // This assumes a new, unified endpoint on the backend. + const url = `/admin/keygroups/${groupId}/bulk-actions`; + const responseData = await apiFetchJson(url, { + method: 'POST', + body: JSON.stringify(payload) + }); + if (!responseData.success || !responseData.data) { + throw new Error(responseData.message || "未能启动分组批量任务。"); + } + return responseData.data; + } + + /** + * [NEW] Fetches all keys for a group, filtered by status, for export purposes using the dedicated export API. + * @param {number} groupId The ID of the group. + * @param {string[]} statuses An array of statuses to filter by (e.g., ['active', 'cooldown']). Use ['all'] for everything. + * @returns {Promise} A promise that resolves to an array of API key strings. + */ + async exportKeysForGroup(groupId, statuses = ['all']) { + const params = new URLSearchParams(); + statuses.forEach(status => params.append('status', status)); + + // This now points to our new, clean, non-paginated API endpoint + const url = `/admin/keygroups/${groupId}/apikeys/export?${params.toString()}`; + const responseData = await apiFetchJson(url, { noCache: true }); + if (!responseData.success || !Array.isArray(responseData.data)) { + throw new Error(responseData.message || '未能获取用于导出的Key列表。'); + } + + return responseData.data; + } + + /** !!!以下为GB预置函数,未做对齐 + * Verifies a single API key. + * @param {string} key - The API key to verify. + * @returns {Promise} A promise that resolves to the API response data. + */ + async verifyKey(key) { + return await apiFetch(`/gemini/v1beta/verify-key/${key}`, { method: "POST" }); + } + + /** + * Verifies a batch of selected API keys. + * @param {string[]} keys - An array of API keys to verify. + * @returns {Promise} A promise that resolves to the API response data. + */ + async verifySelectedKeys(keys) { + return await apiFetch(`/gemini/v1beta/verify-selected-keys`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keys }), + }); + } + + /** + * Resets the failure count for a single API key. + * @param {string} key - The API key whose failure count is to be reset. + * @returns {Promise} A promise that resolves to the API response data. + */ + async resetFailCount(key) { + return await apiFetch(`/gemini/v1beta/reset-fail-count/${key}`, { method: "POST" }); + } + + /** + * Resets the failure count for a batch of selected API keys. + * @param {string[]} keys - An array of API keys to reset. + * @returns {Promise} A promise that resolves to the API response data. + */ + async resetSelectedFailCounts(keys) { + return await apiFetch(`/gemini/v1beta/reset-selected-fail-counts`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keys }), + }); + } + + /** + * Deletes a single API key. + * @param {string} key - The API key to delete. + * @returns {Promise} A promise that resolves to the API response data. + */ + async deleteKey(key) { + return await apiFetch(`/api/config/keys/${key}`, { method: "DELETE" }); + } + + /** + * Deletes a batch of selected API keys. + * @param {string[]} keys - An array of API keys to delete. + * @returns {Promise} A promise that resolves to the API response data. + */ + async deleteSelectedKeys(keys) { + return await apiFetch("/api/config/keys/delete-selected", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keys }), + }); + } + + /** + * Fetches all keys, both valid and invalid. + * @returns {Promise} A promise that resolves to an object containing 'valid_keys' and 'invalid_keys' arrays. + */ + async fetchAllKeys() { + return await apiFetch('/api/keys/all'); + } + + /** + * Fetches usage details for a specific key over the last 24 hours. + * @param {string} key - The API key to get details for. + * @returns {Promise} A promise that resolves to the API response data. + */ + async getKeyUsageDetails(key) { + return await apiFetch(`/api/key-usage-details/${key}`); + } + + /** + * Fetches API call statistics for a given period. + * @param {string} period - The time period for the stats (e.g., '1m', '1h', '24h'). + * @returns {Promise} A promise that resolves to the API response data. + */ + async getStatsDetails(period) { + return await apiFetch(`/api/stats/details?period=${period}`); + } +} + +export const apiKeyManager = new ApiKeyManager(); diff --git a/frontend/js/components/customSelect.js b/frontend/js/components/customSelect.js new file mode 100644 index 0000000..a3f7b29 --- /dev/null +++ b/frontend/js/components/customSelect.js @@ -0,0 +1,126 @@ +// Filename: frontend/js/components/customSelect.js + +export default class CustomSelect { + constructor(container) { + this.container = container; + this.trigger = this.container.querySelector('.custom-select-trigger'); + this.panel = this.container.querySelector('.custom-select-panel'); + + if (!this.trigger || !this.panel) { + console.warn('CustomSelect cannot initialize: missing .custom-select-trigger or .custom-select-panel.', this.container); + return; + } + + this.nativeSelect = this.container.querySelector('select'); + this.triggerText = this.trigger.querySelector('span'); + this.template = this.panel.querySelector('.custom-select-option-template'); + + + if (typeof CustomSelect.openInstance === 'undefined') { + CustomSelect.openInstance = null; + CustomSelect.initGlobalListener(); + } + + if (this.nativeSelect) { + this.generateOptions(); + this.updateTriggerText(); + } + + this.bindEvents(); + } + + static initGlobalListener() { + document.addEventListener('click', (event) => { + if (CustomSelect.openInstance && !CustomSelect.openInstance.container.contains(event.target)) { + CustomSelect.openInstance.close(); + } + }); + } + + generateOptions() { + this.panel.querySelectorAll(':scope > *:not(.custom-select-option-template)').forEach(child => child.remove()); + Array.from(this.nativeSelect.options).forEach(option => { + let item; + if (this.template) { + item = this.template.cloneNode(true); + item.classList.remove('custom-select-option-template'); + item.removeAttribute('hidden'); + } else { + item = document.createElement('a'); + item.href = '#'; + item.className = 'block px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-600'; + } + item.classList.add('custom-select-option'); + item.textContent = option.textContent; + item.dataset.value = option.value; + if (option.selected) { item.classList.add('is-selected'); } + this.panel.appendChild(item); + }); + } + + bindEvents() { + this.trigger.addEventListener('click', (event) => { + // [NEW] Guard clause: If the trigger is functionally disabled, do nothing. + if (this.trigger.classList.contains('is-disabled')) { + return; + } + event.stopPropagation(); + if (CustomSelect.openInstance && CustomSelect.openInstance !== this) { + CustomSelect.openInstance.close(); + } + this.toggle(); + }); + if (this.nativeSelect) { + this.panel.addEventListener('click', (event) => { + event.preventDefault(); + const option = event.target.closest('.custom-select-option'); + if (option) { this.selectOption(option); } + }); + } + } + + selectOption(optionEl) { + const selectedValue = optionEl.dataset.value; + if (this.nativeSelect.value !== selectedValue) { + this.nativeSelect.value = selectedValue; + this.nativeSelect.dispatchEvent(new Event('change', { bubbles: true })); + } + this.updateTriggerText(); + this.panel.querySelectorAll('.custom-select-option').forEach(el => el.classList.remove('is-selected')); + optionEl.classList.add('is-selected'); + this.close(); + } + + updateTriggerText() { + // [IMPROVEMENT] Guard against missing elements. + if (!this.nativeSelect || !this.triggerText) return; + + const selectedOption = this.nativeSelect.options[this.nativeSelect.selectedIndex]; + if (selectedOption) { + this.triggerText.textContent = selectedOption.textContent; + } + } + + toggle() { + this.panel.classList.toggle('hidden'); + if (this.panel.classList.contains('hidden')) { + if (CustomSelect.openInstance === this) { + CustomSelect.openInstance = null; + } + } else { + CustomSelect.openInstance = this; + } + } + + open() { + this.panel.classList.remove('hidden'); + CustomSelect.openInstance = this; + } + + close() { + this.panel.classList.add('hidden'); + if (CustomSelect.openInstance === this) { + CustomSelect.openInstance = null; + } + } +} diff --git a/frontend/js/components/slidingTabs.js b/frontend/js/components/slidingTabs.js new file mode 100644 index 0000000..5b2341e --- /dev/null +++ b/frontend/js/components/slidingTabs.js @@ -0,0 +1,80 @@ +export default class SlidingTabs { + /** + * @param {HTMLElement} containerElement - The main container element with the `data-sliding-tabs-container` attribute. + */ + constructor(containerElement) { + this.container = containerElement; + this.indicator = this.container.querySelector('[data-tab-indicator]'); + this.tabs = this.container.querySelectorAll('[data-tab-item]'); + + // Find the initially active tab and store it as the component's state + this.activeTab = this.container.querySelector('.tab-active'); + + if (!this.indicator || this.tabs.length === 0) { + console.error('SlidingTabs component is missing required elements (indicator or items).', this.container); + return; + } + + this.init(); + } + + init() { + // Set initial indicator position + if (this.activeTab) { + // Use a small delay to ensure layout is fully calculated + setTimeout(() => this.updateIndicator(this.activeTab), 50); + } + + // Bind all necessary event listeners + this.bindEvents(); + } + + updateIndicator(targetTab) { + if (!targetTab) return; + + const containerRect = this.container.getBoundingClientRect(); + const targetRect = targetTab.getBoundingClientRect(); + + const left = targetRect.left - containerRect.left; + const width = targetRect.width; + + this.indicator.style.left = `${left}px`; + this.indicator.style.width = `${width}px`; + } + + bindEvents() { + this.tabs.forEach(tab => { + // On click, update the active state + tab.addEventListener('click', (e) => { + // e.preventDefault(); // Uncomment if using tags for SPA routing + + if (this.activeTab) { + this.activeTab.classList.remove('tab-active'); + } + + tab.classList.add('tab-active'); + this.activeTab = tab; // Update the component's state + this.updateIndicator(this.activeTab); + }); + + // On hover, preview the indicator position + tab.addEventListener('mouseenter', () => { + this.updateIndicator(tab); + }); + }); + + // When the mouse leaves the entire container, reset indicator to the active tab + this.container.addEventListener('mouseleave', () => { + this.updateIndicator(this.activeTab); + }); + } +} + +// ---- Auto-Initialization Logic ---- +// This is the "bootstrapper". It finds all components on the page and brings them to life. +document.addEventListener('DOMContentLoaded', () => { + const allTabContainers = document.querySelectorAll('[data-sliding-tabs-container]'); + allTabContainers.forEach(container => { + new SlidingTabs(container); + }); +}); diff --git a/frontend/js/components/tagInput.js b/frontend/js/components/tagInput.js new file mode 100644 index 0000000..362c46a --- /dev/null +++ b/frontend/js/components/tagInput.js @@ -0,0 +1,132 @@ +// frontend/js/components/tagInput.js + +export default class TagInput { + constructor(container, options = {}) { + if (!container) { + console.error("TagInput container not found."); + return; + } + this.container = container; + this.input = container.querySelector('.tag-input-new'); + this.tags = []; + this.options = { + validator: /.+/, + validationMessage: '输入格式无效', + ...options + }; + + this.copyBtn = document.createElement('button'); + this.copyBtn.className = 'tag-copy-btn'; + this.copyBtn.innerHTML = ''; + this.copyBtn.title = '复制所有'; + this.container.appendChild(this.copyBtn); + + this._initEventListeners(); + } + + _initEventListeners() { + this.container.addEventListener('click', (e) => { + // 使用 .closest() 来处理点击事件,即使点击到图标也能正确触发 + if (e.target.closest('.tag-delete')) { + this._removeTag(e.target.closest('.tag-item')); + } + }); + + if (this.input) { + this.input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ',' || e.key === ' ') { + e.preventDefault(); + const value = this.input.value.trim(); + if (value) { + this._addTag(value); + this.input.value = ''; + } + } + }); + this.input.addEventListener('blur', () => { + const value = this.input.value.trim(); + if (value) { + this._addTag(value); + this.input.value = ''; + } + }); + } + + // 为“复制”按钮绑定点击事件 + this.copyBtn.addEventListener('click', this._handleCopyAll.bind(this)); + } + + _addTag(raw_value) { + // 在所有操作之前,自动转换为小写 + const value = raw_value.toLowerCase(); + + if (!this.options.validator.test(value)) { + console.warn(`Tag validation failed for value: "${value}". Rule: ${this.options.validator}`); + this.input.placeholder = this.options.validationMessage; + this.input.classList.add('input-error'); + setTimeout(() => { + this.input.classList.remove('input-error'); + this.input.placeholder = '添加...'; + }, 2000); + return; + } + + if (this.tags.includes(value)) return; + this.tags.push(value); + + const tagEl = document.createElement('span'); + tagEl.className = 'tag-item'; + tagEl.innerHTML = `${value}`; + this.container.insertBefore(tagEl, this.input); + } + + // 处理复制逻辑的专用方法 + _handleCopyAll() { + const tagsString = this.tags.join(','); + if (!tagsString) { + // 如果没有标签,可以给个提示 + this.copyBtn.innerHTML = '无内容!'; + this.copyBtn.classList.add('none'); + setTimeout(() => { + this.copyBtn.innerHTML = ''; + this.copyBtn.classList.remove('copied'); + }, 1500); + return; + } + + navigator.clipboard.writeText(tagsString).then(() => { + // 复制成功,提供视觉反馈 + this.copyBtn.innerHTML = '已复制!'; + this.copyBtn.classList.add('copied'); + setTimeout(() => { + this.copyBtn.innerHTML = ''; + this.copyBtn.classList.remove('copied'); + }, 2000); + }).catch(err => { + // 复制失败 + console.error('Could not copy text: ', err); + this.copyBtn.innerHTML = '失败!'; + setTimeout(() => { + this.copyBtn.innerHTML = ''; + }, 2000); + }); + } + + _removeTag(tagEl) { + const value = tagEl.querySelector('.tag-text').textContent; + this.tags = this.tags.filter(t => t !== value); + tagEl.remove(); + } + + getValues() { + return this.tags; + } + + setValues(values) { + this.container.querySelectorAll('.tag-item').forEach(el => el.remove()); + this.tags = []; + if (Array.isArray(values)) { + values.filter(value => value).forEach(value => this._addTag(value)); + } + } +} diff --git a/frontend/js/components/taskCenter.js b/frontend/js/components/taskCenter.js new file mode 100644 index 0000000..d262a1c --- /dev/null +++ b/frontend/js/components/taskCenter.js @@ -0,0 +1,502 @@ +/** + * @file taskCenter.js + * @description Centralizes Task component classes for global. + * This module exports singleton instances of `TaskCenterManager` and `ToastManager` + * to ensure consistent task service across the application. + */ +// =================================================================== +// 任务中心UI管理器 +// =================================================================== + +/** + * Manages the UI and state for the global asynchronous task center. + * It handles task rendering, state updates, and user interactions like + * opening/closing the panel and clearing completed tasks. + */ +class TaskCenterManager { + constructor() { + // --- 核心状态 --- + this.tasks = []; // A history of all tasks started in this session. + this.activePolls = new Map(); + this.heartbeatInterval = null; + this.MINIMUM_TASK_DISPLAY_TIME_MS = 800; + this.hasUnreadCompletedTasks = false; + this.isAnimating = false; + this.countdownTimer = null; + // --- 核心DOM元素引用 --- + this.trigger = document.getElementById('task-hub-trigger'); + this.panel = document.getElementById('task-hub-panel'); + this.countdownBar = document.getElementById('task-hub-countdown-bar'); + this.countdownRing = document.getElementById('task-hub-countdown-ring'); + this.indicator = document.getElementById('task-hub-indicator'); + this.clearBtn = document.getElementById('task-hub-clear-btn'); + this.taskListContainer = document.getElementById('task-list-container'); + this.emptyState = document.getElementById('task-list-empty'); + } + // [THE FINAL, DEFINITIVE VERSION] + init() { + if (!this.trigger || !this.panel) { + console.warn('Task Center UI core elements not found. Initialization skipped.'); + return; + } + + // --- UI Event Listeners (Corrected and final) --- + this.trigger.addEventListener('click', (event) => { + event.stopPropagation(); + if (this.isAnimating) return; + if (this.panel.classList.contains('hidden')) { + this._handleUserInteraction(); + this.openPanel(); + } else { + this.closePanel(); + } + }); + + document.addEventListener('click', (event) => { + if (!this.panel.classList.contains('hidden') && !this.isAnimating && !this.panel.contains(event.target) && !this.trigger.contains(event.target)) { + this.closePanel(); + } + }); + this.trigger.addEventListener('mouseenter', this._stopCountdown.bind(this)); + this.panel.addEventListener('mouseenter', this._handleUserInteraction.bind(this)); + + const handleMouseLeave = () => { + if (!this.panel.classList.contains('hidden')) { + this._startCountdown(); + } + }; + this.panel.addEventListener('mouseleave', handleMouseLeave); + this.trigger.addEventListener('mouseleave', handleMouseLeave); + + this.clearBtn?.addEventListener('click', this.clearCompletedTasks.bind(this)); + + this.taskListContainer.addEventListener('click', (event) => { + const toggleHeader = event.target.closest('[data-task-toggle]'); + if (!toggleHeader) return; + this._handleUserInteraction(); + const taskItem = toggleHeader.closest('.task-list-item'); + const content = taskItem.querySelector('[data-task-content]'); + if (!content) return; + const isCollapsed = content.classList.contains('collapsed'); + toggleHeader.classList.toggle('expanded', isCollapsed); + if (isCollapsed) { + content.classList.remove('collapsed'); + content.style.maxHeight = `${content.scrollHeight}px`; + content.style.opacity = '1'; + content.addEventListener('transitionend', () => { + if (!content.classList.contains('collapsed')) content.style.maxHeight = 'none'; + }, { once: true }); + } else { + content.style.maxHeight = `${content.scrollHeight}px`; + requestAnimationFrame(() => { + content.style.maxHeight = '0px'; + content.style.opacity = '0'; + content.classList.add('collapsed'); + }); + } + }); + this._render(); + + // [CRITICAL FIX] IGNITION! START THE ENGINE! + this._startHeartbeat(); + + console.log('Task Center UI Initialized [Multi-Task Heartbeat Polling Architecture - IGNITED].'); + } + async startTask(taskDefinition) { + try { + const initialTaskData = await taskDefinition.start(); + if (!initialTaskData || !initialTaskData.id) throw new Error("Task definition did not return a valid initial task object."); + + const newTask = { + id: initialTaskData.id, + definition: taskDefinition, + data: initialTaskData, + timestamp: new Date(), + startTime: Date.now() + }; + + if (!initialTaskData.is_running) { + console.log(`[TaskCenter] Task ${newTask.id} completed synchronously. Skipping poll.`); + // We still show a brief toast for UX feedback. + taskDefinition.renderToastNarrative(newTask.data, {}, toastManager); + this.tasks.unshift(newTask); + this._render(); + this._handleTaskCompletion(newTask); + return; // IMPORTANT: Exit here to avoid adding it to the polling queue. + } + + this.tasks.unshift(newTask); + this.activePolls.set(newTask.id, newTask); + + this._render(); + this.openPanel(); + + taskDefinition.renderToastNarrative(newTask.data, {}, toastManager); + this._updateIndicatorState(); // [SAFETY] Update indicator immediately on new task. + } catch (error) { + console.error("Failed to start task:", error); + toastManager.show(`任务启动失败: ${error.message}`, 'error'); + } + } + _startHeartbeat() { + if (this.heartbeatInterval) return; + this.heartbeatInterval = setInterval(this._tick.bind(this), 1500); + } + + _stopHeartbeat() { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + } + async _tick() { + if (this.activePolls.size === 0) { + return; + } + // Iterate over a copy of keys to safely remove items during iteration. + for (const taskId of [...this.activePolls.keys()]) { + const task = this.activePolls.get(taskId); + if (!task) continue; // Safety check + try { + const response = await task.definition.poll(taskId); + if (!response.success || !response.data) throw new Error(response.message || "Polling failed"); + const oldData = { ...task.data }; + task.data = response.data; + this._updateTaskItemInHistory(task.id, task.data); // [SAFETY] Keep history in sync. + task.definition.renderToastNarrative(task.data, oldData, toastManager); + + if (!task.data.is_running) { + this._handleTaskCompletion(task); + } + } catch (error) { + console.error(`Polling for task ${taskId} failed:`, error); + task.data.error = error.message; + this._updateTaskItemInHistory(task.id, task.data); + this._handleTaskCompletion(task); + } + } + } + _handleTaskCompletion(task) { + this.activePolls.delete(task.id); + this._updateIndicatorState(); // [SAFETY] Update indicator as soon as a task is no longer active. + + const toastId = `task-${task.id}`; + + const finalize = async () => { + await toastManager.dismiss(toastId, !task.data.error); + this._updateTaskItemInDom(task); + this.hasUnreadCompletedTasks = true; + this._updateIndicatorState(); + if (task.data.error) { + if (task.definition.onError) task.definition.onError(task.data); + } else { + if (task.definition.onSuccess) task.definition.onSuccess(task.data); + } + }; + const elapsedTime = Date.now() - task.startTime; + const remainingTime = this.MINIMUM_TASK_DISPLAY_TIME_MS - elapsedTime; + + if (remainingTime > 0) { + setTimeout(finalize, remainingTime); + } else { + finalize(); + } + } + // [REFACTORED for robustness] + _updateIndicatorState() { + const hasRunningTasks = this.activePolls.size > 0; + const shouldBeVisible = hasRunningTasks || this.hasUnreadCompletedTasks; + this.indicator.classList.toggle('hidden', !shouldBeVisible); + } + + // [REFACTORED for robustness] + clearCompletedTasks() { + // Only keep tasks that are still in the active polling map. + this.tasks = this.tasks.filter(task => this.activePolls.has(task.id)); + this.hasUnreadCompletedTasks = false; + this._render(); + } + + // [NEW SAFETY METHOD] + _updateTaskItemInHistory(taskId, newData) { + const taskInHistory = this.tasks.find(t => t.id === taskId); + if (taskInHistory) { + taskInHistory.data = newData; + } + } + + // --- 渲染与DOM操作 --- + _render() { + this.taskListContainer.innerHTML = this.tasks.map(task => this._createTaskItemHtml(task)).join(''); + + const hasTasks = this.tasks.length > 0; + this.taskListContainer.classList.toggle('hidden', !hasTasks); + this.emptyState.classList.toggle('hidden', hasTasks); + + this._updateIndicatorState(); + } + _createTaskItemHtml(task) { + // [MODIFIED] 将 this._formatTimeAgo 作为一个服务传递给渲染器 + const innerHtml = task.definition.renderTaskCenterItem(task.data, task.timestamp, this._formatTimeAgo); + return `
${innerHtml}
`; + } + _updateTaskItemInDom(task) { + const item = this.taskListContainer.querySelector(`[data-task-id="${task.id}"]`); + if (item) { + // [MODIFIED] 将 this._formatTimeAgo 作为一个服务传递给渲染器 + item.innerHTML = task.definition.renderTaskCenterItem(task.data, task.timestamp, this._formatTimeAgo); + } + } + + // --- 核心面板开关逻辑 --- + openPanel() { + if (this.isAnimating || !this.panel.classList.contains('hidden')) return; + + this.isAnimating = true; + this.panel.classList.remove('hidden'); + this.panel.classList.add('animate-panel-in'); + // 动画结束后,启动倒计时 + setTimeout(() => { + this.panel.classList.remove('animate-panel-in'); + this.isAnimating = false; + this._startCountdown(); // 启动倒计时 + }, 150); + } + + closePanel() { + if (this.isAnimating || this.panel.classList.contains('hidden')) return; + + this._stopCountdown(); // [修改] 关闭前立即停止倒计时 + this.isAnimating = true; + this.panel.classList.add('animate-panel-out'); + setTimeout(() => { + this.panel.classList.remove('animate-panel-out'); + this.panel.classList.add('hidden'); + this.isAnimating = false; + }, 50); + } + + // --- [新增] 倒计时管理方法 --- + /** + * 启动或重启倒计时和进度条动画 + * @private + */ + _startCountdown() { + this._stopCountdown(); // 先重置 + // 启动进度条动画 + this.countdownBar.classList.add('w-full', 'duration-[4950ms]'); + + // 启动圆环动画 (通过可靠的JS强制重绘) + this.countdownRing.style.transition = 'none'; // 1. 禁用动画 + this.countdownRing.style.strokeDashoffset = '72.26'; // 2. 立即重置 + void this.countdownRing.offsetHeight; // 3. 强制浏览器重排 + this.countdownRing.style.transition = 'stroke-dashoffset 4.95s linear'; // 4. 重新启用动画 + this.countdownRing.style.strokeDashoffset = '0'; // 5. 设置目标值,开始动画 + + // 启动关闭计时器 + this.countdownTimer = setTimeout(() => { + this.closePanel(); + }, 4950); + } + /** + * 停止倒计时并重置进度条 + * @private + */ + _stopCountdown() { + if (this.countdownTimer) { + clearTimeout(this.countdownTimer); + this.countdownTimer = null; + } + // 重置进度条的视觉状态 + this.countdownBar.classList.remove('w-full'); + + this.countdownRing.style.transition = 'none'; + this.countdownRing.style.strokeDashoffset = '72.26'; + } + + // [NEW] A central handler for any action that confirms the user has seen the panel. + _handleUserInteraction() { + // 1. Stop the auto-close countdown because the user is now interacting. + this._stopCountdown(); + // 2. If there were unread tasks, mark them as read *now*. + if (this.hasUnreadCompletedTasks) { + this.hasUnreadCompletedTasks = false; + this._updateIndicatorState(); // The indicator light turns off at this moment. + } + } + + _formatTimeAgo(date) { + if (!date) return ''; + const seconds = Math.floor((new Date() - new Date(date)) / 1000); + if (seconds < 2) return "刚刚"; + if (seconds < 60) return `${seconds}秒前`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}分钟前`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}小时前`; + const days = Math.floor(hours / 24); + return `${days}天前`; + } +} + +// =================================================================== +// [NEW] Toast 通知管理器 +// =================================================================== +class ToastManager { + constructor() { + this.container = document.getElementById('toast-container'); + if (!this.container) { + this.container = document.createElement('div'); + this.container.id = 'toast-container'; + this.container.className = 'fixed bottom-4 right-4 z-[100] w-full max-w-sm space-y-3'; // 宽度可稍大 + document.body.appendChild(this.container); + } + this.activeToasts = new Map(); // [NEW] 用于跟踪可更新的进度Toast + } + /** + * 显示一个 Toast 通知 + * @param {string} message - The message to display. + * @param {string} [type='info'] - 'info', 'success', or 'error'. + * @param {number} [duration=4000] - Duration in milliseconds. + */ + show(message, type = 'info', duration = 4000) { + const toastElement = this._createToastHtml(message, type); + this.container.appendChild(toastElement); + // 强制重绘以触发入场动画 + requestAnimationFrame(() => { + toastElement.classList.remove('opacity-0', 'translate-y-2'); + toastElement.classList.add('opacity-100', 'translate-y-0'); + }); + // 设置定时器以移除 Toast + setTimeout(() => { + toastElement.classList.remove('opacity-100', 'translate-y-0'); + toastElement.classList.add('opacity-0', 'translate-y-2'); + // 在动画结束后从 DOM 中移除 + toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true }); + }, duration); + } + + // [NEW] 创建或更新一个带进度条的Toast + showProgressToast(toastId, title, message, progress) { + if (this.activeToasts.has(toastId)) { + // --- 更新现有Toast --- + const toastElement = this.activeToasts.get(toastId); + const messageEl = toastElement.querySelector('.toast-message'); + const progressBar = toastElement.querySelector('.toast-progress-bar'); + + messageEl.textContent = `${message} - ${Math.round(progress)}%`; + anime({ + targets: progressBar, + width: `${progress}%`, + duration: 400, + easing: 'easeOutQuad' + }); + } else { + // --- 创建新的Toast --- + const toastElement = this._createProgressToastHtml(toastId, title, message, progress); + this.container.appendChild(toastElement); + this.activeToasts.set(toastId, toastElement); + requestAnimationFrame(() => { + toastElement.classList.remove('opacity-0', 'translate-x-full'); + toastElement.classList.add('opacity-100', 'translate-x-0'); + }); + } + } + // [NEW] 移除一个进度Toast + dismiss(toastId, success = null) { + return new Promise((resolve) => { + if (!this.activeToasts.has(toastId)) { + resolve(); + return; + } + const toastElement = this.activeToasts.get(toastId); + const performFadeOut = () => { + toastElement.classList.remove('opacity-100', 'translate-x-0'); + toastElement.classList.add('opacity-0', 'translate-x-full'); + toastElement.addEventListener('transitionend', () => { + toastElement.remove(); + this.activeToasts.delete(toastId); + resolve(); // Resolve the promise ONLY when the element is fully gone. + }, { once: true }); + }; + if (success === null) { // Immediate dismissal + performFadeOut(); + } else { // Graceful, animated dismissal + const iconContainer = toastElement.querySelector('.toast-icon'); + const messageEl = toastElement.querySelector('.toast-message'); + if (success) { + const progressBar = toastElement.querySelector('.toast-progress-bar'); + messageEl.textContent = '已完成'; + anime({ + targets: progressBar, + width: '100%', + duration: 300, + easing: 'easeOutQuad', + complete: () => { + iconContainer.innerHTML = ``; + iconContainer.className = `toast-icon bg-green-500`; + setTimeout(performFadeOut, 900); + } + }); + } else { // Failure + iconContainer.innerHTML = ``; + iconContainer.className = `toast-icon bg-red-500`; + messageEl.textContent = '失败'; + setTimeout(performFadeOut, 1200); + } + } + }); + } + + _createToastHtml(message, type) { + const icons = { + info: { class: 'bg-blue-500', icon: 'fa-info-circle' }, + success: { class: 'bg-green-500', icon: 'fa-check-circle' }, + error: { class: 'bg-red-500', icon: 'fa-exclamation-triangle' } + }; + const typeInfo = icons[type] || icons.info; + const toast = document.createElement('div'); + toast.className = 'toast-item opacity-0 translate-y-2 transition-all duration-300 ease-out'; // 初始状态为动画准备 + toast.innerHTML = ` +
+ +
+
+

${this._capitalizeFirstLetter(type)}

+

${message}

+
+ `; + return toast; + } + + // [NEW] 创建带进度条Toast的HTML结构 + _createProgressToastHtml(toastId, title, message, progress) { + const toast = document.createElement('div'); + toast.className = 'toast-item opacity-0 translate-x-full transition-all duration-300 ease-out'; + toast.dataset.toastId = toastId; + toast.innerHTML = ` +
+ +
+
+

${title}

+

${message} - ${Math.round(progress)}%

+
+
+
+
+ `; + return toast; + } + + _capitalizeFirstLetter(string) { + return string.charAt(0).toUpperCase() + string.slice(1); + } +} + + +export const taskCenterManager = new TaskCenterManager(); +export const toastManager = new ToastManager(); + +// [OPTIONAL] 为了向后兼容或简化调用,可以导出一个独立的 showToast 函数 +export const showToast = (message, type, duration) => toastManager.show(message, type, duration); \ No newline at end of file diff --git a/frontend/js/components/themeManager.js b/frontend/js/components/themeManager.js new file mode 100644 index 0000000..deec58f --- /dev/null +++ b/frontend/js/components/themeManager.js @@ -0,0 +1,105 @@ +// Filename: frontend/js/components/themeManager.js + +/** + * 负责管理应用程序的三态主题(系统、亮色、暗色)。 + * 封装了所有与主题切换相关的 DOM 操作、事件监听和 localStorage 交互。 + */ +export const themeManager = { + // 用于存储图标的 SVG HTML + icons: {}, + + init: function() { + this.html = document.documentElement; + this.buttons = document.querySelectorAll('.theme-btn'); + this.cyclerBtn = document.getElementById('theme-cycler-btn'); + this.cyclerIconContainer = document.getElementById('theme-cycler-icon'); + + if (!this.html || this.buttons.length === 0 || !this.cyclerBtn || !this.cyclerIconContainer) { + console.warn("ThemeManager init failed: one or more required elements not found."); + return; + } + + this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + // 初始化时,从三按钮组中提取 SVG 并存储起来 + this.storeIcons(); + + // 绑定宽屏按钮组的点击事件 + this.buttons.forEach(btn => { + btn.addEventListener('click', () => this.setTheme(btn.dataset.theme)); + }); + + // 绑定移动端循环按钮的点击事件 + this.cyclerBtn.addEventListener('click', () => this.cycleTheme()); + + this.mediaQuery.addEventListener('change', () => this.applyTheme()); + this.applyTheme(); + }, + + // 从现有按钮中提取并存储 SVG 图标 + storeIcons: function() { + this.buttons.forEach(btn => { + const theme = btn.dataset.theme; + const svg = btn.querySelector('svg'); + if (theme && svg) { + this.icons[theme] = svg.outerHTML; + } + }); + }, + + // 循环切换主题的核心逻辑 + cycleTheme: function() { + const themes = ['system', 'light', 'dark']; + const currentTheme = this.getTheme(); + const currentIndex = themes.indexOf(currentTheme); + const nextIndex = (currentIndex + 1) % themes.length; // brilliantly simple cycling logic + this.setTheme(themes[nextIndex]); + }, + + applyTheme: function() { + let theme = this.getTheme(); + if (theme === 'system') { + theme = this.mediaQuery.matches ? 'dark' : 'light'; + } + + if (theme === 'dark') { + this.html.classList.add('dark'); + } else { + this.html.classList.remove('dark'); + } + + this.updateButtons(); + this.updateCyclerIcon(); + }, + + setTheme: function(theme) { + localStorage.setItem('theme', theme); + this.applyTheme(); + }, + + getTheme: function() { + return localStorage.getItem('theme') || 'system'; + }, + + updateButtons: function() { + const currentTheme = this.getTheme(); + this.buttons.forEach(btn => { + if (btn.dataset.theme === currentTheme) { + btn.classList.add('bg-white', 'dark:bg-zinc-700'); + } else { + btn.classList.remove('bg-white', 'dark:bg-zinc-700'); + } + }); + }, + + // 更新移动端循环按钮的图标 + updateCyclerIcon: function() { + if (this.cyclerIconContainer) { + const currentTheme = this.getTheme(); + // 从我们存储的 icons 对象中找到对应的 SVG 并注入 + if (this.icons[currentTheme]) { + this.cyclerIconContainer.innerHTML = this.icons[currentTheme]; + } + } + } +}; diff --git a/frontend/js/components/ui.js b/frontend/js/components/ui.js new file mode 100644 index 0000000..b87730c --- /dev/null +++ b/frontend/js/components/ui.js @@ -0,0 +1,338 @@ +/** + * @file ui.js + * @description Centralizes UI component classes for modals and common UI patterns. + * This module exports singleton instances of `ModalManager` and `UIPatterns` + * to ensure consistent UI behavior across the application. + */ + +/** + * Manages the display and interaction of various modals across the application. + * This class centralizes modal logic to ensure consistency and ease of use. + * It assumes specific HTML structures for modals (e.g., resultModal, progressModal). + */ +class ModalManager { + /** + * 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; + + // Re-clone the button to remove old event listeners and attach the new one. + 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 ? "操作成功" : "操作失败"; + + if (success) { + iconElement.innerHTML = ''; + iconElement.className = "text-6xl mb-3 text-success-500"; + } else { + iconElement.innerHTML = ''; + iconElement.className = "text-6xl mb-3 text-danger-500"; + } + + messageElement.innerHTML = ""; + if (typeof message === "string") { + const messageDiv = document.createElement("div"); + messageDiv.innerText = message; // Use innerText for security with plain strings + messageElement.appendChild(messageDiv); + } else if (message instanceof Node) { + messageElement.appendChild(message); // Append if it's already a DOM node + } 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 = "准备开始..."; + 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; // Auto-scroll to the latest log + } + + /** + * 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(); + } + } +} + +/** + * Provides a collection of common UI patterns and animations. + * This class includes helpers for creating engaging and consistent user experiences, + * such as animated counters and collapsible sections. + */ +class UIPatterns { + /** + * 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); // Ease-out cubic + const currentValue = Math.floor(easeOutValue * finalValue); + valueElement.textContent = currentValue; + requestAnimationFrame(updateCounter); + } else { + valueElement.textContent = valueElement.dataset.originalValue; // Ensure final value is accurate + } + }; + 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) { + // Expand + 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"; // Assumes p-4, adjust if needed + 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 { + // Collapse + 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"); + }); + } + } +} + +/** + * Exports singleton instances of the UI component classes for easy import and use elsewhere. + * This allows any part of the application to access the same instance of ModalManager and UIPatterns, + * ensuring a single source of truth for UI component management. + */ +export const modalManager = new ModalManager(); +export const uiPatterns = new UIPatterns(); + diff --git a/frontend/js/layout/base.js b/frontend/js/layout/base.js new file mode 100644 index 0000000..b71c2c3 --- /dev/null +++ b/frontend/js/layout/base.js @@ -0,0 +1,56 @@ +// Filename: frontend/js/layout/base.js + +// [模块导入] +import { themeManager } from '../components/themeManager.js'; +import { apiFetch, apiFetchJson } from '../services/api.js'; + +/** + * 激活当前页面的侧边栏导航项。 + */ +function initActiveNav() { + const currentPath = window.location.pathname; + const navLinks = document.querySelectorAll('.nav-link'); + + navLinks.forEach(link => { + const linkPath = link.getAttribute('href'); + if (linkPath && linkPath !== '/' && currentPath.startsWith(linkPath)) { + const wrapper = link.closest('.nav-item-wrapper'); + if (wrapper) { + wrapper.dataset.active = 'true'; + } + } + }); +} + +/** + * 将核心 API 函数挂载到 window 对象,以便在需要时进行全局访问或调试。 + * 这充当了模块化世界和全局作用域之间的“桥梁”。 + */ +function bridgeApiToGlobal() { + window.apiFetch = apiFetch; + window.apiFetchJson = apiFetchJson; + console.log('[Bridge] apiFetch and apiFetchJson are now globally available.'); +} + +/** + * 初始化所有与 base.html 布局相关的全局UI元素和事件监听器。 + */ +function initLayout() { + console.log('[Init] Executing global layout JavaScript...'); + + // 1. 初始化侧边栏导航状态 + initActiveNav(); + + // 2. 初始化主题管理器 + themeManager.init(); + + // 3. 建立 API 函数的全局桥梁 + bridgeApiToGlobal(); + + // 4. (预留) 在此处添加未来可能的其他布局逻辑,例如侧边栏的折叠/展开功能 + // const sidebarToggle = document.getElementById('sidebar-toggle'); + // if (sidebarToggle) { ... } +} + +// 默认导出主初始化函数 +export default initLayout; diff --git a/frontend/js/main.js b/frontend/js/main.js new file mode 100644 index 0000000..42cab31 --- /dev/null +++ b/frontend/js/main.js @@ -0,0 +1,50 @@ +// Filename: frontend/js/main.js + +// === 1. 导入通用组件 (这些是所有页面都可能用到的,保持静态导入) === +import SlidingTabs from './components/slidingTabs.js'; +import CustomSelect from './components/customSelect.js'; +import { modalManager, uiPatterns } from './components/ui.js'; +import { taskCenterManager, toastManager} from './components/taskCenter.js'; +// === 2. 导入布局专属的初始化模块 === +import initLayout from './layout/base.js'; +// === 3. 定义动态导入的页面模块映射 === +const pageModules = { + // 键 'dashboard' 对应一个函数,该函数调用 import() 返回一个 Promise + // esbuild 看到这个 import() 语法,就会自动将 dashboard.js 及其依赖打包成一个独立的 chunk 文件 + 'dashboard': () => import('./pages/dashboard.js'), + 'keys': () => import('./pages/keys/index.js'), + 'logs': () => import('./pages/logs/index.js'), + // 'settings': () => import('./pages/settings.js'), // 未来启用 settings 页面 + // 未来新增的页面,只需在这里添加一行映射,esbuild会自动处理 +}; +// === 4. 主执行逻辑,现在是异步的 === +document.addEventListener('DOMContentLoaded', async () => { + initLayout(); + // --- 通用组件初始化 (总会执行) --- + const allTabContainers = document.querySelectorAll('[data-sliding-tabs-container]'); + allTabContainers.forEach(container => new SlidingTabs(container)); + const allSelectContainers = document.querySelectorAll('[data-custom-select-container]'); + allSelectContainers.forEach(container => new CustomSelect(container)); + taskCenterManager.init(); + + // --- 页面专属逻辑调度 (按需执行) --- + const pageContainer = document.querySelector('[data-page-id]'); + if (pageContainer) { + const pageId = pageContainer.dataset.pageId; + if (pageId && pageModules[pageId]) { + try { + const pageModule = await pageModules[pageId](); + if (pageModule.default && typeof pageModule.default === 'function') { + pageModule.default(); + } + } catch (error) { + console.error(`Failed to load module for page: ${pageId}`, error); + } + } + } +}); +// 将管理器挂载到全局,方便在浏览器控制台调试 +window.modalManager = modalManager; +window.taskCenterManager = taskCenterManager; +window.toastManager = toastManager; +window.uiPatterns = uiPatterns; diff --git a/frontend/js/pages/dashboard.js b/frontend/js/pages/dashboard.js new file mode 100644 index 0000000..7c770b8 --- /dev/null +++ b/frontend/js/pages/dashboard.js @@ -0,0 +1,34 @@ +// frontend/js/pages/dashboard.js +/** + * @fileoverview Dashboard Page Initialization Module (Placeholder) + * + * @description + * This file is the designated entry point for all modern, modular JavaScript + * specific to the dashboard page. + * + * CURRENT STATUS: + * As of [25.08.23], the dashboard's primary logic is still handled by legacy + * scripts loaded via + + diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..d6bc473 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,220 @@ + + + + + + + + + + + + {% block title %}GEMINI BALANCER{% endblock %} + + {% block head_extra %}{% endblock %} + + + +
+ + +
+
+ {% block content %}{% endblock %} +
+
+ + + + {% block core_scripts %} + + + + {% endblock core_scripts %} + + {% block page_scripts %}{% endblock page_scripts %} + + {% block modals %}{% endblock modals %} + + + + + + +
+ + +
+ + +
+ + + + + +
+ + diff --git a/web/templates/chat.html b/web/templates/chat.html new file mode 100644 index 0000000..3755f99 --- /dev/null +++ b/web/templates/chat.html @@ -0,0 +1,117 @@ +{% extends "base.html" %} +{% block title %}Web Chat - GEMINI BALANCER{% endblock %} + +{% block content %} + +
+ + + + + + + + + +
+ +
+

关于Python异步编程

+
+ + +
+
+ + +
+
+ +
+ + + +
+
+

你能给我解释一下 aiohttp 客户端的 session 和 connector 管理吗?我总是搞不清楚什么时候应该创建,什么时候应该关闭。

+
+
+
+ +
+ + + +
+
+

当然可以。这是一个非常经典的问题。简单来说,`ClientSession` 应该在你的应用程序的生命周期内尽可能地保持单例存在...

+
+
+
+
+
+ + +
+
+ +
+ + +
+
+
+
+
+{% endblock %} + +{% block page_scripts %} +{% endblock page_scripts %} \ No newline at end of file diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html new file mode 100644 index 0000000..92df042 --- /dev/null +++ b/web/templates/dashboard.html @@ -0,0 +1,203 @@ +{% extends "base.html" %} + +{% block title %}监控面板 - Gemini Balancer{% endblock %} + +{% block head_extra %} + + +{% endblock %} + +{% block content %} + + +
+

数据总览

+
+ +
+ + + + + + + + + + +
+ + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+
密钥统计
+ +
+
+
+ 0 / 0 +
+

有效密钥 / 总密钥数

+
+
+ +
+
+
请求总览
+ +
+
+ +
0
+

24小时请求数

+
+
+ +
+
+
Token 消耗
+ +
+
+ +
0
+

24小时消耗

+
+
+ +
+
+
请求成功率
+ +
+
+ +
0%
+

24小时成功率

+
+
+
+ + +
+

API 状态分布

+ +
+ +
+ +
+ ACTIVE (0) + PENDING (0) + COOLDOWN (0) + DISABLED (0) + BANNED (0) +
+
+ + +
+ +
+

请求趋势

+

成功与失败请求数

+ +
+ + 图表加载中... +
+
+ +
+

模型排行

+

24小时内调用次数

+ +
+ +
排行数据加载中...
+
+
+
+ +
+ + + + + + +
+ + + + +{% endblock %} + + +{% block page_scripts %} + + + + + +{% endblock %} diff --git a/web/templates/error.html b/web/templates/error.html new file mode 100644 index 0000000..99835fd --- /dev/null +++ b/web/templates/error.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}发生错误 - Gemini Balance{% endblock %} +{% block content %} +
+ +
+{% endblock %} \ No newline at end of file diff --git a/web/templates/keys.html b/web/templates/keys.html new file mode 100644 index 0000000..92c6ed5 --- /dev/null +++ b/web/templates/keys.html @@ -0,0 +1,943 @@ + +{% extends "base.html" %} +{% block title %}API 分组管理 - GEMINI BALANCER{% endblock %} + +{% block content %} +
+ +
+

API 管理

+ +
+ +
+ + + + + {# 右侧主内容区: Group 详情与 API 管理 #} +
+ +
+ +
+

默认分组 (Default)

+
+ + + + +
+
+ +
+ +
+

7 / 10

+

有效密钥 / 总密钥数

+
+
+ +
+

0

+

24小时请求数

+
+
+ +
+

0

+

24小时消耗

+
+
+ +
+

0%

+

24小时成功率

+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+ + + +
+
+ + +
+ +
+ +
+ 所有状态 + +
+ +
+ +
+
+ + +
+
+ + + + + +
+
+
+ + +
+ + + + + + + +
+ + + +
+ +
+ 20 / 页 + +
+ +
+
+ + +
+
+
+
+ + +
+ +
+ + +
+ +
+
+
+ +
+
+{% endblock %} + +{% block modals %} + + + + + + + + + + + + + + + + + + + +{% endblock modals %} + +{% block page_scripts %} +{% endblock page_scripts %} \ No newline at end of file diff --git a/web/templates/logs.html b/web/templates/logs.html new file mode 100644 index 0000000..19d2cbe --- /dev/null +++ b/web/templates/logs.html @@ -0,0 +1,184 @@ +{% extends "base.html" %} +{% block title %}日志管理 - GEMINI BALANCER{% endblock %} + +{% block content %} +
+ + + +
+
+

日志管理

+

查看、筛选和分析所有通过系统的请求记录。

+
+
+ + +
+
+ + + + + + + + + +
+ +
+ + + + + + + + +
+ + + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + IDGemini 密钥错误类型错误码模型名称请求时间操作
#12346AIza...s7f1API Key Invalid429gemini-1.5-pro-latest2024-05-21 10:31:15 + +
#12347AIza...s7f2Quota Exceeded429gemini-1.0-pro2024-05-21 10:32:15 + +
#12348AIza...s7f3Server Error500gemini-1.5-pro-latest2024-05-21 10:33:15 + +
+
+ + +
+
+ 已选择 0 / 100 +
+
+
+

每页行数

+ +
+
+ 第 1 / 10 页 +
+
+ + + + +
+
+
+
+
+ +{% endblock %} + +{% block modals %} + +{% endblock modals %} + +{% block page_scripts %} +{% endblock page_scripts %} \ No newline at end of file diff --git a/web/templates/settings.html b/web/templates/settings.html new file mode 100644 index 0000000..f2bea8c --- /dev/null +++ b/web/templates/settings.html @@ -0,0 +1,2048 @@ +{% extends "base.html" %} {% block title %}配置编辑器 - Gemini Balance{%endblock %} +{% block head_extra_styles %}{% endblock %} +{% block content %} +
+
+ + +

+ Gemini Balance Logo + Gemini Balance - 系统设置 +

+ + + + + +
+ + + + + + + +
+ + + + +
+ +
+

+ API相关配置 +

+ + +
+
+ + + +
+ + +
+
+ +
+
正在加载令牌列表...
+
+ +
+ +
+ + 在此处管理所有API认证令牌。点击“齿轮”图标进行详细配置。 + +
+ + +
+ +
+
+ + +
+
+ 用于API认证的令牌 +
+ + + + +
+
+ +
+ + + +
+ +
+ + +
+
+ + 当一个IP连续多次登录失败后,是否自动将其封禁一段时间。 + + + + +
+ + +
+ + + Gemini API的基础URL +
+ + +
+ +
+ +
+ 添加自定义请求头,例如 X-Api-Key: your-key +
+
+
+ +
+ 在这里添加的键值对将被添加到所有出站API请求的Header中。 +
+ + +
+
+ +
+ + +
+
+ + 自动客户端请求的url拼接为正确格式(仅保证正常聊天,出现问题请关闭) + +
+ +
+ + + API密钥失败后标记为无效的次数 +
+ + +
+ + + API请求的超时时间 +
+ + +
+ + + API请求失败后的最大重试次数 +
+ +
+
+ +
+ + + + + +
+
+ 代理服务器列表,支持 http 和 socks5 格式,例如: + http://user:pass@host:port 或 + socks5://host:port。点击按钮可批量添加或删除。 +
+ +
+
+ + + +
+ + + + + + + +
+ +
+
+ +
+ + +
+
+ 开启后,对于每一个API_KEY将根据算法从代理列表中选取同一个代理IP,防止一个API_KEY同时被多个IP访问,也同时防止了一个IP访问了过多的API_KEY。 +
+
+ + +
+

+ 模型相关配置 +

+ + +
+ +
+ + +
+ 用于测试API密钥的模型 +
+ + +
+ +
+ +
+
+ + +
+ 支持图像处理的模型列表 +
+ + +
+ +
+ +
+
+ + +
+ 支持搜索功能的模型列表 +
+ + +
+ +
+ +
+
+ + +
+ 需要过滤的模型列表 +
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+
+ + +
+ 支持网址上下文功能的模型列表 +
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+
+ + +
+ 用于"思考过程"的模型列表 +
+ + +
+ +
+ +
+ 请先在上方添加思考模型,然后在此处配置预算。 +
+
+ + + 为每个思考模型设置预算(-1为auto,最大值 + 32767),此项与上方模型列表自动关联。 +
+ +
+ +
+ +
+ 定义模型的安全过滤阈值。 +
+
+
+ +
+ 配置模型的安全过滤级别,例如 HARM_CATEGORY_HARASSMENT: + BLOCK_NONE。 +
+ + 建议设置成OFF,其他值会影响输出速度,非必要不要随便改动。 +
+
+
+ + +
+

+ TTS 相关配置 +

+ + +
+ + + 用于TTS的模型 +
+ + +
+ + + TTS 的语音名称,控制风格、语调、口音和节奏 +
+ + +
+ + + 选择 TTS 的语速 +
+
+ + +
+

+ 图像生成配置 +

+ + +
+ + + 用于图像生成的付费API密钥 +
+ + +
+ +
+ + +
+ 用于图像生成的模型 +
+ + +
+ + + 图片上传服务提供商 +
+ + +
+ + + SM.MS图床的密钥 +
+ + +
+ + + PicGo的API密钥 +
+ + +
+ + + Cloudflare图床的URL +
+ + +
+ + + Cloudflare图床的认证码 +
+ + +
+ + + Cloudflare图床的上传文件夹路径(可选) +
+
+ + +
+

+ 流式输出相关配置 +

+ + +
+ +
+ + +
+
+ + +
+ + + 流式输出的最小延迟时间 +
+ + +
+ + + 流式输出的最大延迟时间 +
+ + +
+ + + 短文本的字符阈值 +
+ + +
+ + + 长文本的字符阈值 +
+ + +
+ + + 流式输出的分块大小 +
+ + +

+ 假流式配置 (Fake + Streaming) +

+ + +
+ +
+ + +
+
+ 当启用时,将调用非流式接口,并在等待响应期间发送空数据以维持连接。 + + +
+ + + 在启用假流式输出时,向客户端发送空数据以维持连接状态的时间间隔(建议 + 3-10 秒)。 +
+
+ + +
+

+ 定时任务配置 +

+ + +
+ + + 定时检查密钥状态的间隔时间(单位:小时) +
+ + +
+ + + 定时任务使用的时区,格式如 "Asia/Shanghai" 或 "UTC" +
+
+ + +
+

+ 日志配置 +

+ + +
+ + + 设置应用程序的日志记录详细程度 +
+ + +
+
+ +
+ + +
+
+ 开启后,将自动删除指定天数前的错误日志。 +
+ + +
+ + + 选择自动删除错误日志的天数。 +
+ + +
+
+ +
+ + +
+
+ 开启后,将自动删除指定天数前的请求日志。 +
+ + +
+ + + 选择自动删除请求日志的天数。 +
+
+ + +
+ + +
+
+
+
+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% endblock %} {% block body_scripts %} + + + +{% endblock %} \ No newline at end of file diff --git a/web/templates/tasks.html b/web/templates/tasks.html new file mode 100644 index 0000000..23d0b29 --- /dev/null +++ b/web/templates/tasks.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} +{% block title %}计划任务 - GEMINI BALANCER{% endblock %} + +{% block content %} +
+ + + +
+
+

计划任务

+

管理和监控所有周期性系统任务。

+
+
+ +
+
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
状态任务名称CRON 表达式上次运行下次运行操作
+
+ + + + + 运行中 +
+
密钥可用性检查0 */1 * * *2024-05-21 10:30:002024-05-21 11:30:00 + + +
+
+ + 已暂停 +
+
清理过期日志0 1 * * *2024-05-20 01:00:00N/A + + +
+
+ + 失败 +
+
同步外部数据源*/30 * * * *2024-05-21 10:00:00 (错误)2024-05-21 10:30:00 + + +
+
+
+
+{% endblock %} + +{% block page_scripts %} +{% endblock page_scripts %} \ No newline at end of file diff --git a/web/templates/test.html b/web/templates/test.html new file mode 100644 index 0000000..3022726 --- /dev/null +++ b/web/templates/test.html @@ -0,0 +1,10 @@ + + + + Pongo2 Test + + +

Hello from Test Template!

+

My name is {{ name }}.

+ + \ No newline at end of file