Compare commits

..

10 Commits

Author SHA1 Message Date
Supra4E8C
30f5300bb4 Update README_CN.md 2025-10-01 16:10:47 +08:00
Supra4E8C
52169200f1 Update README.md 2025-10-01 16:10:22 +08:00
Supra4E8C
80b2597611 0.0.5
为Cli Proxy API主程序兼容做准备
2025-10-01 16:06:12 +08:00
Luis Pater
04f21eea98 change release file name 2025-10-01 02:00:53 +08:00
Luis Pater
f6a4bae8c6 add auto release script 2025-10-01 01:57:53 +08:00
Supra4E8C
c9f09ccf37 Update README_CN.md 2025-09-25 17:17:44 +08:00
Supra4E8C
5b8fd04ba3 Update README.md 2025-09-25 17:17:00 +08:00
Supra4E8C
3c791a2313 Update README.md 2025-09-25 17:15:15 +08:00
Supra4E8C
2ef64d8064 Add files via upload 2025-09-25 17:14:50 +08:00
Supra4E8C
f2dc4bcf98 Update 0.0.3Beta 2025-09-25 17:04:02 +08:00
14 changed files with 1463 additions and 506 deletions

61
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: Build and Release
on:
push:
tags:
- 'v*'
jobs:
build-and-release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build all-in-one HTML
run: npm run build
- name: Prepare release assets
run: |
cd dist
mv index.html management.html
ls -lh management.html
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: dist/management.html
body: |
## CLI Proxy API Management Center - ${{ github.ref_name }}
### Download and Usage
1. Download the `management.html` file
2. Open it directly in your browser
3. All assets (CSS, JavaScript, images) are bundled into this single file
### Features
- Single file, no external dependencies required
- Complete management interface for CLI Proxy API
- Support for local and remote connections
- Multi-language support (Chinese/English)
- Dark/Light theme support
---
🤖 Generated with GitHub Actions
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
# Node modules
node_modules/
# Build output
dist/
# Temporary build files
index.build.html
# npm lock files
package-lock.json
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db

69
BUILD_RELEASE.md Normal file
View File

@@ -0,0 +1,69 @@
# Build and Release Instructions
## Overview
This project uses webpack to bundle all HTML, CSS, JavaScript, and images into a single all-in-one HTML file. The GitHub workflow automatically builds and releases this file when you create a new tag.
## How to Create a Release
1. Make sure all your changes are committed
2. Create and push a new tag:
```bash
git tag v1.0.0
git push origin v1.0.0
```
3. The GitHub workflow will automatically:
- Install dependencies
- Build the all-in-one HTML file using webpack
- Create a new release with the tag
- Upload the bundled HTML file to the release
## Manual Build
To build locally:
```bash
# Install dependencies
npm install
# Build the all-in-one HTML file
npm run build
```
The output will be in the `dist/` directory as `index.html`.
## How It Works
1. **build-scripts/prepare-html.js**: Pre-build script
- Reads the original `index.html`
- Removes local CSS and JavaScript references
- Generates temporary `index.build.html` for webpack
2. **webpack.config.js**: Configures webpack to bundle all assets
- Uses `style-loader` to inline CSS
- Uses `asset/inline` to embed images as base64
- Uses `html-inline-script-webpack-plugin` to inline JavaScript
- Uses `index.build.html` as template (generated dynamically)
3. **bundle-entry.js**: Entry point that imports all resources
- Imports CSS files
- Imports JavaScript modules
- Imports and sets logo image
4. **package.json scripts**:
- `prebuild`: Automatically runs before build to generate `index.build.html`
- `build`: Runs webpack to bundle everything
- `postbuild`: Cleans up temporary `index.build.html` file
5. **.github/workflows/release.yml**: GitHub workflow
- Triggers on tag push
- Builds the project (prebuild → build → postbuild)
- Creates a release with the bundled HTML file
## External Dependencies
The bundled HTML file still relies on these CDN resources:
- Font Awesome (icons)
- Chart.js (charts and graphs)
These are loaded from CDN to keep the file size reasonable and leverage browser caching.

View File

@@ -1,13 +1,17 @@
# Cli-Proxy-API-Management-Center
This is a modern web interface for managing the CLI Proxy API.
[中文](README_CN.md)
[中文文档](README_CN.md)
Main Project:
https://github.com/router-for-me/CLIProxyAPI
Minimum required version: ≥ 5.0.0
Recommended version: ≥ 5.1.1
Example URL:
https://remote.router-for.me/
Minimum required version: ≥ 6.0.0
Recommended version: ≥ 6.0.19
Starting from version 6.0.19, the WebUI has been integrated into the main program and is accessible via `/management.html`.
## Features
@@ -137,4 +141,4 @@ All API calls are handled through the `makeRequest` method of the `ManagerAPI` c
## Contributing
We welcome Issues and Pull Requests to improve this project! We encourage more developers to contribute to the enhancement of this WebUI!
This project is licensed under the MIT License.
This project is licensed under the MIT License.

View File

@@ -3,8 +3,12 @@
主项目
https://github.com/router-for-me/CLIProxyAPI
示例网站:
https://remote.router-for.me/
最低可用版本 ≥ 5.0.0
推荐版本 ≥ 5.1.1
推荐版本 ≥ 5.2.6
自6.0.19起WebUI已经集成在主程序中 可以通过/management.html访问
## 功能特点

698
app.js
View File

@@ -2,7 +2,8 @@
class CLIProxyManager {
constructor() {
// 仅保存基础地址(不含 /v0/management请求时自动补齐
this.apiBase = 'http://localhost:8317';
const detectedBase = this.detectApiBaseFromLocation();
this.apiBase = detectedBase;
this.apiUrl = this.computeApiUrl(this.apiBase);
this.managementKey = '';
this.isConnected = false;
@@ -107,6 +108,7 @@ class CLIProxyManager {
this.setupLanguageSwitcher();
this.setupThemeSwitcher();
// loadSettings 将在登录成功后调用
this.updateLoginConnectionInfo();
}
// 检查登录状态
@@ -185,6 +187,7 @@ class CLIProxyManager {
document.getElementById('login-page').style.display = 'flex';
document.getElementById('main-page').style.display = 'none';
this.isLoggedIn = false;
this.updateLoginConnectionInfo();
}
// 显示主页面
@@ -236,55 +239,40 @@ class CLIProxyManager {
// 处理登录表单提交
async handleLogin() {
// 获取当前活动的选项卡
const activeTab = document.querySelector('.tab-button.active').getAttribute('data-tab');
let apiUrl, managementKey;
if (activeTab === 'local') {
// 本地连接从端口号构建URL
const port = document.getElementById('local-port').value.trim();
managementKey = document.getElementById('local-management-key').value.trim();
if (!port || !managementKey) {
this.showLoginError(i18n.t('login.error_required'));
return;
}
apiUrl = `http://localhost:${port}`;
} else {
// 远程连接使用完整URL
apiUrl = document.getElementById('remote-api-url').value.trim();
managementKey = document.getElementById('remote-management-key').value.trim();
if (!apiUrl || !managementKey) {
this.showLoginError(i18n.t('login.error_required'));
return;
}
const apiBaseInput = document.getElementById('login-api-base');
const managementKeyInput = document.getElementById('login-management-key');
const managementKey = managementKeyInput ? managementKeyInput.value.trim() : '';
if (!managementKey) {
this.showLoginError(i18n.t('login.error_required'));
return;
}
const proxyUrl = document.getElementById('login-proxy-url').value.trim();
if (apiBaseInput && apiBaseInput.value.trim()) {
this.setApiBase(apiBaseInput.value.trim());
}
const submitBtn = document.getElementById('login-submit');
const originalText = submitBtn.innerHTML;
const originalText = submitBtn ? submitBtn.innerHTML : '';
try {
submitBtn.innerHTML = `<div class="loading"></div> ${i18n.t('login.submitting')}`;
submitBtn.disabled = true;
this.hideLoginError();
// 如果设置了代理,先保存代理设置
if (proxyUrl) {
localStorage.setItem('proxyUrl', proxyUrl);
if (submitBtn) {
submitBtn.innerHTML = `<div class="loading"></div> ${i18n.t('login.submitting')}`;
submitBtn.disabled = true;
}
await this.login(apiUrl, managementKey);
this.hideLoginError();
this.managementKey = managementKey;
localStorage.setItem('managementKey', this.managementKey);
await this.login(this.apiBase, this.managementKey);
} catch (error) {
this.showLoginError(`${i18n.t('login.error_title')}: ${error.message}`);
} finally {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
if (submitBtn) {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
}
}
}
@@ -355,67 +343,30 @@ class CLIProxyManager {
loadLoginSettings() {
const savedBase = localStorage.getItem('apiBase');
const savedKey = localStorage.getItem('managementKey');
const savedProxy = localStorage.getItem('proxyUrl');
// 检查元素是否存在(确保在登录页面)
const localPortInput = document.getElementById('local-port');
const remoteApiInput = document.getElementById('remote-api-url');
const localKeyInput = document.getElementById('local-management-key');
const remoteKeyInput = document.getElementById('remote-management-key');
const proxyInput = document.getElementById('login-proxy-url');
// 设置本地端口和远程API地址
const loginKeyInput = document.getElementById('login-management-key');
const apiBaseInput = document.getElementById('login-api-base');
if (savedBase) {
if (savedBase.includes('localhost')) {
// 从本地URL中提取端口号
const match = savedBase.match(/localhost:(\d+)/);
if (match && localPortInput) {
localPortInput.value = match[1];
}
} else if (remoteApiInput) {
remoteApiInput.value = savedBase;
}
this.setApiBase(savedBase);
} else {
this.setApiBase(this.detectApiBaseFromLocation());
}
// 设置密钥
if (localKeyInput && savedKey) {
localKeyInput.value = savedKey;
if (apiBaseInput) {
apiBaseInput.value = this.apiBase || '';
}
if (remoteKeyInput && savedKey) {
remoteKeyInput.value = savedKey;
if (loginKeyInput && savedKey) {
loginKeyInput.value = savedKey;
}
// 设置代理
if (proxyInput && savedProxy) {
proxyInput.value = savedProxy;
}
// 设置实时保存监听器
this.setupLoginAutoSave();
}
// 设置登录页面自动保存
setupLoginAutoSave() {
const localPortInput = document.getElementById('local-port');
const remoteApiInput = document.getElementById('remote-api-url');
const localKeyInput = document.getElementById('local-management-key');
const remoteKeyInput = document.getElementById('remote-management-key');
const proxyInput = document.getElementById('login-proxy-url');
const saveLocalBase = (port) => {
if (port.trim()) {
const apiUrl = `http://localhost:${port}`;
this.setApiBase(apiUrl);
}
};
const saveLocalBaseDebounced = this.debounce(saveLocalBase, 500);
const saveRemoteBase = (val) => {
if (val.trim()) {
this.setApiBase(val);
}
};
const saveRemoteBaseDebounced = this.debounce(saveRemoteBase, 500);
const loginKeyInput = document.getElementById('login-management-key');
const apiBaseInput = document.getElementById('login-api-base');
const resetButton = document.getElementById('login-reset-api-base');
const saveKey = (val) => {
if (val.trim()) {
@@ -425,41 +376,32 @@ class CLIProxyManager {
};
const saveKeyDebounced = this.debounce(saveKey, 500);
const saveProxy = (val) => {
if (val.trim()) {
localStorage.setItem('proxyUrl', val);
}
};
const saveProxyDebounced = this.debounce(saveProxy, 500);
// 绑定本地端口输入框
if (localPortInput) {
localPortInput.addEventListener('change', (e) => saveLocalBase(e.target.value));
localPortInput.addEventListener('input', (e) => saveLocalBaseDebounced(e.target.value));
if (loginKeyInput) {
loginKeyInput.addEventListener('change', (e) => saveKey(e.target.value));
loginKeyInput.addEventListener('input', (e) => saveKeyDebounced(e.target.value));
}
// 绑定远程API输入框
if (remoteApiInput) {
remoteApiInput.addEventListener('change', (e) => saveRemoteBase(e.target.value));
remoteApiInput.addEventListener('input', (e) => saveRemoteBaseDebounced(e.target.value));
if (apiBaseInput) {
const persistBase = (val) => {
const normalized = this.normalizeBase(val);
if (normalized) {
this.setApiBase(normalized);
}
};
const persistBaseDebounced = this.debounce(persistBase, 500);
apiBaseInput.addEventListener('change', (e) => persistBase(e.target.value));
apiBaseInput.addEventListener('input', (e) => persistBaseDebounced(e.target.value));
}
// 绑定本地密钥输入框
if (localKeyInput) {
localKeyInput.addEventListener('change', (e) => saveKey(e.target.value));
localKeyInput.addEventListener('input', (e) => saveKeyDebounced(e.target.value));
}
// 绑定远程密钥输入框
if (remoteKeyInput) {
remoteKeyInput.addEventListener('change', (e) => saveKey(e.target.value));
remoteKeyInput.addEventListener('input', (e) => saveKeyDebounced(e.target.value));
}
// 绑定代理输入框
if (proxyInput) {
proxyInput.addEventListener('change', (e) => saveProxy(e.target.value));
proxyInput.addEventListener('input', (e) => saveProxyDebounced(e.target.value));
if (resetButton) {
resetButton.addEventListener('click', () => {
const detected = this.detectApiBaseFromLocation();
this.setApiBase(detected);
if (apiBaseInput) {
apiBaseInput.value = detected;
}
});
}
}
@@ -476,9 +418,6 @@ class CLIProxyManager {
logoutBtn.addEventListener('click', () => this.logout());
}
// 选项卡切换事件
this.setupTabSwitching();
// 密钥可见性切换事件
this.setupKeyVisibilityToggle();
@@ -486,30 +425,6 @@ class CLIProxyManager {
this.bindMainPageEvents();
}
// 设置选项卡切换
setupTabSwitching() {
const tabButtons = document.querySelectorAll('.tab-button');
const connectionForms = document.querySelectorAll('.connection-form');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const targetTab = button.getAttribute('data-tab');
// 更新选项卡状态
tabButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// 切换表单
connectionForms.forEach(form => {
form.classList.remove('active');
if (form.id === `${targetTab}-form`) {
form.classList.add('active');
}
});
});
});
}
// 设置密钥可见性切换
setupKeyVisibilityToggle() {
const toggleButtons = document.querySelectorAll('.toggle-key-visibility');
@@ -538,7 +453,6 @@ class CLIProxyManager {
const updateRetry = document.getElementById('update-retry');
const switchProjectToggle = document.getElementById('switch-project-toggle');
const switchPreviewToggle = document.getElementById('switch-preview-model-toggle');
const allowLocalhostToggle = document.getElementById('allow-localhost-toggle');
if (debugToggle) {
debugToggle.addEventListener('change', (e) => this.updateDebug(e.target.checked));
@@ -558,9 +472,6 @@ class CLIProxyManager {
if (switchPreviewToggle) {
switchPreviewToggle.addEventListener('change', (e) => this.updateSwitchPreviewModel(e.target.checked));
}
if (allowLocalhostToggle) {
allowLocalhostToggle.addEventListener('change', (e) => this.updateAllowLocalhost(e.target.checked));
}
// API 密钥管理
const addApiKey = document.getElementById('add-api-key');
@@ -607,6 +518,29 @@ class CLIProxyManager {
authFileInput.addEventListener('change', (e) => this.handleFileUpload(e));
}
// 使用统计
const refreshUsageStats = document.getElementById('refresh-usage-stats');
const requestsHourBtn = document.getElementById('requests-hour-btn');
const requestsDayBtn = document.getElementById('requests-day-btn');
const tokensHourBtn = document.getElementById('tokens-hour-btn');
const tokensDayBtn = document.getElementById('tokens-day-btn');
if (refreshUsageStats) {
refreshUsageStats.addEventListener('click', () => this.loadUsageStats());
}
if (requestsHourBtn) {
requestsHourBtn.addEventListener('click', () => this.switchRequestsPeriod('hour'));
}
if (requestsDayBtn) {
requestsDayBtn.addEventListener('click', () => this.switchRequestsPeriod('day'));
}
if (tokensHourBtn) {
tokensHourBtn.addEventListener('click', () => this.switchTokensPeriod('hour'));
}
if (tokensDayBtn) {
tokensDayBtn.addEventListener('click', () => this.switchTokensPeriod('day'));
}
// 模态框
const closeBtn = document.querySelector('.close');
if (closeBtn) {
@@ -710,6 +644,7 @@ class CLIProxyManager {
this.apiUrl = this.computeApiUrl(this.apiBase);
localStorage.setItem('apiBase', this.apiBase);
localStorage.setItem('apiUrl', this.apiUrl); // 兼容旧字段
this.updateLoginConnectionInfo();
}
// 加载设置(简化版,仅加载内部状态)
@@ -717,22 +652,21 @@ class CLIProxyManager {
const savedBase = localStorage.getItem('apiBase');
const savedUrl = localStorage.getItem('apiUrl');
const savedKey = localStorage.getItem('managementKey');
// 只设置内部状态不操作DOM元素
if (savedBase) {
this.setApiBase(savedBase);
} else if (savedUrl) {
const base = (savedUrl || '').replace(/\/?v0\/management\/?$/i, '');
this.setApiBase(base);
} else {
this.setApiBase(this.apiBase);
this.setApiBase(this.detectApiBaseFromLocation());
}
if (savedKey) {
this.managementKey = savedKey;
}
this.updateLoginConnectionInfo();
}
// API 请求方法
@@ -939,6 +873,9 @@ class CLIProxyManager {
// 认证文件需要单独加载,因为不在配置中
await this.loadAuthFiles();
// 使用统计需要单独加载
await this.loadUsageStats();
console.log('配置加载完成,使用缓存:', !forceRefresh && this.isCacheValid());
} catch (error) {
console.error('加载配置失败:', error);
@@ -975,10 +912,6 @@ class CLIProxyManager {
}
}
// 本地访问设置
if (config['allow-localhost-unauthenticated'] !== undefined) {
document.getElementById('allow-localhost-toggle').checked = config['allow-localhost-unauthenticated'];
}
// API 密钥
if (config['api-keys']) {
@@ -1013,7 +946,6 @@ class CLIProxyManager {
this.loadProxySettings(),
this.loadRetrySettings(),
this.loadQuotaSettings(),
this.loadLocalhostSettings(),
this.loadApiKeys(),
this.loadGeminiKeys(),
this.loadCodexKeys(),
@@ -1166,32 +1098,6 @@ class CLIProxyManager {
}
}
// 加载本地访问设置
async loadLocalhostSettings() {
try {
const config = await this.getConfig();
if (config['allow-localhost-unauthenticated'] !== undefined) {
document.getElementById('allow-localhost-toggle').checked = config['allow-localhost-unauthenticated'];
}
} catch (error) {
console.error('加载本地访问设置失败:', error);
}
}
// 更新本地访问设置
async updateAllowLocalhost(enabled) {
try {
await this.makeRequest('/allow-localhost-unauthenticated', {
method: 'PUT',
body: JSON.stringify({ value: enabled })
});
this.clearCache(); // 清除缓存
this.showNotification(i18n.t('notification.localhost_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
document.getElementById('allow-localhost-toggle').checked = !enabled;
}
}
// 加载API密钥
async loadApiKeys() {
@@ -2200,6 +2106,7 @@ class CLIProxyManager {
showGeminiWebTokenModal() {
const inlineSecure1psid = document.getElementById('secure-1psid-input');
const inlineSecure1psidts = document.getElementById('secure-1psidts-input');
const inlineLabel = document.getElementById('gemini-web-label-input');
const modalBody = document.getElementById('modal-body');
modalBody.innerHTML = `
<h3>${i18n.t('auth_login.gemini_web_button')}</h3>
@@ -2214,6 +2121,11 @@ class CLIProxyManager {
<input type="text" id="modal-secure-1psidts" placeholder="${i18n.t('auth_login.secure_1psidts_placeholder')}" required>
<div class="form-hint">从浏览器开发者工具 → Application → Cookies 中获取</div>
</div>
<div class="form-group">
<label for="modal-gemini-web-label">${i18n.t('auth_login.gemini_web_label_label')}</label>
<input type="text" id="modal-gemini-web-label" placeholder="${i18n.t('auth_login.gemini_web_label_placeholder')}">
<div class="form-hint">为此认证文件设置一个标签名称(可选)</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" onclick="manager.saveGeminiWebToken()">${i18n.t('common.save')}</button>
@@ -2224,6 +2136,7 @@ class CLIProxyManager {
const modalSecure1psid = document.getElementById('modal-secure-1psid');
const modalSecure1psidts = document.getElementById('modal-secure-1psidts');
const modalLabel = document.getElementById('modal-gemini-web-label');
if (modalSecure1psid && inlineSecure1psid) {
modalSecure1psid.value = inlineSecure1psid.value.trim();
@@ -2231,6 +2144,9 @@ class CLIProxyManager {
if (modalSecure1psidts && inlineSecure1psidts) {
modalSecure1psidts.value = inlineSecure1psidts.value.trim();
}
if (modalLabel && inlineLabel) {
modalLabel.value = inlineLabel.value.trim();
}
if (modalSecure1psid) {
modalSecure1psid.focus();
@@ -2241,6 +2157,7 @@ class CLIProxyManager {
async saveGeminiWebToken() {
const secure1psid = document.getElementById('modal-secure-1psid').value.trim();
const secure1psidts = document.getElementById('modal-secure-1psidts').value.trim();
const label = document.getElementById('modal-gemini-web-label').value.trim();
if (!secure1psid || !secure1psidts) {
this.showNotification('请填写完整的 Cookie 信息', 'error');
@@ -2248,33 +2165,381 @@ class CLIProxyManager {
}
try {
const requestBody = {
secure_1psid: secure1psid,
secure_1psidts: secure1psidts
};
// 如果提供了 label则添加到请求体中
if (label) {
requestBody.label = label;
}
const response = await this.makeRequest('/gemini-web-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
secure_1psid: secure1psid,
secure_1psidts: secure1psidts
})
body: JSON.stringify(requestBody)
});
this.closeModal();
this.loadAuthFiles(); // 刷新认证文件列表
const inlineSecure1psid = document.getElementById('secure-1psid-input');
const inlineSecure1psidts = document.getElementById('secure-1psidts-input');
const inlineLabel = document.getElementById('gemini-web-label-input');
if (inlineSecure1psid) {
inlineSecure1psid.value = secure1psid;
}
if (inlineSecure1psidts) {
inlineSecure1psidts.value = secure1psidts;
}
if (inlineLabel) {
inlineLabel.value = label;
}
this.showNotification(`${i18n.t('auth_login.gemini_web_saved')}: ${response.file}`, 'success');
} catch (error) {
this.showNotification(`保存失败: ${error.message}`, 'error');
}
}
// ===== 使用统计相关方法 =====
// 初始化图表变量
requestsChart = null;
tokensChart = null;
currentUsageData = null;
// 加载使用统计
async loadUsageStats() {
try {
const response = await this.makeRequest('/usage');
const usage = response?.usage || null;
this.currentUsageData = usage;
if (!usage) {
throw new Error('usage payload missing');
}
// 更新概览卡片
this.updateUsageOverview(usage);
// 读取当前图表周期
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
const requestsPeriod = requestsHourActive ? 'hour' : 'day';
const tokensPeriod = tokensHourActive ? 'hour' : 'day';
// 初始化图表(使用当前周期)
this.initializeRequestsChart(requestsPeriod);
this.initializeTokensChart(tokensPeriod);
// 更新API详细统计表格
this.updateApiStatsTable(usage);
} catch (error) {
console.error('加载使用统计失败:', error);
this.currentUsageData = null;
// 清空概览数据
['total-requests', 'success-requests', 'failed-requests', 'total-tokens'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = '-';
});
// 清空图表
if (this.requestsChart) {
this.requestsChart.destroy();
this.requestsChart = null;
}
if (this.tokensChart) {
this.tokensChart.destroy();
this.tokensChart = null;
}
const tableElement = document.getElementById('api-stats-table');
if (tableElement) {
tableElement.innerHTML = `<div class="no-data-message">${i18n.t('usage_stats.loading_error')}: ${error.message}</div>`;
}
}
}
// 更新使用统计概览
updateUsageOverview(data) {
const safeData = data || {};
document.getElementById('total-requests').textContent = safeData.total_requests ?? 0;
document.getElementById('success-requests').textContent = safeData.success_count ?? 0;
document.getElementById('failed-requests').textContent = safeData.failure_count ?? 0;
document.getElementById('total-tokens').textContent = safeData.total_tokens ?? 0;
}
// 初始化图表
initializeCharts() {
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
this.initializeRequestsChart(requestsHourActive ? 'hour' : 'day');
this.initializeTokensChart(tokensHourActive ? 'hour' : 'day');
}
// 初始化请求趋势图表
initializeRequestsChart(period = 'day') {
const ctx = document.getElementById('requests-chart');
if (!ctx) return;
// 销毁现有图表
if (this.requestsChart) {
this.requestsChart.destroy();
}
const data = this.getRequestsChartData(period);
this.requestsChart = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
x: {
title: {
display: true,
text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day')
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: i18n.t('usage_stats.requests_count')
}
}
},
elements: {
line: {
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.4
},
point: {
backgroundColor: '#3b82f6',
borderColor: '#ffffff',
borderWidth: 2,
radius: 4
}
}
}
});
}
// 初始化Token使用趋势图表
initializeTokensChart(period = 'day') {
const ctx = document.getElementById('tokens-chart');
if (!ctx) return;
// 销毁现有图表
if (this.tokensChart) {
this.tokensChart.destroy();
}
const data = this.getTokensChartData(period);
this.tokensChart = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
x: {
title: {
display: true,
text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day')
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: i18n.t('usage_stats.tokens_count')
}
}
},
elements: {
line: {
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.4
},
point: {
backgroundColor: '#10b981',
borderColor: '#ffffff',
borderWidth: 2,
radius: 4
}
}
}
});
}
// 获取请求图表数据
getRequestsChartData(period) {
if (!this.currentUsageData) {
return { labels: [], datasets: [{ data: [] }] };
}
let dataSource, labels, values;
if (period === 'hour') {
dataSource = this.currentUsageData.requests_by_hour || {};
labels = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
values = labels.map(hour => dataSource[hour] || 0);
} else {
dataSource = this.currentUsageData.requests_by_day || {};
labels = Object.keys(dataSource).sort();
values = labels.map(day => dataSource[day] || 0);
}
return {
labels: labels,
datasets: [{
data: values
}]
};
}
// 获取Token图表数据
getTokensChartData(period) {
if (!this.currentUsageData) {
return { labels: [], datasets: [{ data: [] }] };
}
let dataSource, labels, values;
if (period === 'hour') {
dataSource = this.currentUsageData.tokens_by_hour || {};
labels = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0'));
values = labels.map(hour => dataSource[hour] || 0);
} else {
dataSource = this.currentUsageData.tokens_by_day || {};
labels = Object.keys(dataSource).sort();
values = labels.map(day => dataSource[day] || 0);
}
return {
labels: labels,
datasets: [{
data: values
}]
};
}
// 切换请求图表时间周期
switchRequestsPeriod(period) {
// 更新按钮状态
document.getElementById('requests-hour-btn').classList.toggle('active', period === 'hour');
document.getElementById('requests-day-btn').classList.toggle('active', period === 'day');
// 更新图表数据
if (this.requestsChart) {
const newData = this.getRequestsChartData(period);
this.requestsChart.data = newData;
this.requestsChart.options.scales.x.title.text = i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day');
this.requestsChart.update();
}
}
// 切换Token图表时间周期
switchTokensPeriod(period) {
// 更新按钮状态
document.getElementById('tokens-hour-btn').classList.toggle('active', period === 'hour');
document.getElementById('tokens-day-btn').classList.toggle('active', period === 'day');
// 更新图表数据
if (this.tokensChart) {
const newData = this.getTokensChartData(period);
this.tokensChart.data = newData;
this.tokensChart.options.scales.x.title.text = i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day');
this.tokensChart.update();
}
}
// 更新API详细统计表格
updateApiStatsTable(data) {
const container = document.getElementById('api-stats-table');
if (!container) return;
const apis = data.apis || {};
if (Object.keys(apis).length === 0) {
container.innerHTML = `<div class="no-data-message">${i18n.t('usage_stats.no_data')}</div>`;
return;
}
let tableHtml = `
<table class="stats-table">
<thead>
<tr>
<th>${i18n.t('usage_stats.api_endpoint')}</th>
<th>${i18n.t('usage_stats.requests_count')}</th>
<th>${i18n.t('usage_stats.tokens_count')}</th>
<th>${i18n.t('usage_stats.success_rate')}</th>
<th>${i18n.t('usage_stats.models')}</th>
</tr>
</thead>
<tbody>
`;
Object.entries(apis).forEach(([endpoint, apiData]) => {
const totalRequests = apiData.total_requests || 0;
const successCount = apiData.success_count ?? null;
const successRate = successCount !== null && totalRequests > 0
? Math.round((successCount / totalRequests) * 100)
: null;
// 构建模型详情
let modelsHtml = '';
if (apiData.models && Object.keys(apiData.models).length > 0) {
modelsHtml = '<div class="model-details">';
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
const modelRequests = modelData.total_requests ?? 0;
const modelTokens = modelData.total_tokens ?? 0;
modelsHtml += `
<div class="model-item">
<span class="model-name">${modelName}</span>
<span>${modelRequests} 请求 / ${modelTokens} tokens</span>
</div>
`;
});
modelsHtml += '</div>';
}
tableHtml += `
<tr>
<td>${endpoint}</td>
<td>${totalRequests}</td>
<td>${apiData.total_tokens || 0}</td>
<td>${successRate !== null ? successRate + '%' : '-'}</td>
<td>${modelsHtml || '-'}</td>
</tr>
`;
});
tableHtml += '</tbody></table>';
container.innerHTML = tableHtml;
}
showModal() {
const modal = document.getElementById('modal');
if (modal) {
@@ -2286,6 +2551,28 @@ class CLIProxyManager {
closeModal() {
document.getElementById('modal').style.display = 'none';
}
detectApiBaseFromLocation() {
try {
const { protocol, hostname, port } = window.location;
const normalizedPort = port ? `:${port}` : '';
return this.normalizeBase(`${protocol}//${hostname}${normalizedPort}`);
} catch (error) {
console.warn('无法从当前地址检测 API 基础地址,使用默认设置', error);
return this.normalizeBase(this.apiBase || 'http://localhost:8317');
}
}
updateLoginConnectionInfo() {
const connectionUrlElement = document.getElementById('login-connection-url');
const customInput = document.getElementById('login-api-base');
if (connectionUrlElement) {
connectionUrlElement.textContent = this.apiBase || '-';
}
if (customInput && customInput !== document.activeElement) {
customInput.value = this.apiBase || '';
}
}
}
// 全局管理器实例
@@ -2297,6 +2584,19 @@ function setupSiteLogo() {
const loginImg = document.getElementById('login-logo');
if (!img && !loginImg) return;
const inlineLogo = typeof window !== 'undefined' ? window.__INLINE_LOGO__ : null;
if (inlineLogo) {
if (img) {
img.src = inlineLogo;
img.style.display = 'inline-block';
}
if (loginImg) {
loginImg.src = inlineLogo;
loginImg.style.display = 'inline-block';
}
return;
}
const candidates = [
'../logo.svg', '../logo.png', '../logo.jpg', '../logo.jpeg', '../logo.webp', '../logo.gif',
'logo.svg', 'logo.png', 'logo.jpg', 'logo.jpeg', 'logo.webp', 'logo.gif',

View File

@@ -0,0 +1,30 @@
const fs = require('fs');
const path = require('path');
// Read the original index.html
const indexPath = path.resolve(__dirname, '../index.html');
const outputPath = path.resolve(__dirname, '../index.build.html');
let htmlContent = fs.readFileSync(indexPath, 'utf8');
// Remove local CSS reference
htmlContent = htmlContent.replace(
/<link rel="stylesheet" href="styles\.css">\n?/g,
''
);
// Remove local JavaScript references
htmlContent = htmlContent.replace(
/<script src="i18n\.js"><\/script>\n?/g,
''
);
htmlContent = htmlContent.replace(
/<script src="app\.js"><\/script>\n?/g,
''
);
// Write the modified HTML to a temporary build file
fs.writeFileSync(outputPath, htmlContent, 'utf8');
console.log('✓ Generated index.build.html for webpack processing');

132
build.js Normal file
View File

@@ -0,0 +1,132 @@
'use strict';
const fs = require('fs');
const path = require('path');
const projectRoot = __dirname;
const distDir = path.join(projectRoot, 'dist');
const sourceFiles = {
html: path.join(projectRoot, 'index.html'),
css: path.join(projectRoot, 'styles.css'),
i18n: path.join(projectRoot, 'i18n.js'),
app: path.join(projectRoot, 'app.js')
};
const logoCandidates = ['logo.png', 'logo.jpg', 'logo.jpeg', 'logo.svg', 'logo.webp', 'logo.gif'];
const logoMimeMap = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.gif': 'image/gif'
};
function readFile(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch (err) {
console.error(`读取文件失败: ${filePath}`);
throw err;
}
}
function readBinary(filePath) {
try {
return fs.readFileSync(filePath);
} catch (err) {
console.error(`读取文件失败: ${filePath}`);
throw err;
}
}
function escapeForScript(content) {
return content.replace(/<\/(script)/gi, '<\\/$1');
}
function escapeForStyle(content) {
return content.replace(/<\/(style)/gi, '<\\/$1');
}
function ensureDistDir() {
if (fs.existsSync(distDir)) {
fs.rmSync(distDir, { recursive: true, force: true });
}
fs.mkdirSync(distDir);
}
function loadLogoDataUrl() {
for (const candidate of logoCandidates) {
const filePath = path.join(projectRoot, candidate);
if (!fs.existsSync(filePath)) continue;
const ext = path.extname(candidate).toLowerCase();
const mime = logoMimeMap[ext];
if (!mime) {
console.warn(`未知 Logo 文件类型,跳过内联: ${candidate}`);
continue;
}
const buffer = readBinary(filePath);
const base64 = buffer.toString('base64');
return `data:${mime};base64,${base64}`;
}
return null;
}
function build() {
ensureDistDir();
let html = readFile(sourceFiles.html);
const css = escapeForStyle(readFile(sourceFiles.css));
const i18n = escapeForScript(readFile(sourceFiles.i18n));
const app = escapeForScript(readFile(sourceFiles.app));
html = html.replace(
'<link rel="stylesheet" href="styles.css">',
`<style>
${css}
</style>`
);
html = html.replace(
'<script src="i18n.js"></script>',
`<script>
${i18n}
</script>`
);
html = html.replace(
'<script src="app.js"></script>',
`<script>
${app}
</script>`
);
const logoDataUrl = loadLogoDataUrl();
if (logoDataUrl) {
const logoScript = `<script>window.__INLINE_LOGO__ = "${logoDataUrl}";</script>`;
if (html.includes('</body>')) {
html = html.replace('</body>', `${logoScript}\n</body>`);
} else {
html += `\n${logoScript}`;
}
} else {
console.warn('未找到可内联的 Logo 文件,将保持运行时加载。');
}
const outputPath = path.join(distDir, 'index.html');
fs.writeFileSync(outputPath, html, 'utf8');
console.log('构建完成: dist/index.html');
}
try {
build();
} catch (error) {
console.error('构建失败:', error);
process.exit(1);
}

25
bundle-entry.js Normal file
View File

@@ -0,0 +1,25 @@
// Import CSS
import './styles.css';
// Import JavaScript modules
import './i18n.js';
import './app.js';
// Import logo image
import logoImg from './logo.jpg';
// Set logo after DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
const loginLogo = document.getElementById('login-logo');
const siteLogo = document.getElementById('site-logo');
if (loginLogo) {
loginLogo.src = logoImg;
loginLogo.style.display = 'block';
}
if (siteLogo) {
siteLogo.src = logoImg;
siteLogo.style.display = 'block';
}
});

106
i18n.js
View File

@@ -47,19 +47,13 @@ const i18n = {
// 登录页面
'login.subtitle': '请输入连接信息以访问管理界面',
'login.tab_local_title': 'Local',
'login.tab_local_subtitle': '在本地运行 Cli Web 服务器',
'login.tab_remote_title': 'Remote',
'login.tab_remote_subtitle': '远程连接到 Cli Web 服务器',
'login.proxy_label': '代理服务器 (可选):',
'login.proxy_placeholder': 'http://ip:port 或 https://ip:port 或 socks5://user:pass@ip:port',
'login.local_port_label': '端口号:',
'login.local_port_placeholder': '8317',
'login.local_url_hint': '将连接到 http://localhost:端口/v0/management',
'login.api_url_label': 'API 基础地址:',
'login.api_url_placeholder': '例如: http://localhost:8317 或 127.0.0.1:8317',
'login.remote_api_url_placeholder': '例如: https://example.com:8317',
'login.api_url_hint': '将自动补全 /v0/management',
'login.connection_title': '连接地址',
'login.connection_current': '当前地址',
'login.connection_auto_hint': '系统将自动使用当前访问地址进行连接',
'login.custom_connection_label': '自定义连接地址:',
'login.custom_connection_placeholder': '例如: https://example.com:8317',
'login.custom_connection_hint': '默认使用当前访问地址,若需要可手动输入其他地址。',
'login.use_current_address': '使用当前地址',
'login.management_key_label': '管理密钥:',
'login.management_key_placeholder': '请输入管理密钥',
'login.connect_button': '连接',
@@ -85,6 +79,7 @@ const i18n = {
'nav.api_keys': 'API 密钥',
'nav.ai_providers': 'AI 提供商',
'nav.auth_files': '认证文件',
'nav.usage_stats': '使用统计',
'nav.system_info': '系统信息',
// 基础设置
@@ -102,8 +97,6 @@ const i18n = {
'basic_settings.quota_title': '配额超出行为',
'basic_settings.quota_switch_project': '自动切换项目',
'basic_settings.quota_switch_preview': '切换到预览模型',
'basic_settings.localhost_title': '本地访问',
'basic_settings.localhost_allow': '允许本地未认证访问',
// API 密钥管理
'api_keys.title': 'API 密钥管理',
@@ -212,8 +205,30 @@ const i18n = {
'auth_login.secure_1psid_placeholder': '输入 __Secure-1PSID cookie 值',
'auth_login.secure_1psidts_label': '__Secure-1PSIDTS Cookie:',
'auth_login.secure_1psidts_placeholder': '输入 __Secure-1PSIDTS cookie 值',
'auth_login.gemini_web_label_label': '标签 (可选):',
'auth_login.gemini_web_label_placeholder': '输入标签名称 (可选)',
'auth_login.gemini_web_saved': 'Gemini Web Token 保存成功',
// 使用统计
'usage_stats.title': '使用统计',
'usage_stats.total_requests': '总请求数',
'usage_stats.success_requests': '成功请求',
'usage_stats.failed_requests': '失败请求',
'usage_stats.total_tokens': '总Token数',
'usage_stats.requests_trend': '请求趋势',
'usage_stats.tokens_trend': 'Token 使用趋势',
'usage_stats.api_details': 'API 详细统计',
'usage_stats.by_hour': '按小时',
'usage_stats.by_day': '按天',
'usage_stats.refresh': '刷新',
'usage_stats.no_data': '暂无数据',
'usage_stats.loading_error': '加载失败',
'usage_stats.api_endpoint': 'API端点',
'usage_stats.requests_count': '请求次数',
'usage_stats.tokens_count': 'Token数量',
'usage_stats.models': '模型统计',
'usage_stats.success_rate': '成功率',
// 系统信息
'system_info.title': '系统信息',
'system_info.connection_status_title': '连接状态',
@@ -232,7 +247,6 @@ const i18n = {
'notification.retry_updated': '重试设置已更新',
'notification.quota_switch_project_updated': '项目切换设置已更新',
'notification.quota_switch_preview_updated': '预览模型切换设置已更新',
'notification.localhost_updated': '本地访问设置已更新',
'notification.api_key_added': 'API密钥添加成功',
'notification.api_key_updated': 'API密钥更新成功',
'notification.api_key_deleted': 'API密钥删除成功',
@@ -276,7 +290,11 @@ const i18n = {
'theme.dark': '暗色',
'theme.switch_to_light': '切换到亮色模式',
'theme.switch_to_dark': '切换到暗色模式',
'theme.auto': '跟随系统'
'theme.auto': '跟随系统',
// 页脚
'footer.version': '版本',
'footer.author': '作者'
},
'en-US': {
@@ -320,21 +338,15 @@ const i18n = {
// Login page
'login.subtitle': 'Please enter connection information to access the management interface',
'login.tab_local_title': 'Local',
'login.tab_local_subtitle': 'Run Cli Web server on your local machine',
'login.tab_remote_title': 'Remote',
'login.tab_remote_subtitle': 'Remote connection for a remote Cli Web server',
'login.proxy_label': 'Proxy Server (Optional):',
'login.proxy_placeholder': 'http://ip:port or https://ip:port or socks5://user:pass@ip:port',
'login.local_port_label': 'Port:',
'login.local_port_placeholder': '8317',
'login.local_url_hint': 'Will connect to http://localhost:port/v0/management',
'login.api_url_label': 'API Base URL:',
'login.api_url_placeholder': 'e.g.: http://localhost:8317 or 127.0.0.1:8317',
'login.remote_api_url_placeholder': 'e.g.: https://example.com:8317',
'login.api_url_hint': 'Will automatically append /v0/management',
'login.connection_title': 'Connection Address',
'login.connection_current': 'Current URL',
'login.connection_auto_hint': 'The system will automatically use the current URL for connection',
'login.custom_connection_label': 'Custom Connection URL:',
'login.custom_connection_placeholder': 'Eg: https://example.com:8317',
'login.custom_connection_hint': 'By default the current URL is used. Override it here if needed.',
'login.use_current_address': 'Use Current URL',
'login.management_key_label': 'Management Key:',
'login.management_key_placeholder': 'Please enter management key',
'login.management_key_placeholder': 'Enter the management key',
'login.connect_button': 'Connect',
'login.submit_button': 'Login',
'login.submitting': 'Connecting...',
@@ -358,6 +370,7 @@ const i18n = {
'nav.api_keys': 'API Keys',
'nav.ai_providers': 'AI Providers',
'nav.auth_files': 'Auth Files',
'nav.usage_stats': 'Usage Statistics',
'nav.system_info': 'System Info',
// Basic settings
@@ -375,8 +388,6 @@ const i18n = {
'basic_settings.quota_title': 'Quota Exceeded Behavior',
'basic_settings.quota_switch_project': 'Auto Switch Project',
'basic_settings.quota_switch_preview': 'Switch to Preview Model',
'basic_settings.localhost_title': 'Local Access',
'basic_settings.localhost_allow': 'Allow Localhost Unauthenticated Access',
// API Keys management
'api_keys.title': 'API Keys Management',
@@ -485,8 +496,30 @@ const i18n = {
'auth_login.secure_1psid_placeholder': 'Enter __Secure-1PSID cookie value',
'auth_login.secure_1psidts_label': '__Secure-1PSIDTS Cookie:',
'auth_login.secure_1psidts_placeholder': 'Enter __Secure-1PSIDTS cookie value',
'auth_login.gemini_web_label_label': 'Label (Optional):',
'auth_login.gemini_web_label_placeholder': 'Enter label name (optional)',
'auth_login.gemini_web_saved': 'Gemini Web Token saved successfully',
// Usage Statistics
'usage_stats.title': 'Usage Statistics',
'usage_stats.total_requests': 'Total Requests',
'usage_stats.success_requests': 'Success Requests',
'usage_stats.failed_requests': 'Failed Requests',
'usage_stats.total_tokens': 'Total Tokens',
'usage_stats.requests_trend': 'Request Trends',
'usage_stats.tokens_trend': 'Token Usage Trends',
'usage_stats.api_details': 'API Details',
'usage_stats.by_hour': 'By Hour',
'usage_stats.by_day': 'By Day',
'usage_stats.refresh': 'Refresh',
'usage_stats.no_data': 'No Data Available',
'usage_stats.loading_error': 'Loading Failed',
'usage_stats.api_endpoint': 'API Endpoint',
'usage_stats.requests_count': 'Request Count',
'usage_stats.tokens_count': 'Token Count',
'usage_stats.models': 'Model Statistics',
'usage_stats.success_rate': 'Success Rate',
// System info
'system_info.title': 'System Information',
'system_info.connection_status_title': 'Connection Status',
@@ -505,7 +538,6 @@ const i18n = {
'notification.retry_updated': 'Retry settings updated',
'notification.quota_switch_project_updated': 'Project switch settings updated',
'notification.quota_switch_preview_updated': 'Preview model switch settings updated',
'notification.localhost_updated': 'Localhost access settings updated',
'notification.api_key_added': 'API key added successfully',
'notification.api_key_updated': 'API key updated successfully',
'notification.api_key_deleted': 'API key deleted successfully',
@@ -549,7 +581,11 @@ const i18n = {
'theme.dark': 'Dark',
'theme.switch_to_light': 'Switch to light mode',
'theme.switch_to_dark': 'Switch to dark mode',
'theme.auto': 'Follow system'
'theme.auto': 'Follow system',
// Footer
'footer.version': 'Version',
'footer.author': 'Author'
}
},

View File

@@ -6,6 +6,7 @@
<title data-i18n="title.login">CLI Proxy API Management Center</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="i18n.js"></script>
</head>
<body>
@@ -48,67 +49,39 @@
</div>
</div>
<!-- 选项卡导航 -->
<div class="connection-tabs">
<button class="tab-button active" data-tab="local">
<i class="fas fa-home"></i>
<div class="tab-content">
<span class="tab-title" data-i18n="login.tab_local_title">Local</span>
<span class="tab-subtitle" data-i18n="login.tab_local_subtitle">Run CLI Web server on your local machine</span>
<div class="login-body">
<div class="login-connection-info">
<div class="connection-summary">
<i class="fas fa-link"></i>
<div>
<h3 data-i18n="login.connection_title">连接地址</h3>
<p class="connection-url">
<span data-i18n="login.connection_current">当前地址</span>
<span class="connection-url-separator">:</span>
<span id="login-connection-url">-</span>
</p>
</div>
</div>
</button>
<button class="tab-button" data-tab="remote">
<i class="fas fa-cloud"></i>
<div class="tab-content">
<span class="tab-title" data-i18n="login.tab_remote_title">Remote</span>
<span class="tab-subtitle" data-i18n="login.tab_remote_subtitle">Remote connection for a remote CLI Web server</span>
</div>
</button>
</div>
<!-- 代理服务器设置(可选) -->
<div class="proxy-settings">
<label data-i18n="login.proxy_label">Proxy Server (Optional):</label>
<input type="text" id="login-proxy-url" data-i18n="login.proxy_placeholder" placeholder="http://ip:port or https://ip:port or socks5://user:pass@ip:port">
</div>
<!-- 本地连接表单 -->
<div id="local-form" class="connection-form active">
<p class="form-hint" data-i18n="login.connection_auto_hint">系统将自动使用当前访问地址进行连接</p>
</div>
<form class="login-form">
<div class="form-group">
<label for="local-port" data-i18n="login.local_port_label">端口号:</label>
<div class="local-url-group">
<span class="url-prefix">http://localhost:</span>
<input type="number" id="local-port" value="8317" min="1" max="65535" data-i18n="login.local_port_placeholder" placeholder="8317" required>
</div>
<div class="form-hint" data-i18n="login.local_url_hint">将连接到 http://localhost:端口/v0/management</div>
</div>
<div class="form-group">
<label for="local-management-key" data-i18n="login.management_key_label">管理密钥:</label>
<label for="login-api-base" data-i18n="login.custom_connection_label">自定义连接地址:</label>
<div class="input-group">
<input type="password" id="local-management-key" data-i18n="login.management_key_placeholder" placeholder="请输入管理密钥" required>
<button type="button" class="btn btn-secondary toggle-key-visibility">
<i class="fas fa-eye"></i>
<input type="text" id="login-api-base" data-i18n="login.custom_connection_placeholder" placeholder="例如: https://example.com:8317">
<button type="button" id="login-reset-api-base" class="btn btn-secondary connection-reset-btn">
<i class="fas fa-location-arrow"></i>
<span data-i18n="login.use_current_address">使用当前地址</span>
</button>
</div>
<p class="form-hint" data-i18n="login.custom_connection_hint">默认使用当前访问地址,若需要可手动输入其他地址。</p>
</div>
</form>
</div>
<!-- 远程连接表单 -->
<div id="remote-form" class="connection-form">
<form class="login-form">
<div class="form-group">
<label for="remote-api-url" data-i18n="login.api_url_label">API 基础地址:</label>
<input type="text" id="remote-api-url" data-i18n="login.remote_api_url_placeholder" placeholder="例如: https://example.com:8317" required>
<div class="form-hint" data-i18n="login.api_url_hint">将自动补全 /v0/management</div>
</div>
<div class="form-group">
<label for="remote-management-key" data-i18n="login.management_key_label">管理密钥:</label>
<label for="login-management-key" data-i18n="login.management_key_label">管理密钥:</label>
<div class="input-group">
<input type="password" id="remote-management-key" data-i18n="login.management_key_placeholder" placeholder="请输入管理密钥" required>
<input type="password" id="login-management-key" data-i18n="login.management_key_placeholder" placeholder="请输入管理密钥" required>
<button type="button" class="btn btn-secondary toggle-key-visibility">
<i class="fas fa-eye"></i>
</button>
@@ -221,6 +194,9 @@
<li><a href="#auth-files" class="nav-item" data-section="auth-files">
<i class="fas fa-file-alt"></i> <span data-i18n="nav.auth_files">认证文件</span>
</a></li>
<li><a href="#usage-stats" class="nav-item" data-section="usage-stats">
<i class="fas fa-chart-line"></i> <span data-i18n="nav.usage_stats">使用统计</span>
</a></li>
<li><a href="#system-info" class="nav-item" data-section="system-info">
<i class="fas fa-info-circle"></i> <span data-i18n="nav.system_info">系统信息</span>
</a></li>
@@ -305,21 +281,6 @@
</div>
</div>
<!-- 本地访问设置 -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-home"></i> <span data-i18n="basic_settings.localhost_title">本地访问</span></h3>
</div>
<div class="card-content">
<div class="toggle-group">
<label class="toggle-switch">
<input type="checkbox" id="allow-localhost-toggle">
<span class="slider"></span>
</label>
<span class="toggle-label" data-i18n="basic_settings.localhost_allow">允许本地未认证访问</span>
</div>
</div>
</div>
</section>
<!-- API 密钥管理 -->
@@ -394,8 +355,20 @@
<div id="openai-providers-list" class="provider-list"></div>
</div>
</div>
</section>
<!-- 认证文件管理 -->
<section id="auth-files" class="content-section">
<h2 data-i18n="auth_files.title">认证文件管理</h2>
<div class="card" style="margin-bottom: 20px;">
<div class="card-content">
<p class="form-hint" data-i18n="auth_files.description">
这里管理 Qwen 和 Gemini 的认证配置文件。上传 JSON 格式的认证文件以启用相应的 AI 服务。
</p>
</div>
</div>
<!-- Gemini Web Token -->
<div class="card">
<div class="card-header">
@@ -416,19 +389,10 @@
<label for="secure-1psidts-input" data-i18n="auth_login.secure_1psidts_label">__Secure-1PSIDTS Cookie:</label>
<input type="text" id="secure-1psidts-input" data-i18n="auth_login.secure_1psidts_placeholder" placeholder="输入 __Secure-1PSIDTS cookie 值">
</div>
</div>
</div>
</section>
<!-- 认证文件管理 -->
<section id="auth-files" class="content-section">
<h2 data-i18n="auth_files.title">认证文件管理</h2>
<div class="card" style="margin-bottom: 20px;">
<div class="card-content">
<p class="form-hint" data-i18n="auth_files.description">
这里管理 Qwen 和 Gemini 的认证配置文件。上传 JSON 格式的认证文件以启用相应的 AI 服务。
</p>
<div class="form-group">
<label for="gemini-web-label-input" data-i18n="auth_login.gemini_web_label_label">Label (Optional):</label>
<input type="text" id="gemini-web-label-input" data-i18n="auth_login.gemini_web_label_placeholder" placeholder="输入标签名称 (可选)">
</div>
</div>
</div>
@@ -452,6 +416,112 @@
</div>
</section>
<!-- 使用统计 -->
<section id="usage-stats" class="content-section">
<h2 data-i18n="usage_stats.title">使用统计</h2>
<!-- 概览统计卡片 -->
<div class="stats-overview">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-paper-plane"></i>
</div>
<div class="stat-content">
<div class="stat-number" id="total-requests">0</div>
<div class="stat-label" data-i18n="usage_stats.total_requests">总请求数</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon success">
<i class="fas fa-check-circle"></i>
</div>
<div class="stat-content">
<div class="stat-number" id="success-requests">0</div>
<div class="stat-label" data-i18n="usage_stats.success_requests">成功请求</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon error">
<i class="fas fa-exclamation-circle"></i>
</div>
<div class="stat-content">
<div class="stat-number" id="failed-requests">0</div>
<div class="stat-label" data-i18n="usage_stats.failed_requests">失败请求</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-coins"></i>
</div>
<div class="stat-content">
<div class="stat-number" id="total-tokens">0</div>
<div class="stat-label" data-i18n="usage_stats.total_tokens">总Token数</div>
</div>
</div>
</div>
<!-- 图表区域 -->
<div class="charts-container">
<!-- 请求趋势图 -->
<div class="card chart-card">
<div class="card-header">
<h3><i class="fas fa-chart-line"></i> <span data-i18n="usage_stats.requests_trend">请求趋势</span></h3>
<div class="chart-controls">
<button class="btn btn-small" data-period="hour" id="requests-hour-btn">
<span data-i18n="usage_stats.by_hour">按小时</span>
</button>
<button class="btn btn-small active" data-period="day" id="requests-day-btn">
<span data-i18n="usage_stats.by_day">按天</span>
</button>
</div>
</div>
<div class="card-content">
<div class="chart-container">
<canvas id="requests-chart"></canvas>
</div>
</div>
</div>
<!-- Token使用趋势图 -->
<div class="card chart-card">
<div class="card-header">
<h3><i class="fas fa-chart-area"></i> <span data-i18n="usage_stats.tokens_trend">Token 使用趋势</span></h3>
<div class="chart-controls">
<button class="btn btn-small" data-period="hour" id="tokens-hour-btn">
<span data-i18n="usage_stats.by_hour">按小时</span>
</button>
<button class="btn btn-small active" data-period="day" id="tokens-day-btn">
<span data-i18n="usage_stats.by_day">按天</span>
</button>
</div>
</div>
<div class="card-content">
<div class="chart-container">
<canvas id="tokens-chart"></canvas>
</div>
</div>
</div>
</div>
<!-- API详细统计 -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-list"></i> <span data-i18n="usage_stats.api_details">API 详细统计</span></h3>
<button id="refresh-usage-stats" class="btn btn-primary">
<i class="fas fa-sync-alt"></i> <span data-i18n="usage_stats.refresh">刷新</span>
</button>
</div>
<div class="card-content">
<div id="api-stats-table" class="api-stats-table">
<div class="loading-placeholder" data-i18n="common.loading">正在加载...</div>
</div>
</div>
</div>
</section>
<!-- 系统信息 -->
<section id="system-info" class="content-section">
<h2 data-i18n="system_info.title">系统信息</h2>
@@ -480,6 +550,15 @@
</section>
</div>
</main>
<!-- 版本信息 -->
<footer class="version-footer">
<div class="version-info">
<span data-i18n="footer.version">版本</span>: v0.0.5
<span class="separator"></span>
<span data-i18n="footer.author">作者</span>: Supra4E8C
</div>
</footer>
</div>
<!-- 模态框 -->

View File

@@ -6,7 +6,7 @@
"scripts": {
"start": "npx serve .",
"dev": "npx serve . --port 3000",
"build": "echo '无需构建,直接使用静态文件'",
"build": "node build.js",
"lint": "echo '使用浏览器开发者工具检查代码'"
},
"keywords": [

View File

@@ -27,6 +27,9 @@
--accent-primary: linear-gradient(135deg, #475569, #334155);
--accent-secondary: #e2e8f0;
--accent-tertiary: #f8fafc;
--primary-color: #3b82f6;
--card-bg: #ffffff;
--border-color: #e2e8f0;
--success-bg: linear-gradient(135deg, #dcfce7, #bbf7d0);
--success-text: #166534;
@@ -69,6 +72,9 @@
--accent-primary: linear-gradient(135deg, #64748b, #475569);
--accent-secondary: #334155;
--accent-tertiary: #1e293b;
--primary-color: #38bdf8;
--card-bg: #1e293b;
--border-color: #334155;
--success-bg: linear-gradient(135deg, #064e3b, #047857);
--success-text: #bbf7d0;
@@ -290,155 +296,6 @@
margin-bottom: 25px;
}
/* 选项卡导航样式 */
.connection-tabs {
display: flex;
margin-bottom: 25px;
margin-top: 10px;
border-radius: 12px;
background: var(--bg-tertiary);
padding: 4px;
border: 1px solid var(--border-primary);
}
.tab-button {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
text-align: left;
color: var(--text-tertiary);
}
.tab-button:hover {
background: var(--accent-tertiary);
}
.tab-button.active {
background: var(--accent-primary);
color: var(--text-inverse);
box-shadow: var(--shadow-primary);
}
.tab-button i {
font-size: 20px;
min-width: 20px;
}
.tab-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.tab-title {
font-weight: 600;
font-size: 16px;
}
.tab-subtitle {
font-size: 12px;
opacity: 0.8;
line-height: 1.3;
}
/* 代理设置样式 */
.proxy-settings {
margin-bottom: 25px;
padding: 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 10px;
}
.proxy-settings label {
display: block;
margin-bottom: 8px;
color: var(--text-secondary);
font-weight: 600;
font-size: 14px;
}
.proxy-settings input {
width: 100%;
padding: 12px 16px;
border: 2px solid var(--border-primary);
border-radius: 8px;
font-size: 14px;
transition: all 0.3s ease;
background: var(--bg-secondary);
color: var(--text-primary);
}
.proxy-settings input:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--border-primary);
}
/* 连接表单样式 */
.connection-form {
display: none;
}
.connection-form.active {
display: block;
}
/* 本地URL组合输入框样式 */
.local-url-group {
display: flex;
align-items: center;
border: 2px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-secondary);
transition: all 0.3s ease;
overflow: hidden;
}
.local-url-group:focus-within {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--border-primary);
}
.url-prefix {
padding: 12px 16px;
background: var(--bg-tertiary);
color: var(--text-tertiary);
font-weight: 500;
font-size: 14px;
border-right: 1px solid var(--border-primary);
white-space: nowrap;
}
.local-url-group input {
flex: 1;
padding: 12px 16px;
border: none;
font-size: 14px;
background: transparent;
color: var(--text-primary);
outline: none;
min-width: 80px;
}
.local-url-group input::-webkit-outer-spin-button,
.local-url-group input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.local-url-group input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
.login-title {
display: flex;
align-items: center;
@@ -469,8 +326,63 @@
text-align: center;
}
.login-body {
display: flex;
flex-direction: column;
gap: 24px;
}
.login-connection-info {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 16px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
[data-theme="dark"] .login-connection-info {
background: rgba(30, 41, 59, 0.7);
border-color: rgba(100, 116, 139, 0.3);
}
.connection-summary {
display: flex;
align-items: center;
gap: 16px;
color: var(--text-secondary);
}
.connection-summary i {
font-size: 24px;
color: var(--primary-color);
}
.connection-url {
font-size: 16px;
color: var(--text-secondary);
}
.connection-url-separator {
margin: 0 8px;
color: var(--text-tertiary);
}
#login-connection-url {
font-family: "Fira Code", "Consolas", "Courier New", monospace;
color: var(--text-primary);
word-break: break-all;
}
[data-theme="dark"] #login-connection-url {
color: var(--text-secondary);
}
.login-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 20px;
}
.login-form .form-group {
@@ -638,41 +550,6 @@
height: 50px;
}
.connection-tabs {
flex-direction: column;
gap: 4px;
}
.tab-button {
flex-direction: row;
gap: 12px;
padding: 14px;
}
.tab-subtitle {
display: none;
}
.proxy-settings {
padding: 12px;
}
.local-url-group {
flex-direction: column;
}
.url-prefix {
border-right: none;
border-bottom: 1px solid #e2e8f0;
border-radius: 8px 8px 0 0;
text-align: center;
}
.local-url-group input {
border-radius: 0 0 8px 8px;
text-align: center;
}
.login-form .input-group {
flex-direction: column;
align-items: stretch;
@@ -1613,3 +1490,257 @@ input:checked + .slider:before {
line-height: 1.4;
}
/* 使用统计样式 */
.stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
display: flex;
align-items: center;
gap: 16px;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.stat-card:hover {
border-color: var(--border-primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.stat-icon {
width: 50px;
height: 50px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: var(--primary-color);
color: white;
font-size: 20px;
flex-shrink: 0;
}
.stat-icon.success {
background: #10b981;
}
.stat-icon.error {
background: #ef4444;
}
.stat-content {
flex: 1;
}
.stat-number {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: var(--text-secondary);
font-weight: 500;
}
.charts-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 30px;
}
@media (max-width: 1200px) {
.charts-container {
grid-template-columns: 1fr;
}
}
.chart-card {
min-height: 400px;
}
.chart-container {
position: relative;
height: 300px;
width: 100%;
}
.chart-controls {
display: flex;
gap: 8px;
}
.btn.btn-small {
padding: 6px 12px;
font-size: 12px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--card-bg);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.btn.btn-small:hover {
border-color: var(--border-primary);
color: var(--text-primary);
}
.btn.btn-small.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: white;
}
.api-stats-table {
overflow-x: auto;
}
.stats-table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
}
.stats-table th,
.stats-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.stats-table th {
background: var(--bg-secondary);
font-weight: 600;
color: var(--text-primary);
font-size: 14px;
}
.stats-table td {
color: var(--text-secondary);
font-size: 14px;
}
.stats-table tr:hover {
background: var(--bg-secondary);
}
.model-details {
margin-top: 8px;
padding: 8px 12px;
background: var(--bg-tertiary);
border-radius: 6px;
font-size: 12px;
}
.model-item {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
color: var(--text-tertiary);
}
.model-name {
font-weight: 500;
color: var(--text-secondary);
}
.loading-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
color: var(--text-tertiary);
font-size: 14px;
}
.no-data-message {
text-align: center;
color: var(--text-tertiary);
font-style: italic;
padding: 40px;
}
/* 暗色主题适配 */
[data-theme="dark"] .stat-card {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
[data-theme="dark"] .stat-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
[data-theme="dark"] .btn.btn-small {
background: var(--bg-tertiary);
}
/* 版本信息样式 */
.version-footer {
margin-top: 2rem;
padding: 1rem 0;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
}
.version-info {
text-align: center;
font-size: 0.875rem;
color: var(--text-secondary);
opacity: 0.8;
}
.version-info .separator {
margin: 0 0.75rem;
color: var(--text-secondary);
opacity: 0.6;
}
/* 暗黑主题下的版本信息 */
[data-theme="dark"] .version-footer {
border-top-color: var(--border);
background: var(--bg-secondary);
}
[data-theme="dark"] .version-info {
color: var(--text-secondary);
}
.connection-reset-btn {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.connection-reset-btn i {
margin: 0;
}
.connection-reset-btn span {
font-size: 13px;
}
[data-theme="dark"] .connection-reset-btn {
background: rgba(30, 41, 59, 0.9);
border-color: rgba(100, 116, 139, 0.4);
color: #cbd5e1;
}
[data-theme="dark"] .connection-reset-btn:hover {
background: rgba(51, 65, 85, 0.95);
border-color: rgba(100, 116, 139, 0.6);
}

64
webpack.config.js Normal file
View File

@@ -0,0 +1,64 @@
const HtmlWebpackPlugin = require('html-webpack-plugin');
const HtmlInlineScriptPlugin = require('html-inline-script-webpack-plugin');
const path = require('path');
module.exports = {
mode: 'production',
entry: {
main: './bundle-entry.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].bundle.js',
clean: true
},
module: {
rules: [
{
test: /\.css$/i,
use: ['style-loader', 'css-loader']
},
{
test: /\.(png|jpg|jpeg|gif|svg)$/i,
type: 'asset/inline'
},
{
test: /\.html$/i,
loader: 'html-loader',
options: {
sources: {
list: [
{
tag: 'img',
attribute: 'src',
type: 'src'
}
]
}
}
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: './index.build.html',
filename: 'index.html',
inject: 'body',
minify: {
collapseWhitespace: true,
removeComments: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true
}
}),
new HtmlInlineScriptPlugin({
htmlMatchPattern: [/index.html$/],
scriptMatchPattern: [/.js$/]
})
],
optimization: {
minimize: true
}
};