mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa729914c5 | ||
|
|
f98f31f2ed | ||
|
|
1e79f918e2 | ||
|
|
257260b1d2 | ||
|
|
8372906820 | ||
|
|
5feea2e345 | ||
|
|
825ad53c2c | ||
|
|
3e9413172c |
442
app.js
442
app.js
@@ -1498,24 +1498,16 @@ class CLIProxyManager {
|
||||
}
|
||||
|
||||
// Gemini 密钥
|
||||
if (config['generative-language-api-key']) {
|
||||
await this.renderGeminiKeys(config['generative-language-api-key']);
|
||||
}
|
||||
await this.renderGeminiKeys(Array.isArray(config['generative-language-api-key']) ? config['generative-language-api-key'] : []);
|
||||
|
||||
// Codex 密钥
|
||||
if (config['codex-api-key']) {
|
||||
await this.renderCodexKeys(config['codex-api-key']);
|
||||
}
|
||||
await this.renderCodexKeys(Array.isArray(config['codex-api-key']) ? config['codex-api-key'] : []);
|
||||
|
||||
// Claude 密钥
|
||||
if (config['claude-api-key']) {
|
||||
await this.renderClaudeKeys(config['claude-api-key']);
|
||||
}
|
||||
await this.renderClaudeKeys(Array.isArray(config['claude-api-key']) ? config['claude-api-key'] : []);
|
||||
|
||||
// OpenAI 兼容提供商
|
||||
if (config['openai-compatibility']) {
|
||||
await this.renderOpenAIProviders(config['openai-compatibility']);
|
||||
}
|
||||
await this.renderOpenAIProviders(Array.isArray(config['openai-compatibility']) ? config['openai-compatibility'] : []);
|
||||
}
|
||||
|
||||
// 回退方法:原来的逐个加载方式
|
||||
@@ -2179,9 +2171,8 @@ class CLIProxyManager {
|
||||
async loadGeminiKeys() {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
if (config['generative-language-api-key']) {
|
||||
await this.renderGeminiKeys(config['generative-language-api-key']);
|
||||
}
|
||||
const keys = Array.isArray(config['generative-language-api-key']) ? config['generative-language-api-key'] : [];
|
||||
await this.renderGeminiKeys(keys);
|
||||
} catch (error) {
|
||||
console.error('加载Gemini密钥失败:', error);
|
||||
}
|
||||
@@ -2190,8 +2181,12 @@ class CLIProxyManager {
|
||||
// 渲染Gemini密钥列表
|
||||
async renderGeminiKeys(keys) {
|
||||
const container = document.getElementById('gemini-keys-list');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const list = Array.isArray(keys) ? keys : [];
|
||||
|
||||
if (keys.length === 0) {
|
||||
if (list.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fab fa-google"></i>
|
||||
@@ -2205,7 +2200,7 @@ class CLIProxyManager {
|
||||
// 获取使用统计,按 source 聚合
|
||||
const stats = await this.getKeyStats();
|
||||
|
||||
container.innerHTML = keys.map((key, index) => {
|
||||
container.innerHTML = list.map((key, index) => {
|
||||
const masked = this.maskApiKey(key);
|
||||
const keyStats = stats[key] || stats[masked] || { success: 0, failure: 0 };
|
||||
return `
|
||||
@@ -2344,9 +2339,8 @@ class CLIProxyManager {
|
||||
async loadCodexKeys() {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
if (config['codex-api-key']) {
|
||||
await this.renderCodexKeys(config['codex-api-key']);
|
||||
}
|
||||
const keys = Array.isArray(config['codex-api-key']) ? config['codex-api-key'] : [];
|
||||
await this.renderCodexKeys(keys);
|
||||
} catch (error) {
|
||||
console.error('加载Codex密钥失败:', error);
|
||||
}
|
||||
@@ -2355,8 +2349,12 @@ class CLIProxyManager {
|
||||
// 渲染Codex密钥列表
|
||||
async renderCodexKeys(keys) {
|
||||
const container = document.getElementById('codex-keys-list');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const list = Array.isArray(keys) ? keys : [];
|
||||
|
||||
if (keys.length === 0) {
|
||||
if (list.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-code"></i>
|
||||
@@ -2370,7 +2368,7 @@ class CLIProxyManager {
|
||||
// 获取使用统计,按 source 聚合
|
||||
const stats = await this.getKeyStats();
|
||||
|
||||
container.innerHTML = keys.map((config, index) => {
|
||||
container.innerHTML = list.map((config, index) => {
|
||||
const rawKey = config['api-key'];
|
||||
const masked = rawKey ? this.maskApiKey(rawKey) : '';
|
||||
const keyStats = (rawKey && (stats[rawKey] || stats[masked])) || { success: 0, failure: 0 };
|
||||
@@ -2549,9 +2547,8 @@ class CLIProxyManager {
|
||||
async loadClaudeKeys() {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
if (config['claude-api-key']) {
|
||||
await this.renderClaudeKeys(config['claude-api-key']);
|
||||
}
|
||||
const keys = Array.isArray(config['claude-api-key']) ? config['claude-api-key'] : [];
|
||||
await this.renderClaudeKeys(keys);
|
||||
} catch (error) {
|
||||
console.error('加载Claude密钥失败:', error);
|
||||
}
|
||||
@@ -2560,8 +2557,12 @@ class CLIProxyManager {
|
||||
// 渲染Claude密钥列表
|
||||
async renderClaudeKeys(keys) {
|
||||
const container = document.getElementById('claude-keys-list');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const list = Array.isArray(keys) ? keys : [];
|
||||
|
||||
if (keys.length === 0) {
|
||||
if (list.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-brain"></i>
|
||||
@@ -2575,7 +2576,7 @@ class CLIProxyManager {
|
||||
// 获取使用统计,按 source 聚合
|
||||
const stats = await this.getKeyStats();
|
||||
|
||||
container.innerHTML = keys.map((config, index) => {
|
||||
container.innerHTML = list.map((config, index) => {
|
||||
const rawKey = config['api-key'];
|
||||
const masked = rawKey ? this.maskApiKey(rawKey) : '';
|
||||
const keyStats = (rawKey && (stats[rawKey] || stats[masked])) || { success: 0, failure: 0 };
|
||||
@@ -2754,9 +2755,8 @@ class CLIProxyManager {
|
||||
async loadOpenAIProviders() {
|
||||
try {
|
||||
const config = await this.getConfig();
|
||||
if (config['openai-compatibility']) {
|
||||
await this.renderOpenAIProviders(config['openai-compatibility']);
|
||||
}
|
||||
const providers = Array.isArray(config['openai-compatibility']) ? config['openai-compatibility'] : [];
|
||||
await this.renderOpenAIProviders(providers);
|
||||
} catch (error) {
|
||||
console.error('加载OpenAI提供商失败:', error);
|
||||
}
|
||||
@@ -2765,8 +2765,12 @@ class CLIProxyManager {
|
||||
// 渲染OpenAI提供商列表
|
||||
async renderOpenAIProviders(providers) {
|
||||
const container = document.getElementById('openai-providers-list');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const list = Array.isArray(providers) ? providers : [];
|
||||
|
||||
if (!Array.isArray(providers) || providers.length === 0) {
|
||||
if (list.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-plug"></i>
|
||||
@@ -2781,7 +2785,7 @@ class CLIProxyManager {
|
||||
}
|
||||
|
||||
// 根据提供商数量设置滚动条
|
||||
if (providers.length > 5) {
|
||||
if (list.length > 5) {
|
||||
container.style.maxHeight = '400px';
|
||||
container.style.overflowY = 'auto';
|
||||
} else {
|
||||
@@ -2792,7 +2796,7 @@ class CLIProxyManager {
|
||||
// 获取使用统计,按 source 聚合
|
||||
const stats = await this.getKeyStats();
|
||||
|
||||
container.innerHTML = providers.map((provider, index) => {
|
||||
container.innerHTML = list.map((provider, index) => {
|
||||
const item = typeof provider === 'object' && provider !== null ? provider : {};
|
||||
|
||||
// 处理两种API密钥格式:新的 api-key-entries 和旧的 api-keys
|
||||
@@ -3071,6 +3075,17 @@ class CLIProxyManager {
|
||||
// 获取使用统计,按 source 聚合
|
||||
const stats = await this.getKeyStats();
|
||||
|
||||
// 收集所有文件类型(使用API返回的type字段)
|
||||
const existingTypes = new Set(['all']); // 'all' 总是存在
|
||||
files.forEach(file => {
|
||||
if (file.type) {
|
||||
existingTypes.add(file.type);
|
||||
}
|
||||
});
|
||||
|
||||
// 更新筛选按钮显示
|
||||
this.updateFilterButtons(existingTypes);
|
||||
|
||||
container.innerHTML = files.map(file => {
|
||||
// 认证文件的统计匹配逻辑:
|
||||
// 1. 首先尝试完整文件名匹配
|
||||
@@ -3118,12 +3133,44 @@ class CLIProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用API返回的文件类型
|
||||
const fileType = file.type || 'unknown';
|
||||
// 首字母大写显示类型,特殊处理 iFlow
|
||||
let typeDisplayKey;
|
||||
switch (fileType) {
|
||||
case 'qwen':
|
||||
typeDisplayKey = 'auth_files.type_qwen';
|
||||
break;
|
||||
case 'gemini':
|
||||
typeDisplayKey = 'auth_files.type_gemini';
|
||||
break;
|
||||
case 'claude':
|
||||
typeDisplayKey = 'auth_files.type_claude';
|
||||
break;
|
||||
case 'codex':
|
||||
typeDisplayKey = 'auth_files.type_codex';
|
||||
break;
|
||||
case 'iflow':
|
||||
typeDisplayKey = 'auth_files.type_iflow';
|
||||
break;
|
||||
case 'empty':
|
||||
typeDisplayKey = 'auth_files.type_empty';
|
||||
break;
|
||||
default:
|
||||
typeDisplayKey = 'auth_files.type_unknown';
|
||||
break;
|
||||
}
|
||||
const typeBadge = `<span class="file-type-badge ${fileType}">${i18n.t(typeDisplayKey)}</span>`;
|
||||
|
||||
return `
|
||||
<div class="file-item">
|
||||
<div class="file-item" data-file-type="${fileType}">
|
||||
<div class="item-content">
|
||||
<div class="item-title">${file.name}</div>
|
||||
<div class="item-subtitle">${i18n.t('auth_files.file_size')}: ${this.formatFileSize(file.size)}</div>
|
||||
<div class="item-subtitle">${i18n.t('auth_files.file_modified')}: ${new Date(file.modtime).toLocaleString(i18n.currentLanguage === 'zh-CN' ? 'zh-CN' : 'en-US')}</div>
|
||||
<div class="item-title">${typeBadge}${file.name}</div>
|
||||
<div class="item-meta">
|
||||
<span class="item-subtitle">${i18n.t('auth_files.file_modified')}: ${new Date(file.modtime).toLocaleString(i18n.currentLanguage === 'zh-CN' ? 'zh-CN' : 'en-US')}</span>
|
||||
<span class="item-subtitle">${i18n.t('auth_files.file_size')}: ${this.formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
<div class="item-footer">
|
||||
<div class="item-stats">
|
||||
<span class="stat-badge stat-success">
|
||||
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${fileStats.success}
|
||||
@@ -3132,18 +3179,213 @@ class CLIProxyManager {
|
||||
<i class="fas fa-times-circle"></i> ${i18n.t('stats.failure')}: ${fileStats.failure}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="btn btn-primary" onclick="manager.downloadAuthFile('${file.name}')">
|
||||
<div class="item-actions" data-filename="${file.name}">
|
||||
<button class="btn-small btn-info" data-action="showDetails" title="详细信息">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
</button>
|
||||
<button class="btn-small btn-primary" data-action="download" title="下载">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick="manager.deleteAuthFile('${file.name}')">
|
||||
<button class="btn-small btn-danger" data-action="delete" title="删除">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// 绑定筛选按钮事件
|
||||
this.bindAuthFileFilterEvents();
|
||||
|
||||
// 绑定认证文件操作按钮事件(使用事件委托)
|
||||
this.bindAuthFileActionEvents();
|
||||
}
|
||||
|
||||
// 更新筛选按钮显示
|
||||
updateFilterButtons(existingTypes) {
|
||||
const filterContainer = document.querySelector('.auth-file-filter');
|
||||
if (!filterContainer) return;
|
||||
|
||||
// 预定义的按钮顺序和显示文本
|
||||
const predefinedTypes = [
|
||||
{ type: 'all', labelKey: 'auth_files.filter_all' },
|
||||
{ type: 'qwen', labelKey: 'auth_files.filter_qwen' },
|
||||
{ type: 'gemini', labelKey: 'auth_files.filter_gemini' },
|
||||
{ type: 'claude', labelKey: 'auth_files.filter_claude' },
|
||||
{ type: 'codex', labelKey: 'auth_files.filter_codex' },
|
||||
{ type: 'iflow', labelKey: 'auth_files.filter_iflow' },
|
||||
{ type: 'empty', labelKey: 'auth_files.filter_empty' }
|
||||
];
|
||||
|
||||
// 获取现有按钮
|
||||
const existingButtons = filterContainer.querySelectorAll('.filter-btn');
|
||||
const existingButtonTypes = new Set();
|
||||
existingButtons.forEach(btn => {
|
||||
existingButtonTypes.add(btn.dataset.type);
|
||||
});
|
||||
|
||||
// 显示/隐藏预定义按钮
|
||||
existingButtons.forEach(btn => {
|
||||
const btnType = btn.dataset.type;
|
||||
if (existingTypes.has(btnType)) {
|
||||
btn.style.display = 'inline-block';
|
||||
const match = predefinedTypes.find(item => item.type === btnType);
|
||||
if (match) {
|
||||
btn.textContent = i18n.t(match.labelKey);
|
||||
btn.setAttribute('data-i18n-text', match.labelKey);
|
||||
}
|
||||
} else {
|
||||
btn.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// 为未知类型添加新按钮
|
||||
const predefinedTypeSet = new Set(predefinedTypes.map(t => t.type));
|
||||
existingTypes.forEach(type => {
|
||||
if (type !== 'all' && !predefinedTypeSet.has(type) && !existingButtonTypes.has(type)) {
|
||||
// 创建新按钮
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'filter-btn';
|
||||
btn.dataset.type = type;
|
||||
|
||||
const match = predefinedTypes.find(item => item.type === type);
|
||||
if (match) {
|
||||
btn.setAttribute('data-i18n-text', match.labelKey);
|
||||
btn.textContent = i18n.t(match.labelKey);
|
||||
} else {
|
||||
const dynamicKey = `auth_files.filter_${type}`;
|
||||
btn.setAttribute('data-i18n-text', dynamicKey);
|
||||
btn.textContent = this.generateDynamicTypeLabel(type);
|
||||
}
|
||||
|
||||
// 插入到 Empty 按钮之前(如果存在)
|
||||
const emptyBtn = filterContainer.querySelector('[data-type="empty"]');
|
||||
if (emptyBtn) {
|
||||
filterContainer.insertBefore(btn, emptyBtn);
|
||||
} else {
|
||||
filterContainer.appendChild(btn);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理筛选按钮点击
|
||||
handleFilterClick(clickedBtn) {
|
||||
const filterBtns = document.querySelectorAll('.auth-file-filter .filter-btn');
|
||||
|
||||
// 更新按钮状态
|
||||
filterBtns.forEach(b => b.classList.remove('active'));
|
||||
clickedBtn.classList.add('active');
|
||||
|
||||
// 获取筛选类型
|
||||
const filterType = clickedBtn.dataset.type;
|
||||
|
||||
// 筛选文件
|
||||
const fileItems = document.querySelectorAll('.file-item');
|
||||
fileItems.forEach(item => {
|
||||
if (filterType === 'all' || item.dataset.fileType === filterType) {
|
||||
item.classList.remove('hidden');
|
||||
} else {
|
||||
item.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 更新筛选按钮文本(以防语言切换后新按钮未刷新)
|
||||
this.refreshFilterButtonTexts();
|
||||
}
|
||||
|
||||
// 生成动态类型标签
|
||||
generateDynamicTypeLabel(type) {
|
||||
if (!type) return '';
|
||||
const key = `auth_files.type_${type}`;
|
||||
const translated = i18n.t(key);
|
||||
if (translated && translated !== key) {
|
||||
return translated;
|
||||
}
|
||||
if (type.toLowerCase() === 'iflow') return 'iFlow';
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
// 绑定认证文件筛选事件
|
||||
bindAuthFileFilterEvents() {
|
||||
const filterContainer = document.querySelector('.auth-file-filter');
|
||||
if (!filterContainer) return;
|
||||
|
||||
// 清理旧的监听器,避免重复绑定
|
||||
if (filterContainer._filterListener) {
|
||||
filterContainer.removeEventListener('click', filterContainer._filterListener);
|
||||
}
|
||||
|
||||
const listener = (event) => {
|
||||
const button = event.target.closest('.filter-btn');
|
||||
if (!button || !filterContainer.contains(button)) return;
|
||||
event.preventDefault();
|
||||
this.handleFilterClick(button);
|
||||
};
|
||||
|
||||
filterContainer._filterListener = listener;
|
||||
filterContainer.addEventListener('click', listener);
|
||||
|
||||
// 首次渲染时刷新按钮文本
|
||||
this.refreshFilterButtonTexts();
|
||||
}
|
||||
|
||||
// 刷新筛选按钮文本(根据 data-i18n-text)
|
||||
refreshFilterButtonTexts() {
|
||||
document.querySelectorAll('.auth-file-filter .filter-btn[data-i18n-text]').forEach(btn => {
|
||||
const key = btn.getAttribute('data-i18n-text');
|
||||
if (key) {
|
||||
btn.textContent = i18n.t(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 绑定认证文件操作按钮事件(使用事件委托)
|
||||
bindAuthFileActionEvents() {
|
||||
const container = document.getElementById('auth-files-list');
|
||||
if (!container) return;
|
||||
|
||||
// 移除旧的事件监听器(如果存在)
|
||||
const oldListener = container._authFileActionListener;
|
||||
if (oldListener) {
|
||||
container.removeEventListener('click', oldListener);
|
||||
}
|
||||
|
||||
// 创建新的事件监听器
|
||||
const listener = (e) => {
|
||||
// 查找最近的按钮元素
|
||||
const button = e.target.closest('button[data-action]');
|
||||
if (!button) return;
|
||||
|
||||
// 获取操作类型和文件名
|
||||
const action = button.dataset.action;
|
||||
const actionsContainer = button.closest('.item-actions');
|
||||
if (!actionsContainer) return;
|
||||
|
||||
const filename = actionsContainer.dataset.filename;
|
||||
if (!filename) return;
|
||||
|
||||
// 根据操作类型调用相应的方法
|
||||
switch (action) {
|
||||
case 'showDetails':
|
||||
this.showAuthFileDetails(filename);
|
||||
break;
|
||||
case 'download':
|
||||
this.downloadAuthFile(filename);
|
||||
break;
|
||||
case 'delete':
|
||||
this.deleteAuthFile(filename);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 保存监听器引用以便后续移除
|
||||
container._authFileActionListener = listener;
|
||||
|
||||
// 添加事件监听器
|
||||
container.addEventListener('click', listener);
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
@@ -3198,6 +3440,124 @@ class CLIProxyManager {
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
// 显示认证文件详细信息
|
||||
async showAuthFileDetails(filename) {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/auth-files/download?name=${encodeURIComponent(filename)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.managementKey}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const jsonData = await response.json();
|
||||
|
||||
// 格式化JSON数据
|
||||
const formattedJson = JSON.stringify(jsonData, null, 2);
|
||||
|
||||
// 显示模态框
|
||||
this.showJsonModal(filename, formattedJson);
|
||||
} catch (error) {
|
||||
this.showNotification(`读取文件详情失败: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示JSON模态框
|
||||
showJsonModal(filename, jsonContent) {
|
||||
// 创建模态框HTML
|
||||
const modalHtml = `
|
||||
<div id="json-modal" class="json-modal">
|
||||
<div class="json-modal-content">
|
||||
<div class="json-modal-header">
|
||||
<h3><i class="fas fa-file-code"></i> ${filename}</h3>
|
||||
<button class="json-modal-close" data-action="close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="json-modal-body">
|
||||
<pre class="json-content">${this.escapeHtml(jsonContent)}</pre>
|
||||
</div>
|
||||
<div class="json-modal-footer">
|
||||
<button class="btn btn-secondary" data-action="copy">
|
||||
<i class="fas fa-copy"></i> ${i18n.t('common.copy')}
|
||||
</button>
|
||||
<button class="btn btn-secondary" data-action="close">
|
||||
${i18n.t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 移除旧的模态框(如果存在)
|
||||
const oldModal = document.getElementById('json-modal');
|
||||
if (oldModal) {
|
||||
oldModal.remove();
|
||||
}
|
||||
|
||||
// 添加新模态框
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// 获取模态框元素
|
||||
const modal = document.getElementById('json-modal');
|
||||
|
||||
// 添加点击背景关闭功能
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
this.closeJsonModal();
|
||||
}
|
||||
});
|
||||
|
||||
// 绑定模态框按钮事件(使用事件委托)
|
||||
this.bindJsonModalEvents(modal);
|
||||
}
|
||||
|
||||
// 绑定JSON模态框按钮事件(使用事件委托)
|
||||
bindJsonModalEvents(modal) {
|
||||
modal.addEventListener('click', (e) => {
|
||||
// 查找最近的按钮元素
|
||||
const button = e.target.closest('button[data-action]');
|
||||
if (!button) return;
|
||||
|
||||
// 获取操作类型
|
||||
const action = button.dataset.action;
|
||||
|
||||
// 根据操作类型调用相应的方法
|
||||
switch (action) {
|
||||
case 'copy':
|
||||
this.copyJsonContent();
|
||||
break;
|
||||
case 'close':
|
||||
this.closeJsonModal();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 关闭JSON模态框
|
||||
closeJsonModal() {
|
||||
const modal = document.getElementById('json-modal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// 复制JSON内容
|
||||
copyJsonContent() {
|
||||
const jsonContent = document.querySelector('.json-content');
|
||||
if (jsonContent) {
|
||||
const text = jsonContent.textContent;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this.showNotification('内容已复制到剪贴板', 'success');
|
||||
}).catch(() => {
|
||||
this.showNotification('复制失败', 'error');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 下载认证文件
|
||||
async downloadAuthFile(filename) {
|
||||
try {
|
||||
|
||||
32
i18n.js
32
i18n.js
@@ -40,6 +40,7 @@ const i18n = {
|
||||
'common.alias': '别名',
|
||||
'common.failure': '失败',
|
||||
'common.unknown_error': '未知错误',
|
||||
'common.copy': '复制',
|
||||
|
||||
// 页面标题
|
||||
'title.main': 'CLI Proxy API Management Center',
|
||||
@@ -221,6 +222,21 @@ const i18n = {
|
||||
'auth_files.delete_success': '文件删除成功',
|
||||
'auth_files.delete_all_success': '成功删除',
|
||||
'auth_files.files_count': '个文件',
|
||||
'auth_files.filter_all': '全部',
|
||||
'auth_files.filter_qwen': 'Qwen',
|
||||
'auth_files.filter_gemini': 'Gemini',
|
||||
'auth_files.filter_claude': 'Claude',
|
||||
'auth_files.filter_codex': 'Codex',
|
||||
'auth_files.filter_iflow': 'iFlow',
|
||||
'auth_files.filter_empty': '空文件',
|
||||
'auth_files.filter_unknown': '其他',
|
||||
'auth_files.type_qwen': 'Qwen',
|
||||
'auth_files.type_gemini': 'Gemini',
|
||||
'auth_files.type_claude': 'Claude',
|
||||
'auth_files.type_codex': 'Codex',
|
||||
'auth_files.type_iflow': 'iFlow',
|
||||
'auth_files.type_empty': '空文件',
|
||||
'auth_files.type_unknown': '其他',
|
||||
|
||||
|
||||
// Codex OAuth
|
||||
@@ -466,6 +482,7 @@ const i18n = {
|
||||
'common.alias': 'Alias',
|
||||
'common.failure': 'Failure',
|
||||
'common.unknown_error': 'Unknown error',
|
||||
'common.copy': 'Copy',
|
||||
|
||||
// Page titles
|
||||
'title.main': 'CLI Proxy API Management Center',
|
||||
@@ -647,6 +664,21 @@ const i18n = {
|
||||
'auth_files.delete_success': 'File deleted successfully',
|
||||
'auth_files.delete_all_success': 'Successfully deleted',
|
||||
'auth_files.files_count': 'files',
|
||||
'auth_files.filter_all': 'All',
|
||||
'auth_files.filter_qwen': 'Qwen',
|
||||
'auth_files.filter_gemini': 'Gemini',
|
||||
'auth_files.filter_claude': 'Claude',
|
||||
'auth_files.filter_codex': 'Codex',
|
||||
'auth_files.filter_iflow': 'iFlow',
|
||||
'auth_files.filter_empty': 'Empty',
|
||||
'auth_files.filter_unknown': 'Other',
|
||||
'auth_files.type_qwen': 'Qwen',
|
||||
'auth_files.type_gemini': 'Gemini',
|
||||
'auth_files.type_claude': 'Claude',
|
||||
'auth_files.type_codex': 'Codex',
|
||||
'auth_files.type_iflow': 'iFlow',
|
||||
'auth_files.type_empty': 'Empty',
|
||||
'auth_files.type_unknown': 'Other',
|
||||
|
||||
// Codex OAuth
|
||||
'auth_login.codex_oauth_title': 'Codex OAuth',
|
||||
|
||||
16
index.html
16
index.html
@@ -418,9 +418,21 @@
|
||||
|
||||
<!-- 认证文件 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-header card-header-with-filter">
|
||||
<div class="header-left">
|
||||
<h3><i class="fas fa-file-alt"></i> <span
|
||||
data-i18n="auth_files.title_section">认证文件</span></h3>
|
||||
<!-- 类型筛选 -->
|
||||
<div class="auth-file-filter">
|
||||
<button class="filter-btn active" data-type="all" data-i18n-text="auth_files.filter_all">All</button>
|
||||
<button class="filter-btn" data-type="qwen" data-i18n-text="auth_files.filter_qwen">Qwen</button>
|
||||
<button class="filter-btn" data-type="gemini" data-i18n-text="auth_files.filter_gemini">Gemini</button>
|
||||
<button class="filter-btn" data-type="claude" data-i18n-text="auth_files.filter_claude">Claude</button>
|
||||
<button class="filter-btn" data-type="codex" data-i18n-text="auth_files.filter_codex">Codex</button>
|
||||
<button class="filter-btn" data-type="iflow" data-i18n-text="auth_files.filter_iflow">iFlow</button>
|
||||
<button class="filter-btn" data-type="empty" data-i18n-text="auth_files.filter_empty">Empty</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button id="upload-auth-file" class="btn btn-primary">
|
||||
<i class="fas fa-upload"></i> <span
|
||||
@@ -433,7 +445,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div id="auth-files-list" class="file-list"></div>
|
||||
<div id="auth-files-list" class="file-list file-grid"></div>
|
||||
<input type="file" id="auth-file-input" accept=".json" style="display: none;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
490
styles.css
490
styles.css
@@ -1195,6 +1195,76 @@ body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 小按钮样式 - 与stat-badge高度一致 */
|
||||
.btn-small {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-height: 28px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-small.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-small.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-small.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.btn-small.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-small.btn-info {
|
||||
background: #06b6d4;
|
||||
color: white;
|
||||
border-color: #06b6d4;
|
||||
}
|
||||
|
||||
.btn-small.btn-info:hover {
|
||||
background: #0891b2;
|
||||
box-shadow: 0 2px 4px rgba(6, 182, 212, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-small i {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.btn-small {
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.btn-small i {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 表单元素 */
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
@@ -1454,10 +1524,168 @@ input:checked+.slider:before {
|
||||
/* 认证文件列表填满页面,保留版本信息空间 */
|
||||
max-height: calc(100vh - 300px); /* 减去导航栏、padding和版本信息的高度 */
|
||||
overflow-y: auto;
|
||||
margin-right: -12px; /* 向右扩展 */
|
||||
padding-right: 12px; /* 滚动条不贴着最右侧卡片,保持左右边距一致 */
|
||||
}
|
||||
|
||||
/* 认证文件Grid布局 */
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* 响应式:中等屏幕2列 */
|
||||
@media (max-width: 1400px) {
|
||||
.file-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式:小屏幕单列 */
|
||||
@media (max-width: 768px) {
|
||||
.file-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 带筛选器的卡片头部布局 */
|
||||
.card-header-with-filter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.card-header-with-filter .header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-header-with-filter .header-left h3 {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 认证文件筛选按钮 */
|
||||
.auth-file-filter {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--border-primary);
|
||||
background: var(--bg-quaternary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-secondary);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* 文件类型标签 */
|
||||
.file-type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.file-type-badge.qwen {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .file-type-badge.qwen {
|
||||
background: #1b5e20;
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
.file-type-badge.gemini {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .file-type-badge.gemini {
|
||||
background: #0d47a1;
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
.file-type-badge.claude {
|
||||
background: #fce4ec;
|
||||
color: #c2185b;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .file-type-badge.claude {
|
||||
background: #880e4f;
|
||||
color: #f48fb1;
|
||||
}
|
||||
|
||||
.file-type-badge.codex {
|
||||
background: #fff3e0;
|
||||
color: #ef6c00;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .file-type-badge.codex {
|
||||
background: #e65100;
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
.file-type-badge.iflow {
|
||||
background: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .file-type-badge.iflow {
|
||||
background: #4a148c;
|
||||
color: #ce93d8;
|
||||
}
|
||||
|
||||
.file-type-badge.empty {
|
||||
background: #f5f5f5;
|
||||
color: #616161;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .file-type-badge.empty {
|
||||
background: #424242;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
/* 未知类型通用样式 */
|
||||
.file-type-badge:not(.qwen):not(.gemini):not(.claude):not(.codex):not(.iflow):not(.empty) {
|
||||
background: #f0f0f0;
|
||||
color: #666666;
|
||||
border: 1px dashed #999999;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .file-type-badge:not(.qwen):not(.gemini):not(.claude):not(.codex):not(.iflow):not(.empty) {
|
||||
background: #3a3a3a;
|
||||
color: #aaaaaa;
|
||||
border: 1px dashed #666666;
|
||||
}
|
||||
|
||||
.provider-list {
|
||||
/* 默认不限制高度,动态设置 */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.key-item,
|
||||
@@ -1469,9 +1697,13 @@ input:checked+.slider:before {
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-item.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.key-item:hover,
|
||||
@@ -1484,16 +1716,87 @@ input:checked+.slider:before {
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 为 API Keys 和 AI Providers 的项优化按钮位置 */
|
||||
.key-item .item-content,
|
||||
.provider-item .item-content {
|
||||
padding-right: 120px; /* 为按钮预留空间,防止内容重叠 */
|
||||
min-width: 0; /* 确保内容可以正常换行 */
|
||||
}
|
||||
|
||||
.item-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.item-stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* API Keys 和 AI Providers 的按钮组 - 绝对定位到右侧垂直居中 */
|
||||
.key-item .item-actions,
|
||||
.provider-item .item-actions {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 15px;
|
||||
transform: translateY(-50%);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 按钮样式优化 */
|
||||
.key-item .item-actions .btn,
|
||||
.provider-item .item-actions .btn {
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.key-item .item-actions .btn:hover,
|
||||
.provider-item .item-actions .btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.key-item .item-actions .btn i,
|
||||
.provider-item .item-actions .btn i {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
@@ -1572,6 +1875,150 @@ input:checked+.slider:before {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* JSON模态框 */
|
||||
.json-modal {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--bg-modal);
|
||||
backdrop-filter: blur(5px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.json-modal-content {
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
box-shadow: var(--shadow-modal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.json-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.json-modal-header h3 {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.json-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.json-modal-close:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.json-modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
min-height: 200px;
|
||||
max-height: calc(90vh - 160px);
|
||||
}
|
||||
|
||||
.json-content {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.json-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.json-modal-content {
|
||||
width: 95%;
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.json-modal-header,
|
||||
.json-modal-body,
|
||||
.json-modal-footer {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.json-content {
|
||||
font-size: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.json-modal-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.json-modal-footer .btn {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 模态框 */
|
||||
.modal {
|
||||
display: none;
|
||||
@@ -2987,7 +3434,6 @@ input:checked+.slider:before {
|
||||
.item-stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -3060,6 +3506,44 @@ input:checked+.slider:before {
|
||||
font-size: 11px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 - API Keys 和 AI Providers 按钮优化 */
|
||||
@media (max-width: 768px) {
|
||||
/* 移动端按钮位置调整 */
|
||||
.key-item .item-actions,
|
||||
.provider-item .item-actions {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
transform: none;
|
||||
margin-top: 12px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* 移动端移除内容右侧内边距 */
|
||||
.key-item .item-content,
|
||||
.provider-item .item-content {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* 移动端按钮尺寸调整 */
|
||||
.key-item .item-actions .btn,
|
||||
.provider-item .item-actions .btn {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.key-item .item-actions .btn:hover,
|
||||
.provider-item .item-actions .btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.key-item .item-actions .btn i,
|
||||
.provider-item .item-actions .btn i {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
#config-management .CodeMirror .CodeMirror-lines {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user