feat: 重构管理界面并添加统一 loading 效果

- 完全重构前端管理界面,采用模块化架构
- 新增独立的 CSS 和 JS 模块(utils, auth, channels, tokens, pricing, api-test, database)
- 实现统一的 loading 遮罩效果,所有 API 请求自动显示加载状态
- 添加现代化多选下拉框组件用于频道选择
- 实现模型部署映射的可视化表格编辑
- 修复页面高度自适应问题,管理页面占满全屏
- 优化侧边栏布局,添加 GitHub 链接和版本号
- 令牌管理页面隐藏敏感 key,添加复制功能
- 管理页面切换时自动重置为列表视图并重新加载数据
- 新增 Claude Proxy 提供商支持
- 统一所有 API 调用的错误处理和 loading 状态管理

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
dreamhunter2333andClaude Sonnet 4.5 committed 2025-12-17 18:41:05 +08:00
1 parent e376a8a539
commit 198dc500aa
14 files changed
+8872 -3500

No files matched your search

+981
View File
@@ -0,0 +1,981 @@
/* ===== CSS Variables (shadcn/ui inspired) ===== */
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 330 81% 60%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 330 45% 96%;
--accent-foreground: 330 81% 30%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 100%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 330 81% 60%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 330 81% 68%;
--primary-foreground: 330 20% 10%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 330 25% 15%;
--accent-foreground: 330 81% 68%;
--destructive: 0 62.8% 50%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 330 81% 68%;
}
/* ===== Base Styles ===== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* ===== Layout ===== */
.app-container {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: 280px;
border-right: 1px solid hsl(var(--border));
background-color: hsl(var(--card));
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
overflow-y: auto;
}
.sidebar-header {
padding: 1.5rem 1.5rem 1rem;
border-bottom: 1px solid hsl(var(--border));
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 600;
}
.sidebar-nav {
flex: 1;
padding: 1rem 0.75rem;
}
.nav-section {
margin-bottom: 1.5rem;
}
.nav-section-title {
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--muted-foreground));
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
margin: 0.125rem 0;
border-radius: var(--radius);
color: hsl(var(--foreground));
text-decoration: none;
cursor: pointer;
transition: all 0.15s;
font-size: 0.875rem;
font-weight: 500;
border: none;
background: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
.nav-item.active {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.nav-item-icon {
font-size: 1.25rem;
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-footer {
padding: 1rem 1.5rem;
border-top: 1px solid hsl(var(--border));
}
/* Main Content */
.main-content {
flex: 1;
margin-left: 280px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-header {
position: sticky;
top: 0;
z-index: 10;
background-color: hsl(var(--background) / 0.95);
backdrop-filter: blur(8px);
border-bottom: 1px solid hsl(var(--border));
padding: 1rem 2rem;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1400px;
}
.header-title {
font-size: 1.5rem;
font-weight: 600;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.content-wrapper {
flex: 1;
padding: 2rem;
max-width: 1400px;
}
/* ===== Components ===== */
/* Card */
.card {
background-color: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) + 2px);
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.card-header {
padding: 1.5rem;
border-bottom: 1px solid hsl(var(--border));
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.card-header > .flex {
margin-left: auto;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
line-height: 1;
}
.card-description {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin-top: 0.375rem;
}
.card-content {
padding: 1.5rem;
margin-top: 0;
}
.card-footer {
padding: 1.5rem;
border-top: 1px solid hsl(var(--border));
display: flex;
align-items: center;
justify-content: space-between;
}
/* Button */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 500;
padding: 0.625rem 1rem;
border: none;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
white-space: nowrap;
}
.btn:disabled {
pointer-events: none;
opacity: 0.5;
}
.btn-primary {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover {
background-color: hsl(var(--primary) / 0.9);
}
.btn-secondary {
background-color: hsl(var(--secondary));
color: hsl(var(--secondary-foreground));
}
.btn-secondary:hover {
background-color: hsl(var(--secondary) / 0.8);
}
.btn-outline {
background-color: transparent;
border: 1px solid hsl(var(--input));
color: hsl(var(--foreground));
}
.btn-outline:hover {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
.btn-destructive {
background-color: hsl(var(--destructive));
color: hsl(var(--destructive-foreground));
}
.btn-destructive:hover {
background-color: hsl(var(--destructive) / 0.9);
}
.btn-ghost {
background-color: transparent;
color: hsl(var(--foreground));
}
.btn-ghost:hover {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
/* Input */
.input {
display: flex;
width: 100%;
border-radius: var(--radius);
border: 1px solid hsl(var(--input));
background-color: hsl(var(--background));
padding: 0.625rem 0.75rem;
font-size: 0.875rem;
transition: all 0.15s;
color: hsl(var(--foreground));
}
select.input[multiple] {
display: block;
padding: 0.5rem;
}
select.input[multiple] option {
padding: 0.5rem;
border-radius: 4px;
margin-bottom: 2px;
}
select.input[multiple] option:checked {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
/* Multi-Select Component */
.multi-select-wrapper {
position: relative;
width: 100%;
}
.multi-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid hsl(var(--input));
border-radius: var(--radius);
background-color: hsl(var(--background));
cursor: pointer;
transition: all 0.15s;
}
.multi-select-trigger:hover {
border-color: hsl(var(--ring));
}
.multi-select-value {
flex: 1;
font-size: 0.875rem;
color: hsl(var(--foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.multi-select-value.placeholder {
color: hsl(var(--muted-foreground));
}
.multi-select-arrow {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
transition: transform 0.2s;
}
.multi-select-trigger.open .multi-select-arrow {
transform: rotate(180deg);
}
.multi-select-dropdown {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
right: 0;
background: hsl(var(--background));
border: 1px solid hsl(var(--input));
border-radius: var(--radius);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
z-index: 50;
max-height: 300px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.multi-select-search {
padding: 0.5rem;
border-bottom: 1px solid hsl(var(--border));
}
.multi-select-search .input {
padding: 0.5rem;
font-size: 0.875rem;
}
.multi-select-options {
overflow-y: auto;
max-height: 250px;
}
.multi-select-option {
display: flex;
align-items: center;
padding: 0.625rem 0.75rem;
cursor: pointer;
transition: background-color 0.15s;
gap: 0.5rem;
}
.multi-select-option:hover {
background-color: hsl(var(--accent));
}
.multi-select-option input[type="checkbox"] {
width: 1rem;
height: 1rem;
cursor: pointer;
}
.multi-select-option label {
flex: 1;
font-size: 0.875rem;
cursor: pointer;
margin: 0;
}
.multi-select-loading,
.multi-select-empty {
padding: 1rem;
text-align: center;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
}
.input:focus {
outline: none;
border-color: hsl(var(--ring));
box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2);
}
.input:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Label */
.label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: hsl(var(--foreground));
}
/* Badge */
.badge {
display: inline-flex;
align-items: center;
border-radius: calc(var(--radius) - 2px);
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
transition: all 0.15s;
}
.badge-default {
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.badge-secondary {
background-color: hsl(var(--secondary));
color: hsl(var(--secondary-foreground));
}
.badge-success {
background-color: hsl(142.1 76.2% 36.3%);
color: hsl(0 0% 100%);
}
.badge-warning {
background-color: hsl(47.9 95.8% 53.1%);
color: hsl(26 83.3% 14.1%);
}
.badge-destructive {
background-color: hsl(var(--destructive));
color: hsl(var(--destructive-foreground));
}
.badge-outline {
background-color: transparent;
border: 1px solid hsl(var(--border));
color: hsl(var(--foreground));
}
/* Table */
.table-container {
overflow-x: auto;
border-radius: var(--radius);
border: 1px solid hsl(var(--border));
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.table thead {
background-color: hsl(var(--muted));
}
.table th {
padding: 0.75rem 1rem;
text-align: left;
font-weight: 600;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
}
.table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid hsl(var(--border));
}
.table tbody tr:hover {
background-color: hsl(var(--muted) / 0.5);
}
.table tbody tr:last-child td {
border-bottom: none;
}
/* Alert */
.alert {
padding: 1rem;
border-radius: var(--radius);
border: 1px solid hsl(var(--border));
margin-bottom: 1rem;
}
.alert-info {
background-color: hsl(330 100% 97%);
border-color: hsl(330 81% 85%);
color: hsl(330 81% 30%);
}
.dark .alert-info {
background-color: hsl(330 40% 15%);
border-color: hsl(330 40% 25%);
color: hsl(330 81% 85%);
}
.alert-success {
background-color: hsl(142.1 76.2% 36.3% / 0.1);
border-color: hsl(142.1 76.2% 36.3% / 0.3);
}
.alert-warning {
background-color: hsl(47.9 95.8% 53.1% / 0.1);
border-color: hsl(47.9 95.8% 53.1% / 0.3);
}
.alert-error {
background-color: hsl(var(--destructive) / 0.1);
border-color: hsl(var(--destructive) / 0.3);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 50;
display: none;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
}
.modal-overlay.active {
display: flex;
}
.modal-content {
background-color: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) + 4px);
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
max-width: 32rem;
width: 90%;
max-height: 90vh;
overflow-y: auto;
animation: slideUp 0.2s ease;
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid hsl(var(--border));
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1.5rem;
border-top: 1px solid hsl(var(--border));
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Form Group */
.form-group {
margin-bottom: 1.25rem;
}
.form-group:last-child {
margin-bottom: 0;
}
/* Grid */
.grid {
display: grid;
gap: 1.5rem;
}
.grid-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
.grid-cols-3 {
grid-template-columns: repeat(3, 1fr);
}
.grid-cols-4 {
grid-template-columns: repeat(4, 1fr);
}
/* Stats Card */
.stats-card {
background-color: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) + 2px);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.stats-card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.stats-card-title {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
}
.stats-card-value {
font-size: 2rem;
font-weight: 700;
}
.stats-card-trend {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
/* Utilities */
.hidden {
display: none !important;
}
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-2 {
gap: 0.5rem;
}
.gap-4 {
gap: 1rem;
}
.mt-1 {
margin-top: 0.25rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.mb-3 {
margin-bottom: 0.75rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.mb-6 {
margin-bottom: 1.5rem;
}
.text-sm {
font-size: 0.875rem;
}
.text-center {
text-align: center;
}
.text-muted {
color: hsl(var(--muted-foreground));
}
.font-medium {
font-weight: 500;
}
.font-semibold {
font-weight: 600;
}
.w-full {
width: 100%;
}
code {
background-color: hsl(var(--muted));
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
font-family: 'Courier New', monospace;
}
/* Tab Content */
.tab-content {
display: none;
}
.tab-content.active {
display: flex;
flex-direction: column;
flex: 1;
}
/* Responsive */
@media (max-width: 1024px) {
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s;
}
.sidebar.mobile-open {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
.grid-cols-2,
.grid-cols-3,
.grid-cols-4 {
grid-template-columns: 1fr;
}
}
/* ===== Loading Overlay ===== */
#loadingOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 9999;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
#loadingOverlay.active {
opacity: 1;
pointer-events: auto;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: hsl(var(--primary));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text {
margin-top: 1rem;
color: white;
font-size: 0.875rem;
font-weight: 500;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* ===== Item List Styles ===== */
#tokenListView,
#channelListView,
#pricingContent {
display: flex;
flex-direction: column;
flex: 1;
}
.item-list {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
padding-right: 1.5rem;
min-height: 0;
}
.item-list::-webkit-scrollbar {
width: 6px;
}
.item-list::-webkit-scrollbar-track {
background: hsl(var(--muted));
border-radius: 3px;
}
.item-list::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border-radius: 3px;
}
.item-list::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground));
}
.item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 20px;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
margin-bottom: 12px;
background: hsl(var(--card));
transition: all 0.2s ease;
}
.item:hover {
border-color: hsl(var(--primary));
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
transform: translateX(4px);
}
.item-info {
flex: 1;
min-width: 0;
}
.item-key {
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 6px;
font-size: 15px;
}
.item-details {
font-size: 13px;
color: hsl(var(--muted-foreground));
line-height: 1.5;
}
.actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn-danger {
background-color: hsl(var(--destructive));
color: hsl(var(--destructive-foreground));
}
.btn-danger:hover {
opacity: 0.9;
}
+538 -3044
View File
File diff suppressed because it is too large. Load diff
+123
View File
@@ -0,0 +1,123 @@
// ===== API Test Module =====
import { showNotification } from './utils.js';
// API Test Template
export function updateRequestTemplate() {
const endpoint = document.getElementById('testEndpoint').value;
const requestBody = document.getElementById('testRequestBody');
const templates = {
'/v1/chat/completions': {
model: "gpt-3.5-turbo",
messages: [
{ role: "user", content: "你好,请介绍一下你自己。" }
],
temperature: 0.7,
max_tokens: 1024
},
'/v1/messages': {
model: "claude-haiku-4-5-20251001",
max_tokens: 1024,
messages: [
{ role: "user", content: "你好,请介绍一下你自己。" }
]
},
'/v1/completions': {
model: "gpt-3.5-turbo-instruct",
prompt: "你好,请介绍一下你自己。",
max_tokens: 1024,
temperature: 0.7
}
};
requestBody.value = JSON.stringify(templates[endpoint], null, 2);
}
export async function testAPI(event) {
event.preventDefault();
const endpoint = document.getElementById('testEndpoint').value;
const token = document.getElementById('testApiToken').value;
const requestBodyText = document.getElementById('testRequestBody').value;
const resultDiv = document.getElementById('testResult');
const resultContent = document.getElementById('testResultContent');
const statusBadge = document.getElementById('testStatusBadge');
const timeBadge = document.getElementById('testTimeBadge');
const submitBtn = document.getElementById('testSubmitBtn');
// Validate JSON
let requestBody;
try {
requestBody = JSON.parse(requestBodyText);
} catch (error) {
showNotification('JSON 格式错误:' + error.message, 'error');
return;
}
resultDiv.classList.remove('hidden');
resultContent.textContent = '发送请求中...';
statusBadge.textContent = '请求中';
statusBadge.className = 'badge badge-warning';
submitBtn.disabled = true;
const startTime = Date.now();
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(requestBody)
});
const endTime = Date.now();
const duration = endTime - startTime;
timeBadge.textContent = `${duration}ms`;
if (response.ok) {
statusBadge.textContent = '成功';
statusBadge.className = 'badge badge-success';
} else {
statusBadge.textContent = `失败 ${response.status}`;
statusBadge.className = 'badge badge-destructive';
}
// Handle streaming vs non-streaming
const isStreaming = requestBody.stream === true;
if (isStreaming) {
resultContent.textContent = '';
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
resultContent.textContent += chunk;
}
} else {
const data = await response.json();
resultContent.textContent = JSON.stringify(data, null, 2);
}
if (!response.ok) {
showNotification('API 请求失败', 'error');
}
} catch (error) {
const endTime = Date.now();
const duration = endTime - startTime;
timeBadge.textContent = `${duration}ms`;
statusBadge.textContent = '错误';
statusBadge.className = 'badge badge-destructive';
resultContent.textContent = '错误:' + error.message;
showNotification('请求失败:' + error.message, 'error');
} finally {
submitBtn.disabled = false;
}
}
+80
View File
@@ -0,0 +1,80 @@
// ===== Authentication Module =====
import { toggleModal, showNotification, showLoading, hideLoading } from './utils.js';
export function toggleAuthModal() {
const adminToken = localStorage.getItem('adminToken');
if (adminToken) {
// Logout
if (confirm('确定要退出登录吗?')) {
localStorage.removeItem('adminToken');
onLogout();
}
} else {
toggleModal('authModal');
}
}
export async function authenticate(event) {
event.preventDefault();
const token = document.getElementById('adminToken').value;
showLoading('验证令牌...');
try {
const response = await fetch('/api/admin/channel', {
headers: { 'x-admin-token': token }
});
if (response.ok) {
localStorage.setItem('adminToken', token);
toggleModal('authModal');
onAuthenticated();
showNotification('登录成功', 'success');
} else {
showAuthError('管理员令牌无效');
}
} catch (error) {
showAuthError('认证失败:' + error.message);
} finally {
hideLoading();
}
}
function showAuthError(message) {
document.getElementById('authError').classList.remove('hidden');
document.getElementById('authErrorMessage').textContent = message;
}
function onAuthenticated() {
document.querySelectorAll('.admin-only').forEach(el => {
el.classList.remove('hidden');
});
document.getElementById('loginPrompt').classList.add('hidden');
const authButton = document.getElementById('authButton');
authButton.textContent = '🚪 退出登录';
authButton.style.color = 'hsl(var(--destructive))';
authButton.style.borderColor = 'hsl(var(--destructive))';
}
function onLogout() {
document.querySelectorAll('.admin-only').forEach(el => {
el.classList.add('hidden');
});
document.getElementById('loginPrompt').classList.remove('hidden');
const authButton = document.getElementById('authButton');
authButton.textContent = '🔑 管理员登录';
authButton.style.color = '';
authButton.style.borderColor = '';
if (window.switchTab) {
window.switchTab('dashboard');
}
}
// Initialize on page load
export function initAuth() {
const adminToken = localStorage.getItem('adminToken');
if (adminToken) {
onAuthenticated();
}
}
+407
View File
@@ -0,0 +1,407 @@
// ===== Channel Management Module =====
import { toggleModal, showNotification, showLoading, hideLoading } from './utils.js';
let channelEditMode = 'form'; // 'form' or 'json'
let currentEditingKey = null;
// Add model mapper row
export function addMapperRow() {
const tbody = document.getElementById('channelMapperTable');
if (!tbody) return;
// Remove empty message if exists
if (tbody.querySelector('.text-center')) {
tbody.innerHTML = '';
}
const row = document.createElement('tr');
row.innerHTML = `
<td style="padding: 12px;">
<input type="text" class="input" placeholder="例如: gpt-4" style="padding: 0.5rem;">
</td>
<td style="padding: 12px;">
<input type="text" class="input" placeholder="例如: gpt-4-deployment" style="padding: 0.5rem;">
</td>
<td style="padding: 12px; text-align: center;">
<button type="button" class="btn btn-danger btn-sm" onclick="window.removeMapperRow(this)">🗑️ 删除</button>
</td>
`;
tbody.appendChild(row);
}
// Remove model mapper row
export function removeMapperRow(button) {
const row = button.closest('tr');
if (row) {
row.remove();
}
// If no rows left, show empty message
const tbody = document.getElementById('channelMapperTable');
if (tbody && tbody.children.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted">暂无映射,点击上方按钮添加</td></tr>';
}
}
// Display mapper table
function displayMapperTable(mapper) {
const tbody = document.getElementById('channelMapperTable');
if (!tbody) return;
tbody.innerHTML = '';
if (!mapper || Object.keys(mapper).length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted">暂无映射,点击上方按钮添加</td></tr>';
return;
}
Object.entries(mapper).forEach(([requestModel, deploymentModel]) => {
const row = document.createElement('tr');
row.innerHTML = `
<td style="padding: 12px;">
<input type="text" class="input" value="${requestModel}" placeholder="例如: gpt-4" style="padding: 0.5rem;">
</td>
<td style="padding: 12px;">
<input type="text" class="input" value="${deploymentModel}" placeholder="例如: gpt-4-deployment" style="padding: 0.5rem;">
</td>
<td style="padding: 12px; text-align: center;">
<button type="button" class="btn btn-danger btn-sm" onclick="window.removeMapperRow(this)">🗑️ 删除</button>
</td>
`;
tbody.appendChild(row);
});
}
// Collect mapper table data
function collectMapperTableData() {
const tbody = document.getElementById('channelMapperTable');
const mapper = {};
if (!tbody) return mapper;
Array.from(tbody.children).forEach((row) => {
// Skip empty message row
if (row.querySelector('.text-center')) return;
const inputs = row.querySelectorAll('input');
if (inputs.length < 2) return;
const requestModel = inputs[0].value.trim();
const deploymentModel = inputs[1].value.trim();
if (requestModel && deploymentModel) {
mapper[requestModel] = deploymentModel;
}
});
return mapper;
}
// Load channels from API
export async function loadChannels() {
const adminToken = localStorage.getItem('adminToken');
showLoading('加载频道列表...');
try {
const response = await fetch('/api/admin/channel', {
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
const result = await response.json();
const channels = result.data || [];
displayChannels(channels);
}
} catch (error) {
console.error('Failed to load channels:', error);
showNotification('加载频道失败', 'error');
} finally {
hideLoading();
}
}
// Display channels in the list
function displayChannels(channels) {
const container = document.getElementById('channelsList');
if (!container) return;
if (!channels || channels.length === 0) {
container.innerHTML = '<div style="text-align: center; padding: 3rem 1rem; color: hsl(var(--muted-foreground));">📂 暂无频道数据</div>';
return;
}
container.innerHTML = channels.map(item => {
let parsedValue = {};
try {
parsedValue = typeof item.value === 'string' ? JSON.parse(item.value) : item.value;
} catch {
parsedValue = { name: 'Invalid JSON', type: 'unknown', endpoint: '' };
}
const name = parsedValue.name || 'No name';
const type = parsedValue.type || 'Unknown';
const endpoint = parsedValue.endpoint || '';
const endpointDisplay = endpoint.length > 50 ? endpoint.substring(0, 50) + '...' : endpoint;
const modelCount = Object.keys(parsedValue.deployment_mapper || {}).length;
return `
<div class="item">
<div class="item-info">
<div class="item-key">${name}</div>
<div class="item-details">
${item.key} | 类型: ${type} | 端点: ${endpointDisplay} | ${modelCount} 个模型
</div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="window.editChannel('${item.key}')">✏️ 编辑</button>
<button class="btn btn-danger" onclick="window.deleteChannel('${item.key}')">🗑️ 删除</button>
</div>
</div>
`;
}).join('');
}
// Show channel list view
export function showChannelList() {
document.getElementById('channelListView').style.display = 'block';
document.getElementById('channelFormView').style.display = 'none';
loadChannels();
}
// Show channel form for adding
export function showChannelForm() {
currentEditingKey = null;
document.getElementById('channelFormTitle').textContent = '添加频道';
document.getElementById('channelListView').style.display = 'none';
document.getElementById('channelFormView').style.display = 'block';
clearChannelForm();
// Reset to form mode
channelEditMode = 'form';
document.getElementById('channelFormMode').style.display = 'block';
document.getElementById('channelJsonMode').style.display = 'none';
document.getElementById('channelModeToggle').textContent = '📝 切换到JSON模式';
// Channel key should be editable when creating
document.getElementById('channelKey').disabled = false;
document.getElementById('channelKey').placeholder = '例如: azure-openai-1';
}
// Edit existing channel
export async function editChannel(key) {
currentEditingKey = key;
document.getElementById('channelFormTitle').textContent = '编辑频道';
document.getElementById('channelListView').style.display = 'none';
document.getElementById('channelFormView').style.display = 'block';
const adminToken = localStorage.getItem('adminToken');
showLoading('加载频道数据...');
try {
const response = await fetch('/api/admin/channel', {
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
const result = await response.json();
const channels = result.data || [];
const channel = channels.find(c => c.key === key);
if (channel) {
const config = typeof channel.value === 'string' ? JSON.parse(channel.value) : channel.value;
fillChannelForm(key, config);
// Set channel key as readonly when editing
document.getElementById('channelKey').disabled = true;
}
}
} catch (error) {
showNotification('加载频道数据失败', 'error');
} finally {
hideLoading();
}
}
// Fill form with channel data
function fillChannelForm(key, config) {
document.getElementById('channelKey').value = key;
// Don't change disabled state here - it's set by editChannel
document.getElementById('channelName').value = config.name || '';
document.getElementById('channelType').value = config.type || 'azure-openai';
document.getElementById('channelEndpoint').value = config.endpoint || '';
document.getElementById('channelApiKey').value = config.api_key || '';
document.getElementById('channelApiVersion').value = config.api_version || '';
// Display mapper table
displayMapperTable(config.deployment_mapper || {});
// Update JSON mode
document.getElementById('channelValueJson').value = JSON.stringify(config, null, 2);
}
// Clear form
function clearChannelForm() {
document.getElementById('channelKey').value = '';
document.getElementById('channelKey').disabled = false;
document.getElementById('channelName').value = '';
document.getElementById('channelType').value = 'azure-openai';
document.getElementById('channelEndpoint').value = '';
document.getElementById('channelApiKey').value = '';
document.getElementById('channelApiVersion').value = '';
// Clear mapper table
const tbody = document.getElementById('channelMapperTable');
if (tbody) {
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted">暂无映射,点击上方按钮添加</td></tr>';
}
document.getElementById('channelValueJson').value = '';
}
// Toggle between form and JSON mode
export function toggleChannelEditMode() {
if (channelEditMode === 'form') {
// Switch to JSON mode - collect form data and show in JSON
const formData = collectChannelFormData();
if (formData) {
document.getElementById('channelValueJson').value = JSON.stringify(formData, null, 2);
}
document.getElementById('channelFormMode').style.display = 'none';
document.getElementById('channelJsonMode').style.display = 'block';
document.getElementById('channelModeToggle').textContent = '📋 切换到表单模式';
channelEditMode = 'json';
} else {
// Switch to form mode - parse JSON and fill form
try {
const jsonValue = document.getElementById('channelValueJson').value;
if (jsonValue) {
const config = JSON.parse(jsonValue);
document.getElementById('channelName').value = config.name || '';
document.getElementById('channelType').value = config.type || 'azure-openai';
document.getElementById('channelEndpoint').value = config.endpoint || '';
document.getElementById('channelApiKey').value = config.api_key || '';
document.getElementById('channelApiVersion').value = config.api_version || '';
// Display mapper table
displayMapperTable(config.deployment_mapper || {});
}
} catch (error) {
showNotification('JSON格式错误', 'error');
return;
}
document.getElementById('channelFormMode').style.display = 'block';
document.getElementById('channelJsonMode').style.display = 'none';
document.getElementById('channelModeToggle').textContent = '📝 切换到JSON模式';
channelEditMode = 'form';
}
}
// Collect form data
function collectChannelFormData() {
const name = document.getElementById('channelName').value;
const type = document.getElementById('channelType').value;
const endpoint = document.getElementById('channelEndpoint').value;
const apiKey = document.getElementById('channelApiKey').value;
const apiVersion = document.getElementById('channelApiVersion').value;
if (!name || !endpoint || !apiKey) {
return null;
}
// Collect mapper data from table
const deploymentMapper = collectMapperTableData();
const config = {
name,
type,
endpoint,
api_key: apiKey,
deployment_mapper: deploymentMapper
};
if (apiVersion) {
config.api_version = apiVersion;
}
return config;
}
// Save channel (add or update)
export async function saveChannel() {
const key = document.getElementById('channelKey').value || currentEditingKey;
if (!key) {
showNotification('请填写频道标识', 'error');
return;
}
let config;
if (channelEditMode === 'form') {
config = collectChannelFormData();
if (!config) {
showNotification('请填写所有必填字段(频道名称、端点、API密钥)', 'error');
return;
}
} else {
const jsonValue = document.getElementById('channelValueJson').value;
if (!jsonValue) {
showNotification('请填写配置JSON', 'error');
return;
}
try {
config = JSON.parse(jsonValue);
} catch {
showNotification('JSON格式错误,请检查配置格式', 'error');
return;
}
}
const adminToken = localStorage.getItem('adminToken');
showLoading('保存频道...');
try {
const response = await fetch(`/api/admin/channel/${encodeURIComponent(key)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-token': adminToken
},
body: JSON.stringify(config)
});
if (response.ok) {
showNotification(currentEditingKey ? '频道更新成功' : '频道添加成功', 'success');
clearChannelForm();
showChannelList();
} else {
const error = await response.text();
showNotification('保存失败:' + error, 'error');
}
} catch (error) {
showNotification('保存失败:' + error.message, 'error');
} finally {
hideLoading();
}
}
// Delete channel
export async function deleteChannel(key) {
if (!confirm(`确定要删除频道 "${key}" 吗?`)) return;
const adminToken = localStorage.getItem('adminToken');
showLoading('删除频道...');
try {
const response = await fetch(`/api/admin/channel/${encodeURIComponent(key)}`, {
method: 'DELETE',
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
showNotification('频道已删除', 'success');
loadChannels();
} else {
showNotification('删除失败', 'error');
}
} catch (error) {
showNotification('删除失败:' + error.message, 'error');
} finally {
hideLoading();
}
}
+27
View File
@@ -0,0 +1,27 @@
// ===== Database Management Module =====
import { showNotification, showLoading, hideLoading } from './utils.js';
export async function initDatabase() {
const adminToken = localStorage.getItem('adminToken');
showLoading('初始化数据库...');
try {
const response = await fetch('/api/admin/db_initialize', {
method: 'POST',
headers: { 'x-admin-token': adminToken }
});
const data = await response.json();
document.getElementById('dbResult').classList.remove('hidden');
document.getElementById('dbResultContent').textContent = JSON.stringify(data, null, 2);
if (response.ok) {
showNotification('数据库初始化成功', 'success');
} else {
showNotification('数据库初始化失败', 'error');
}
} catch (error) {
showNotification('初始化失败:' + error.message, 'error');
} finally {
hideLoading();
}
}
+64
View File
@@ -0,0 +1,64 @@
// ===== Main Entry Point =====
import { toggleModal, switchTab, toggleTheme, showNotification, copyToClipboard } from './utils.js';
import { toggleAuthModal, authenticate, initAuth } from './auth.js';
import { updateRequestTemplate, testAPI } from './api-test.js';
import { loadChannels, showChannelList, showChannelForm, editChannel, saveChannel, toggleChannelEditMode, deleteChannel, addMapperRow, removeMapperRow } from './channels.js';
import { loadTokens, showTokenList, showTokenForm, editToken, saveToken, generateTokenKey, toggleTokenEditMode, deleteToken, setQuotaValue, toggleChannelDropdown, toggleChannelSelection, filterChannels } from './tokens.js';
import { loadPricing, savePricing, addPricingModel, removePricingRow, togglePricingEditMode } from './pricing.js';
import { initDatabase } from './database.js';
// Expose functions to global scope for onclick handlers
window.toggleModal = toggleModal;
window.switchTab = switchTab;
window.toggleTheme = toggleTheme;
window.copyToClipboard = copyToClipboard;
window.toggleAuthModal = toggleAuthModal;
window.authenticate = authenticate;
window.updateRequestTemplate = updateRequestTemplate;
window.testAPI = testAPI;
window.loadChannels = loadChannels;
window.showChannelList = showChannelList;
window.showChannelForm = showChannelForm;
window.editChannel = editChannel;
window.saveChannel = saveChannel;
window.toggleChannelEditMode = toggleChannelEditMode;
window.deleteChannel = deleteChannel;
window.addMapperRow = addMapperRow;
window.removeMapperRow = removeMapperRow;
window.loadTokens = loadTokens;
window.showTokenList = showTokenList;
window.showTokenForm = showTokenForm;
window.editToken = editToken;
window.saveToken = saveToken;
window.generateTokenKey = generateTokenKey;
window.setQuotaValue = setQuotaValue;
window.toggleTokenEditMode = toggleTokenEditMode;
window.deleteToken = deleteToken;
window.toggleChannelDropdown = toggleChannelDropdown;
window.toggleChannelSelection = toggleChannelSelection;
window.filterChannels = filterChannels;
window.loadPricing = loadPricing;
window.savePricing = savePricing;
window.addPricingModel = addPricingModel;
window.removePricingRow = removePricingRow;
window.togglePricingEditMode = togglePricingEditMode;
window.initDatabase = initDatabase;
// Initialize on DOMContentLoaded
document.addEventListener('DOMContentLoaded', () => {
// Initialize theme
const isDark = localStorage.getItem('theme') === 'dark';
if (isDark) {
document.documentElement.classList.add('dark');
document.getElementById('themeIcon').textContent = '☀️';
} else {
document.documentElement.classList.remove('dark');
document.getElementById('themeIcon').textContent = '🌙';
}
// Initialize authentication
initAuth();
// Initialize API test template
updateRequestTemplate();
});
+203
View File
@@ -0,0 +1,203 @@
// ===== Pricing Management Module =====
import { showNotification, showLoading, hideLoading } from './utils.js';
let pricingEditMode = 'table'; // 'table' or 'json'
export async function loadPricing() {
const adminToken = localStorage.getItem('adminToken');
showLoading('加载定价配置...');
try {
const response = await fetch('/api/admin/pricing', {
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
const data = await response.json();
const pricing = data.data || {};
if (pricingEditMode === 'table') {
displayPricingTable(pricing);
const addBtn = document.getElementById('addPricingModelBtn');
if (addBtn) addBtn.style.display = 'block';
} else {
document.getElementById('pricingConfigJson').value = JSON.stringify(pricing, null, 2);
const addBtn = document.getElementById('addPricingModelBtn');
if (addBtn) addBtn.style.display = 'none';
}
}
} catch (error) {
console.error('Failed to load pricing:', error);
showNotification('加载定价失败', 'error');
} finally {
hideLoading();
}
}
function displayPricingTable(pricing) {
const tbody = document.getElementById('pricingTableBody');
if (!tbody) return;
tbody.innerHTML = '';
// 如果没有定价数据,添加一个空行
if (!pricing || Object.keys(pricing).length === 0) {
addPricingModel();
return;
}
// 为每个模型创建一行
Object.entries(pricing).forEach(([modelName, modelPricing]) => {
const row = document.createElement('tr');
const inputPrice = modelPricing.input || 0;
const outputPrice = modelPricing.output || 0;
row.innerHTML = `
<td style="padding: 12px;">
<input type="text" class="input" value="${modelName}" placeholder="例如: gpt-4">
</td>
<td style="padding: 12px;">
<input type="number" class="input" value="${inputPrice}" step="0.000001" min="0" placeholder="0.001">
</td>
<td style="padding: 12px;">
<input type="number" class="input" value="${outputPrice}" step="0.000001" min="0" placeholder="0.002">
</td>
<td style="padding: 12px; text-align: center;">
<button class="btn btn-danger btn-sm" onclick="window.removePricingRow(this)">🗑️ 删除</button>
</td>
`;
tbody.appendChild(row);
});
}
export function addPricingModel() {
const tbody = document.getElementById('pricingTableBody');
if (!tbody) return;
const row = document.createElement('tr');
row.innerHTML = `
<td style="padding: 12px;">
<input type="text" class="input" placeholder="例如: gpt-4">
</td>
<td style="padding: 12px;">
<input type="number" class="input" step="0.000001" min="0" placeholder="0.001">
</td>
<td style="padding: 12px;">
<input type="number" class="input" step="0.000001" min="0" placeholder="0.002">
</td>
<td style="padding: 12px; text-align: center;">
<button class="btn btn-danger btn-sm" onclick="window.removePricingRow(this)">🗑️ 删除</button>
</td>
`;
tbody.appendChild(row);
}
export function removePricingRow(button) {
const row = button.closest('tr');
if (row) {
row.remove();
}
}
function collectPricingTableData() {
const tbody = document.getElementById('pricingTableBody');
const config = {};
if (!tbody) return config;
Array.from(tbody.children).forEach((row) => {
const inputs = row.querySelectorAll('input');
if (inputs.length < 3) return;
const modelName = inputs[0].value.trim();
const inputPrice = parseFloat(inputs[1].value) || 0;
const outputPrice = parseFloat(inputs[2].value) || 0;
if (modelName) {
config[modelName] = {
input: inputPrice,
output: outputPrice
};
}
});
return config;
}
export function togglePricingEditMode() {
if (pricingEditMode === 'table') {
// Switch to JSON mode - collect table data and show in JSON
const config = collectPricingTableData();
document.getElementById('pricingConfigJson').value = JSON.stringify(config, null, 2);
document.getElementById('pricingTableMode').style.display = 'none';
document.getElementById('pricingJsonMode').style.display = 'block';
document.getElementById('pricingModeToggle').textContent = '📊 切换到表格模式';
const addBtn = document.getElementById('addPricingModelBtn');
if (addBtn) addBtn.style.display = 'none';
pricingEditMode = 'json';
} else {
// Switch to table mode - parse JSON and display table
try {
const jsonValue = document.getElementById('pricingConfigJson').value;
const config = jsonValue ? JSON.parse(jsonValue) : {};
displayPricingTable(config);
} catch (error) {
showNotification('JSON格式错误', 'error');
return;
}
document.getElementById('pricingTableMode').style.display = 'block';
document.getElementById('pricingJsonMode').style.display = 'none';
document.getElementById('pricingModeToggle').textContent = '📝 切换到JSON模式';
const addBtn = document.getElementById('addPricingModelBtn');
if (addBtn) addBtn.style.display = 'block';
pricingEditMode = 'table';
}
}
export async function savePricing(event) {
if (event) event.preventDefault();
const adminToken = localStorage.getItem('adminToken');
let config;
try {
if (pricingEditMode === 'table') {
// Validate table data
config = collectPricingTableData();
if (Object.keys(config).length === 0) {
showNotification('请至少添加一个模型定价', 'error');
return;
}
} else {
// Validate JSON
const configText = document.getElementById('pricingConfigJson').value;
if (!configText) {
showNotification('请填写定价配置', 'error');
return;
}
config = JSON.parse(configText);
}
// Save to API
showLoading('保存定价配置...');
const response = await fetch('/api/admin/pricing', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-token': adminToken
},
body: JSON.stringify(config)
});
if (response.ok) {
showNotification('定价配置已保存', 'success');
loadPricing();
} else {
const error = await response.text();
showNotification('保存失败:' + error, 'error');
}
} catch (error) {
showNotification('保存失败:' + error.message, 'error');
} finally {
hideLoading();
}
}
+454
View File
@@ -0,0 +1,454 @@
// ===== Token Management Module =====
import { toggleModal, showNotification, showLoading, hideLoading } from './utils.js';
let tokenEditMode = 'form'; // 'form' or 'json'
let currentEditingKey = null;
let availableChannels = []; // Store available channels for select options
let selectedChannels = []; // Store selected channel keys
// Load available channels
async function loadAvailableChannels() {
const adminToken = localStorage.getItem('adminToken');
try {
const response = await fetch('/api/admin/channel', {
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
const result = await response.json();
const channels = result.data || [];
availableChannels = channels.map(c => c.key);
populateChannelOptions();
}
} catch (error) {
console.error('Failed to load channels:', error);
}
}
// Populate channel options in the dropdown
function populateChannelOptions() {
const container = document.getElementById('channelOptionsContainer');
if (!container) return;
if (availableChannels.length === 0) {
container.innerHTML = '<div class="multi-select-empty">暂无可用频道</div>';
} else {
container.innerHTML = availableChannels.map(key => `
<div class="multi-select-option" onclick="window.toggleChannelSelection('${key}', event)">
<input type="checkbox" id="channel_${key}" value="${key}" ${selectedChannels.includes(key) ? 'checked' : ''}>
<label for="channel_${key}">${key}</label>
</div>
`).join('');
}
updateChannelDisplay();
}
// Toggle channel dropdown
export function toggleChannelDropdown() {
const dropdown = document.getElementById('channelSelectDropdown');
const trigger = document.getElementById('channelSelectTrigger');
if (dropdown.classList.contains('hidden')) {
dropdown.classList.remove('hidden');
trigger.classList.add('open');
// Close dropdown when clicking outside
setTimeout(() => {
document.addEventListener('click', closeChannelDropdownOnClickOutside);
}, 0);
} else {
dropdown.classList.add('hidden');
trigger.classList.remove('open');
document.removeEventListener('click', closeChannelDropdownOnClickOutside);
}
}
function closeChannelDropdownOnClickOutside(event) {
const wrapper = document.querySelector('.multi-select-wrapper');
if (wrapper && !wrapper.contains(event.target)) {
const dropdown = document.getElementById('channelSelectDropdown');
const trigger = document.getElementById('channelSelectTrigger');
dropdown.classList.add('hidden');
trigger.classList.remove('open');
document.removeEventListener('click', closeChannelDropdownOnClickOutside);
}
}
// Toggle channel selection
export function toggleChannelSelection(channelKey, event) {
event.stopPropagation();
const checkbox = document.getElementById(`channel_${channelKey}`);
if (checkbox) {
checkbox.checked = !checkbox.checked;
}
if (selectedChannels.includes(channelKey)) {
selectedChannels = selectedChannels.filter(k => k !== channelKey);
} else {
selectedChannels.push(channelKey);
}
updateChannelDisplay();
}
// Update the display of selected channels
function updateChannelDisplay() {
const valueElement = document.getElementById('channelSelectValue');
if (!valueElement) return;
if (selectedChannels.length === 0) {
valueElement.textContent = '请选择频道...';
valueElement.classList.add('placeholder');
} else if (selectedChannels.length === 1) {
valueElement.textContent = selectedChannels[0];
valueElement.classList.remove('placeholder');
} else {
valueElement.textContent = `已选择 ${selectedChannels.length} 个频道`;
valueElement.classList.remove('placeholder');
}
}
// Filter channels based on search
export function filterChannels() {
const searchInput = document.getElementById('channelSearchInput');
const searchTerm = searchInput.value.toLowerCase();
const options = document.querySelectorAll('.multi-select-option');
options.forEach(option => {
const label = option.querySelector('label').textContent.toLowerCase();
if (label.includes(searchTerm)) {
option.style.display = '';
} else {
option.style.display = 'none';
}
});
}
// Load tokens from API
export async function loadTokens() {
const adminToken = localStorage.getItem('adminToken');
showLoading('加载令牌列表...');
try {
const response = await fetch('/api/admin/token', {
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
const result = await response.json();
const tokens = result.data || [];
displayTokens(tokens);
}
} catch (error) {
console.error('Failed to load tokens:', error);
showNotification('加载令牌失败', 'error');
} finally {
hideLoading();
}
}
// Display tokens in the list
function displayTokens(tokens) {
const container = document.getElementById('tokensList');
if (!container) return;
if (!tokens || tokens.length === 0) {
container.innerHTML = '<div style="text-align: center; padding: 3rem 1rem; color: hsl(var(--muted-foreground));">🎫 暂无令牌数据</div>';
return;
}
container.innerHTML = tokens.map(item => {
let parsedValue = {};
try {
parsedValue = typeof item.value === 'string' ? JSON.parse(item.value) : item.value;
} catch {
parsedValue = { name: 'Invalid JSON', channel_keys: [], total_quota: 0 };
}
const name = parsedValue.name || '未命名';
const channelKeys = parsedValue.channel_keys || [];
const channelDisplay = channelKeys.length === 0 ? '所有频道' : channelKeys.slice(0, 2).join(', ') + (channelKeys.length > 2 ? '...' : '');
const usage = item.usage || 0;
const totalQuota = parsedValue.total_quota || 0;
// 将配额转换为美元显示
const totalQuotaValue = totalQuota || 0;
const quotaInDollars = (totalQuotaValue / 1000000).toFixed(2);
const usageValue = usage || 0;
const usageInDollars = (usageValue / 1000000).toFixed(2);
return `
<div class="item">
<div class="item-info">
<div class="item-key">${name}</div>
<div class="item-details">
频道: ${channelDisplay} | 配额: $${quotaInDollars} | 已用: $${usageInDollars}
</div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="window.copyToClipboard('${item.key}')" title="复制token">📋 复制</button>
<button class="btn btn-secondary" onclick="window.editToken('${item.key}')">✏️ 编辑</button>
<button class="btn btn-danger" onclick="window.deleteToken('${item.key}')">🗑️ 删除</button>
</div>
</div>
`;
}).join('');
}
// Show token list view
export function showTokenList() {
document.getElementById('tokenListView').style.display = 'block';
document.getElementById('tokenFormView').style.display = 'none';
loadTokens();
}
// Show token form for adding
export async function showTokenForm() {
currentEditingKey = null;
document.getElementById('tokenFormTitle').textContent = '添加令牌';
document.getElementById('tokenListView').style.display = 'none';
document.getElementById('tokenFormView').style.display = 'block';
clearTokenForm();
// Reset to form mode
tokenEditMode = 'form';
document.getElementById('tokenFormMode').style.display = 'block';
document.getElementById('tokenJsonMode').style.display = 'none';
document.getElementById('tokenModeToggle').textContent = '📝 切换到JSON模式';
// Token key should be editable when creating
document.getElementById('tokenKey').disabled = false;
document.getElementById('tokenKey').placeholder = '输入令牌标识或点击生成';
// Enable generate button for new tokens
const generateBtn = document.getElementById('generateTokenBtn');
if (generateBtn) {
generateBtn.disabled = false;
}
// Load available channels and auto-generate token key
await loadAvailableChannels();
generateTokenKey();
}
// Edit existing token
export async function editToken(key) {
currentEditingKey = key;
document.getElementById('tokenFormTitle').textContent = '编辑令牌';
document.getElementById('tokenListView').style.display = 'none';
document.getElementById('tokenFormView').style.display = 'block';
showLoading('加载令牌数据...');
try {
// Load available channels first
await loadAvailableChannels();
const adminToken = localStorage.getItem('adminToken');
const response = await fetch('/api/admin/token', {
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
const result = await response.json();
const tokens = result.data || [];
const token = tokens.find(t => t.key === key);
if (token) {
const config = typeof token.value === 'string' ? JSON.parse(token.value) : token.value;
fillTokenForm(key, config, token.usage || 0);
// Set token key as readonly when editing
document.getElementById('tokenKey').disabled = true;
// Disable generate button when editing
const generateBtn = document.getElementById('generateTokenBtn');
if (generateBtn) {
generateBtn.disabled = true;
}
}
}
} catch (error) {
showNotification('加载令牌数据失败', 'error');
} finally {
hideLoading();
}
}
// Fill form with token data
function fillTokenForm(key, config, usage) {
document.getElementById('tokenKey').value = key;
// Don't change disabled state here - it's set by editToken
document.getElementById('tokenName').value = config.name || '';
// Set selected channels
selectedChannels = config.channel_keys && Array.isArray(config.channel_keys) ? config.channel_keys : [];
populateChannelOptions();
document.getElementById('totalQuotaInput').value = config.total_quota || 0;
// Update JSON mode
document.getElementById('tokenValueJson').value = JSON.stringify(config, null, 2);
}
// Clear form
function clearTokenForm() {
document.getElementById('tokenKey').value = '';
document.getElementById('tokenKey').disabled = false;
document.getElementById('tokenName').value = '';
// Clear selected channels
selectedChannels = [];
updateChannelDisplay();
document.getElementById('totalQuotaInput').value = '';
document.getElementById('tokenValueJson').value = '';
}
// Set quota value (for quick buttons)
export function setQuotaValue(value) {
document.getElementById('totalQuotaInput').value = value;
}
// Generate random token key
export function generateTokenKey() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let token = 'sk-';
for (let i = 0; i < 48; i++) {
token += chars.charAt(Math.floor(Math.random() * chars.length));
}
document.getElementById('tokenKey').value = token;
}
// Toggle between form and JSON mode
export function toggleTokenEditMode() {
if (tokenEditMode === 'form') {
// Switch to JSON mode - collect form data and show in JSON
const formData = collectTokenFormData();
if (formData) {
document.getElementById('tokenValueJson').value = JSON.stringify(formData, null, 2);
}
document.getElementById('tokenFormMode').style.display = 'none';
document.getElementById('tokenJsonMode').style.display = 'block';
document.getElementById('tokenModeToggle').textContent = '📋 切换到表单模式';
tokenEditMode = 'json';
} else {
// Switch to form mode - parse JSON and fill form
try {
const jsonValue = document.getElementById('tokenValueJson').value;
if (jsonValue) {
const config = JSON.parse(jsonValue);
document.getElementById('tokenName').value = config.name || '';
// Set selected channels
selectedChannels = config.channel_keys && Array.isArray(config.channel_keys) ? config.channel_keys : [];
populateChannelOptions();
document.getElementById('totalQuotaInput').value = config.total_quota || 0;
}
} catch (error) {
showNotification('JSON格式错误', 'error');
return;
}
document.getElementById('tokenFormMode').style.display = 'block';
document.getElementById('tokenJsonMode').style.display = 'none';
document.getElementById('tokenModeToggle').textContent = '📝 切换到JSON模式';
tokenEditMode = 'form';
}
}
// Collect form data
function collectTokenFormData() {
const name = document.getElementById('tokenName').value;
const totalQuota = parseFloat(document.getElementById('totalQuotaInput').value) || 0;
if (!name) {
return null;
}
return {
name,
channel_keys: selectedChannels,
total_quota: totalQuota
};
}
// Save token (add or update)
export async function saveToken() {
const key = document.getElementById('tokenKey').value || currentEditingKey;
if (!key) {
showNotification('请填写令牌标识', 'error');
return;
}
let config;
if (tokenEditMode === 'form') {
config = collectTokenFormData();
if (!config) {
showNotification('请填写所有必填字段(令牌名称)', 'error');
return;
}
} else {
const jsonValue = document.getElementById('tokenValueJson').value;
if (!jsonValue) {
showNotification('请填写配置JSON', 'error');
return;
}
try {
config = JSON.parse(jsonValue);
} catch {
showNotification('JSON格式错误,请检查配置格式', 'error');
return;
}
}
const adminToken = localStorage.getItem('adminToken');
showLoading('保存令牌...');
try {
const response = await fetch(`/api/admin/token/${encodeURIComponent(key)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-token': adminToken
},
body: JSON.stringify(config)
});
if (response.ok) {
showNotification(currentEditingKey ? '令牌更新成功' : '令牌添加成功', 'success');
clearTokenForm();
showTokenList();
} else {
const error = await response.text();
showNotification('保存失败:' + error, 'error');
}
} catch (error) {
showNotification('保存失败:' + error.message, 'error');
} finally {
hideLoading();
}
}
// Delete token
export async function deleteToken(key) {
if (!confirm(`确定要删除令牌 "${key}" 吗?`)) return;
const adminToken = localStorage.getItem('adminToken');
showLoading('删除令牌...');
try {
const response = await fetch(`/api/admin/token/${encodeURIComponent(key)}`, {
method: 'DELETE',
headers: { 'x-admin-token': adminToken }
});
if (response.ok) {
showNotification('令牌已删除', 'success');
loadTokens();
} else {
showNotification('删除失败', 'error');
}
} catch (error) {
showNotification('删除失败:' + error.message, 'error');
} finally {
hideLoading();
}
}
+180
View File
@@ -0,0 +1,180 @@
// ===== Utility Functions =====
// Modal Management
export function toggleModal(modalId) {
const modal = document.getElementById(modalId);
modal.classList.toggle('active');
}
// Tab Switching
export function switchTab(tabName) {
// Update active tab content
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.remove('active');
});
document.getElementById(tabName).classList.add('active');
// Update active nav item
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
});
const targetNav = document.querySelector(`[data-tab="${tabName}"]`);
if (targetNav) {
targetNav.classList.add('active');
}
// Update page title
const titles = {
'dashboard': '仪表盘',
'api-test': 'API 测试',
'channels': '频道管理',
'tokens': '令牌管理',
'pricing': '定价管理',
'database': '数据库管理'
};
document.getElementById('pageTitle').textContent = titles[tabName] || tabName;
// Load data for specific tabs if authenticated
const adminToken = localStorage.getItem('adminToken');
if (adminToken) {
// Reset to list view and reload data for management pages
if (tabName === 'channels') {
window.showChannelList && window.showChannelList();
}
if (tabName === 'tokens') {
window.showTokenList && window.showTokenList();
}
if (tabName === 'pricing') {
window.loadPricing && window.loadPricing();
}
}
}
// Theme Toggle
export function toggleTheme() {
const isDark = document.documentElement.classList.contains('dark');
if (isDark) {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
document.getElementById('themeIcon').textContent = '🌙';
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
document.getElementById('themeIcon').textContent = '☀️';
}
}
// Notification System
export function showNotification(message, type = 'info') {
const className = type === 'success' ? 'alert-success' : (type === 'error' ? 'alert-error' : 'alert-info');
console.log(`[${type.toUpperCase()}] ${message}`);
// Create toast notification
const toast = document.createElement('div');
toast.className = `alert ${className}`;
toast.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 100; min-width: 300px; animation: slideIn 0.3s ease;';
toast.innerHTML = `<strong>${type === 'success' ? '✅' : type === 'error' ? '❌' : 'ℹ️'}</strong> <span>${message}</span>`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'fadeOut 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Copy to Clipboard
export function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
showNotification('已复制到剪贴板', 'success');
}).catch(err => {
console.error('复制失败:', err);
fallbackCopy(text);
});
} else {
fallbackCopy(text);
}
}
// Fallback copy method for older browsers
function fallbackCopy(text) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.width = '2em';
textArea.style.height = '2em';
textArea.style.padding = '0';
textArea.style.border = 'none';
textArea.style.outline = 'none';
textArea.style.boxShadow = 'none';
textArea.style.background = 'transparent';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
showNotification('已复制到剪贴板', 'success');
} else {
showNotification('复制失败', 'error');
}
} catch (err) {
console.error('复制失败:', err);
showNotification('复制失败', 'error');
}
document.body.removeChild(textArea);
}
// Loading Overlay
let loadingTimeout = null;
export function showLoading(message = '加载中...') {
const overlay = document.getElementById('loadingOverlay');
const textEl = overlay.querySelector('.loading-text');
if (textEl) {
textEl.textContent = message;
}
// 延迟显示,避免快速请求闪烁
clearTimeout(loadingTimeout);
loadingTimeout = setTimeout(() => {
overlay.classList.add('active');
}, 100);
}
export function hideLoading() {
clearTimeout(loadingTimeout);
const overlay = document.getElementById('loadingOverlay');
overlay.classList.remove('active');
}
// Fetch wrapper with automatic loading
export async function fetchWithLoading(url, options = {}, loadingMessage = '加载中...') {
const adminToken = localStorage.getItem('adminToken');
// Add admin token to headers
const headers = {
...options.headers,
'x-admin-token': adminToken
};
// Show loading
showLoading(loadingMessage);
try {
const response = await fetch(url, {
...options,
headers
});
return response;
} finally {
hideLoading();
}
}
+276
View File
@@ -0,0 +1,276 @@
import { Context } from "hono"
/**
* Claude API Proxy Provider
*
* Implements relay/proxy functionality for Anthropic Claude API (/v1/messages endpoint)
* Handles both streaming and non-streaming requests with token usage tracking
*/
// Claude-specific types
type ClaudeMessageResponse = {
id: string;
type: "message";
role: "assistant";
model: string;
content: Array<{ type: "text"; text: string }>;
stop_reason: string | null;
stop_sequence: string | null;
usage: {
input_tokens: number;
output_tokens: number;
};
}
type ClaudeStreamEvent = {
type: "message_start" | "content_block_start" | "content_block_delta" |
"content_block_stop" | "message_delta" | "message_stop" | "ping" | "error";
message?: {
usage: {
input_tokens: number;
output_tokens: number;
};
};
delta?: {
stop_reason?: string;
stop_sequence?: string | null;
};
usage?: {
output_tokens: number;
};
}
/**
* Builds the proxy request for Claude API
* - Sets x-api-key header (Claude uses this instead of Authorization)
* - Sets anthropic-version header (required by Claude API)
* - Forces stream: true for usage tracking (similar to OpenAI pattern)
*/
const buildProxyRequest = (
request: Request,
reqJson: any,
config: ChannelConfig
): Request => {
const url = new URL(request.url)
const targetUrl = new URL(config.endpoint)
// Claude API uses /v1/messages endpoint
targetUrl.pathname = url.pathname
const targetHeaders = new Headers(request.headers)
// Claude uses x-api-key header instead of Authorization
targetHeaders.set("x-api-key", config.api_key)
// Required API version header
targetHeaders.set("anthropic-version", config.api_version || "2023-06-01")
// Ensure content-type is set
targetHeaders.set("Content-Type", "application/json")
return new Request(targetUrl, {
method: request.method,
headers: targetHeaders,
body: JSON.stringify(reqJson),
})
}
/**
* Extracts and saves usage data from Claude response
* Claude provides usage in a different format than OpenAI:
* - Non-streaming: usage.input_tokens + usage.output_tokens
* - Streaming: message_start (input_tokens) + message_delta (output_tokens)
*/
const checkoutUsageData = async (
saveUsage: (usage: Usage) => Promise<void>,
response: Response | ClaudeMessageResponse
): Promise<void> => {
try {
const res = response instanceof Response
? await response.clone().json<ClaudeMessageResponse>()
: response
if (!res.usage) return;
// Convert Claude usage format to internal Usage type
const usage: Usage = {
prompt_tokens: res.usage.input_tokens,
completion_tokens: res.usage.output_tokens,
total_tokens: res.usage.input_tokens + res.usage.output_tokens,
}
await saveUsage(usage)
} catch (error) {
console.error("Error logging Claude usage data:", error)
}
}
/**
* Processes Claude SSE stream data to extract token usage
*
* Claude SSE format:
* event: message_start
* data: {"type": "message_start", "message": {..., "usage": {"input_tokens": 26, "output_tokens": 0}}}
*
* event: message_delta
* data: {"type": "message_delta", "delta": {...}, "usage": {"output_tokens": 40}}
*
* We accumulate input_tokens from message_start and output_tokens from message_delta
*/
const processStreamData = async (
lines: string[],
usageAccumulator: { input_tokens: number; output_tokens: number },
saveUsage: (usage: Usage) => Promise<void>
): Promise<void> => {
for (const line of lines) {
const trimmedLine = line.trim()
// Skip empty lines and non-data lines
if (!trimmedLine.length || !trimmedLine.startsWith('data:')) continue
// Extract JSON content after "data: " prefix
const jsonContent = trimmedLine.replace('data:', '').trim()
// Skip ping events
if (jsonContent === '{}') continue
try {
const event = JSON.parse(jsonContent) as ClaudeStreamEvent
// Extract input tokens from message_start event
if (event.type === "message_start" && event.message?.usage) {
usageAccumulator.input_tokens = event.message.usage.input_tokens
}
// Extract output tokens from message_delta event
if (event.type === "message_delta" && event.usage) {
usageAccumulator.output_tokens = event.usage.output_tokens
}
// When stream ends, save the accumulated usage
if (event.type === "message_stop") {
const usage: Usage = {
prompt_tokens: usageAccumulator.input_tokens,
completion_tokens: usageAccumulator.output_tokens,
total_tokens: usageAccumulator.input_tokens + usageAccumulator.output_tokens,
}
await saveUsage(usage)
}
} catch (e) {
console.error("Error parsing Claude stream data:", e, "Line:", jsonContent)
}
}
}
/**
* Handles Claude streaming response
* - Reads the stream chunk by chunk
* - Buffers incomplete lines
* - Extracts token usage from SSE events
* - Runs in background via c.executionCtx.waitUntil()
*/
const handleStreamResponse = async (
c: Context<HonoCustomType>,
streamForServer: ReadableStream<any> | undefined,
saveUsage: (usage: Usage) => Promise<void>
): Promise<void> => {
const reader = streamForServer?.getReader()
if (!reader) {
throw new Error("No reader found in response body")
}
const decoder = new TextDecoder('utf-8')
let buffer = ""
// Accumulator for token usage across stream events
const usageAccumulator = {
input_tokens: 0,
output_tokens: 0,
}
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
buffer += chunk
// Wait until we have complete lines
if (!chunk.includes('\n')) continue
const lines = buffer.split('\n')
buffer = lines.pop() || ""
await processStreamData(lines, usageAccumulator, saveUsage)
}
// Process any remaining buffered data
if (buffer.trim()) {
await processStreamData([buffer], usageAccumulator, saveUsage)
}
}
/**
* Main Claude proxy handler
*
* Flow:
* 1. Extract and validate model from request
* 2. Map model name via deployment_mapper
* 3. Force stream: true for usage tracking (Claude doesn't need stream_options)
* 4. Build proxy request with Claude-specific headers
* 5. Forward request to Claude API
* 6. For streaming: tee the response and extract usage in background
* 7. For non-streaming: extract usage from response body
*/
export default {
async fetch(
c: Context<HonoCustomType>,
config: ChannelConfig,
saveUsage: (usage: Usage) => Promise<void>,
): Promise<Response> {
// Parse request body
const reqJson = await c.req.json()
const { model: modelName, stream } = reqJson;
// Validate model support
const deploymentName = config.deployment_mapper[modelName]
if (!deploymentName) {
throw new Error(`Model ${modelName} not supported`)
}
// Map model name
reqJson.model = deploymentName
// Force streaming for usage tracking
// Unlike OpenAI, Claude doesn't need stream_options - usage is always included
if (!stream) {
// Even for non-streaming, we might want to force stream for consistency
// But Claude's non-streaming response already includes usage, so we keep it as-is
}
// Build and send proxy request
const proxyRequest = buildProxyRequest(c.req.raw, reqJson, config)
const response = await fetch(proxyRequest)
// Handle streaming response
if (stream || reqJson.stream) {
const [streamForClient, streamForServer] = response.body?.tee() || []
// Process stream in background to extract usage
c.executionCtx.waitUntil(handleStreamResponse(c, streamForServer, saveUsage))
return new Response(streamForClient, {
headers: response.headers,
status: response.status,
statusText: response.statusText,
})
}
// Handle non-streaming response
if (response.ok) {
await checkoutUsageData(saveUsage, response)
}
return response
}
}
+3
View File
@@ -3,6 +3,7 @@ import { contentJson, fromHono, OpenAPIRoute } from 'chanfana';
import azureOpenaiProxy from "./azure-openai-proxy"
import openaiProxy from "./openai-proxy"
import claudeProxy from "./claude-proxy"
import utils from "../utils"
import { TokenUtils } from "../admin/token_utils"
import { CONSTANTS } from "../constants"
@@ -20,6 +21,7 @@ const providerMap: Record<
> = {
"azure-openai": azureOpenaiProxy.fetch,
"openai": openaiProxy.fetch,
"claude": claudeProxy.fetch,
};
class ProxyEndpoint extends OpenAPIRoute {
@@ -134,3 +136,4 @@ class ProxyEndpoint extends OpenAPIRoute {
}
api.post("/v1/chat/completions", ProxyEndpoint)
api.post("/v1/messages", ProxyEndpoint) // Claude API endpoint
Vendored
+1 -1
View File
@@ -32,7 +32,7 @@ type ApiTokenRow = BaseDbRow & {
usage: number;
}
type ChannelType = "azure-openai" | "openai" | undefined | null;
type ChannelType = "azure-openai" | "openai" | "claude" | undefined | null;
type ChannelConfig = {
name: string;
+5535 -455
View File
File diff suppressed because it is too large. Load diff