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:
1 parent
e376a8a539
commit
198dc500aa
14 files changed
+8872
-3500
No files matched your search
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
@@ -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;
|
||||
|
||||
Vendored
+5535
-455
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user