Files

953 lines
36 KiB
JavaScript

let apiKey = '';
let currentConfig = {};
let modelRoutingMeta = { models: [], pools: ['ssoBasic', 'ssoSuper'] };
let modelRoutingAssignments = {};
let modelRoutingDragId = '';
const byId = (id) => document.getElementById(id);
const NUMERIC_FIELDS = new Set([
'timeout',
'max_retry',
'retry_backoff_base',
'retry_backoff_factor',
'retry_backoff_max',
'retry_budget',
'refresh_interval_hours',
'super_refresh_interval_hours',
'fail_threshold',
'limit_mb',
'save_delay_ms',
'upload_concurrent',
'upload_timeout',
'download_concurrent',
'download_timeout',
'list_concurrent',
'list_timeout',
'list_batch_size',
'delete_concurrent',
'delete_timeout',
'delete_batch_size',
'reload_interval_sec',
'stream_timeout',
'final_timeout',
'final_min_bytes',
'medium_min_bytes',
'concurrent',
'batch_size'
]);
const LOCALE_MAP = {
"app": {
"label": "应用设置",
"api_key": { title: "API 密钥", desc: "调用 Grok2API 服务的 Token(可选)。" },
"app_key": { title: "后台密码", desc: "登录 Grok2API 管理后台的密码(必填)。" },
"public_enabled": { title: "启用功能玩法", desc: "是否启用功能玩法入口(关闭则功能玩法页面不可访问)。" },
"public_key": { title: "Public 密码", desc: "功能玩法页面的访问密码(可选)。" },
"app_url": { title: "应用地址", desc: "当前 Grok2API 服务的外部访问 URL,用于文件链接访问。" },
"image_format": { title: "图片格式", desc: "默认生成的图片格式(url 或 base64)。" },
"video_format": { title: "视频格式", desc: "默认生成的视频格式(html 或 url,url 为处理后的链接)。" },
"temporary": { title: "临时对话", desc: "是否默认启用临时对话模式。" },
"disable_memory": { title: "禁用记忆", desc: "是否默认禁用 Grok 记忆功能。" },
"stream": { title: "流式响应", desc: "是否默认启用流式输出。" },
"thinking": { title: "思维链", desc: "是否默认启用思维链输出。" },
"dynamic_statsig": { title: "动态指纹", desc: "是否默认启用动态生成 Statsig 指纹。" },
"filter_tags": { title: "过滤标签", desc: "设置自动过滤 Grok 响应中的特殊标签。" },
"app_log_enabled": { title: "应用运行日志", desc: "控制是否将服务运行日志写入 logs/app_YYYY-MM-DD.log,用于排查启动、接口错误和系统异常。" },
"chat_capture_enabled": { title: "对话抓包日志", desc: "控制是否记录对话上游原始响应到抓包文件,用于排查图片来源、卡片附件和搜索结果关联问题。" },
"chat_capture_file": { title: "对话抓包文件", desc: "对话抓包日志的写入路径,建议保留在 logs 目录下统一管理。" }
},
"proxy": {
"label": "代理配置",
"base_proxy_url": { title: "基础代理 URL", desc: "代理请求到 Grok 官网的基础服务地址。" },
"asset_proxy_url": { title: "资源代理 URL", desc: "代理请求到 Grok 官网的静态资源(图片/视频)地址。" },
"enabled": { title: "启用 CF 自动刷新", desc: "启用后将通过 FlareSolverr 自动获取 cf_clearance。" },
"flaresolverr_url": { title: "FlareSolverr 地址", desc: "FlareSolverr 服务的 HTTP 地址(如 http://flaresolverr:8191)。" },
"refresh_interval": { title: "刷新间隔(秒)", desc: "自动刷新 cf_clearance 的时间间隔,建议不低于 300 秒。" },
"timeout": { title: "挑战超时(秒)", desc: "等待 FlareSolverr 解决 CF 挑战的最大时间。" },
"cf_clearance": { title: "CF Clearance", desc: "Cloudflare Clearance Cookie,用于绕过反爬虫验证。启用自动刷新时由系统自动管理。" },
"browser": { title: "浏览器指纹", desc: "curl_cffi 浏览器指纹标识(如 chrome136)。启用自动刷新时由系统自动管理。" },
"user_agent": { title: "User-Agent", desc: "HTTP 请求的 User-Agent 字符串。启用自动刷新时由系统自动管理。" }
},
"retry": {
"label": "重试策略",
"max_retry": { title: "最大重试次数", desc: "请求 Grok 服务失败时的最大重试次数。" },
"retry_status_codes": { title: "重试状态码", desc: "触发重试的 HTTP 状态码列表。" },
"retry_backoff_base": { title: "退避基数", desc: "重试退避的基础延迟(秒)。" },
"retry_backoff_factor": { title: "退避倍率", desc: "重试退避的指数放大系数。" },
"retry_backoff_max": { title: "退避上限", desc: "单次重试等待的最大延迟(秒)。" },
"retry_budget": { title: "退避预算", desc: "单次请求的最大重试总耗时(秒)。" }
},
"chat": {
"label": "对话配置",
"concurrent": { title: "并发上限", desc: "Reverse 接口并发上限。" },
"timeout": { title: "请求超时", desc: "Reverse 接口超时时间(秒)。" },
"stream_timeout": { title: "流空闲超时", desc: "流式空闲超时时间(秒)。" }
},
"video": {
"label": "视频配置",
"concurrent": { title: "并发上限", desc: "Reverse 接口并发上限。" },
"timeout": { title: "请求超时", desc: "Reverse 接口超时时间(秒)。" },
"stream_timeout": { title: "流空闲超时", desc: "流式空闲超时时间(秒)。" }
},
"image": {
"label": "图像配置",
"timeout": { title: "请求超时", desc: "WebSocket 请求超时时间(秒)。" },
"stream_timeout": { title: "流空闲超时", desc: "WebSocket 流式空闲超时时间(秒)。" },
"final_timeout": { title: "最终图超时", desc: "收到中等图后等待最终图的超时秒数。" },
"nsfw": { title: "NSFW 模式", desc: "WebSocket 请求是否启用 NSFW。" },
"medium_min_bytes": { title: "中等图最小字节", desc: "判定中等质量图的最小字节数。" },
"final_min_bytes": { title: "最终图最小字节", desc: "判定最终图的最小字节数(通常 JPG > 100KB)。" }
},
"asset": {
"label": "资产配置",
"upload_concurrent": { title: "上传并发", desc: "上传接口的最大并发数。推荐 30。" },
"upload_timeout": { title: "上传超时", desc: "上传接口超时时间(秒)。推荐 60。" },
"download_concurrent": { title: "下载并发", desc: "下载接口的最大并发数。推荐 30。" },
"download_timeout": { title: "下载超时", desc: "下载接口超时时间(秒)。推荐 60。" },
"list_concurrent": { title: "查询并发", desc: "资产查询接口的最大并发数。推荐 10。" },
"list_timeout": { title: "查询超时", desc: "资产查询接口超时时间(秒)。推荐 60。" },
"list_batch_size": { title: "查询批次大小", desc: "单次查询可处理的 Token 数量。推荐 10。" },
"delete_concurrent": { title: "删除并发", desc: "资产删除接口的最大并发数。推荐 10。" },
"delete_timeout": { title: "删除超时", desc: "资产删除接口超时时间(秒)。推荐 60。" },
"delete_batch_size": { title: "删除批次大小", desc: "单次删除可处理的 Token 数量。推荐 10。" }
},
"voice": {
"label": "语音配置",
"timeout": { title: "请求超时", desc: "Voice 请求超时时间(秒)。" },
"livekit_url": { title: "LiveKit 地址", desc: "优先使用的 LiveKit 信令地址,留空则使用上游返回值。" },
"livekit_urls": { title: "LiveKit 地址列表", desc: "可配置多个信令地址,连接时会按顺序回退。" },
"signal_proxy_enabled": { title: "Signal 代理", desc: "开启后返回同域 WebSocket 代理地址,移动端可优先尝试。" },
"signal_proxy_url": { title: "代理地址", desc: "同域 signal 代理的自定义地址,留空将自动按当前域名生成。" }
},
"token": {
"label": "Token 池管理",
"auto_refresh": { title: "自动刷新", desc: "是否开启 Token 自动刷新机制。" },
"refresh_interval_hours": { title: "刷新间隔", desc: "普通 Token 刷新的时间间隔(小时)。" },
"super_refresh_interval_hours": { title: "Super 刷新间隔", desc: "Super Token 刷新的时间间隔(小时)。" },
"fail_threshold": { title: "失败阈值", desc: "单个 Token 连续失败多少次后被标记为不可用。" },
"save_delay_ms": { title: "保存延迟", desc: "Token 变更合并写入的延迟(毫秒)。" },
"reload_interval_sec": { title: "同步间隔", desc: "多 worker 场景下 Token 状态刷新间隔(秒)。" }
},
"model_routing": {
"label": "模型池路由",
"model_pools": { title: "模型到 Token 池映射", desc: "为每个模型手动指定使用哪个 Token 池。值支持字符串或字符串数组,例如 {\"grok-4.20-expert\":\"ssoSuper\",\"grok-4.20-auto\":[\"ssoBasic\",\"ssoSuper\"]}。" }
},
"cache": {
"label": "缓存管理",
"enable_auto_clean": { title: "自动清理", desc: "是否启用缓存自动清理,开启后按上限自动回收。" },
"limit_mb": { title: "清理阈值", desc: "缓存大小阈值(MB),超过阈值会触发清理。" }
},
"nsfw": {
"label": "NSFW 配置",
"concurrent": { title: "并发上限", desc: "批量开启 NSFW 模式时的并发请求上限。推荐 10。" },
"batch_size": { title: "批次大小", desc: "批量开启 NSFW 模式的单批处理数量。推荐 50。" },
"timeout": { title: "请求超时", desc: "NSFW 开启相关请求的超时时间(秒)。推荐 60。" }
},
"usage": {
"label": "Usage 配置",
"concurrent": { title: "并发上限", desc: "批量刷新用量时的并发请求上限。推荐 10。" },
"batch_size": { title: "批次大小", desc: "批量刷新用量的单批处理数量。推荐 50。" },
"timeout": { title: "请求超时", desc: "用量查询接口的超时时间(秒)。推荐 60。" }
}
};
// 配置部分说明(可选)
const SECTION_DESCRIPTIONS = {
"proxy": "配置不正确将导致 403 错误。服务首次请求 Grok 时的 IP 必须与获取 CF Clearance 时的 IP 一致,后续服务器请求 IP 变化不会导致 403。",
"model_routing": "这里可以手动指定每个模型优先走哪个 Token 池。未配置的模型仍会按系统默认路由。"
};
// CF 自动刷新联动禁用字段(全部在 proxy section 内)
const CF_MANAGED_PROXY_KEYS = ['cf_clearance', 'browser', 'user_agent'];
const CF_REFRESH_SUB_KEYS = ['flaresolverr_url', 'refresh_interval', 'timeout'];
const SECTION_ORDER = new Map(Object.keys(LOCALE_MAP).map((key, index) => [key, index]));
const HIDDEN_CONFIG_KEYS = new Map([
['chat', new Set(['capture_enabled', 'capture_file'])],
]);
function getText(section, key) {
if (LOCALE_MAP[section] && LOCALE_MAP[section][key]) {
return LOCALE_MAP[section][key];
}
return {
title: key.replace(/_/g, ' '),
desc: '暂无说明,请参考配置文档。'
};
}
function getSectionLabel(section) {
return (LOCALE_MAP[section] && LOCALE_MAP[section].label) || `${section} 设置`;
}
function sortByOrder(keys, orderMap) {
if (!orderMap) return keys;
return keys.sort((a, b) => {
const ia = orderMap.get(a);
const ib = orderMap.get(b);
if (ia !== undefined && ib !== undefined) return ia - ib;
if (ia !== undefined) return -1;
if (ib !== undefined) return 1;
return 0;
});
}
function setInputMeta(input, section, key) {
input.dataset.section = section;
input.dataset.key = key;
}
function createOption(value, text, selectedValue) {
const option = document.createElement('option');
option.value = value;
option.text = text;
if (selectedValue !== undefined && selectedValue === value) option.selected = true;
return option;
}
function buildBooleanInput(section, key, val) {
const label = document.createElement('label');
label.className = 'relative inline-flex items-center cursor-pointer';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = val;
input.className = 'sr-only peer';
setInputMeta(input, section, key);
const slider = document.createElement('div');
slider.className = "w-9 h-5 bg-[var(--accents-2)] peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-black";
label.appendChild(input);
label.appendChild(slider);
return { input, node: label };
}
function buildSelectInput(section, key, val, options) {
const input = document.createElement('select');
input.className = 'geist-input h-[34px]';
setInputMeta(input, section, key);
options.forEach(opt => {
input.appendChild(createOption(opt.val, opt.text, val));
});
return { input, node: input };
}
function buildJsonInput(section, key, val) {
const input = document.createElement('textarea');
input.className = 'geist-input font-mono text-xs';
input.rows = 4;
input.value = JSON.stringify(val, null, 2);
setInputMeta(input, section, key);
input.dataset.type = 'json';
return { input, node: input };
}
function buildTextInput(section, key, val) {
const input = document.createElement('input');
input.type = 'text';
input.className = 'geist-input';
input.value = val;
setInputMeta(input, section, key);
return { input, node: input };
}
function buildSecretInput(section, key, val) {
const input = document.createElement('input');
input.type = 'text';
input.className = 'geist-input flex-1 h-[34px]';
input.value = val;
setInputMeta(input, section, key);
const wrapper = document.createElement('div');
wrapper.className = 'flex items-center gap-2';
const genBtn = document.createElement('button');
genBtn.className = 'flex-none w-[32px] h-[32px] flex items-center justify-center bg-black text-white rounded-md hover:opacity-80 transition-opacity';
genBtn.type = 'button';
genBtn.title = '生成';
genBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"/><polyline points="21 3 21 9 15 9"/></svg>`;
genBtn.onclick = () => {
input.value = randomKey(16);
};
const copyBtn = document.createElement('button');
copyBtn.className = 'flex-none w-[32px] h-[32px] flex items-center justify-center bg-black text-white rounded-md hover:opacity-80 transition-opacity';
copyBtn.type = 'button';
copyBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
copyBtn.onclick = () => copyToClipboard(input.value, copyBtn);
wrapper.appendChild(input);
wrapper.appendChild(genBtn);
wrapper.appendChild(copyBtn);
return { input, node: wrapper };
}
function randomKey(len) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const out = [];
if (window.crypto && window.crypto.getRandomValues) {
const buf = new Uint8Array(len);
window.crypto.getRandomValues(buf);
for (let i = 0; i < len; i++) {
out.push(chars[buf[i] % chars.length]);
}
return out.join('');
}
for (let i = 0; i < len; i++) {
out.push(chars[Math.floor(Math.random() * chars.length)]);
}
return out.join('');
}
async function init() {
apiKey = await ensureAdminKey();
if (apiKey === null) return;
await loadModelRoutingMeta();
loadData();
}
async function loadModelRoutingMeta() {
try {
const res = await fetch('/v1/admin/model-routing/meta', {
headers: buildAuthHeaders(apiKey)
});
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data.models)) {
modelRoutingMeta.models = data.models;
}
if (Array.isArray(data.pools) && data.pools.length) {
modelRoutingMeta.pools = data.pools;
}
} catch (e) {
console.warn('模型池路由元数据加载失败', e);
}
}
async function loadData() {
try {
const res = await fetch('/v1/admin/config', {
headers: buildAuthHeaders(apiKey)
});
if (res.ok) {
currentConfig = await res.json();
renderConfig(currentConfig);
} else if (res.status === 401) {
logout();
}
} catch (e) {
showToast('连接失败', 'error');
}
}
function renderConfig(data) {
const container = byId('config-container');
if (!container) return;
container.replaceChildren();
const fragment = document.createDocumentFragment();
const sections = sortByOrder(Object.keys(data), SECTION_ORDER);
sections.forEach(section => {
const items = data[section];
const localeSection = LOCALE_MAP[section];
const keyOrder = localeSection ? new Map(Object.keys(localeSection).map((k, i) => [k, i])) : null;
const allKeys = sortByOrder(Object.keys(items), keyOrder);
const hiddenKeys = HIDDEN_CONFIG_KEYS.get(section) || new Set();
const visibleKeys = allKeys.filter(key => !(section === 'proxy' && key === 'cf_cookies') && !hiddenKeys.has(key));
if (visibleKeys.length > 0) {
const card = document.createElement('div');
card.className = 'config-section';
const header = document.createElement('div');
header.innerHTML = `<div class="config-section-title">${getSectionLabel(section)}</div>`;
if (section === 'proxy') {
const actionRow = document.createElement('div');
actionRow.className = 'config-section-actions';
const refreshBtn = document.createElement('button');
refreshBtn.type = 'button';
refreshBtn.id = 'cf-refresh-btn';
refreshBtn.className = 'geist-button-outline gap-2';
refreshBtn.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
手动刷新 CF
`;
refreshBtn.addEventListener('click', manualRefreshCfClearance);
actionRow.appendChild(refreshBtn);
header.appendChild(actionRow);
}
// 添加部分说明(如果有)
if (SECTION_DESCRIPTIONS[section]) {
const descP = document.createElement('p');
descP.className = 'text-[var(--accents-4)] text-sm mt-1 mb-4';
descP.textContent = SECTION_DESCRIPTIONS[section];
header.appendChild(descP);
}
card.appendChild(header);
const grid = document.createElement('div');
grid.className = 'config-grid';
visibleKeys.forEach(key => {
const fieldCard = buildFieldCard(section, key, items[key]);
grid.appendChild(fieldCard);
});
card.appendChild(grid);
if (section === 'app') {
const footerActions = document.createElement('div');
footerActions.className = 'config-footer-actions';
const clearLogsBtn = document.createElement('button');
clearLogsBtn.type = 'button';
clearLogsBtn.id = 'clear-logs-btn';
clearLogsBtn.className = 'geist-button-danger gap-2';
clearLogsBtn.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"></path>
<path d="M8 6V4h8v2"></path>
<path d="M19 6l-1 14H6L5 6"></path>
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
</svg>
清空日志文件夹
`;
clearLogsBtn.addEventListener('click', clearLogsDirectory);
card.appendChild(footerActions);
footerActions.appendChild(clearLogsBtn);
}
if (grid.children.length > 0) {
fragment.appendChild(card);
}
}
});
container.appendChild(fragment);
// 初始化 CF 自动刷新联动状态
const cfEnabled = data.proxy && data.proxy.enabled;
applyCfRefreshState(cfEnabled);
}
async function manualRefreshCfClearance() {
const btn = byId('cf-refresh-btn');
if (!btn) return;
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `
<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
</svg>
刷新中...
`;
try {
const res = await fetch('/v1/admin/config/cf-refresh', {
method: 'POST',
headers: buildAuthHeaders(apiKey)
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail || data.message || `HTTP ${res.status}`);
}
showToast(data.message || 'CF Clearance 已刷新', 'success');
await loadData();
} catch (e) {
showToast(`刷新失败: ${e.message || e}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalHtml;
}
}
function formatBytes(bytes) {
const size = Number(bytes || 0);
if (!size) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = size;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
}
async function clearLogsDirectory() {
const btn = byId('clear-logs-btn');
if (!btn) return;
const confirmed = window.confirm('将清空 logs 文件夹中的全部日志文件,是否继续?');
if (!confirmed) return;
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `
<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
</svg>
清理中...
`;
try {
const res = await fetch('/v1/admin/config/clear-logs', {
method: 'POST',
headers: buildAuthHeaders(apiKey)
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail || data.message || `HTTP ${res.status}`);
}
const info = data.data || {};
const filesText = Number(info.deleted_files || 0);
const dirsText = Number(info.deleted_dirs || 0);
const bytesText = formatBytes(info.released_bytes || 0);
showToast(`已清空日志:${filesText} 个文件,${dirsText} 个目录,释放 ${bytesText}`, 'success');
} catch (e) {
showToast(`清空失败: ${e.message || e}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalHtml;
}
}
function normalizeModelRoutingAssignments(raw) {
const next = {};
const source = raw && typeof raw === 'object' ? raw : {};
Object.entries(source).forEach(([modelId, value]) => {
if (typeof value === 'string' && value.trim()) {
next[modelId] = value.trim();
return;
}
if (Array.isArray(value)) {
const first = value.map(item => String(item).trim()).find(Boolean);
if (first) next[modelId] = first;
}
});
return next;
}
function buildModelRoutingInput(section, key, val) {
modelRoutingAssignments = normalizeModelRoutingAssignments(val);
const wrapper = document.createElement('div');
wrapper.className = 'routing-board';
wrapper.dataset.type = 'model-routing';
wrapper.dataset.section = section;
wrapper.dataset.key = key;
const leftPane = document.createElement('div');
leftPane.className = 'routing-pane routing-pane-models';
leftPane.innerHTML = `
<div class="routing-pane-header">
<div class="routing-pane-title">模型列表</div>
<div class="routing-pane-subtitle">拖到右侧池中即可指定路由,拖回这里表示按系统默认池处理。</div>
</div>
`;
const unassignedZone = document.createElement('div');
unassignedZone.className = 'routing-dropzone routing-dropzone-source';
unassignedZone.dataset.pool = '';
registerRoutingDropzone(unassignedZone, '');
const rightPane = document.createElement('div');
rightPane.className = 'routing-pane routing-pane-pools';
rightPane.innerHTML = `
<div class="routing-pane-header">
<div class="routing-pane-title">Token 池</div>
<div class="routing-pane-subtitle">模型放到哪个池,后端请求就优先走哪个池。</div>
</div>
`;
const poolGrid = document.createElement('div');
poolGrid.className = 'routing-pool-grid';
const pools = Array.isArray(modelRoutingMeta.pools) && modelRoutingMeta.pools.length
? modelRoutingMeta.pools
: ['ssoBasic', 'ssoSuper'];
pools.forEach(poolName => {
const card = document.createElement('div');
card.className = 'routing-pool-card';
const header = document.createElement('div');
header.className = 'routing-pool-header';
header.innerHTML = `
<div class="routing-pool-title">${poolName}</div>
<div class="routing-pool-hint">拖拽模型到这里</div>
`;
const zone = document.createElement('div');
zone.className = 'routing-dropzone routing-dropzone-pool';
zone.dataset.pool = poolName;
registerRoutingDropzone(zone, poolName);
card.appendChild(header);
card.appendChild(zone);
poolGrid.appendChild(card);
});
leftPane.appendChild(unassignedZone);
rightPane.appendChild(poolGrid);
wrapper.appendChild(leftPane);
wrapper.appendChild(rightPane);
renderModelRoutingBoard(wrapper);
return { input: wrapper, node: wrapper };
}
function registerRoutingDropzone(zone, poolName) {
zone.addEventListener('dragover', (event) => {
event.preventDefault();
zone.classList.add('is-over');
});
zone.addEventListener('dragleave', () => {
zone.classList.remove('is-over');
});
zone.addEventListener('drop', (event) => {
event.preventDefault();
zone.classList.remove('is-over');
const modelId = event.dataTransfer.getData('text/plain') || modelRoutingDragId;
if (!modelId) return;
if (poolName) {
modelRoutingAssignments[modelId] = poolName;
} else {
delete modelRoutingAssignments[modelId];
}
const board = zone.closest('.routing-board');
if (board) renderModelRoutingBoard(board);
modelRoutingDragId = '';
});
}
function createModelChip(model) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'routing-chip';
chip.draggable = true;
chip.dataset.modelId = model.id;
chip.innerHTML = `
<span class="routing-chip-name">${model.display_name || model.id}</span>
<span class="routing-chip-id">${model.id}</span>
`;
chip.addEventListener('dragstart', (event) => {
modelRoutingDragId = model.id;
event.dataTransfer.setData('text/plain', model.id);
event.dataTransfer.effectAllowed = 'move';
chip.classList.add('is-dragging');
});
chip.addEventListener('dragend', () => {
chip.classList.remove('is-dragging');
modelRoutingDragId = '';
document.querySelectorAll('.routing-dropzone.is-over').forEach((node) => {
node.classList.remove('is-over');
});
});
return chip;
}
function renderModelRoutingBoard(board) {
const models = Array.isArray(modelRoutingMeta.models) ? [...modelRoutingMeta.models] : [];
models.sort((a, b) => String(a.id).localeCompare(String(b.id)));
const sourceZone = board.querySelector('.routing-dropzone-source');
const poolZones = Array.from(board.querySelectorAll('.routing-dropzone-pool'));
if (!sourceZone) return;
sourceZone.replaceChildren();
poolZones.forEach(zone => zone.replaceChildren());
const assignedModelIds = new Set();
poolZones.forEach(zone => {
const poolName = zone.dataset.pool || '';
const assigned = models.filter(model => modelRoutingAssignments[model.id] === poolName);
assigned.forEach(model => {
assignedModelIds.add(model.id);
zone.appendChild(createModelChip(model));
});
if (!assigned.length) {
zone.appendChild(createRoutingEmptyState('拖拽模型到这里'));
}
});
const unassigned = models.filter(model => !assignedModelIds.has(model.id));
unassigned.forEach(model => {
sourceZone.appendChild(createModelChip(model));
});
if (!unassigned.length) {
sourceZone.appendChild(createRoutingEmptyState('所有模型都已分配到池'));
}
}
function createRoutingEmptyState(text) {
const empty = document.createElement('div');
empty.className = 'routing-empty';
empty.textContent = text;
return empty;
}
function applyCfRefreshState(enabled) {
// 设置字段禁用状态的辅助函数
function setFieldDisabled(section, key, disabled) {
const input = document.querySelector(
`input[data-section="${section}"][data-key="${key}"],` +
`textarea[data-section="${section}"][data-key="${key}"],` +
`select[data-section="${section}"][data-key="${key}"]`
);
if (!input) return;
input.disabled = disabled;
// 找到最近的 .config-field 父元素设置样式
const field = input.closest('.config-field');
if (field) {
field.style.opacity = disabled ? '0.45' : '';
field.style.pointerEvents = disabled ? 'none' : '';
}
}
// enabled=true → 灰掉 cf_clearance/browser/user_agent
CF_MANAGED_PROXY_KEYS.forEach(k => setFieldDisabled('proxy', k, !!enabled));
// enabled=false → 灰掉 flaresolverr_url/refresh_interval/timeout
CF_REFRESH_SUB_KEYS.forEach(k => setFieldDisabled('proxy', k, !enabled));
}
function buildFieldCard(section, key, val) {
const text = getText(section, key);
const fieldCard = document.createElement('div');
fieldCard.className = 'config-field';
// Title
const titleEl = document.createElement('div');
titleEl.className = 'config-field-title';
titleEl.textContent = text.title;
fieldCard.appendChild(titleEl);
// Description (Muted) - 只在有描述时显示
if (text.desc) {
const descEl = document.createElement('p');
descEl.className = 'config-field-desc';
descEl.textContent = text.desc;
fieldCard.appendChild(descEl);
}
// Input Wrapper
const inputWrapper = document.createElement('div');
inputWrapper.className = 'config-field-input';
// Input Logic
let built;
if (typeof val === 'boolean') {
built = buildBooleanInput(section, key, val);
}
else if (key === 'image_format') {
built = buildSelectInput(section, key, val, [
{ val: 'url', text: 'URL' },
{ val: 'base64', text: 'Base64' }
]);
}
else if (key === 'video_format') {
built = buildSelectInput(section, key, val, [
{ val: 'html', text: 'HTML' },
{ val: 'url', text: 'URL' }
]);
}
else if (section === 'imagine_fast' && key === 'size') {
built = buildSelectInput(section, key, val, [
{ val: '1024x1024', text: '1024x1024 (1:1)' },
{ val: '1280x720', text: '1280x720 (16:9)' },
{ val: '720x1280', text: '720x1280 (9:16)' },
{ val: '1792x1024', text: '1792x1024 (3:2)' },
{ val: '1024x1792', text: '1024x1792 (2:3)' }
]);
}
else if (section === 'imagine_fast' && key === 'response_format') {
built = buildSelectInput(section, key, val, [
{ val: 'url', text: 'URL' },
{ val: 'b64_json', text: 'B64 JSON' },
{ val: 'base64', text: 'Base64' }
]);
}
else if (Array.isArray(val) || typeof val === 'object') {
if (section === 'model_routing' && key === 'model_pools') {
built = buildModelRoutingInput(section, key, val);
} else {
built = buildJsonInput(section, key, val);
}
}
else {
if (key === 'api_key' || key === 'app_key' || key === 'public_key') {
built = buildSecretInput(section, key, val);
} else {
built = buildTextInput(section, key, val);
}
}
if (built) {
inputWrapper.appendChild(built.node);
}
fieldCard.appendChild(inputWrapper);
// proxy.enabled (CF 自动刷新) 联动(toggle 本身始终可交互)
if (section === 'proxy' && key === 'enabled' && built && built.input) {
fieldCard.style.pointerEvents = 'auto';
fieldCard.style.opacity = '';
built.input.addEventListener('change', () => {
applyCfRefreshState(built.input.checked);
});
}
if (section === 'app' && key === 'public_enabled') {
fieldCard.classList.add('has-action');
const link = document.createElement('a');
link.href = '/login';
link.className = 'config-field-action flex-none w-[32px] h-[32px] flex items-center justify-center bg-black text-white rounded-md hover:opacity-80 transition-opacity';
link.title = '功能玩法';
link.setAttribute('aria-label', '功能玩法');
link.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7"/><path d="M7 7h10v10"/></svg>`;
link.style.display = val ? 'inline-flex' : 'none';
fieldCard.appendChild(link);
if (built && built.input) {
built.input.addEventListener('change', () => {
link.style.display = built.input.checked ? 'inline-flex' : 'none';
});
}
}
return fieldCard;
}
async function saveConfig() {
const btn = byId('save-btn');
const originalText = btn.innerText;
btn.disabled = true;
btn.innerText = '保存中...';
try {
const newConfig = typeof structuredClone === 'function'
? structuredClone(currentConfig)
: JSON.parse(JSON.stringify(currentConfig));
const inputs = document.querySelectorAll('input[data-section], textarea[data-section], select[data-section], [data-type="model-routing"][data-section]');
inputs.forEach(input => {
const s = input.dataset.section;
const k = input.dataset.key;
if (input.dataset.type === 'model-routing') {
if (!newConfig[s]) newConfig[s] = {};
newConfig[s][k] = { ...modelRoutingAssignments };
return;
}
let val = input.value;
if (input.type === 'checkbox') {
val = input.checked;
} else if (input.dataset.type === 'json') {
try { val = JSON.parse(val); } catch (e) { throw new Error(`无效的 JSON: ${getText(s, k).title}`); }
} else if (k === 'app_key' && val.trim() === '') {
throw new Error('app_key 不能为空(后台密码)');
} else if (NUMERIC_FIELDS.has(k)) {
if (val.trim() !== '' && !Number.isNaN(Number(val))) {
val = Number(val);
}
}
if (!newConfig[s]) newConfig[s] = {};
newConfig[s][k] = val;
});
if (newConfig.proxy && newConfig.proxy.enabled) {
const url = String(newConfig.proxy.flaresolverr_url || '').trim();
if (!url) {
showToast('启用自动刷新时必须填写 FlareSolverr 地址', 'error');
btn.disabled = false;
btn.innerText = originalText;
return;
}
}
const res = await fetch('/v1/admin/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildAuthHeaders(apiKey)
},
body: JSON.stringify(newConfig)
});
if (res.ok) {
btn.innerText = '成功';
showToast('配置已保存', 'success');
setTimeout(() => {
btn.innerText = originalText;
btn.style.backgroundColor = '';
}, 2000);
} else {
showToast('保存失败', 'error');
}
} catch (e) {
showToast('错误: ' + e.message, 'error');
} finally {
if (btn.innerText === '保存中...') {
btn.disabled = false;
btn.innerText = originalText;
} else {
btn.disabled = false;
}
}
}
async function copyToClipboard(text, btn) {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
btn.style.backgroundColor = '#10b981';
btn.style.borderColor = '#10b981';
setTimeout(() => {
btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
btn.style.backgroundColor = '';
btn.style.borderColor = '';
}, 2000);
} catch (err) {
console.error('Failed to copy', err);
}
}
window.onload = init;