Initial commit
This commit is contained in:
753
frontend/input.css
Normal file
753
frontend/input.css
Normal file
@@ -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;
|
||||
}
|
||||
312
frontend/js/components/apiKeyManager.js
Normal file
312
frontend/js/components/apiKeyManager.js
Normal file
@@ -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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<string[]>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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<object>} 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();
|
||||
126
frontend/js/components/customSelect.js
Normal file
126
frontend/js/components/customSelect.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
80
frontend/js/components/slidingTabs.js
Normal file
80
frontend/js/components/slidingTabs.js
Normal file
@@ -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 <a> 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);
|
||||
});
|
||||
});
|
||||
132
frontend/js/components/tagInput.js
Normal file
132
frontend/js/components/tagInput.js
Normal file
@@ -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 = '<i class="fas fa-copy"></i>';
|
||||
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 = `<span class="tag-text">${value}</span><button class="tag-delete">×</button>`;
|
||||
this.container.insertBefore(tagEl, this.input);
|
||||
}
|
||||
|
||||
// 处理复制逻辑的专用方法
|
||||
_handleCopyAll() {
|
||||
const tagsString = this.tags.join(',');
|
||||
if (!tagsString) {
|
||||
// 如果没有标签,可以给个提示
|
||||
this.copyBtn.innerHTML = '<span>无内容!</span>';
|
||||
this.copyBtn.classList.add('none');
|
||||
setTimeout(() => {
|
||||
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
||||
this.copyBtn.classList.remove('copied');
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(tagsString).then(() => {
|
||||
// 复制成功,提供视觉反馈
|
||||
this.copyBtn.innerHTML = '<span>已复制!</span>';
|
||||
this.copyBtn.classList.add('copied');
|
||||
setTimeout(() => {
|
||||
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
||||
this.copyBtn.classList.remove('copied');
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
// 复制失败
|
||||
console.error('Could not copy text: ', err);
|
||||
this.copyBtn.innerHTML = '<span>失败!</span>';
|
||||
setTimeout(() => {
|
||||
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
||||
}, 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
502
frontend/js/components/taskCenter.js
Normal file
502
frontend/js/components/taskCenter.js
Normal file
@@ -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 `<div class="task-list-item" data-task-id="${task.id}">${innerHtml}</div>`;
|
||||
}
|
||||
_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 = `<i class="fas fa-check-circle text-white"></i>`;
|
||||
iconContainer.className = `toast-icon bg-green-500`;
|
||||
setTimeout(performFadeOut, 900);
|
||||
}
|
||||
});
|
||||
} else { // Failure
|
||||
iconContainer.innerHTML = `<i class="fas fa-times-circle text-white"></i>`;
|
||||
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 = `
|
||||
<div class="toast-icon ${typeInfo.class}">
|
||||
<i class="fas ${typeInfo.icon}"></i>
|
||||
</div>
|
||||
<div class="toast-content">
|
||||
<p class="toast-title">${this._capitalizeFirstLetter(type)}</p>
|
||||
<p class="toast-message">${message}</p>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="toast-icon bg-blue-500">
|
||||
<i class="fas fa-spinner animate-spin"></i>
|
||||
</div>
|
||||
<div class="toast-content">
|
||||
<p class="toast-title">${title}</p>
|
||||
<p class="toast-message">${message} - ${Math.round(progress)}%</p>
|
||||
<div class="w-full bg-slate-200 dark:bg-zinc-700 rounded-full h-1 mt-1.5 overflow-hidden">
|
||||
<div class="toast-progress-bar bg-blue-500 h-1 rounded-full" style="width: ${progress}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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);
|
||||
105
frontend/js/components/themeManager.js
Normal file
105
frontend/js/components/themeManager.js
Normal file
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
338
frontend/js/components/ui.js
Normal file
338
frontend/js/components/ui.js
Normal file
@@ -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 = '<i class="fas fa-check-circle text-success-500"></i>';
|
||||
iconElement.className = "text-6xl mb-3 text-success-500";
|
||||
} else {
|
||||
iconElement.innerHTML = '<i class="fas fa-times-circle text-danger-500"></i>';
|
||||
iconElement.className = "text-6xl mb-3 text-danger-500";
|
||||
}
|
||||
|
||||
messageElement.innerHTML = "";
|
||||
if (typeof message === "string") {
|
||||
const messageDiv = document.createElement("div");
|
||||
messageDiv.innerText = message; // 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();
|
||||
|
||||
56
frontend/js/layout/base.js
Normal file
56
frontend/js/layout/base.js
Normal file
@@ -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;
|
||||
50
frontend/js/main.js
Normal file
50
frontend/js/main.js
Normal file
@@ -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;
|
||||
34
frontend/js/pages/dashboard.js
Normal file
34
frontend/js/pages/dashboard.js
Normal file
@@ -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 <script> tags in `dashboard.html` (e.g., `static/js/dashboard.js`).
|
||||
*
|
||||
* MIGRATION STRATEGY:
|
||||
* 1. Identify a piece of functionality in the legacy scripts (e.g., auto-refresh timer).
|
||||
* 2. Re-implement that functionality within the `init()` function below, following
|
||||
* modern ES module standards.
|
||||
* 3. Remove the corresponding code from the legacy script file.
|
||||
* 4. Repeat until the legacy scripts are empty and can be removed entirely.
|
||||
*
|
||||
* @version 0.1.0
|
||||
* @author [xof/团队名]
|
||||
*/
|
||||
export default function init() {
|
||||
// This console log serves as a confirmation that the modern module is being
|
||||
// correctly dispatched by main.js. It's safe to leave here during migration.
|
||||
console.log('[Modern Frontend] Dashboard module loaded. Future logic will execute here.');
|
||||
// === MIGRATION AREA ===
|
||||
// When you migrate a feature, add its initialization code here.
|
||||
// For example:
|
||||
//
|
||||
// import { initializeAutoRefresh } from '../features/autoRefresh.js';
|
||||
// initializeAutoRefresh();
|
||||
//
|
||||
}
|
||||
1182
frontend/js/pages/error_logs.js
Normal file
1182
frontend/js/pages/error_logs.js
Normal file
File diff suppressed because it is too large
Load Diff
1823
frontend/js/pages/keys.js
Normal file
1823
frontend/js/pages/keys.js
Normal file
File diff suppressed because it is too large
Load Diff
167
frontend/js/pages/keys/addApiModal.js
Normal file
167
frontend/js/pages/keys/addApiModal.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// frontend/js/pages/keys/addApiModal.js
|
||||
|
||||
// [REFACTORED] 引入全局的 taskCenterManager 和 modalManager
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
import { taskCenterManager, toastManager } from '../../components/taskCenter.js';
|
||||
import { apiKeyManager } from '../../components/apiKeyManager.js';
|
||||
import { isValidApiKeyFormat } from '../../utils/utils.js';
|
||||
|
||||
export default class AddApiModal {
|
||||
constructor({ onImportSuccess }) {
|
||||
this.modalId = 'add-api-modal';
|
||||
this.onImportSuccess = onImportSuccess;
|
||||
this.activeGroupId = null;
|
||||
|
||||
this.elements = {
|
||||
modal: document.getElementById(this.modalId),
|
||||
title: document.getElementById('add-api-modal-title'),
|
||||
inputView: document.getElementById('add-api-input-view'),
|
||||
textarea: document.getElementById('api-add-textarea'),
|
||||
importBtn: document.getElementById('add-api-import-btn'),
|
||||
validateCheckbox: document.getElementById('validate-on-import-checkbox'),
|
||||
};
|
||||
|
||||
if (!this.elements.modal) {
|
||||
throw new Error(`Modal with id "${this.modalId}" not found.`);
|
||||
}
|
||||
|
||||
this._initEventListeners();
|
||||
}
|
||||
|
||||
open(activeGroupId) {
|
||||
if (!activeGroupId) {
|
||||
console.error("Cannot open AddApiModal: activeGroupId is required.");
|
||||
return;
|
||||
}
|
||||
this.activeGroupId = activeGroupId;
|
||||
this._reset();
|
||||
modalManager.show(this.modalId);
|
||||
}
|
||||
|
||||
_initEventListeners() {
|
||||
this.elements.importBtn?.addEventListener('click', this._handleSubmit.bind(this));
|
||||
|
||||
const closeAction = () => {
|
||||
this._reset();
|
||||
modalManager.hide(this.modalId);
|
||||
};
|
||||
|
||||
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||||
closeTriggers.forEach(trigger => trigger.addEventListener('click', closeAction));
|
||||
this.elements.modal.addEventListener('click', (event) => {
|
||||
if (event.target === this.elements.modal) closeAction();
|
||||
});
|
||||
}
|
||||
|
||||
async _handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
const cleanedKeys = this._parseAndCleanKeys(this.elements.textarea.value);
|
||||
if (cleanedKeys.length === 0) {
|
||||
alert('没有检测到有效的API Keys。');
|
||||
return;
|
||||
}
|
||||
|
||||
this.elements.importBtn.disabled = true;
|
||||
this.elements.importBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>正在启动...`;
|
||||
const addKeysTask = {
|
||||
start: async () => {
|
||||
const shouldValidate = this.elements.validateCheckbox.checked;
|
||||
const response = await apiKeyManager.addKeysToGroup(this.activeGroupId, cleanedKeys.join('\n'), shouldValidate);
|
||||
if (!response.success || !response.data) throw new Error(response.message || '启动导入任务失败。');
|
||||
return response.data;
|
||||
},
|
||||
poll: async (taskId) => {
|
||||
return await apiKeyManager.getTaskStatus(taskId, { noCache: true });
|
||||
},
|
||||
renderTaskCenterItem: (data, timestamp, formatTimeAgo) => {
|
||||
const timeAgo = formatTimeAgo(timestamp);
|
||||
let contentHtml = '';
|
||||
if (!data.is_running && !data.error) { // --- SUCCESS state ---
|
||||
const result = data.result || {};
|
||||
const newlyLinked = result.newly_linked_count || 0;
|
||||
const alreadyLinked = result.already_linked_count || 0;
|
||||
const summaryTitle = `批量链接 ${newlyLinked} Key,已跳过 ${alreadyLinked}`;
|
||||
|
||||
contentHtml = `
|
||||
<div class="task-item-main">
|
||||
<div class="task-item-icon-summary text-green-500"><i class="fas fa-check-circle"></i></div>
|
||||
<div class="task-item-content flex-grow">
|
||||
<div class="flex justify-between items-center cursor-pointer" data-task-toggle>
|
||||
<p class="task-item-title">${summaryTitle}</p>
|
||||
<i class="fas fa-chevron-down task-toggle-icon"></i>
|
||||
</div>
|
||||
<div class="task-details-content collapsed" data-task-content>
|
||||
<div class="task-details-body space-y-1">
|
||||
<p class="flex justify-between"><span>有效输入:</span> <span class="font-semibold">${data.total}</span></p>
|
||||
<p class="flex justify-between"><span>分组中已存在 (跳过):</span> <span class="font-semibold">${alreadyLinked}</span></p>
|
||||
<p class="flex justify-between font-bold"><span>新增链接:</span> <span>${newlyLinked}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (!data.is_running && data.error) { // --- ERROR state ---
|
||||
contentHtml = `
|
||||
<div class="task-item-main">
|
||||
<div class="task-item-icon-summary text-red-500"><i class="fas fa-times-circle"></i></div>
|
||||
<div class="task-item-content flex-grow">
|
||||
<p class="task-item-title">批量添加失败</p>
|
||||
<p class="task-item-status text-red-500 truncate" title="${data.error || '未知错误'}">
|
||||
${data.error || '未知错误'}
|
||||
</p>
|
||||
</div>
|
||||
</div>`;
|
||||
} else { // --- RUNNING state ---
|
||||
contentHtml = `
|
||||
<div class="task-item-main gap-3">
|
||||
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
|
||||
<div class="task-item-content flex-grow">
|
||||
<p class="task-item-title">批量添加 ${data.total} 个API Key</p>
|
||||
<p class="task-item-status">运行中...</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
|
||||
},
|
||||
renderToastNarrative: (data, oldData, toastManager) => {
|
||||
const toastId = `task-${data.id}`;
|
||||
const progress = data.total > 0 ? (data.processed / data.total) * 100 : 0;
|
||||
// It just reports the current progress, that's its only job.
|
||||
toastManager.showProgressToast(toastId, `批量添加Key`, '处理中', progress); // (Change title for delete modal)
|
||||
},
|
||||
|
||||
// This now ONLY shows the FINAL summary toast, after everything else is done.
|
||||
onSuccess: (data) => {
|
||||
if (this.onImportSuccess) this.onImportSuccess(); // (Or onDeleteSuccess)
|
||||
const newlyLinked = data.result?.newly_linked_count || 0; // (Or unlinked_count)
|
||||
toastManager.show(`任务完成!成功链接 ${newlyLinked} 个Key。`, 'success'); // (Adjust text for delete)
|
||||
},
|
||||
|
||||
// This is the final error handler.
|
||||
onError: (data) => {
|
||||
toastManager.show(`任务失败: ${data.error || '未知错误'}`, 'error');
|
||||
}
|
||||
};
|
||||
// Pass the entire definition to the dispatcher
|
||||
taskCenterManager.startTask(addKeysTask);
|
||||
|
||||
modalManager.hide(this.modalId);
|
||||
this._reset();
|
||||
}
|
||||
|
||||
_reset() {
|
||||
// [REMOVED] 不再需要管理 resultView
|
||||
this.elements.title.textContent = '批量添加 API Keys';
|
||||
this.elements.inputView.classList.remove('hidden');
|
||||
this.elements.textarea.value = '';
|
||||
this.elements.textarea.disabled = false;
|
||||
this.elements.importBtn.disabled = false;
|
||||
this.elements.importBtn.innerHTML = '导入'; // 使用 innerHTML 避免潜在的 XSS
|
||||
}
|
||||
|
||||
_parseAndCleanKeys(text) {
|
||||
const keys = text.replace(/[,;]/g, ' ').split(/[\s\n]+/);
|
||||
const cleanedKeys = keys.map(key => key.trim()).filter(key => isValidApiKeyFormat(key));
|
||||
return [...new Set(cleanedKeys)];
|
||||
}
|
||||
}
|
||||
1434
frontend/js/pages/keys/apiKeyList.js
Normal file
1434
frontend/js/pages/keys/apiKeyList.js
Normal file
File diff suppressed because it is too large
Load Diff
90
frontend/js/pages/keys/cloneGroupModal.js
Normal file
90
frontend/js/pages/keys/cloneGroupModal.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// Filename: frontend/js/pages/keys/cloneGroupModal.js
|
||||
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
import { apiFetch } from '../../services/api.js';
|
||||
import { toastManager } from '../../components/taskCenter.js';
|
||||
|
||||
export default class CloneGroupModal {
|
||||
constructor({ onCloneSuccess }) {
|
||||
this.modalId = 'clone-group-modal';
|
||||
this.onCloneSuccess = onCloneSuccess;
|
||||
this.activeGroup = null;
|
||||
|
||||
this.elements = {
|
||||
modal: document.getElementById(this.modalId),
|
||||
title: document.getElementById('clone-group-modal-title'),
|
||||
confirmBtn: document.getElementById('clone-group-confirm-btn'),
|
||||
};
|
||||
|
||||
if (!this.elements.modal) {
|
||||
console.error(`Modal with id "${this.modalId}" not found. Ensure the HTML is in your document.`);
|
||||
return;
|
||||
}
|
||||
this._initEventListeners();
|
||||
}
|
||||
|
||||
open(group) {
|
||||
if (!group || !group.id) {
|
||||
console.error("Cannot open CloneGroupModal: a group object with an ID is required.");
|
||||
return;
|
||||
}
|
||||
this.activeGroup = group;
|
||||
this.elements.title.innerHTML = `确认克隆分组 <code class="text-base font-semibold text-blue-500">${group.display_name}</code>`;
|
||||
this._reset();
|
||||
modalManager.show(this.modalId);
|
||||
}
|
||||
|
||||
_initEventListeners() {
|
||||
this.elements.confirmBtn?.addEventListener('click', this._handleSubmit.bind(this));
|
||||
|
||||
const closeAction = () => {
|
||||
this._reset();
|
||||
modalManager.hide(this.modalId);
|
||||
};
|
||||
|
||||
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||||
closeTriggers.forEach(trigger => trigger.addEventListener('click', closeAction));
|
||||
this.elements.modal.addEventListener('click', (event) => {
|
||||
if (event.target === this.elements.modal) closeAction();
|
||||
});
|
||||
}
|
||||
|
||||
async _handleSubmit() {
|
||||
if (!this.activeGroup) return;
|
||||
|
||||
this.elements.confirmBtn.disabled = true;
|
||||
this.elements.confirmBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>克隆中...`;
|
||||
|
||||
try {
|
||||
const endpoint = `/admin/keygroups/${this.activeGroup.id}/clone`;
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: 'POST',
|
||||
noCache: true
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
toastManager.show(`分组 '${this.activeGroup.display_name}' 已成功克隆。`, 'success');
|
||||
if (this.onCloneSuccess) {
|
||||
// Pass the entire new group object back to the main controller.
|
||||
this.onCloneSuccess(result.data);
|
||||
}
|
||||
modalManager.hide(this.modalId);
|
||||
} else {
|
||||
throw new Error(result.error?.message || result.message || '克隆失败,请稍后再试。');
|
||||
}
|
||||
} catch (error) {
|
||||
toastManager.show(`克隆失败: ${error.message}`, 'error');
|
||||
} finally {
|
||||
this._reset();
|
||||
}
|
||||
}
|
||||
|
||||
_reset() {
|
||||
if (this.elements.confirmBtn) {
|
||||
this.elements.confirmBtn.disabled = false;
|
||||
this.elements.confirmBtn.innerHTML = '确认克隆';
|
||||
}
|
||||
}
|
||||
}
|
||||
165
frontend/js/pages/keys/deleteApiModal.js
Normal file
165
frontend/js/pages/keys/deleteApiModal.js
Normal file
@@ -0,0 +1,165 @@
|
||||
// frontend/js/pages/keys/deleteApiModal.js
|
||||
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
import { taskCenterManager, toastManager } from '../../components/taskCenter.js';
|
||||
import { apiKeyManager } from '../../components/apiKeyManager.js';
|
||||
import { isValidApiKeyFormat } from '../../utils/utils.js';
|
||||
|
||||
export default class DeleteApiModal {
|
||||
constructor({ onDeleteSuccess }) {
|
||||
this.modalId = 'delete-api-modal';
|
||||
this.onDeleteSuccess = onDeleteSuccess;
|
||||
this.activeGroupId = null;
|
||||
|
||||
this.elements = {
|
||||
modal: document.getElementById(this.modalId),
|
||||
textarea: document.getElementById('api-delete-textarea'),
|
||||
deleteBtn: document.getElementById(this.modalId).querySelector('.modal-btn-danger'),
|
||||
};
|
||||
|
||||
if (!this.elements.modal) {
|
||||
throw new Error(`Modal with id "${this.modalId}" not found.`);
|
||||
}
|
||||
|
||||
this._initEventListeners();
|
||||
}
|
||||
|
||||
open(activeGroupId) {
|
||||
if (!activeGroupId) {
|
||||
console.error("Cannot open DeleteApiModal: activeGroupId is required.");
|
||||
return;
|
||||
}
|
||||
this.activeGroupId = activeGroupId;
|
||||
this._reset();
|
||||
modalManager.show(this.modalId);
|
||||
}
|
||||
|
||||
_initEventListeners() {
|
||||
this.elements.deleteBtn?.addEventListener('click', this._handleSubmit.bind(this));
|
||||
|
||||
const closeAction = () => {
|
||||
this._reset();
|
||||
modalManager.hide(this.modalId);
|
||||
};
|
||||
|
||||
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||||
closeTriggers.forEach(trigger => trigger.addEventListener('click', closeAction));
|
||||
this.elements.modal.addEventListener('click', (event) => {
|
||||
if (event.target === this.elements.modal) closeAction();
|
||||
});
|
||||
}
|
||||
|
||||
async _handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
const cleanedKeys = this._parseAndCleanKeys(this.elements.textarea.value);
|
||||
if (cleanedKeys.length === 0) {
|
||||
alert('没有检测到有效的API Keys。');
|
||||
return;
|
||||
}
|
||||
this.elements.deleteBtn.disabled = true;
|
||||
this.elements.deleteBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>正在启动...`;
|
||||
const deleteKeysTask = {
|
||||
start: async () => {
|
||||
const response = await apiKeyManager.unlinkKeysFromGroup(this.activeGroupId, cleanedKeys.join('\n'));
|
||||
if (!response.success || !response.data) throw new Error(response.message || '启动解绑任务失败。');
|
||||
return response.data;
|
||||
},
|
||||
poll: async (taskId) => {
|
||||
return await apiKeyManager.getTaskStatus(taskId, { noCache: true });
|
||||
},
|
||||
renderTaskCenterItem: (data, timestamp, formatTimeAgo) => {
|
||||
const timeAgo = formatTimeAgo(timestamp);
|
||||
let contentHtml = '';
|
||||
if (!data.is_running && !data.error) { // --- SUCCESS state ---
|
||||
const result = data.result || {};
|
||||
const unlinked = result.unlinked_count || 0;
|
||||
const deleted = result.hard_deleted_count || 0;
|
||||
const notFound = result.not_found_count || 0;
|
||||
const totalInput = data.total;
|
||||
const summaryTitle = `解绑 ${unlinked} Key,清理 ${deleted}`;
|
||||
|
||||
// [MODIFIED] Applied Flexbox layout for proper spacing.
|
||||
contentHtml = `
|
||||
<div class="task-item-main">
|
||||
<div class="task-item-icon-summary text-green-500"><i class="fas fa-check-circle"></i></div>
|
||||
<div class="task-item-content flex-grow">
|
||||
<div class="flex justify-between items-center cursor-pointer" data-task-toggle>
|
||||
<p class="task-item-title">${summaryTitle}</p>
|
||||
<i class="fas fa-chevron-down task-toggle-icon"></i>
|
||||
</div>
|
||||
<div class="task-details-content collapsed" data-task-content>
|
||||
<div class="task-details-body space-y-1">
|
||||
<p class="flex justify-between"><span>有效输入:</span> <span class="font-semibold">${totalInput}</span></p>
|
||||
<p class="flex justify-between"><span>未在分组中找到:</span> <span class="font-semibold">${notFound}</span></p>
|
||||
<p class="flex justify-between"><span>从分组中解绑:</span> <span class="font-semibold">${unlinked}</span></p>
|
||||
<p class="flex justify-between font-bold"><span>彻底清理孤立Key:</span> <span>${deleted}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (!data.is_running && data.error) { // --- ERROR state ---
|
||||
// [MODIFIED] Applied Flexbox layout for proper spacing.
|
||||
contentHtml = `
|
||||
<div class="task-item-main">
|
||||
<div class="task-item-icon-summary text-red-500"><i class="fas fa-times-circle"></i></div>
|
||||
<div class="task-item-content flex-grow">
|
||||
<p class="task-item-title">批量删除失败</p>
|
||||
<p class="task-item-status text-red-500 truncate" title="${data.error || '未知错误'}">
|
||||
${data.error || '未知错误'}
|
||||
</p>
|
||||
</div>
|
||||
</div>`;
|
||||
} else { // --- RUNNING state ---
|
||||
// [MODIFIED] Applied Flexbox layout with gap for spacing.
|
||||
// [FIX] Replaced 'fa-spin' with Tailwind's 'animate-spin' for reliable animation.
|
||||
contentHtml = `
|
||||
<div class="task-item-main gap-3">
|
||||
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
|
||||
<div class="task-item-content flex-grow">
|
||||
<p class="task-item-title">批量删除 ${data.total} 个API Key</p>
|
||||
<p class="task-item-status">运行中...</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
|
||||
},
|
||||
// he Toast is now solely responsible for showing real-time progress.
|
||||
renderToastNarrative: (data, oldData, toastManager) => {
|
||||
const toastId = `task-${data.id}`;
|
||||
const progress = data.total > 0 ? (data.processed / data.total) * 100 : 0;
|
||||
// It just reports the current progress, that's its only job.
|
||||
toastManager.showProgressToast(toastId, `批量删除Key`, '处理中', progress); // (Change title for delete modal)
|
||||
},
|
||||
|
||||
// This now ONLY shows the FINAL summary toast, after everything else is done.
|
||||
onSuccess: (data) => {
|
||||
if (this.onDeleteSuccess) this.onDeleteSuccess(); // (Or onDeleteSuccess)
|
||||
const newlyLinked = data.result?.newly_linked_count || 0; // (Or unlinked_count)
|
||||
toastManager.show(`任务完成!成功删除 ${newlyLinked} 个Key。`, 'success'); // (Adjust text for delete)
|
||||
},
|
||||
|
||||
// This is the final error handler.
|
||||
onError: (data) => {
|
||||
toastManager.show(`任务失败: ${data.error || '未知错误'}`, 'error');
|
||||
}
|
||||
};
|
||||
taskCenterManager.startTask(deleteKeysTask);
|
||||
|
||||
modalManager.hide(this.modalId);
|
||||
this._reset();
|
||||
}
|
||||
|
||||
_reset() {
|
||||
this.elements.textarea.value = '';
|
||||
this.elements.deleteBtn.disabled = false;
|
||||
this.elements.deleteBtn.innerHTML = '删除';
|
||||
}
|
||||
|
||||
_parseAndCleanKeys(text) {
|
||||
const keys = text.replace(/[,;]/g, ' ').split(/[\s\n]+/);
|
||||
const cleanedKeys = keys.map(key => key.trim()).filter(key => isValidApiKeyFormat(key));
|
||||
return [...new Set(cleanedKeys)];
|
||||
}
|
||||
}
|
||||
|
||||
88
frontend/js/pages/keys/deleteGroupModal.js
Normal file
88
frontend/js/pages/keys/deleteGroupModal.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// Filename: frontend/js/pages/keys/deleteGroupModal.js
|
||||
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
import { apiFetch } from '../../services/api.js';
|
||||
import { toastManager } from '../../components/taskCenter.js';
|
||||
|
||||
export default class DeleteGroupModal {
|
||||
constructor({ onDeleteSuccess }) {
|
||||
this.modalId = 'delete-group-modal';
|
||||
this.onDeleteSuccess = onDeleteSuccess;
|
||||
this.activeGroup = null;
|
||||
this.elements = {
|
||||
modal: document.getElementById(this.modalId),
|
||||
title: document.getElementById('delete-group-modal-title'),
|
||||
confirmInput: document.getElementById('delete-group-confirm-input'),
|
||||
confirmBtn: document.getElementById('delete-group-confirm-btn'),
|
||||
};
|
||||
if (!this.elements.modal) {
|
||||
throw new Error(`Modal with id "${this.modalId}" not found.`);
|
||||
}
|
||||
this._initEventListeners();
|
||||
}
|
||||
open(group) {
|
||||
if (!group || !group.id) {
|
||||
console.error("Cannot open DeleteGroupModal: group object with id is required.");
|
||||
return;
|
||||
}
|
||||
this.activeGroup = group;
|
||||
this.elements.title.innerHTML = `确认删除分组 <code class="text-base font-semibold text-red-500">${group.display_name}</code>`;
|
||||
this._reset();
|
||||
modalManager.show(this.modalId);
|
||||
}
|
||||
_initEventListeners() {
|
||||
this.elements.confirmBtn?.addEventListener('click', this._handleSubmit.bind(this));
|
||||
this.elements.confirmInput?.addEventListener('input', () => {
|
||||
const isConfirmed = this.elements.confirmInput.value.trim() === '删除';
|
||||
this.elements.confirmBtn.disabled = !isConfirmed;
|
||||
});
|
||||
const closeAction = () => {
|
||||
this._reset();
|
||||
modalManager.hide(this.modalId);
|
||||
};
|
||||
|
||||
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||||
closeTriggers.forEach(trigger => trigger.addEventListener('click', closeAction));
|
||||
this.elements.modal.addEventListener('click', (event) => {
|
||||
if (event.target === this.elements.modal) closeAction();
|
||||
});
|
||||
}
|
||||
async _handleSubmit() {
|
||||
if (!this.activeGroup) return;
|
||||
this.elements.confirmBtn.disabled = true;
|
||||
this.elements.confirmBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>删除中...`;
|
||||
try {
|
||||
// [FIX] Use apiFetch directly to call the backend endpoint.
|
||||
const endpoint = `/admin/keygroups/${this.activeGroup.id}`;
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: 'DELETE',
|
||||
noCache: true // Ensure a fresh request
|
||||
});
|
||||
|
||||
const result = await response.json(); // Parse the JSON response
|
||||
if (result.success) {
|
||||
toastManager.show(`分组 '${this.activeGroup.display_name}' 已成功删除。`, 'success');
|
||||
if (this.onDeleteSuccess) {
|
||||
this.onDeleteSuccess(this.activeGroup.id);
|
||||
}
|
||||
modalManager.hide(this.modalId);
|
||||
} else {
|
||||
// Use the error message from the backend response
|
||||
throw new Error(result.error?.message || result.message || '删除失败,请稍后再试。');
|
||||
}
|
||||
} catch (error) {
|
||||
toastManager.show(`删除失败: ${error.message}`, 'error');
|
||||
} finally {
|
||||
// We do a full reset in finally to ensure the button state is always correct.
|
||||
this._reset();
|
||||
}
|
||||
}
|
||||
|
||||
_reset() {
|
||||
if (this.elements.confirmInput) this.elements.confirmInput.value = '';
|
||||
if (this.elements.confirmBtn) {
|
||||
this.elements.confirmBtn.disabled = true;
|
||||
this.elements.confirmBtn.innerHTML = '确认删除';
|
||||
}
|
||||
}
|
||||
}
|
||||
657
frontend/js/pages/keys/index.js
Normal file
657
frontend/js/pages/keys/index.js
Normal file
@@ -0,0 +1,657 @@
|
||||
// frontend/js/pages/keys/index.js
|
||||
|
||||
// --- 導入全局和頁面專屬模塊 ---
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
import TagInput from '../../components/tagInput.js';
|
||||
import CustomSelect from '../../components/customSelect.js';
|
||||
import RequestSettingsModal from './requestSettingsModal.js';
|
||||
import AddApiModal from './addApiModal.js';
|
||||
import DeleteApiModal from './deleteApiModal.js';
|
||||
import KeyGroupModal from './keyGroupModal.js';
|
||||
import CloneGroupModal from './cloneGroupModal.js';
|
||||
import DeleteGroupModal from './deleteGroupModal.js';
|
||||
import { debounce } from '../../utils/utils.js';
|
||||
import { apiFetch, apiFetchJson } from '../../services/api.js';
|
||||
import { apiKeyManager } from '../../components/apiKeyManager.js';
|
||||
import { toastManager } from '../../components/taskCenter.js';
|
||||
import ApiKeyList from './apiKeyList.js';
|
||||
import Sortable from '../../vendor/sortable.esm.js';
|
||||
|
||||
class KeyGroupsPage {
|
||||
constructor() {
|
||||
this.state = {
|
||||
groups: [],
|
||||
groupDetailsCache: {},
|
||||
activeGroupId: null,
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
this.debouncedSaveOrder = debounce(this.saveGroupOrder.bind(this), 1500);
|
||||
|
||||
// elements對象現在只關心頁面級元素
|
||||
this.elements = {
|
||||
|
||||
dashboardTitle: document.querySelector('#group-dashboard h2'),
|
||||
dashboardControls: document.querySelector('#group-dashboard .flex.items-center.gap-x-3'),
|
||||
apiListContainer: document.getElementById('api-list-container'),
|
||||
groupListCollapsible: document.getElementById('group-list-collapsible'),
|
||||
desktopGroupContainer: document.querySelector('#desktop-group-cards-list .card-list-content'),
|
||||
mobileGroupContainer: document.getElementById('mobile-group-cards-list'),
|
||||
addGroupBtnContainer: document.getElementById('add-group-btn-container'),
|
||||
groupMenuToggle: document.getElementById('group-menu-toggle'),
|
||||
mobileActiveGroupDisplay: document.querySelector('.mobile-group-selector > div'),
|
||||
};
|
||||
|
||||
this.initialized = this.elements.desktopGroupContainer !== null &&
|
||||
this.elements.apiListContainer !== null;
|
||||
|
||||
if (this.initialized) {
|
||||
this.apiKeyList = new ApiKeyList(this.elements.apiListContainer);
|
||||
}
|
||||
// 實例化頁面專屬的子組件
|
||||
const allowedModelsInput = new TagInput(document.getElementById('allowed-models-container'), {
|
||||
validator: /^[a-z0-9\.-]+$/,
|
||||
validationMessage: '无效的模型格式'
|
||||
});
|
||||
// 验证上游地址:一个基础的 URL 格式验证
|
||||
const allowedUpstreamsInput = new TagInput(document.getElementById('allowed-upstreams-container'), {
|
||||
validator: /^(https?:\/\/)?[\w\.-]+\.[a-z]{2,}(\/[\w\.-]*)*\/?$/i,
|
||||
validationMessage: '无效的 URL 格式'
|
||||
});
|
||||
// 令牌验证:确保不为空即可
|
||||
const allowedTokensInput = new TagInput(document.getElementById('allowed-tokens-container'), {
|
||||
validator: /.+/,
|
||||
validationMessage: '令牌不能为空'
|
||||
});
|
||||
this.keyGroupModal = new KeyGroupModal({
|
||||
onSave: this.handleSaveGroup.bind(this),
|
||||
tagInputInstances: {
|
||||
models: allowedModelsInput,
|
||||
upstreams: allowedUpstreamsInput,
|
||||
tokens: allowedTokensInput,
|
||||
}
|
||||
});
|
||||
|
||||
this.deleteGroupModal = new DeleteGroupModal({
|
||||
onDeleteSuccess: (deletedGroupId) => {
|
||||
if (this.state.activeGroupId === deletedGroupId) {
|
||||
this.state.activeGroupId = null;
|
||||
this.apiKeyList.loadApiKeys(null);
|
||||
}
|
||||
this.loadKeyGroups(true);
|
||||
}
|
||||
});
|
||||
|
||||
this.addApiModal = new AddApiModal({
|
||||
onImportSuccess: () => this.apiKeyList.loadApiKeys(this.state.activeGroupId, true),
|
||||
});
|
||||
// CloneGroupModal
|
||||
this.cloneGroupModal = new CloneGroupModal({
|
||||
onCloneSuccess: (clonedGroup) => {
|
||||
if (clonedGroup && clonedGroup.id) {
|
||||
this.state.activeGroupId = clonedGroup.id;
|
||||
}
|
||||
this.loadKeyGroups(true);
|
||||
}
|
||||
});
|
||||
// DeleteApiModal
|
||||
this.deleteApiModal = new DeleteApiModal({
|
||||
onDeleteSuccess: () => this.apiKeyList.loadApiKeys(this.state.activeGroupId, true),
|
||||
});
|
||||
|
||||
this.requestSettingsModal = new RequestSettingsModal({
|
||||
onSave: this.handleSaveRequestSettings.bind(this)
|
||||
});
|
||||
this.activeTooltip = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.initialized) {
|
||||
console.error("KeyGroupsPage: Could not initialize. Essential container elements like 'desktopGroupContainer' or 'apiListContainer' are missing from the DOM.");
|
||||
return;
|
||||
}
|
||||
this.initEventListeners();
|
||||
if (this.apiKeyList) {
|
||||
this.apiKeyList.init();
|
||||
}
|
||||
await this.loadKeyGroups();
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
// --- 模态框全局触发器 ---
|
||||
document.body.addEventListener('click', (event) => {
|
||||
const addGroupBtn = event.target.closest('.add-group-btn');
|
||||
const addApiBtn = event.target.closest('#add-api-btn');
|
||||
const deleteApiBtn = event.target.closest('#delete-api-btn');
|
||||
if (addGroupBtn) this.keyGroupModal.open();
|
||||
if (addApiBtn) this.addApiModal.open(this.state.activeGroupId);
|
||||
if (deleteApiBtn) this.deleteApiModal.open(this.state.activeGroupId);
|
||||
});
|
||||
|
||||
// --- 使用事件委託來統一處理儀表板上的所有操作 ---
|
||||
this.elements.dashboardControls?.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button[data-action]');
|
||||
if (!button) return;
|
||||
const action = button.dataset.action;
|
||||
const activeGroup = this.state.groups.find(g => g.id === this.state.activeGroupId);
|
||||
switch(action) {
|
||||
case 'edit-group':
|
||||
if (activeGroup) {
|
||||
this.openEditGroupModal(activeGroup.id);
|
||||
} else {
|
||||
alert("请先选择一个分组进行编辑。");
|
||||
}
|
||||
break;
|
||||
case 'open-settings':
|
||||
this.openRequestSettingsModal();
|
||||
break;
|
||||
case 'clone-group':
|
||||
if (activeGroup) {
|
||||
this.cloneGroupModal.open(activeGroup);
|
||||
} else {
|
||||
alert("请先选择一个分组进行克隆。");
|
||||
}
|
||||
break;
|
||||
case 'delete-group':
|
||||
console.log('Delete action triggered for group:', this.state.activeGroupId);
|
||||
this.deleteGroupModal.open(activeGroup);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// --- 核心交互区域的事件委托 ---
|
||||
// 在共同父级上监听群组卡片点击
|
||||
this.elements.groupListCollapsible?.addEventListener('click', (event) => {
|
||||
this.handleGroupCardClick(event);
|
||||
});
|
||||
|
||||
// 移动端菜单切换
|
||||
this.elements.groupMenuToggle?.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const menu = this.elements.groupListCollapsible;
|
||||
if (!menu) return;
|
||||
menu.classList.toggle('hidden');
|
||||
setTimeout(() => {
|
||||
menu.classList.toggle('mobile-group-menu-active');
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Add a global listener to close the menu if clicking outside
|
||||
document.addEventListener('click', (event) => {
|
||||
const menu = this.elements.groupListCollapsible;
|
||||
const toggle = this.elements.groupMenuToggle;
|
||||
if (menu && menu.classList.contains('mobile-group-menu-active') && !menu.contains(event.target) && !toggle.contains(event.target)) {
|
||||
this._closeMobileMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// ... [其他頁面級事件監聽] ...
|
||||
this.initCustomSelects();
|
||||
this.initTooltips();
|
||||
this.initDragAndDrop();
|
||||
this._initBatchActions();
|
||||
}
|
||||
|
||||
// 4. 数据获取与渲染逻辑
|
||||
async loadKeyGroups(force = false) {
|
||||
this.state.isLoading = true;
|
||||
try {
|
||||
const responseData = await apiFetchJson("/admin/keygroups", { noCache: force });
|
||||
if (responseData && responseData.success && Array.isArray(responseData.data)) {
|
||||
this.state.groups = responseData.data;
|
||||
} else {
|
||||
console.error("API response format is incorrect:", responseData);
|
||||
this.state.groups = [];
|
||||
}
|
||||
|
||||
if (this.state.groups.length > 0 && !this.state.activeGroupId) {
|
||||
this.state.activeGroupId = this.state.groups[0].id;
|
||||
}
|
||||
|
||||
this.renderGroupList();
|
||||
if (this.state.activeGroupId) {
|
||||
this.updateDashboard();
|
||||
}
|
||||
this.updateAllHealthIndicators();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to load or parse key groups:", error);
|
||||
this.state.groups = [];
|
||||
this.renderGroupList(); // 渲染空列表
|
||||
this.updateDashboard(); // 更新仪表盘为空状态
|
||||
} finally {
|
||||
this.state.isLoading = false;
|
||||
}
|
||||
if (this.state.activeGroupId) {
|
||||
this.updateDashboard();
|
||||
} else {
|
||||
// If no groups exist, ensure the API key list is also cleared.
|
||||
this.apiKeyList.loadApiKeys(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to determine health indicator CSS classes based on success rate.
|
||||
* @param {number} rate - The success rate (0-100).
|
||||
* @returns {{ring: string, dot: string}} - The CSS classes for the ring and dot.
|
||||
*/
|
||||
_getHealthIndicatorClasses(rate) {
|
||||
if (rate >= 50) return { ring: 'bg-green-500/20', dot: 'bg-green-500' };
|
||||
if (rate >= 30) return { ring: 'bg-yellow-500/20', dot: 'bg-yellow-500' };
|
||||
if (rate >= 10) return { ring: 'bg-orange-500/20', dot: 'bg-orange-500' };
|
||||
return { ring: 'bg-red-500/20', dot: 'bg-red-500' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the list of group cards based on the current state.
|
||||
*/
|
||||
renderGroupList() {
|
||||
if (!this.state.groups) return;
|
||||
// --- 桌面端列表渲染 (最终卡片布局) ---
|
||||
const desktopListHtml = this.state.groups.map(group => {
|
||||
const isActive = group.id === this.state.activeGroupId;
|
||||
const cardClass = isActive ? 'group-card-active' : 'group-card-inactive';
|
||||
const successRate = 100; // Placeholder
|
||||
const healthClasses = this._getHealthIndicatorClasses(successRate);
|
||||
|
||||
// [核心修正] 同时生成两种类型的标签
|
||||
const channelTag = this._getChannelTypeTag(group.channel_type || 'Local');
|
||||
const customTags = this._getCustomTags(group.custom_tags); // 假设 group.custom_tags 是一个数组
|
||||
return `
|
||||
<div class="${cardClass}" data-group-id="${group.id}" data-success-rate="${successRate}">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<div data-health-indicator class="health-indicator-ring ${healthClasses.ring}">
|
||||
<div data-health-dot class="health-indicator-dot ${healthClasses.dot}"></div>
|
||||
</div>
|
||||
<div class="grow">
|
||||
<!-- [最终布局] 1. 名称 -> 2. 描述 -> 3. 标签 -->
|
||||
<h3 class="font-semibold text-sm">${group.display_name}</h3>
|
||||
<p class="card-sub-text my-1.5">${group.description || 'No description available'}</p>
|
||||
<div class="flex items-center gap-x-1.5 flex-wrap">
|
||||
${channelTag}
|
||||
${customTags}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (this.elements.desktopGroupContainer) {
|
||||
this.elements.desktopGroupContainer.innerHTML = desktopListHtml;
|
||||
if (this.elements.addGroupBtnContainer) {
|
||||
this.elements.desktopGroupContainer.parentElement.appendChild(this.elements.addGroupBtnContainer);
|
||||
}
|
||||
}
|
||||
// --- 移动端列表渲染 (保持不变) ---
|
||||
const mobileListHtml = this.state.groups.map(group => {
|
||||
const isActive = group.id === this.state.activeGroupId;
|
||||
const cardClass = isActive ? 'group-card-active' : 'group-card-inactive';
|
||||
return `
|
||||
<div class="${cardClass}" data-group-id="${group.id}">
|
||||
<h3 class="font-semibold text-sm">${group.display_name})</h3>
|
||||
<p class="card-sub-text my-1.5">${group.description || 'No description available'}</p>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (this.elements.mobileGroupContainer) {
|
||||
this.elements.mobileGroupContainer.innerHTML = mobileListHtml;
|
||||
}
|
||||
}
|
||||
|
||||
// 事件处理器和UI更新函数,现在完全由 state 驱动
|
||||
handleGroupCardClick(event) {
|
||||
const clickedCard = event.target.closest('[data-group-id]');
|
||||
if (!clickedCard) return;
|
||||
const groupId = parseInt(clickedCard.dataset.groupId, 10);
|
||||
if (this.state.activeGroupId !== groupId) {
|
||||
this.state.activeGroupId = groupId;
|
||||
this.renderGroupList();
|
||||
this.updateDashboard(); // updateDashboard 现在会处理 API key 的加载
|
||||
}
|
||||
|
||||
|
||||
if (window.innerWidth < 1024) {
|
||||
this._closeMobileMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// [NEW HELPER METHOD] Centralizes the logic for closing the mobile menu.
|
||||
_closeMobileMenu() {
|
||||
const menu = this.elements.groupListCollapsible;
|
||||
if (!menu) return;
|
||||
|
||||
menu.classList.remove('mobile-group-menu-active');
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
|
||||
updateDashboard() {
|
||||
const activeGroup = this.state.groups.find(g => g.id === this.state.activeGroupId);
|
||||
|
||||
if (activeGroup) {
|
||||
if (this.elements.dashboardTitle) {
|
||||
this.elements.dashboardTitle.textContent = `${activeGroup.display_name}`;
|
||||
}
|
||||
if (this.elements.mobileActiveGroupDisplay) {
|
||||
this.elements.mobileActiveGroupDisplay.innerHTML = `
|
||||
<h3 class="font-semibold text-sm">${activeGroup.display_name}</h3>
|
||||
<p class="card-sub-text">当前选择</p>`;
|
||||
}
|
||||
// 更新 Dashboard 时,加载对应的 API Keys
|
||||
this.apiKeyList.setActiveGroup(activeGroup.id, activeGroup.display_name);
|
||||
this.apiKeyList.loadApiKeys(activeGroup.id);
|
||||
} else {
|
||||
if (this.elements.dashboardTitle) this.elements.dashboardTitle.textContent = 'No Group Selected';
|
||||
if (this.elements.mobileActiveGroupDisplay) this.elements.mobileActiveGroupDisplay.innerHTML = `<h3 class="font-semibold text-sm">请选择一个分组</h3>`;
|
||||
// 如果没有选中的分组,清空 API Key 列表
|
||||
this.apiKeyList.loadApiKeys(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the saving of a key group with modern toast notifications.
|
||||
* @param {object} groupData The data collected from the KeyGroupModal.
|
||||
*/
|
||||
async handleSaveGroup(groupData) {
|
||||
const isEditing = !!groupData.id;
|
||||
const endpoint = isEditing ? `/admin/keygroups/${groupData.id}` : '/admin/keygroups';
|
||||
const method = isEditing ? 'PUT' : 'POST';
|
||||
console.log(`[CONTROLLER] ${isEditing ? 'Updating' : 'Creating'} group...`, { endpoint, method, data: groupData });
|
||||
try {
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: method,
|
||||
body: JSON.stringify(groupData),
|
||||
noCache: true
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'An unknown error occurred on the server.');
|
||||
}
|
||||
if (isEditing) {
|
||||
console.log(`[CACHE INVALIDATION] Deleting cached details for group ${groupData.id}.`);
|
||||
delete this.state.groupDetailsCache[groupData.id];
|
||||
}
|
||||
|
||||
if (!isEditing && result.data && result.data.id) {
|
||||
this.state.activeGroupId = result.data.id;
|
||||
}
|
||||
|
||||
toastManager.show(`分组 "${groupData.display_name}" 已成功保存。`, 'success');
|
||||
|
||||
await this.loadKeyGroups(true);
|
||||
} catch (error) {
|
||||
console.error(`Failed to save group:`, error.message);
|
||||
toastManager.show(`保存失败: ${error.message}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the KeyGroupModal for editing, utilizing a cache-then-fetch strategy.
|
||||
* @param {number} groupId The ID of the group to edit.
|
||||
*/
|
||||
async openEditGroupModal(groupId) {
|
||||
// Step 1: Check the details cache first.
|
||||
if (this.state.groupDetailsCache[groupId]) {
|
||||
console.log(`[CACHE HIT] Using cached details for group ${groupId}.`);
|
||||
// If details exist, open the modal immediately with the cached data.
|
||||
this.keyGroupModal.open(this.state.groupDetailsCache[groupId]);
|
||||
return;
|
||||
}
|
||||
// Step 2: If not in cache, fetch from the API.
|
||||
console.log(`[CACHE MISS] Fetching details for group ${groupId}.`);
|
||||
try {
|
||||
// NOTE: No complex UI spinners on the button itself. The user just waits a moment.
|
||||
const endpoint = `/admin/keygroups/${groupId}`;
|
||||
const responseData = await apiFetchJson(endpoint, { noCache: true });
|
||||
if (responseData && responseData.success) {
|
||||
const groupDetails = responseData.data;
|
||||
// Step 3: Store the newly fetched details in the cache.
|
||||
this.state.groupDetailsCache[groupId] = groupDetails;
|
||||
|
||||
// Step 4: Open the modal with the fetched data.
|
||||
this.keyGroupModal.open(groupDetails);
|
||||
} else {
|
||||
throw new Error(responseData.message || 'Failed to load group details.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch details for group ${groupId}:`, error);
|
||||
alert(`无法加载分组详情: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async openRequestSettingsModal() {
|
||||
if (!this.state.activeGroupId) {
|
||||
modalManager.showResult(false, "请先选择一个分组。");
|
||||
return;
|
||||
}
|
||||
// [重構] 簡化後的邏輯:獲取數據,然後調用子模塊的 open 方法
|
||||
console.log(`Opening request settings for group ID: ${this.state.activeGroupId}`);
|
||||
const data = {}; // 模擬從API獲取數據
|
||||
this.requestSettingsModal.open(data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param {object} data The data collected from the RequestSettingsModal.
|
||||
*/
|
||||
async handleSaveRequestSettings(data) {
|
||||
if (!this.state.activeGroupId) {
|
||||
throw new Error("No active group selected.");
|
||||
}
|
||||
console.log(`[CONTROLLER] Saving request settings for group ${this.state.activeGroupId}:`, data);
|
||||
// 此處執行API調用
|
||||
// await apiFetch(...)
|
||||
// 成功後可以觸發一個全局通知或刷新列表
|
||||
// this.loadKeyGroups();
|
||||
return Promise.resolve(); // 模擬API調用成功
|
||||
}
|
||||
|
||||
initCustomSelects() {
|
||||
const customSelects = document.querySelectorAll('.custom-select');
|
||||
customSelects.forEach(select => new CustomSelect(select));
|
||||
}
|
||||
|
||||
_initBatchActions() {}
|
||||
|
||||
/**
|
||||
* Sends the new group UI order to the backend API.
|
||||
* @param {Array<object>} orderData - An array of objects, e.g., [{id: 1, order: 0}, {id: 2, order: 1}]
|
||||
*/
|
||||
async saveGroupOrder(orderData) {
|
||||
console.log('Debounced save triggered. Sending UI order to API:', orderData);
|
||||
try {
|
||||
// 调用您已验证成功的API端点
|
||||
const response = await apiFetch('/admin/keygroups/order', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(orderData),
|
||||
noCache: true
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
// 如果后端返回操作失败,抛出错误
|
||||
throw new Error(result.message || 'Failed to save UI order on the server.');
|
||||
}
|
||||
console.log('UI order saved successfully.');
|
||||
// (可选) 在这里可以显示一个短暂的 "保存成功" 的提示消息 (Toast/Snackbar)
|
||||
} catch (error) {
|
||||
console.error('Failed to save new group UI order:', error);
|
||||
// [重要] 如果API调用失败,应该重新加载一次分组列表,
|
||||
// 以便UI回滚到数据库中存储的、未经修改的正确顺序。
|
||||
this.loadKeyGroups();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes drag-and-drop functionality for the group list.
|
||||
*/
|
||||
initDragAndDrop() {
|
||||
const container = this.elements.desktopGroupContainer;
|
||||
if (!container) return;
|
||||
|
||||
new Sortable(container, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
filter: '#add-group-btn-container',
|
||||
onEnd: (evt) => {
|
||||
const groupCards = Array.from(container.querySelectorAll('[data-group-id]'));
|
||||
const orderedState = groupCards.map(card => {
|
||||
const cardId = parseInt(card.dataset.groupId, 10);
|
||||
return this.state.groups.find(group => group.id === cardId);
|
||||
}).filter(Boolean);
|
||||
|
||||
if (orderedState.length !== this.state.groups.length) {
|
||||
console.error("Drag-and-drop failed: Could not map all DOM elements to state. Aborting.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新正确的状态数组
|
||||
this.state.groups = orderedState;
|
||||
|
||||
const payload = this.state.groups.map((group, index) => ({
|
||||
id: group.id,
|
||||
order: index
|
||||
}));
|
||||
|
||||
this.debouncedSaveOrder(payload);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to generate a styled HTML tag for the channel type.
|
||||
* @param {string} type - The channel type string (e.g., 'OpenAI', 'Azure').
|
||||
* @returns {string} - The generated HTML span element.
|
||||
*/
|
||||
_getChannelTypeTag(type) {
|
||||
if (!type) return ''; // 如果没有类型,则返回空字符串
|
||||
const styles = {
|
||||
'OpenAI': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
|
||||
'Azure': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||
'Claude': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
|
||||
'Gemini': 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300',
|
||||
'Local': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
};
|
||||
const baseClass = 'inline-block text-xs font-medium px-2 py-0.5 rounded-md';
|
||||
const tagClass = styles[type] || styles['Local']; // 如果类型未知,则使用默认样式
|
||||
return `<span class="${baseClass} ${tagClass}">${type}</span>`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generates styled HTML for custom tags with deterministically assigned colors.
|
||||
* @param {string[]} tags - An array of custom tag strings.
|
||||
* @returns {string} - The generated HTML for all custom tags.
|
||||
*/
|
||||
_getCustomTags(tags) {
|
||||
if (!tags || !Array.isArray(tags) || tags.length === 0) {
|
||||
return '';
|
||||
}
|
||||
// 预设的彩色背景调色板 (Tailwind classes)
|
||||
const colorPalette = [
|
||||
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300',
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
|
||||
'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300',
|
||||
'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-300',
|
||||
'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300',
|
||||
'bg-lime-100 text-lime-800 dark:bg-lime-900 dark:text-lime-300',
|
||||
];
|
||||
const baseClass = 'inline-block text-xs font-medium px-2 py-0.5 rounded-md';
|
||||
return tags.map(tag => {
|
||||
// 使用一个简单的确定性哈希算法,确保同一个标签名总能获得同一种颜色
|
||||
let hash = 0;
|
||||
for (let i = 0; i < tag.length; i++) {
|
||||
hash += tag.charCodeAt(i);
|
||||
}
|
||||
const colorClass = colorPalette[hash % colorPalette.length];
|
||||
return `<span class="${baseClass} ${colorClass}">${tag}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
|
||||
_updateHealthIndicator(cardElement) {
|
||||
const rate = parseFloat(cardElement.dataset.successRate);
|
||||
if (isNaN(rate)) return;
|
||||
|
||||
const indicator = cardElement.querySelector('[data-health-indicator]');
|
||||
const dot = cardElement.querySelector('[data-health-dot]');
|
||||
if (!indicator || !dot) return;
|
||||
|
||||
const colors = {
|
||||
green: ['bg-green-500/20', 'bg-green-500'],
|
||||
yellow: ['bg-yellow-500/20', 'bg-yellow-500'],
|
||||
orange: ['bg-orange-500/20', 'bg-orange-500'],
|
||||
red: ['bg-red-500/20', 'bg-red-500'],
|
||||
};
|
||||
|
||||
Object.values(colors).forEach(([bgClass, dotClass]) => {
|
||||
indicator.classList.remove(bgClass);
|
||||
dot.classList.remove(dotClass);
|
||||
});
|
||||
|
||||
let newColor;
|
||||
if (rate >= 50) newColor = colors.green;
|
||||
else if (rate >= 25) newColor = colors.yellow;
|
||||
else if (rate >= 10) newColor = colors.orange;
|
||||
else newColor = colors.red;
|
||||
|
||||
indicator.classList.add(newColor[0]);
|
||||
dot.classList.add(newColor[1]);
|
||||
}
|
||||
|
||||
updateAllHealthIndicators() {
|
||||
if (!this.elements.groupListCollapsible) return;
|
||||
const allCards = this.elements.groupListCollapsible.querySelectorAll('[data-success-rate]');
|
||||
allCards.forEach(card => this._updateHealthIndicator(card));
|
||||
}
|
||||
|
||||
initTooltips() {
|
||||
const tooltipIcons = document.querySelectorAll('.tooltip-icon');
|
||||
tooltipIcons.forEach(icon => {
|
||||
icon.addEventListener('mouseenter', (e) => this.showTooltip(e));
|
||||
icon.addEventListener('mouseleave', () => this.hideTooltip());
|
||||
});
|
||||
}
|
||||
|
||||
showTooltip(e) {
|
||||
this.hideTooltip();
|
||||
|
||||
const target = e.currentTarget;
|
||||
const text = target.dataset.tooltipText;
|
||||
if (!text) return;
|
||||
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'global-tooltip';
|
||||
tooltip.textContent = text;
|
||||
document.body.appendChild(tooltip);
|
||||
this.activeTooltip = tooltip;
|
||||
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const tooltipRect = tooltip.getBoundingClientRect();
|
||||
|
||||
let top = targetRect.top - tooltipRect.height - 8;
|
||||
let left = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2);
|
||||
|
||||
if (top < 0) top = targetRect.bottom + 8;
|
||||
if (left < 0) left = 8;
|
||||
if (left + tooltipRect.width > window.innerWidth) {
|
||||
left = window.innerWidth - tooltipRect.width - 8;
|
||||
}
|
||||
|
||||
tooltip.style.top = `${top}px`;
|
||||
tooltip.style.left = `${left}px`;
|
||||
}
|
||||
|
||||
hideTooltip() {
|
||||
if (this.activeTooltip) {
|
||||
this.activeTooltip.remove();
|
||||
this.activeTooltip = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function init() {
|
||||
console.log('[Modern Frontend] Keys page controller loaded.');
|
||||
const page = new KeyGroupsPage();
|
||||
page.init();
|
||||
}
|
||||
221
frontend/js/pages/keys/keyGroupModal.js
Normal file
221
frontend/js/pages/keys/keyGroupModal.js
Normal file
@@ -0,0 +1,221 @@
|
||||
// frontend/js/pages/keys/keyGroupModal.js
|
||||
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
|
||||
const MAX_GROUP_NAME_LENGTH = 32;
|
||||
|
||||
export default class KeyGroupModal {
|
||||
constructor({ onSave, tagInputInstances }) {
|
||||
this.modalId = 'keygroup-modal';
|
||||
this.onSave = onSave;
|
||||
this.tagInputs = tagInputInstances;
|
||||
this.editingGroupId = null;
|
||||
|
||||
const modal = document.getElementById(this.modalId);
|
||||
if (!modal) {
|
||||
throw new Error(`Modal with id "${this.modalId}" not found.`);
|
||||
}
|
||||
|
||||
this.elements = {
|
||||
modal: modal,
|
||||
title: document.getElementById('modal-title'),
|
||||
saveBtn: document.getElementById('modal-save-btn'),
|
||||
|
||||
// 表单字段
|
||||
nameInput: document.getElementById('group-name'),
|
||||
nameHelper: document.getElementById('group-name-helper'),
|
||||
displayNameInput: document.getElementById('group-display-name'),
|
||||
descriptionInput: document.getElementById('group-description'),
|
||||
strategySelect: document.getElementById('group-strategy'),
|
||||
maxRetriesInput: document.getElementById('group-max-retries'),
|
||||
failureThresholdInput: document.getElementById('group-key-blacklist-threshold'),
|
||||
enableProxyToggle: document.getElementById('group-enable-proxy'),
|
||||
enableSmartGatewayToggle: document.getElementById('group-enable-smart-gateway'),
|
||||
|
||||
// 自动验证设置
|
||||
enableKeyCheckToggle: document.getElementById('group-enable-key-check'),
|
||||
keyCheckSettingsPanel: document.getElementById('key-check-settings'),
|
||||
keyCheckModelInput: document.getElementById('group-key-check-model'),
|
||||
keyCheckIntervalInput: document.getElementById('group-key-check-interval-minutes'),
|
||||
keyCheckConcurrencyInput: document.getElementById('group-key-check-concurrency'),
|
||||
keyCooldownInput: document.getElementById('group-key-cooldown-minutes'),
|
||||
keyCheckEndpointInput: document.getElementById('group-key-check-endpoint'),
|
||||
};
|
||||
|
||||
this._initEventListeners();
|
||||
}
|
||||
|
||||
open(groupData = null) {
|
||||
this._populateForm(groupData);
|
||||
modalManager.show(this.modalId);
|
||||
}
|
||||
|
||||
close() {
|
||||
modalManager.hide(this.modalId);
|
||||
}
|
||||
|
||||
_initEventListeners() {
|
||||
if (this.elements.saveBtn) {
|
||||
this.elements.saveBtn.addEventListener('click', this._handleSave.bind(this));
|
||||
}
|
||||
if (this.elements.nameInput) {
|
||||
this.elements.nameInput.addEventListener('input', this._sanitizeGroupName.bind(this));
|
||||
}
|
||||
// 自动验证开关控制面板显隐
|
||||
if (this.elements.enableKeyCheckToggle) {
|
||||
this.elements.enableKeyCheckToggle.addEventListener('change', (e) => {
|
||||
this.elements.keyCheckSettingsPanel.classList.toggle('hidden', !e.target.checked);
|
||||
});
|
||||
}
|
||||
|
||||
const closeAction = () => this.close();
|
||||
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||||
closeTriggers.forEach(trigger => trigger.addEventListener('click', closeAction));
|
||||
this.elements.modal.addEventListener('click', (event) => {
|
||||
if (event.target === this.elements.modal) closeAction();
|
||||
});
|
||||
}
|
||||
// 实时净化 group name 的哨兵函数
|
||||
_sanitizeGroupName(event) {
|
||||
const input = event.target;
|
||||
let value = input.value;
|
||||
// 1. Convert to lowercase.
|
||||
value = value.toLowerCase();
|
||||
// 2. Remove all illegal characters.
|
||||
value = value.replace(/[^a-z0-9-]/g, '');
|
||||
// 3. Enforce the length limit by truncating.
|
||||
if (value.length > MAX_GROUP_NAME_LENGTH) {
|
||||
value = value.substring(0, MAX_GROUP_NAME_LENGTH);
|
||||
}
|
||||
if (input.value !== value) {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
async _handleSave() {
|
||||
// [MODIFICATION] The save button's disabled state is now reset in a finally block for robustness.
|
||||
this._sanitizeGroupName({ target: this.elements.nameInput });
|
||||
const data = this._collectFormData();
|
||||
if (!data.name || !data.display_name) {
|
||||
alert('分组名称和显示名称是必填项。');
|
||||
return;
|
||||
}
|
||||
// 最终提交前的正则验证
|
||||
const groupNameRegex = /^[a-z0-9-]+$/;
|
||||
if (!groupNameRegex.test(data.name) || data.name.length > MAX_GROUP_NAME_LENGTH) {
|
||||
alert('分组名称格式无效。仅限使用小写字母、数字和连字符(-),且长度不超过32个字符。');
|
||||
return;
|
||||
}
|
||||
if (this.onSave) {
|
||||
this.elements.saveBtn.disabled = true;
|
||||
this.elements.saveBtn.textContent = '保存中...';
|
||||
try {
|
||||
await this.onSave(data);
|
||||
this.close();
|
||||
} catch (error) {
|
||||
console.error("Failed to save key group:", error);
|
||||
} finally {
|
||||
this.elements.saveBtn.disabled = false;
|
||||
this.elements.saveBtn.textContent = '保存';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_populateForm(data) {
|
||||
if (data) { // 编辑模式
|
||||
this.editingGroupId = data.id;
|
||||
this.elements.title.textContent = '编辑 Key Group';
|
||||
this.elements.nameInput.value = data.name || '';
|
||||
this.elements.nameInput.disabled = false;
|
||||
this.elements.displayNameInput.value = data.display_name || '';
|
||||
this.elements.descriptionInput.value = data.description || '';
|
||||
this.elements.strategySelect.value = data.polling_strategy || 'random';
|
||||
this.elements.enableProxyToggle.checked = data.enable_proxy || false;
|
||||
|
||||
const settings = data.settings && data.settings.SettingsJSON ? data.settings.SettingsJSON : {};
|
||||
|
||||
this.elements.maxRetriesInput.value = settings.max_retries ?? '';
|
||||
this.elements.failureThresholdInput.value = settings.key_blacklist_threshold ?? '';
|
||||
this.elements.enableSmartGatewayToggle.checked = settings.enable_smart_gateway || false;
|
||||
|
||||
const isKeyCheckEnabled = settings.enable_key_check || false;
|
||||
this.elements.enableKeyCheckToggle.checked = isKeyCheckEnabled;
|
||||
|
||||
this.elements.keyCheckSettingsPanel.classList.toggle('hidden', !isKeyCheckEnabled);
|
||||
this.elements.keyCheckModelInput.value = settings.key_check_model || '';
|
||||
this.elements.keyCheckIntervalInput.value = settings.key_check_interval_minutes ?? '';
|
||||
this.elements.keyCheckConcurrencyInput.value = settings.key_check_concurrency ?? '';
|
||||
this.elements.keyCooldownInput.value = settings.key_cooldown_minutes ?? '';
|
||||
this.elements.keyCheckEndpointInput.value = settings.key_check_endpoint || '';
|
||||
|
||||
this.tagInputs.models.setValues(data.allowed_models || []);
|
||||
this.tagInputs.upstreams.setValues(data.allowed_upstreams || []);
|
||||
this.tagInputs.tokens.setValues(data.allowed_tokens || []);
|
||||
|
||||
} else { // 创建模式
|
||||
this.editingGroupId = null;
|
||||
this.elements.title.textContent = '创建新的 Key Group';
|
||||
this._resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
_collectFormData() {
|
||||
const parseIntOrNull = (value) => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === '' ? null : parseInt(trimmed, 10);
|
||||
};
|
||||
const formData = {
|
||||
name: this.elements.nameInput.value.trim(),
|
||||
display_name: this.elements.displayNameInput.value.trim(),
|
||||
description: this.elements.descriptionInput.value.trim(),
|
||||
polling_strategy: this.elements.strategySelect.value,
|
||||
max_retries: parseIntOrNull(this.elements.maxRetriesInput.value),
|
||||
key_blacklist_threshold: parseIntOrNull(this.elements.failureThresholdInput.value),
|
||||
enable_proxy: this.elements.enableProxyToggle.checked,
|
||||
enable_smart_gateway: this.elements.enableSmartGatewayToggle.checked,
|
||||
|
||||
enable_key_check: this.elements.enableKeyCheckToggle.checked,
|
||||
key_check_model: this.elements.keyCheckModelInput.value.trim() || null,
|
||||
key_check_interval_minutes: parseIntOrNull(this.elements.keyCheckIntervalInput.value),
|
||||
key_check_concurrency: parseIntOrNull(this.elements.keyCheckConcurrencyInput.value),
|
||||
key_cooldown_minutes: parseIntOrNull(this.elements.keyCooldownInput.value),
|
||||
key_check_endpoint: this.elements.keyCheckEndpointInput.value.trim() || null,
|
||||
|
||||
allowed_models: this.tagInputs.models.getValues(),
|
||||
allowed_upstreams: this.tagInputs.upstreams.getValues(),
|
||||
allowed_tokens: this.tagInputs.tokens.getValues(),
|
||||
};
|
||||
if (this.editingGroupId) {
|
||||
formData.id = this.editingGroupId;
|
||||
}
|
||||
return formData;
|
||||
}
|
||||
|
||||
/**
|
||||
* [核心修正] 完整且健壮的表单重置方法
|
||||
*/
|
||||
_resetForm() {
|
||||
this.elements.nameInput.value = '';
|
||||
this.elements.nameInput.disabled = false;
|
||||
this.elements.displayNameInput.value = '';
|
||||
this.elements.descriptionInput.value = '';
|
||||
this.elements.strategySelect.value = 'random';
|
||||
this.elements.maxRetriesInput.value = '';
|
||||
this.elements.failureThresholdInput.value = '';
|
||||
this.elements.enableProxyToggle.checked = false;
|
||||
this.elements.enableSmartGatewayToggle.checked = false;
|
||||
|
||||
this.elements.enableKeyCheckToggle.checked = false;
|
||||
this.elements.keyCheckSettingsPanel.classList.add('hidden');
|
||||
this.elements.keyCheckModelInput.value = '';
|
||||
this.elements.keyCheckIntervalInput.value = '';
|
||||
this.elements.keyCheckConcurrencyInput.value = '';
|
||||
this.elements.keyCooldownInput.value = '';
|
||||
this.elements.keyCheckEndpointInput.value = '';
|
||||
|
||||
this.tagInputs.models.setValues([]);
|
||||
this.tagInputs.upstreams.setValues([]);
|
||||
this.tagInputs.tokens.setValues([]);
|
||||
}
|
||||
}
|
||||
306
frontend/js/pages/keys/requestSettingsModal.js
Normal file
306
frontend/js/pages/keys/requestSettingsModal.js
Normal file
@@ -0,0 +1,306 @@
|
||||
// frontend/js/pages/keys/requestSettingsModal.js
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
|
||||
export default class RequestSettingsModal {
|
||||
constructor({ onSave }) {
|
||||
this.modalId = 'request-settings-modal';
|
||||
this.modal = document.getElementById(this.modalId);
|
||||
this.onSave = onSave; // 注入保存回調函數
|
||||
if (!this.modal) {
|
||||
throw new Error(`Modal with id "${this.modalId}" not found.`);
|
||||
}
|
||||
// 映射所有內部DOM元素
|
||||
this.elements = {
|
||||
saveBtn: document.getElementById('request-settings-save-btn'),
|
||||
customHeadersContainer: document.getElementById('CUSTOM_HEADERS_container'),
|
||||
addCustomHeaderBtn: document.getElementById('addCustomHeaderBtn'),
|
||||
streamOptimizerEnabled: document.getElementById('STREAM_OPTIMIZER_ENABLED'),
|
||||
streamingSettingsPanel: document.getElementById('streaming-settings-panel'),
|
||||
streamMinDelay: document.getElementById('STREAM_MIN_DELAY'),
|
||||
streamMaxDelay: document.getElementById('STREAM_MAX_DELAY'),
|
||||
streamShortTextThresh: document.getElementById('STREAM_SHORT_TEXT_THRESHOLD'),
|
||||
streamLongTextThresh: document.getElementById('STREAM_LONG_TEXT_THRESHOLD'),
|
||||
streamChunkSize: document.getElementById('STREAM_CHUNK_SIZE'),
|
||||
fakeStreamEnabled: document.getElementById('FAKE_STREAM_ENABLED'),
|
||||
fakeStreamInterval: document.getElementById('FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS'),
|
||||
toolsCodeExecutionEnabled: document.getElementById('TOOLS_CODE_EXECUTION_ENABLED'),
|
||||
urlContextEnabled: document.getElementById('URL_CONTEXT_ENABLED'),
|
||||
showSearchLink: document.getElementById('SHOW_SEARCH_LINK'),
|
||||
showThinkingProcess: document.getElementById('SHOW_THINKING_PROCESS'),
|
||||
safetySettingsContainer: document.getElementById('SAFETY_SETTINGS_container'),
|
||||
addSafetySettingBtn: document.getElementById('addSafetySettingBtn'),
|
||||
configOverrides: document.getElementById('group-config-overrides'),
|
||||
};
|
||||
this._initEventListeners();
|
||||
}
|
||||
// --- 公共 API ---
|
||||
/**
|
||||
* 打開模態框並填充數據
|
||||
* @param {object} data - 用於填充表單的數據
|
||||
*/
|
||||
open(data) {
|
||||
this._populateForm(data);
|
||||
modalManager.show(this.modalId);
|
||||
}
|
||||
/**
|
||||
* 關閉模態框
|
||||
*/
|
||||
close() {
|
||||
modalManager.hide(this.modalId);
|
||||
}
|
||||
// --- 內部事件與邏輯 ---
|
||||
_initEventListeners() {
|
||||
// 事件委託,處理動態添加元素的移除
|
||||
this.modal.addEventListener('click', (e) => {
|
||||
const removeBtn = e.target.closest('.remove-btn');
|
||||
if (removeBtn) {
|
||||
removeBtn.parentElement.remove();
|
||||
}
|
||||
});
|
||||
if (this.elements.addCustomHeaderBtn) {
|
||||
this.elements.addCustomHeaderBtn.addEventListener('click', () => this.addCustomHeaderItem());
|
||||
}
|
||||
if (this.elements.addSafetySettingBtn) {
|
||||
this.elements.addSafetySettingBtn.addEventListener('click', () => this.addSafetySettingItem());
|
||||
}
|
||||
if (this.elements.saveBtn) {
|
||||
this.elements.saveBtn.addEventListener('click', this._handleSave.bind(this));
|
||||
}
|
||||
if (this.elements.streamOptimizerEnabled) {
|
||||
this.elements.streamOptimizerEnabled.addEventListener('change', (e) => {
|
||||
this._toggleStreamingPanel(e.target.checked);
|
||||
});
|
||||
}
|
||||
// --- 完整的、統一的關閉邏輯 ---
|
||||
const closeAction = () => {
|
||||
// 此處無需 _reset(),因為每次 open 都會重新 populate
|
||||
modalManager.hide(this.modalId);
|
||||
};
|
||||
// 綁定所有帶有 data-modal-close 屬性的按鈕
|
||||
const closeTriggers = this.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||||
closeTriggers.forEach(trigger => {
|
||||
trigger.addEventListener('click', closeAction);
|
||||
});
|
||||
// 綁定點擊模態框背景遮罩層的事件
|
||||
this.modal.addEventListener('click', (event) => {
|
||||
if (event.target === this.modal) {
|
||||
closeAction();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _handleSave() {
|
||||
const data = this._collectFormData();
|
||||
if (this.onSave) {
|
||||
try {
|
||||
if (this.elements.saveBtn) {
|
||||
this.elements.saveBtn.disabled = true;
|
||||
this.elements.saveBtn.textContent = 'Saving...';
|
||||
}
|
||||
// 調用注入的回調函數,將數據傳遞給頁面控制器處理
|
||||
await this.onSave(data);
|
||||
this.close();
|
||||
} catch (error) {
|
||||
console.error("Failed to save request settings:", error);
|
||||
alert(`保存失敗: ${error.message}`); // 在模態框內給出反饋
|
||||
} finally {
|
||||
if (this.elements.saveBtn) {
|
||||
this.elements.saveBtn.disabled = false;
|
||||
this.elements.saveBtn.textContent = 'Save Changes';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- 所有表單處理輔助方法 ---
|
||||
_populateForm(data = {}) {
|
||||
// [完整遷移] 填充表單的邏輯
|
||||
const isStreamOptimizerEnabled = !!data.stream_optimizer_enabled;
|
||||
this._setToggle(this.elements.streamOptimizerEnabled, isStreamOptimizerEnabled);
|
||||
this._toggleStreamingPanel(isStreamOptimizerEnabled);
|
||||
this._setValue(this.elements.streamMinDelay, data.stream_min_delay);
|
||||
this._setValue(this.elements.streamMaxDelay, data.stream_max_delay);
|
||||
this._setValue(this.elements.streamShortTextThresh, data.stream_short_text_threshold);
|
||||
this._setValue(this.elements.streamLongTextThresh, data.stream_long_text_threshold);
|
||||
this._setValue(this.elements.streamChunkSize, data.stream_chunk_size);
|
||||
this._setToggle(this.elements.fakeStreamEnabled, data.fake_stream_enabled);
|
||||
this._setValue(this.elements.fakeStreamInterval, data.fake_stream_empty_data_interval_seconds);
|
||||
this._setToggle(this.elements.toolsCodeExecutionEnabled, data.tools_code_execution_enabled);
|
||||
this._setToggle(this.elements.urlContextEnabled, data.url_context_enabled);
|
||||
this._setToggle(this.elements.showSearchLink, data.show_search_link);
|
||||
this._setToggle(this.elements.showThinkingProcess, data.show_thinking_process);
|
||||
this._setValue(this.elements.configOverrides, data.config_overrides);
|
||||
// --- Dynamic & Complex Fields ---
|
||||
this._populateKVItems(this.elements.customHeadersContainer, data.custom_headers, this.addCustomHeaderItem.bind(this));
|
||||
|
||||
this._clearContainer(this.elements.safetySettingsContainer);
|
||||
if (data.safety_settings && typeof data.safety_settings === 'object') {
|
||||
for (const [key, value] of Object.entries(data.safety_settings)) {
|
||||
this.addSafetySettingItem(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Collects all data from the form fields and returns it as an object.
|
||||
* @returns {object} The collected request configuration data.
|
||||
*/
|
||||
collectFormData() {
|
||||
return {
|
||||
// Simple Toggles & Inputs
|
||||
stream_optimizer_enabled: this.elements.streamOptimizerEnabled.checked,
|
||||
stream_min_delay: parseInt(this.elements.streamMinDelay.value, 10),
|
||||
stream_max_delay: parseInt(this.elements.streamMaxDelay.value, 10),
|
||||
stream_short_text_threshold: parseInt(this.elements.streamShortTextThresh.value, 10),
|
||||
stream_long_text_threshold: parseInt(this.elements.streamLongTextThresh.value, 10),
|
||||
stream_chunk_size: parseInt(this.elements.streamChunkSize.value, 10),
|
||||
fake_stream_enabled: this.elements.fakeStreamEnabled.checked,
|
||||
fake_stream_empty_data_interval_seconds: parseInt(this.elements.fakeStreamInterval.value, 10),
|
||||
tools_code_execution_enabled: this.elements.toolsCodeExecutionEnabled.checked,
|
||||
url_context_enabled: this.elements.urlContextEnabled.checked,
|
||||
show_search_link: this.elements.showSearchLink.checked,
|
||||
show_thinking_process: this.elements.showThinkingProcess.checked,
|
||||
config_overrides: this.elements.configOverrides.value,
|
||||
|
||||
// Dynamic & Complex Fields
|
||||
custom_headers: this._collectKVItems(this.elements.customHeadersContainer),
|
||||
safety_settings: this._collectSafetySettings(this.elements.safetySettingsContainer),
|
||||
|
||||
// TODO: Collect from Tag Inputs
|
||||
// image_models: this.imageModelsInput.getValues(),
|
||||
};
|
||||
}
|
||||
|
||||
// 控制流式面板显示/隐藏的辅助函数
|
||||
_toggleStreamingPanel(is_enabled) {
|
||||
if (this.elements.streamingSettingsPanel) {
|
||||
if (is_enabled) {
|
||||
this.elements.streamingSettingsPanel.classList.remove('hidden');
|
||||
} else {
|
||||
this.elements.streamingSettingsPanel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new key-value pair item for Custom Headers.
|
||||
* @param {string} [key=''] - The initial key.
|
||||
* @param {string} [value=''] - The initial value.
|
||||
*/
|
||||
addCustomHeaderItem(key = '', value = '') {
|
||||
const container = this.elements.customHeadersContainer;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'dynamic-kv-item';
|
||||
item.innerHTML = `
|
||||
<input type="text" class="modal-input text-xs bg-zinc-100 dark:bg-zinc-700/50" placeholder="Header Name" value="${key}">
|
||||
<input type="text" class="modal-input text-xs" placeholder="Header Value" value="${value}">
|
||||
<button type="button" class="remove-btn text-zinc-400 hover:text-red-500 transition-colors"><i class="fas fa-trash-alt"></i></button>
|
||||
`;
|
||||
container.appendChild(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new item for Safety Settings.
|
||||
* @param {string} [category=''] - The initial category.
|
||||
* @param {string} [threshold=''] - The initial threshold.
|
||||
*/
|
||||
addSafetySettingItem(category = '', threshold = '') {
|
||||
const container = this.elements.safetySettingsContainer;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'safety-setting-item flex items-center gap-x-2';
|
||||
|
||||
const harmCategories = [
|
||||
"HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_HATE_SPEECH",
|
||||
"HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_DANGEROUS_CONTENT","HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
"HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||
];
|
||||
const harmThresholds = [
|
||||
"BLOCK_OFF","BLOCK_NONE", "BLOCK_LOW_AND_ABOVE",
|
||||
"BLOCK_MEDIUM_AND_ABOVE", "BLOCK_ONLY_HIGH"
|
||||
];
|
||||
|
||||
const categorySelect = document.createElement('select');
|
||||
categorySelect.className = 'modal-input flex-grow'; // .modal-input 在静态<select>上是有效的
|
||||
harmCategories.forEach(cat => {
|
||||
const option = new Option(cat.replace('HARM_CATEGORY_', ''), cat);
|
||||
if (cat === category) option.selected = true;
|
||||
categorySelect.add(option);
|
||||
});
|
||||
const thresholdSelect = document.createElement('select');
|
||||
thresholdSelect.className = 'modal-input w-48';
|
||||
harmThresholds.forEach(thr => {
|
||||
const option = new Option(thr.replace('BLOCK_', '').replace('_AND_ABOVE', '+'), thr);
|
||||
if (thr === threshold) option.selected = true;
|
||||
thresholdSelect.add(option);
|
||||
});
|
||||
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.className = 'remove-btn text-zinc-400 hover:text-red-500 transition-colors';
|
||||
removeButton.innerHTML = `<i class="fas fa-trash-alt"></i>`;
|
||||
item.appendChild(categorySelect);
|
||||
item.appendChild(thresholdSelect);
|
||||
item.appendChild(removeButton);
|
||||
container.appendChild(item);
|
||||
}
|
||||
|
||||
// --- Private Helper Methods for Form Handling ---
|
||||
|
||||
_setValue(element, value) {
|
||||
if (element && value !== null && value !== undefined) {
|
||||
element.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
_setToggle(element, value) {
|
||||
if (element) {
|
||||
element.checked = !!value;
|
||||
}
|
||||
}
|
||||
|
||||
_clearContainer(container) {
|
||||
if (container) {
|
||||
// Keep the first child if it's a template or header
|
||||
const firstChild = container.firstElementChild;
|
||||
const isTemplate = firstChild && (firstChild.tagName === 'TEMPLATE' || firstChild.id === 'kv-item-header');
|
||||
|
||||
let child = isTemplate ? firstChild.nextElementSibling : container.firstElementChild;
|
||||
while (child) {
|
||||
const next = child.nextElementSibling;
|
||||
child.remove();
|
||||
child = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_populateKVItems(container, items, addItemFn) {
|
||||
this._clearContainer(container);
|
||||
if (items && typeof items === 'object') {
|
||||
for (const [key, value] of Object.entries(items)) {
|
||||
addItemFn(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_collectKVItems(container) {
|
||||
const items = {};
|
||||
container.querySelectorAll('.dynamic-kv-item').forEach(item => {
|
||||
const keyEl = item.querySelector('.dynamic-kv-key');
|
||||
const valueEl = item.querySelector('.dynamic-kv-value');
|
||||
if (keyEl && valueEl && keyEl.value) {
|
||||
items[keyEl.value] = valueEl.value;
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
_collectSafetySettings(container) {
|
||||
const items = {};
|
||||
container.querySelectorAll('.safety-setting-item').forEach(item => {
|
||||
const categorySelect = item.querySelector('select:first-child');
|
||||
const thresholdSelect = item.querySelector('select:last-of-type');
|
||||
if (categorySelect && thresholdSelect && categorySelect.value) {
|
||||
items[categorySelect.value] = thresholdSelect.value;
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}
|
||||
}
|
||||
2190
frontend/js/pages/keys_status.js
Normal file
2190
frontend/js/pages/keys_status.js
Normal file
File diff suppressed because it is too large
Load Diff
77
frontend/js/pages/logs/index.js
Normal file
77
frontend/js/pages/logs/index.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// Filename: frontend/js/pages/logs/index.js
|
||||
|
||||
import { apiFetchJson } from '../../services/api.js';
|
||||
import LogList from './logList.js';
|
||||
|
||||
class LogsPage {
|
||||
constructor() {
|
||||
this.state = {
|
||||
logs: [],
|
||||
// [修正] 暂时将分页状态设为默认值,直到后端添加分页支持
|
||||
pagination: { page: 1, pages: 1, total: 0 },
|
||||
isLoading: true,
|
||||
filters: { page: 1, page_size: 20 }
|
||||
};
|
||||
|
||||
this.elements = {
|
||||
tableBody: document.getElementById('logs-table-body'),
|
||||
};
|
||||
|
||||
this.initialized = !!this.elements.tableBody;
|
||||
|
||||
if (this.initialized) {
|
||||
this.logList = new LogList(this.elements.tableBody);
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.initialized) {
|
||||
console.error("LogsPage: Could not initialize. Essential container element 'logs-table-body' is missing.");
|
||||
return;
|
||||
}
|
||||
this.initEventListeners();
|
||||
await this.loadAndRenderLogs();
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
// 分页和筛选的事件监听器将在后续任务中添加
|
||||
}
|
||||
|
||||
async loadAndRenderLogs() {
|
||||
this.state.isLoading = true;
|
||||
this.logList.renderLoading();
|
||||
|
||||
try {
|
||||
const url = `/admin/logs?page=${this.state.filters.page}&page_size=${this.state.filters.page_size}`;
|
||||
const responseData = await apiFetchJson(url);
|
||||
|
||||
// [核心修正] 调整条件以匹配当前 API 返回的 { success: true, data: [...] } 结构
|
||||
if (responseData && responseData.success && Array.isArray(responseData.data)) {
|
||||
|
||||
// [核心修正] 直接从 responseData.data 获取日志数组
|
||||
this.state.logs = responseData.data;
|
||||
|
||||
// [临时] 由于当前响应不包含分页信息,我们暂时不更新 this.state.pagination
|
||||
// 等待后端完善分页后,再恢复这里的逻辑
|
||||
|
||||
this.logList.render(this.state.logs);
|
||||
|
||||
// this.renderPaginationControls();
|
||||
} else {
|
||||
console.error("API response for logs is incorrect:", responseData);
|
||||
this.logList.render([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load logs:", error);
|
||||
// this.logList.renderError(error);
|
||||
} finally {
|
||||
this.state.isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出符合 main.js 规范的 default 函数
|
||||
export default function() {
|
||||
const page = new LogsPage();
|
||||
page.init();
|
||||
}
|
||||
60
frontend/js/pages/logs/logList.js
Normal file
60
frontend/js/pages/logs/logList.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Filename: frontend/js/pages/logs/logList.js
|
||||
|
||||
class LogList {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
if (!this.container) {
|
||||
console.error("LogList: container element (tbody) not found.");
|
||||
}
|
||||
}
|
||||
|
||||
renderLoading() {
|
||||
if (!this.container) return;
|
||||
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground"><i class="fas fa-spinner fa-spin mr-2"></i> 加载日志中...</td></tr>`;
|
||||
}
|
||||
|
||||
render(logs) {
|
||||
if (!this.container) return;
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground">没有找到相关的日志记录。</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const logsHtml = logs.map(log => this.createLogRowHtml(log)).join('');
|
||||
this.container.innerHTML = logsHtml;
|
||||
}
|
||||
|
||||
createLogRowHtml(log) {
|
||||
// [后端协作点] 假设后端未来会提供 GroupDisplayName 和 APIKeyName
|
||||
const groupName = log.GroupDisplayName || (log.GroupID ? `Group #${log.GroupID}` : 'N/A');
|
||||
const apiKeyName = log.APIKeyName || (log.KeyID ? `Key #${log.KeyID}` : 'N/A');
|
||||
|
||||
const errorTag = log.IsSuccess
|
||||
? `<span class="inline-flex items-center rounded-md bg-green-500/10 px-2 py-1 text-xs font-medium text-green-600">成功</span>`
|
||||
: `<span class="inline-flex items-center rounded-md bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">${log.ErrorCode || '失败'}</span>`;
|
||||
|
||||
// 使用 toLocaleString 格式化时间,更符合用户本地习惯
|
||||
const requestTime = new Date(log.RequestTime).toLocaleString();
|
||||
|
||||
return `
|
||||
<tr class="border-b border-b-border transition-colors hover:bg-muted/80" data-log-id="${log.ID}">
|
||||
<td class="p-4 align-middle"><input type="checkbox" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500"></td>
|
||||
<td class="p-4 align-middle font-mono text-muted-foreground">#${log.ID}</td>
|
||||
<td class="p-4 align-middle font-medium font-mono">${apiKeyName}</td>
|
||||
<td class="p-4 align-middle">${groupName}</td>
|
||||
<td class="p-4 align-middle text-foreground">${log.ErrorMessage || (log.IsSuccess ? '' : '未知错误')}</td>
|
||||
<td class="p-4 align-middle">${errorTag}</td>
|
||||
<td class="p-4 align-middle font-mono">${log.ModelName}</td>
|
||||
<td class="p-4 align-middle text-muted-foreground text-xs">${requestTime}</td>
|
||||
<td class="p-4 align-middle">
|
||||
<button class="btn btn-ghost btn-icon btn-sm" aria-label="查看详情">
|
||||
<i class="fas fa-ellipsis-h h-4 w-4"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export default LogList;
|
||||
124
frontend/js/services/api.js
Normal file
124
frontend/js/services/api.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// Filename: frontend/js/services/api.js
|
||||
|
||||
class APIClientError extends Error {
|
||||
constructor(message, status, code, rawMessageFromServer) {
|
||||
super(message);
|
||||
this.name = 'APIClientError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.rawMessageFromServer = rawMessageFromServer;
|
||||
}
|
||||
}
|
||||
|
||||
// Global Promise cache for raw responses
|
||||
const apiPromiseCache = new Map();
|
||||
|
||||
/**
|
||||
* [CORRECTED & CACHE-AWARE] A low-level fetch wrapper.
|
||||
* It handles caching, authentication, and centralized error handling.
|
||||
* On success (2xx), it returns the raw, unread Response object.
|
||||
* On failure (non-2xx), it consumes the body to throw a detailed APIClientError.
|
||||
*/
|
||||
export async function apiFetch(url, options = {}) {
|
||||
// For non-GET requests or noCache requests, we bypass the promise cache.
|
||||
const isGetRequest = !options.method || options.method.toUpperCase() === 'GET';
|
||||
const cacheKey = isGetRequest && !options.noCache ? url : null;
|
||||
|
||||
if (cacheKey && apiPromiseCache.has(cacheKey)) {
|
||||
return apiPromiseCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('bearerToken');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const requestPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (response.status === 401) {
|
||||
// On auth error, always clear caches for this URL.
|
||||
if (cacheKey) apiPromiseCache.delete(cacheKey);
|
||||
// ... (rest of the 401 logic is correct)
|
||||
localStorage.removeItem('bearerToken');
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login?error=会话已过期,请重新登录。';
|
||||
}
|
||||
throw new APIClientError('Unauthorized', 401, 'UNAUTHORIZED', 'Session expired or token is invalid.');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData = null;
|
||||
let rawMessage = '';
|
||||
try {
|
||||
// This is the ONLY place the body is consumed in the failure path.
|
||||
rawMessage = await response.text();
|
||||
if(rawMessage) { // Avoid parsing empty string
|
||||
errorData = JSON.parse(rawMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
errorData = { error: { code: 'UNKNOWN_FORMAT', message: rawMessage || response.statusText } };
|
||||
}
|
||||
|
||||
const code = errorData?.error?.code || 'UNKNOWN_ERROR';
|
||||
const messageFromServer = errorData?.error?.message || rawMessage || 'No message provided by server.';
|
||||
|
||||
const error = new APIClientError(
|
||||
`API request failed: ${response.status}`,
|
||||
response.status,
|
||||
code,
|
||||
messageFromServer
|
||||
);
|
||||
|
||||
// Throwing the error will cause this promise to reject.
|
||||
throw error;
|
||||
}
|
||||
|
||||
// On success, the promise resolves with the PRISTINE response object.
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
// If an error occurred (either thrown by us or a network error),
|
||||
// ensure the promise cache is cleared for this key before re-throwing.
|
||||
if (cacheKey) apiPromiseCache.delete(cacheKey);
|
||||
throw error; // Re-throw to propagate the failure.
|
||||
}
|
||||
})();
|
||||
|
||||
// If we are caching, store the promise.
|
||||
if (cacheKey) {
|
||||
apiPromiseCache.set(cacheKey, requestPromise);
|
||||
}
|
||||
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* [CORRECTED & CACHE-AWARE] High-level wrapper that expects a JSON response.
|
||||
* It leverages apiFetch and is ONLY responsible for calling .json() on a successful response.
|
||||
*/
|
||||
export async function apiFetchJson(url, options = {}) {
|
||||
try {
|
||||
// 1. Get the raw response from apiFetch. It's either fresh or from the promise cache.
|
||||
// If it fails, apiFetch will throw, and this function will propagate the error.
|
||||
const response = await apiFetch(url, options);
|
||||
|
||||
// 2. We have a successful response. We need to clone it before reading the body.
|
||||
// This is CRITICAL because the original response in the promise cache MUST remain unread.
|
||||
const clonedResponse = response.clone();
|
||||
|
||||
// 3. Now we can safely consume the body of the CLONE.
|
||||
const jsonData = await clonedResponse.json();
|
||||
|
||||
return jsonData;
|
||||
|
||||
} catch (error) {
|
||||
// Just propagate the detailed error from apiFetch.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
39
frontend/js/services/errorHandler.js
Normal file
39
frontend/js/services/errorHandler.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Filename: frontend/js/services/errorHandler.js
|
||||
|
||||
/**
|
||||
* This module provides a centralized place for handling application-wide errors,
|
||||
* particularly those originating from API calls. It promotes a consistent user
|
||||
* experience for error notifications.
|
||||
*/
|
||||
|
||||
// Step 1: Define the single, authoritative map for all client-side error messages.
|
||||
// This is the "dictionary" that translates API error codes into user-friendly text.
|
||||
export const ERROR_MESSAGES = {
|
||||
'STATE_CONFLICT_MASTER_REVOKED': '操作失败:无法激活一个已被永久吊销(Revoked)的Key。',
|
||||
'NOT_FOUND': '操作失败:目标资源不存在或已从本组移除。列表将自动刷新。',
|
||||
'NO_KEYS_MATCH_FILTER': '没有找到任何符合当前过滤条件的Key可供操作。',
|
||||
// You can add many more specific codes here as your application grows.
|
||||
|
||||
'DEFAULT': '操作失败,请稍后重试或联系管理员。'
|
||||
};
|
||||
|
||||
/**
|
||||
* A universal API error handler function.
|
||||
* It inspects an error object, determines the best message to show,
|
||||
* and displays it using the provided toastManager.
|
||||
*
|
||||
* @param {Error|APIClientError} error - The error object caught in a try...catch block.
|
||||
* @param {object} toastManager - The toastManager instance to display notifications.
|
||||
* @param {object} [options={}] - Optional parameters for customization.
|
||||
* @param {string} [options.prefix=''] - A string to prepend to the error message (e.g., "任务启动失败: ").
|
||||
*/
|
||||
export function handleApiError(error, toastManager, options = {}) {
|
||||
const prefix = options.prefix || '';
|
||||
|
||||
// Use the exact same robust logic we developed before.
|
||||
const errorCode = error?.code || 'DEFAULT';
|
||||
const displayMessage = ERROR_MESSAGES[errorCode] || error.rawMessageFromServer || error.message || ERROR_MESSAGES['DEFAULT'];
|
||||
|
||||
toastManager.show(`${prefix}${displayMessage}`, 'error');
|
||||
}
|
||||
|
||||
123
frontend/js/utils/utils.js
Normal file
123
frontend/js/utils/utils.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @file utils/utils.js
|
||||
* @description Provides a collection of common, reusable helper functions.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A debounce utility to delay function execution, now with a cancel method.
|
||||
* @param {Function} func The function to debounce.
|
||||
* @param {number} wait The delay in milliseconds.
|
||||
* @returns {Function} The new debounced function with a `.cancel()` method attached.
|
||||
*/
|
||||
export function debounce(func, wait) {
|
||||
let timeout;
|
||||
|
||||
// create a named function expression here instead of returning an anonymous one directly.
|
||||
const debounced = function(...args) {
|
||||
// Store the context of 'this' in case it's needed inside the debounced function.
|
||||
const context = this;
|
||||
|
||||
// The core logic remains the same.
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
// Use .apply() to preserve the original 'this' context.
|
||||
func.apply(context, args);
|
||||
};
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
|
||||
// Attach a 'cancel' method to the debounced function.
|
||||
// This allows us to abort a pending execution.
|
||||
debounced.cancel = () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asynchronously copies a given string of text to the user's clipboard.
|
||||
* @param {string} text The text to be copied.
|
||||
* @returns {Promise<void>} A promise that resolves on success, rejects on failure.
|
||||
*/
|
||||
export function copyToClipboard(text) {
|
||||
// Use the modern Clipboard API if available
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
// Fallback for older browsers
|
||||
return new Promise((resolve, reject) => {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
|
||||
// Make the textarea invisible and prevent scrolling
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.top = "-9999px";
|
||||
textArea.style.left = "-9999px";
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
const successful = document.execCommand("copy");
|
||||
if (successful) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error("Fallback: Unable to copy text to clipboard."));
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
} finally {
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a single API Key against a list of known formats.
|
||||
* @param {string} key - The API key string to validate.
|
||||
* @returns {boolean} - True if the key matches any of the known formats.
|
||||
*/
|
||||
export function isValidApiKeyFormat(key) {
|
||||
// [核心] 这是一个正则表达式列表。未来支持新的Key格式,只需在此处添加新的正则即可。
|
||||
const patterns = [
|
||||
// Google Gemini API Key: AIzaSy + 33 characters (alphanumeric, _, -)
|
||||
/^AIzaSy[\w-]{33}$/,
|
||||
// OpenAI API Key (新格式): sk- + 48 alphanumeric characters
|
||||
/^sk-[\w]{48}$/,
|
||||
// Google AI Studio Key: gsk_ + alphanumeric & hyphens
|
||||
/^gsk_[\w-]{40,}$/,
|
||||
// Anthropic API Key (示例): sk-ant-api03- + long string
|
||||
/^sk-ant-api\d{2}-[\w-]{80,}$/,
|
||||
// Fallback for other potential "sk-" keys with a reasonable length
|
||||
/^sk-[\w-]{20,}$/
|
||||
];
|
||||
// 使用 .some() 方法,只要key匹配列表中的任意一个模式,就返回true。
|
||||
return patterns.some(pattern => pattern.test(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* [NEW] A simple utility to escape HTML special characters from a string.
|
||||
* This is a critical security function to prevent XSS attacks when using .innerHTML.
|
||||
* @param {any} str The input string to escape. If not a string, it's returned as is.
|
||||
* @returns {string} The escaped, HTML-safe string.
|
||||
*/
|
||||
export function escapeHTML(str) {
|
||||
if (typeof str !== 'string') {
|
||||
return str;
|
||||
}
|
||||
return str.replace(/[&<>"']/g, function(match) {
|
||||
return {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[match];
|
||||
});
|
||||
}
|
||||
// ... 其他未来可能添加的工具函数 ...
|
||||
3378
frontend/js/vendor/sortable.esm.js
vendored
Normal file
3378
frontend/js/vendor/sortable.esm.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user