grok2api / app /statics /admin /config.html
FUCAT's picture
Deploy grok2api to HF Spaces (Docker)
7e55e53
Raw
History Blame Contribute Delete
62 kB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="config.pageTitle">Grok2API - ้…็ฝฎ็ฎก็†</title>
<link rel="icon" href="/favicon.ico?v={{APP_VERSION}}">
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet">
<link href="/static/css/app.css?v={{APP_VERSION}}" rel="stylesheet">
<style>
body { background: #FAF9F5 }
/* Page header */
.page-hd { display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:20px }
.page-title{ font-size:22px; font-weight:700; line-height:1.2 }
.page-sub { font-size:13px; color:var(--fg-muted); margin-top:5px }
.page-actions { display:flex; gap:8px; align-items:center; flex-shrink:0 }
.page-action-btn {
height:32px; padding:0 14px; border-radius:999px;
display:inline-flex; align-items:center; justify-content:center; gap:6px;
font-size:13px; font-weight:600;
border:1px solid #e6e6e6; background:#fafafa; color:#444;
}
.page-action-btn:hover { background:#f3f3f3; border-color:#dcdcdc }
.page-action-btn-primary { background:#111; border-color:#111; color:#fff }
.page-action-btn-primary:hover { background:#222; border-color:#222 }
.page-action-btn:disabled { opacity:.4; pointer-events:none }
/* Section nav tabs */
.cfg-nav {
display:flex;
align-items:center;
gap:6px;
flex-wrap:wrap;
margin-bottom:16px;
padding:0;
}
.cfg-tab {
height:30px;
padding:0 12px;
border-radius:999px;
font-size:12px;
font-weight:500;
color:#8f8f8f;
background:#f5f5f5;
transition:color .15s, background .15s;
cursor:pointer;
}
.cfg-tab:hover { color:#555; background:#f1f1f1 }
.cfg-tab.active {
background:#111;
color:#fff;
font-weight:600;
}
.cfg-tab .dot {
display:none;
width:6px;
height:6px;
border-radius:50%;
background:currentColor;
margin-left:5px;
vertical-align:middle;
opacity:.45;
}
.cfg-tab.dirty .dot { display:inline-block }
/* Section panels */
.cfg-panel { display:none }
.cfg-panel.active { display:block }
/* Field rows */
.cfg-group {
background:#fff;
border:none;
border-radius:12px;
overflow:hidden;
margin-bottom:12px;
}
.cfg-group-title {
font-size:11px;
font-weight:700;
color:#8d8d8d;
text-transform:uppercase;
letter-spacing:.08em;
padding:12px 16px 6px;
background:#fff;
border-bottom:none;
}
.cfg-row {
display:grid;
grid-template-columns:minmax(0, 2fr) minmax(300px, 1fr);
gap:18px;
align-items:center;
padding:11px 16px;
border-bottom:1px solid #f5f5f5;
transition:background .15s;
}
.cfg-row:last-child { border-bottom:none }
.cfg-row:hover { background:#fcfcfc }
.cfg-row.modified { background:#fffaf0 }
.cfg-row.modified:hover { background:#fff6e7 }
.cfg-label-col { min-width:0; display:grid; gap:3px; align-content:start }
.cfg-label {
display:inline-flex;
align-items:center;
gap:6px;
font-size:12px;
font-weight:600;
color:#222;
line-height:1.35;
}
.cfg-desc { max-width:520px; font-size:11px; color:#9a9a9a; line-height:1.45 }
.cfg-help {
position:relative;
width:16px;
height:16px;
display:inline-flex;
align-items:center;
justify-content:center;
padding:0;
border-radius:50%;
border:1px solid #e6e6e6;
background:#fafafa;
color:#8d8d8d;
font-size:11px;
line-height:1;
font-weight:700;
cursor:help;
flex-shrink:0;
}
.cfg-help:hover,
.cfg-help:focus-visible {
color:#222;
border-color:#d6d6d6;
background:#fff;
outline:none;
}
.cfg-help-tip {
position:fixed;
box-sizing:border-box;
max-width:min(320px, calc(100vw - 24px));
padding:9px 10px;
border-radius:8px;
background:#171717;
color:#fff;
font-size:12px;
font-weight:500;
line-height:1.5;
text-align:left;
white-space:pre-line;
overflow-wrap:anywhere;
box-shadow:0 8px 24px rgba(0,0,0,.16);
opacity:0;
pointer-events:none;
transform:translateY(3px);
transition:opacity .15s, transform .15s;
z-index:1000;
}
.cfg-help-tip.visible {
opacity:1;
transform:translateY(0);
}
.cfg-help-tip::before {
content:'';
position:absolute;
left:var(--arrow-left, 50%);
width:8px;
height:8px;
background:#171717;
transform:translateX(-50%) rotate(45deg);
}
.cfg-help-tip[data-placement="top"]::before { bottom:-4px }
.cfg-help-tip[data-placement="bottom"]::before { top:-4px }
.cfg-input-col { min-width:0; display:flex; align-items:center; justify-content:flex-start; gap:8px }
.cfg-input-col.is-bool { justify-content:flex-end }
.cfg-inline-action {
width:16px;
height:16px;
display:inline-flex;
align-items:center;
justify-content:center;
padding:0;
color:#9a9a9a;
border:0;
background:transparent;
cursor:pointer;
flex-shrink:0;
transition:color .15s, opacity .15s, transform .15s;
}
.cfg-inline-action:hover { color:#222; transform:translateY(-.5px) }
.cfg-inline-action:disabled {
opacity:.32;
cursor:not-allowed;
pointer-events:none;
}
.cfg-inline-action svg { width:14px; height:14px; display:block }
/* Inputs */
.cfg-text {
width:100%;
max-width:none;
height:32px;
padding:0 10px;
font-size:13px;
border-radius:8px;
border:1px solid #ececec;
background:#fbfbfb;
transition:border-color .15s, background .15s;
font-family: inherit; text-align:right;
}
.cfg-text:focus { border-color:#d7d7d7; background:#fff; outline:none; box-shadow:none }
.cfg-text.wide { max-width:none }
.cfg-textarea {
width:100%;
max-width:none;
min-height:72px;
padding:8px 10px;
font-size:12px;
border-radius:10px;
border:1px solid #ececec;
background:#fbfbfb;
resize:vertical;
transition:border-color .15s, background .15s;
font-family: 'Geist Mono', 'Courier New', monospace; line-height:1.5; text-align:right;
}
.cfg-textarea:focus { border-color:#d7d7d7; background:#fff; outline:none }
.cfg-number {
width:100%;
max-width:none;
height:32px;
padding:0 10px;
font-size:13px;
border-radius:8px;
border:1px solid #ececec;
background:#fbfbfb;
text-align:right;
}
.cfg-number:focus { border-color:#d7d7d7; background:#fff; outline:none }
.cfg-select {
width:100%;
height:32px;
padding:0 28px 0 10px;
font-size:13px;
border-radius:8px;
border:1px solid #ececec;
background:#fbfbfb;
appearance:none; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24'%3E%3Cpath d='M6 9l6 6 6-6' stroke='%23666' stroke-width='2' fill='none'/%3E%3C/svg%3E");
background-repeat:no-repeat; background-position:right 8px center;
cursor:pointer; text-align:right;
}
.cfg-select:focus { border-color:#d7d7d7; background:#fff; outline:none }
/* Toggle switch */
.toggle {
position:relative; display:inline-block; width:36px; height:20px; flex-shrink:0;
}
.toggle input { opacity:0; width:0; height:0 }
.toggle-track {
position:absolute; cursor:pointer; inset:0;
background:#ddd; border-radius:20px; transition:background .2s;
}
.toggle-track::before {
content:''; position:absolute; width:14px; height:14px;
left:3px; bottom:3px; background:#fff; border-radius:50%;
transition:transform .2s; box-shadow:0 1px 2px rgba(0,0,0,.15);
}
.toggle input:checked + .toggle-track { background:#111 }
.toggle input:checked + .toggle-track::before { transform:translateX(16px) }
/* Modified badge */
.cfg-modified-dot {
display:inline-block; width:6px; height:6px; border-radius:50%;
background:#f59e0b; margin-left:6px; vertical-align:middle;
visibility:hidden;
}
.cfg-row.modified .cfg-modified-dot { visibility:visible }
/* Random button */
.cfg-rand-btn {
width:30px; height:30px; border-radius:999px;
display:inline-flex; align-items:center; justify-content:center;
color:#6a6a6a; border:1px solid #ececec; background:#fafafa;
cursor:pointer; flex-shrink:0; transition:background .15s, border-color .15s;
}
.cfg-rand-btn:hover { background:#f3f3f3; border-color:#dddddd; color:#222 }
/* Save bar */
.save-bar {
position:fixed;
left:50%;
bottom:18px;
z-index:50;
width:min(calc(100% - 56px), 1280px);
transform:translate(-50%, calc(100% + 20px));
background:rgba(255,255,255,.96);
border:1px solid #efefef;
border-radius:16px;
padding:12px 16px;
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
transition:transform .25s ease;
}
.save-bar.visible { transform:translate(-50%, 0) }
.save-bar-msg { font-size:13px; color:var(--fg-muted) }
.save-bar-actions { display:flex; gap:8px }
.save-bar-btn {
height:32px;
padding:0 14px;
border-radius:999px;
font-size:13px;
font-weight:600;
border:1px solid transparent;
cursor:pointer;
}
.save-bar-btn-cancel { border-color:#e6e6e6; background:#fafafa; color:#444 }
.save-bar-btn-cancel:hover { background:#f3f3f3; border-color:#dcdcdc }
.save-bar-btn-save { background:#111; color:#fff }
.save-bar-btn-save:hover { background:#222 }
@media (max-width:640px) {
.cfg-row { grid-template-columns:1fr; gap:8px; align-items:flex-start }
.cfg-input-col { justify-content:flex-start }
.cfg-nav { gap:8px }
.save-bar {
width:calc(100% - 24px);
bottom:12px;
padding:12px;
flex-direction:column;
align-items:stretch;
}
.save-bar-actions { justify-content:flex-end }
}
</style>
</head>
<body>
<!-- โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -->
<div id="admin-header" data-active="/admin/config"></div>
<!-- โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -->
<main class="admin-main" style="padding-bottom:80px">
<div class="page-hd">
<div>
<div class="page-title" data-i18n="config.pageHeading">้…็ฝฎ็ฎก็†</div>
<div class="page-sub" data-i18n="config.pageSubtitle">็ปŸไธ€็ฎก็†่ฟ่กŒๆ—ถๅ‚ๆ•ฐไธŽๆœๅŠก่กŒไธบใ€‚ไฟๅญ˜ๅŽๅณๆ—ถ็”Ÿๆ•ˆ๏ผŒ้ƒจๅˆ†้…็ฝฎ้œ€้‡ๅฏๆœๅŠกใ€‚</div>
</div>
<div class="page-actions">
<button id="btn-reset" onclick="doReset()" class="page-action-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 3-6.708"/><path d="M3 4v5h5"/></svg>
<span data-i18n="config.resetShort">้‡็ฝฎ</span>
</button>
<button id="btn-save" onclick="doSave()" class="page-action-btn page-action-btn-primary" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m5 12 4.2 4.2L19 6.5"/></svg>
<span data-i18n="config.saveShort">ไฟๅญ˜</span>
</button>
</div>
</div>
<!-- Section tabs -->
<div class="cfg-nav" id="cfg-nav"></div>
<!-- Section panels, rendered by JS -->
<div id="cfg-panels"></div>
</main>
<!-- โ”€โ”€ Save bar (bottom) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -->
<div class="save-bar" id="save-bar">
<div class="save-bar-msg" id="save-bar-msg" data-i18n="config.unsavedDefault">ๅญ˜ๅœจๆœชไฟๅญ˜็š„ๆ›ดๆ”น</div>
<div class="save-bar-actions">
<button class="save-bar-btn save-bar-btn-cancel" onclick="doReset()" data-i18n="config.discardShort">ๆ’ค้”€</button>
<button class="save-bar-btn save-bar-btn-save" onclick="doSave()" data-i18n="config.saveBarShort">ไฟๅญ˜</button>
</div>
</div>
<script src="/static/js/i18n.js?v={{APP_VERSION}}"></script>
<script src="/static/js/auth.js?v={{APP_VERSION}}"></script>
<script src="/static/js/admin-header.js?v={{APP_VERSION}}"></script>
<script src="/static/js/toast.js?v={{APP_VERSION}}"></script>
<script src="/static/js/footer.js?v={{APP_VERSION}}"></script>
<script>
// โ”€โ”€ Config schema โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Each group has a `section` (full dotted TOML path, e.g. "proxy.egress").
// Each field has a `key` (leaf key within that section).
// Full config path = section + '.' + key.
const SCHEMA_DEF = [
{
id: 'system', label: '็ณป็ปŸ้…็ฝฎ', labelKey: 'config.schema.tabs.system',
groups: [
{
title: 'ๅŸบ็ก€่ฎฟ้—ฎๆŽงๅˆถ', titleKey: 'config.schema.groups.baseAccess',
section: 'app',
fields: [
{ key: 'app_url', label: 'APP ่ฎฟ้—ฎๅœฐๅ€', labelKey: 'config.schema.fields.appUrl.label', type: 'text', desc: 'ๅบ”็”จๅฏนๅค–ๆไพ›ๆœๅŠก็š„ๆ น URL๏ผŒๆ ผๅผๅฆ‚ https://your-domain.comใ€‚ๆœฌๅœฐไปฃ็†ๆจกๅผๅฟ…้กป้…็ฝฎใ€‚', descKey: 'config.schema.fields.appUrl.desc', wide: true },
{ key: 'app_key', label: 'APP ่ฎฟ้—ฎๅฏ†็ ', labelKey: 'config.schema.fields.appKey.label', type: 'password', desc: '็ฎก็†ๅŽๅฐ้‰ดๆƒๅฏ†็ ใ€‚ไฟฎๆ”นๅŽๅทฒๆœ‰ Session ๅฐ†็ซ‹ๅณๅคฑๆ•ˆ๏ผŒ้œ€้‡ๆ–ฐ็™ปๅฝ•ใ€‚', descKey: 'config.schema.fields.appKey.desc', rand: true },
{ key: 'api_key', label: 'API ่ฐƒ็”จๅฏ†้’ฅ', labelKey: 'config.schema.fields.apiKey.label', type: 'text', desc: 'OpenAI ๅ…ผๅฎน API ็š„้‰ดๆƒๅฏ†้’ฅใ€‚ๅคšไธชๅ€ผ่ฏทไฝฟ็”จ่‹ฑๆ–‡้€—ๅทๅˆ†้š”๏ผ›็•™็ฉบๅˆ™็ฆ็”จ้‰ดๆƒใ€‚', descKey: 'config.schema.fields.apiKey.desc', rand: true },
{ key: 'webui_enabled', label: 'ๅฏ็”จ WebUI', labelKey: 'config.schema.fields.webuiEnabled.label', type: 'bool', desc: 'ๅผ€ๆ”พ WebUI ๅŠŸ่ƒฝๅ…ฅๅฃใ€‚ๅ…ณ้—ญๅŽ็›ธๅ…ณ่ทฏ็”ฑๅฐ†่ฟ”ๅ›ž 404ใ€‚', descKey: 'config.schema.fields.webuiEnabled.desc' },
{ key: 'webui_key', label: 'WebUI ่ฎฟ้—ฎๅฏ†็ ', labelKey: 'config.schema.fields.webuiKey.label', type: 'password', desc: 'WebUI ็š„่ฎฟ้—ฎ้‰ดๆƒๅฏ†็ ใ€‚้…็ฝฎๅŽ่ฎฟ้—ฎๆ—ถๅฟ…้กปๆไพ›ใ€‚', descKey: 'config.schema.fields.webuiKey.desc', rand: true, showIf: 'app.webui_enabled' },
]
},
{
title: 'ๆ—ฅๅฟ—้…็ฝฎ', titleKey: 'config.schema.groups.logging',
section: 'logging',
fields: [
{
key: 'file_level',
label: 'ๆ–‡ไปถๆ—ฅๅฟ—็ญ‰็บง',
labelKey: 'config.schema.fields.fileLogLevel.label',
type: 'select',
options: [
{ value: 'DEBUG', label: 'DEBUG' },
{ value: 'INFO', label: 'INFO' },
{ value: 'WARNING', label: 'WARNING' },
{ value: 'ERROR', label: 'ERROR' },
],
desc: 'ๆ—ฅๅฟ—ๆ–‡ไปถๅ†™ๅ…ฅ็š„ๆœ€ไฝŽ็ญ‰็บง๏ผŒๅฏ็‹ฌ็ซ‹ไบŽๆŽงๅˆถๅฐ้…็ฝฎใ€‚้ป˜่ฎค INFOใ€‚',
descKey: 'config.schema.fields.fileLogLevel.desc',
},
{
key: 'max_files',
label: 'ๆ—ฅๅฟ—ไฟ็•™ๅคฉๆ•ฐ',
labelKey: 'config.schema.fields.logMaxFiles.label',
type: 'number',
desc: 'ๆŒ‰ๅคฉ่ฝฎ่ฝฌๆ—ถๆœ€ๅคšไฟ็•™็š„ๆ—ฅๅฟ—ๆ–‡ไปถๆ•ฐ้‡ใ€‚้ป˜่ฎค 7ใ€‚',
descKey: 'config.schema.fields.logMaxFiles.desc',
},
]
},
]
},
{
id: 'features', label: 'ๅบ”็”จๅŠŸ่ƒฝ', labelKey: 'config.schema.tabs.features',
groups: [
{
title: 'ๅŸบๆœฌๅŠŸ่ƒฝ่ฎพ็ฝฎ', titleKey: 'config.schema.groups.features',
section: 'features',
fields: [
{ key: 'temporary', label: 'ไธดๆ—ถไผš่ฏ', labelKey: 'config.schema.fields.temporary.label', type: 'bool', desc: 'ๅฏ็”จๅŽ่ฏทๆฑ‚ไธไผšๅ†™ๅ…ฅ Grok ไผš่ฏๅކๅฒ๏ผŒ็ญ‰ๅŒไบŽๆ— ็—•ๅฏน่ฏใ€‚', descKey: 'config.schema.fields.temporary.desc' },
{ key: 'memory', label: 'ๅผ€ๅฏไผš่ฏ่ฎฐๅฟ†', labelKey: 'config.schema.fields.memory.label', type: 'bool', desc: 'ๅฏ็”จ Grok Memory๏ผŒๅ…่ฎธๆจกๅž‹่ทจไผš่ฏ่ฎฐๅฟ†็”จๆˆทๅๅฅฝไธŽไธŠไธ‹ๆ–‡ใ€‚', descKey: 'config.schema.fields.memory.desc' },
{ key: 'stream', label: '้ป˜่ฎคๆตๅผ่พ“ๅ‡บ', labelKey: 'config.schema.fields.stream.label', type: 'bool', desc: '่ฏทๆฑ‚ๆœชๆ˜พๅผๆŒ‡ๅฎš stream ๅ‚ๆ•ฐๆ—ถๆ‰€้‡‡็”จ็š„้ป˜่ฎคๅ€ผใ€‚', descKey: 'config.schema.fields.stream.desc' },
{ key: 'thinking', label: '้ป˜่ฎคๆ€่€ƒ่พ“ๅ‡บ', labelKey: 'config.schema.fields.thinking.label', type: 'bool', desc: '่ฏทๆฑ‚ๆœชๆ˜พๅผๆŒ‡ๅฎš thinking ๅ‚ๆ•ฐๆ—ถๆ‰€้‡‡็”จ็š„้ป˜่ฎคๅ€ผใ€‚ๅฏ็”จๅŽๅฐ†ๅœจ reasoning_content ๅญ—ๆฎต่ฟ”ๅ›žๆ€่€ƒ่ฟ‡็จ‹ใ€‚', descKey: 'config.schema.fields.thinking.desc' },
{ key: 'auto_chat_mode_fallback', label: 'AUTO ่Šๅคฉๆจกๅผๅ›ž้€€', labelKey: 'config.schema.fields.autoChatModeFallback.label', type: 'bool', desc: 'AUTO ่Šๅคฉๆจกๅž‹ๅœจ auto ้ขๅบฆไธๅฏ็”จๆ—ถ๏ผŒ่‡ชๅŠจๅฐ่ฏ• fast ๅ’Œ expert ่Šๅคฉ้ขๅบฆ็ช—ๅฃใ€‚', descKey: 'config.schema.fields.autoChatModeFallback.desc' },
{ key: 'thinking_summary', label: 'ๆ€่€ƒ็ฒพ็ฎ€่พ“ๅ‡บ', labelKey: 'config.schema.fields.thinkingSummary.label', type: 'bool', desc: 'ๅฏ็”จๅŽๅฐ†ๆ€่€ƒ่ฟ‡็จ‹ๆ็‚ผไธบ็ป“ๆž„ๅŒ–ๆ‘˜่ฆใ€‚ๅ…ณ้—ญๆ—ถ่พ“ๅ‡บๅฎŒๆ•ด็š„ๅŽŸๅง‹ๆŽจ็†่ฟ‡็จ‹๏ผŒๆ”ฏๆŒๅคš Agent ๆจกๅž‹็š„ๅไฝœ่ฏฆๆƒ…ไธŽๅทฅๅ…ท่ฐƒ็”จๅฑ•็คบใ€‚', descKey: 'config.schema.fields.thinkingSummary.desc' },
{ key: 'dynamic_statsig', label: 'ๅŠจๆ€ Statsig', labelKey: 'config.schema.fields.dynamicStatsig.label', type: 'bool', desc: 'ไธบๆฏๆฌก่ฏทๆฑ‚ๅŠจๆ€็”Ÿๆˆ Statsig ่ฎพๅค‡ๆŒ‡็บน๏ผŒไปฅ้™ไฝŽ้ฃŽๆŽงๆ‹ฆๆˆชๆฆ‚็އใ€‚', descKey: 'config.schema.fields.dynamicStatsig.desc' },
{ key: 'enable_nsfw', label: 'ๅ…่ฎธ NSFW ็”Ÿๆˆ', labelKey: 'config.schema.fields.enableNsfw.label', type: 'bool', desc: 'ๅ…่ฎธๅ›พๅƒ็”ŸๆˆๆŽฅๅฃ็ป•่ฟ‡ NSFW ๅ†…ๅฎน่ฟ‡ๆปคใ€‚', descKey: 'config.schema.fields.enableNsfw.desc' },
{ key: 'show_search_sources', label: 'ๆญฃๆ–‡่ฟฝๅŠ ไฟกๆบ', labelKey: 'config.schema.fields.showSearchSources.label', type: 'bool', desc: 'ๆœ็ดขไฟกๆบๅง‹็ปˆไปฅ search_sources ๅญ—ๆฎต่พ“ๅ‡บใ€‚ๆญค้€‰้กนๆŽงๅˆถๆ˜ฏๅฆๅŒๆ—ถๅœจๆญฃๆ–‡ๆœซๅฐพ่ฟฝๅŠ  ## Sources ๆฎต่ฝ๏ผˆๅ…ผๅฎนๆ–‡ๆœฌ่งฃๆžๅฎขๆˆท็ซฏ๏ผ‰ใ€‚', descKey: 'config.schema.fields.showSearchSources.desc' },
{ key: 'custom_instruction', label: 'ๅ…จๅฑ€้™„ๅŠ ๆŒ‡ไปค', labelKey: 'config.schema.fields.customInstruction.label', type: 'textarea', desc: 'ไธบๆฏๆฌก่ฏทๆฑ‚ๆณจๅ…ฅ็ปŸไธ€็š„ system ๆถˆๆฏ๏ผŒ็”จไบŽ็บฆๆŸๆจกๅž‹่กŒไธบๆˆ–ๅ›บๅฎš่ง’่‰ฒ่ฎพๅฎšใ€‚', descKey: 'config.schema.fields.customInstruction.desc' },
]
},
{
title: 'ๅช’ไฝ“่ฟ”ๅ›žๆ ผๅผ', titleKey: 'config.schema.groups.mediaFormat',
section: 'features',
fields: [
{
key: 'image_format',
label: 'ๅ›พ็‰‡่ฟ”ๅ›žๆ ผๅผ',
labelKey: 'config.schema.fields.imageFormat.label',
type: 'select',
options: [
{ value: 'grok_url', label: 'URL๏ผˆGrok ๅŽŸ็”Ÿ๏ผ‰', labelKey: 'config.schema.options.imageFormat.grokUrl' },
{ value: 'local_url', label: 'URL๏ผˆๆœฌๅœฐไปฃ็†๏ผ‰', labelKey: 'config.schema.options.imageFormat.localUrl', disabledWhen: 'no_app_url', disabledTip: '่ฏทๅ…ˆๅกซๅ†™ APP ่ฎฟ้—ฎๅœฐๅ€', disabledTipKey: 'config.disabledTip.appUrlRequired' },
{ value: 'grok_md', label: 'Markdown๏ผˆGrok ๅŽŸ็”Ÿ๏ผ‰', labelKey: 'config.schema.options.imageFormat.grokMarkdown' },
{ value: 'local_md', label: 'Markdown๏ผˆๆœฌๅœฐไปฃ็†๏ผ‰', labelKey: 'config.schema.options.imageFormat.localMarkdown', disabledWhen: 'no_app_url', disabledTip: '่ฏทๅ…ˆๅกซๅ†™ APP ่ฎฟ้—ฎๅœฐๅ€', disabledTipKey: 'config.disabledTip.appUrlRequired' },
{ value: 'base64', label: 'Base64๏ผˆๅ†…ๅตŒ๏ผ‰', labelKey: 'config.schema.options.imageFormat.base64' },
],
desc: 'grok_url / grok_md ้ป˜่ฎค็›ดๆŽฅ่ฟ”ๅ›ž Grok CDN ๅœฐๅ€๏ผ›local_* ๆจกๅผไผšๅ…ˆไธ‹่ฝฝๅˆฐๆœๅŠก็ซฏ๏ผŒๅ†้€š่ฟ‡ๆœฌๅœฐ URL ไปฃ็†ๅˆ†ๅ‘๏ผ›base64 ไผšไปฅๅ†…ๅตŒ Data URI ่ฟ”ๅ›žใ€‚ๅผ€ๅฏ Imagine Public ๅ›พ็‰‡ไปฃ็†ๅŽ๏ผŒWebSocket ่ฟ”ๅ›ž็š„ imagine-public ๅ›พ็‰‡ไนŸไผšๆœฌๅœฐไปฃ็†ใ€‚',
descKey: 'config.schema.fields.imageFormat.desc',
},
{
key: 'imagine_public_image_proxy',
label: 'Imagine Public ๅ›พ็‰‡ไปฃ็†',
labelKey: 'config.schema.fields.imaginePublicImageProxy.label',
type: 'bool',
desc: 'ๅผ€ๅฏๅŽๅฐ† WebSocket ่ฟ”ๅ›ž็š„ imagine-public ๅ›พ็‰‡ไธ‹่ฝฝๅˆฐๆœๅŠก็ซฏ๏ผŒๅ†้€š่ฟ‡ๆœฌๅœฐ URL ไปฃ็†ๅˆ†ๅ‘๏ผ›ๅ…ณ้—ญๆ—ถไฟๆŒๅ…ฌๅผ€ๅ›พ็‰‡็›ด่ฟ”ใ€‚',
descKey: 'config.schema.fields.imaginePublicImageProxy.desc',
},
{
key: 'video_format',
label: '่ง†้ข‘่ฟ”ๅ›žๆ ผๅผ',
labelKey: 'config.schema.fields.videoFormat.label',
type: 'select',
options: [
{ value: 'grok_url', label: 'URL๏ผˆGrok ๅŽŸ็”Ÿ๏ผ‰', labelKey: 'config.schema.options.videoFormat.grokUrl' },
{ value: 'local_url', label: 'URL๏ผˆๆœฌๅœฐไปฃ็†๏ผ‰', labelKey: 'config.schema.options.videoFormat.localUrl', disabledWhen: 'no_app_url', disabledTip: '่ฏทๅ…ˆๅกซๅ†™ APP ่ฎฟ้—ฎๅœฐๅ€', disabledTipKey: 'config.disabledTip.appUrlRequired' },
{ value: 'grok_html', label: 'HTML๏ผˆGrok ๅŽŸ็”Ÿ๏ผ‰', labelKey: 'config.schema.options.videoFormat.grokHtml' },
{ value: 'local_html', label: 'HTML๏ผˆๆœฌๅœฐไปฃ็†๏ผ‰', labelKey: 'config.schema.options.videoFormat.localHtml', disabledWhen: 'no_app_url', disabledTip: '่ฏทๅ…ˆๅกซๅ†™ APP ่ฎฟ้—ฎๅœฐๅ€', disabledTipKey: 'config.disabledTip.appUrlRequired' },
],
desc: 'local_* ๆจกๅผไผšๅ…ˆๅฐ†่ง†้ข‘ไธ‹่ฝฝ่‡ณๆœๅŠก็ซฏ๏ผŒๅ†้€š่ฟ‡ๆœฌๅœฐ URL ไปฃ็†ๅˆ†ๅ‘๏ผ›่ฏท็กฎไฟๅทฒ้…็ฝฎ APP ่ฎฟ้—ฎๅœฐๅ€๏ผŒๅนถ่ฏ„ไผฐๅ‡บ็ซ™ๅธฆๅฎฝๆถˆ่€—ใ€‚',
descKey: 'config.schema.fields.videoFormat.desc',
},
]
},
]
},
{
id: 'schedule', label: 'ไปปๅŠก่ฐƒๅบฆ', labelKey: 'config.schema.tabs.schedule',
groups: [
{
title: '้…้ขๅˆทๆ–ฐ่ฐƒๅบฆ', titleKey: 'config.schema.groups.refreshSchedule',
section: 'account.refresh',
fields: [
{ key: 'enabled', label: 'ๅฏ็”จ้…้ขๅˆทๆ–ฐ', labelKey: 'config.schema.fields.refreshEnabled.label', type: 'bool', desc: 'ๅผ€ๅฏๅŽ่‡ชๅŠจ่ฟ›ๅ…ฅ้…้ขๅˆทๆ–ฐๆจกๅผ๏ผŒๅ…ณ้—ญๅŽ่‡ชๅŠจ่ฟ›ๅ…ฅ่‡ชๅŠจ้‡่ฏ•ๆจกๅผใ€‚', descKey: 'config.schema.fields.refreshEnabled.desc', help: 'ๅผ€ๅฏ๏ผš้…้ขๅˆทๆ–ฐๆจกๅผ๏ผŒscheduler ๅ‘จๆœŸๅŒๆญฅ็œŸๅฎž้…้ข๏ผŒ้€‰ๅทๆŒ‰่ฏ„ๅˆ†ใ€‚\nๅ…ณ้—ญ๏ผš่‡ชๅŠจ้‡่ฏ•ๆจกๅผ๏ผŒไธไธปๅŠจๆŽขๆต‹ upstream๏ผŒ้€‰ๅท้šๆœบ๏ผŒๅ‡บ้”™่‡ชๅŠจๆขๅท้‡่ฏ•ๆœ€ๅคš 5 ๆฌกใ€‚\nๅปบ่ฎฎ๏ผšไธ‡็บงไปฅไธŠ่ดฆๅทๅ…ณ้—ญ๏ผŒ้ฟๅ…ไธปๅŠจๆŽขๆต‹่งฆๅ‘ upstream 429ใ€‚', helpKey: 'config.schema.fields.refreshEnabled.help' },
{ key: 'basic_interval_sec', label: 'Basic ๅ‘จๆœŸ๏ผˆ็ง’๏ผ‰', labelKey: 'config.schema.fields.basicInterval.label', type: 'number', desc: 'basic ๅทๆฑ ๅ…ฑ็”จๅ‘จๆœŸ๏ผšquota ๆจกๅผไธ‹็”จไบŽๅŽๅฐๅˆทๆ–ฐ๏ผŒrandom ๆจกๅผไธ‹็”จไบŽ 429 ๅ†ทๅดใ€‚้ป˜่ฎค 86400sใ€‚', descKey: 'config.schema.fields.basicInterval.desc' },
{ key: 'super_interval_sec', label: 'Super ๅ‘จๆœŸ๏ผˆ็ง’๏ผ‰', labelKey: 'config.schema.fields.superInterval.label', type: 'number', desc: 'super ๅทๆฑ ๅ…ฑ็”จๅ‘จๆœŸ๏ผšquota ๆจกๅผไธ‹็”จไบŽๅŽๅฐๅˆทๆ–ฐ๏ผŒrandom ๆจกๅผไธ‹็”จไบŽ 429 ๅ†ทๅดใ€‚้ป˜่ฎค 7200sใ€‚', descKey: 'config.schema.fields.superInterval.desc' },
{ key: 'heavy_interval_sec', label: 'Heavy ๅ‘จๆœŸ๏ผˆ็ง’๏ผ‰', labelKey: 'config.schema.fields.heavyInterval.label', type: 'number', desc: 'heavy ๅทๆฑ ๅ…ฑ็”จๅ‘จๆœŸ๏ผšquota ๆจกๅผไธ‹็”จไบŽๅŽๅฐๅˆทๆ–ฐ๏ผŒrandom ๆจกๅผไธ‹็”จไบŽ 429 ๅ†ทๅดใ€‚้ป˜่ฎค 7200sใ€‚', descKey: 'config.schema.fields.heavyInterval.desc' },
{ key: 'on_demand_min_interval_sec', label: 'ๆŒ‰้œ€ๅˆทๆ–ฐ้—ด้š”๏ผˆ็ง’๏ผ‰', labelKey: 'config.schema.fields.onDemandMinInterval.label', type: 'number', desc: '่ฏทๆฑ‚้“พ่ทฏ่งฆๅ‘ๅˆทๆ–ฐ๏ผˆไพ‹ๅฆ‚ๆ”ถๅˆฐ 429๏ผ‰ๆ—ถ็š„่Š‚ๆต้—ด้š”ใ€‚N ็ง’ๅ†…้‡ๅค่งฆๅ‘ๅชๆ‰ง่กŒไธ€ๆฌก๏ผŒ้ฟๅ…ๆ‰น้‡ๆ‰“็ˆ†้…้ขๆŽฅๅฃใ€‚', descKey: 'config.schema.fields.onDemandMinInterval.desc' },
{ key: 'usage_concurrency', label: 'ๅˆทๆ–ฐๅนถๅ‘ๆ•ฐ', labelKey: 'config.schema.fields.usageConcurrency.label', type: 'number', desc: 'ๅนถๅ‘่ฐƒ็”จ usage ๆŽฅๅฃๅˆทๆ–ฐ้ขๅบฆๆ—ถๅ…่ฎธ็š„ๆœ€ๅคงๅนถ่กŒๆ•ฐใ€‚', descKey: 'config.schema.fields.usageConcurrency.desc' },
]
},
{
title: '้€‰ๅทๅนถๅ‘้™ๅˆถ', titleKey: 'config.schema.groups.selection',
section: 'account.selection',
fields: [
{ key: 'max_inflight', label: 'ๅ•ๅทๅนถๅ‘ไธŠ้™', labelKey: 'config.schema.fields.maxInflight.label', type: 'number', desc: 'ๅ•ไธช่ดฆๅทๅŒๆ—ถๅœจๆ‰ง่กŒ็š„่ฏทๆฑ‚ไธŠ้™๏ผŒ่ถ…่ฟ‡ๅˆ™้€‰ๅท่ทฏๅพ„่ทณ่ฟ‡่ฏฅๅท๏ผˆไธค็งๆจกๅผๅ…ฑ็”จ๏ผ‰ใ€‚', descKey: 'config.schema.fields.maxInflight.desc' },
]
},
{
title: 'ๆ‰น้‡ๆ“ไฝœๅนถๅ‘', titleKey: 'config.schema.groups.batchConcurrency',
section: 'batch',
fields: [
{ key: 'nsfw_concurrency', label: 'ๅผ€ๅฏ NSFW ๅนถๅ‘ๆ•ฐ', labelKey: 'config.schema.fields.nsfwConcurrency.label', type: 'number', desc: 'ๆ‰น้‡ๅผ€ๅฏ NSFW ๆ—ถ็š„ token ็บงๅนถๅ‘ๆ•ฐ๏ผ›ๆฏไธช token ้œ€็ปๅކไธ‰ๆญฅ็ฝ‘็ปœ่ฏทๆฑ‚ใ€‚', descKey: 'config.schema.fields.nsfwConcurrency.desc' },
{ key: 'refresh_concurrency', label: 'ๅˆทๆ–ฐ Usage ๅนถๅ‘ๆ•ฐ', labelKey: 'config.schema.fields.refreshConcurrency.label', type: 'number', desc: 'ๆ‰น้‡ๅˆทๆ–ฐ่ดฆๆˆท้ขๅบฆๆ—ถ็š„ token ็บงๅนถๅ‘ๆ•ฐใ€‚', descKey: 'config.schema.fields.refreshConcurrency.desc' },
{ key: 'asset_upload_concurrency', label: 'ไธŠไผ  Asset ๅนถๅ‘ๆ•ฐ', labelKey: 'config.schema.fields.assetUploadConcurrency.label', type: 'number', desc: 'API ๆŽฅๆ”ถๆ–‡ไปถ้™„ไปถๅŽ่งฆๅ‘็š„ Asset ไธŠไผ ไปปๅŠก๏ผŒๅ…จๅฑ€ๆœ€ๅคงๅนถๅ‘ๆ•ฐ๏ผŒ่ทจๆ‰€ๆœ‰่ฏทๆฑ‚ๅ…ฑไบซใ€‚', descKey: 'config.schema.fields.assetUploadConcurrency.desc' },
{ key: 'asset_list_concurrency', label: 'ๆŸฅ่ฏข Asset ๅนถๅ‘ๆ•ฐ', labelKey: 'config.schema.fields.assetListConcurrency.label', type: 'number', desc: 'list_assets ๆŽฅๅฃ็š„ๅ…จๅฑ€ๆœ€ๅคงๅนถๅ‘ๆ•ฐ๏ผŒ่ทจๆ‰€ๆœ‰่ฏทๆฑ‚ๅ…ฑไบซใ€‚', descKey: 'config.schema.fields.assetListConcurrency.desc' },
{ key: 'asset_delete_concurrency', label: 'ๅˆ ้™ค Asset ๅนถๅ‘ๆ•ฐ', labelKey: 'config.schema.fields.assetDeleteConcurrency.label', type: 'number', desc: 'delete_asset ๆŽฅๅฃ็š„ๅ…จๅฑ€ๆœ€ๅคงๅนถๅ‘ๆ•ฐ๏ผŒๅŒๆ—ถไฝœไธบ็ฎก็†ๅŽๅฐๆ‰น้‡ๆธ…็†็š„ token ็บง้ป˜่ฎคๅนถๅ‘ๅ€ผใ€‚', descKey: 'config.schema.fields.assetDeleteConcurrency.desc' },
]
},
]
},
{
id: 'proxy', label: '็ฝ‘็ปœไปฃ็†', labelKey: 'config.schema.tabs.proxy',
groups: [
{
title: 'ๅ‡บ็ซ™ไปฃ็†่ฎพ็ฝฎ', titleKey: 'config.schema.groups.egressProxy',
section: 'proxy.egress',
fields: [
{
key: 'mode',
label: 'ไปฃ็†ๆจกๅผ',
labelKey: 'config.schema.fields.proxyMode.label',
type: 'select',
options: [
{ value: 'direct', label: '็›ด่ฟž', labelKey: 'config.schema.options.proxyMode.direct' },
{ value: 'single_proxy', label: 'ไปฃ็†๏ผˆๅ›บๅฎš๏ผ‰', labelKey: 'config.schema.options.proxyMode.singleProxy' },
{ value: 'proxy_pool', label: 'ไปฃ็†๏ผˆ่ฝฎ่ฝฌ๏ผ‰', labelKey: 'config.schema.options.proxyMode.proxyPool' },
],
desc: 'ๆŽงๅˆถๅ‡บ็ซ™ๆต้‡ไปฃ็†็ญ–็•ฅใ€‚direct ไธบ็›ด่ฟž๏ผ›single_proxy ไฝฟ็”จๅ›บๅฎšไปฃ็†๏ผ›proxy_pool ๅœจไปฃ็†ๆฑ ไธญ่ฝฎ่ฝฌใ€‚',
descKey: 'config.schema.fields.proxyMode.desc',
},
{
key: 'proxy_url',
label: 'ๅŸบ็ก€ไปฃ็† URL',
labelKey: 'config.schema.fields.proxyUrl.label',
type: 'text',
wide: true,
desc: 'API ๆต้‡็š„ๅ‡บ็ซ™ไปฃ็†ๅœฐๅ€๏ผŒๆ”ฏๆŒ httpใ€httpsใ€socks5 ๅ่ฎฎใ€‚',
descKey: 'config.schema.fields.proxyUrl.desc',
showIf: { path: 'proxy.egress.mode', values: ['single_proxy'] },
},
{
key: 'resource_proxy_url',
label: '่ต„ๆบไปฃ็† URL',
labelKey: 'config.schema.fields.resourceProxyUrl.label',
type: 'text',
wide: true,
desc: 'ๅ›พ็‰‡ใ€่ง†้ข‘็ญ‰ๅช’ไฝ“่ต„ๆบไธ‹่ฝฝ็š„ไธ“็”จไปฃ็†ๅœฐๅ€๏ผ›็•™็ฉบๆ—ถๅ›ž่ฝ่‡ณๅŸบ็ก€ไปฃ็†ใ€‚',
descKey: 'config.schema.fields.resourceProxyUrl.desc',
showIf: { path: 'proxy.egress.mode', values: ['single_proxy'] },
},
{
key: 'proxy_pool',
label: 'ๅŸบ็ก€ไปฃ็†ๆฑ ',
labelKey: 'config.schema.fields.proxyPool.label',
type: 'textarea',
desc: 'API ๆต้‡ไปฃ็†ๆฑ ๏ผŒๆฏ่กŒไธ€ไธชไปฃ็† URL๏ผŒ่ฏทๆฑ‚ๆ—ถ่ฝฎ่ฝฌไฝฟ็”จใ€‚',
descKey: 'config.schema.fields.proxyPool.desc',
showIf: { path: 'proxy.egress.mode', values: ['proxy_pool'] },
},
{
key: 'resource_proxy_pool',
label: '่ต„ๆบไปฃ็†ๆฑ ',
labelKey: 'config.schema.fields.resourceProxyPool.label',
type: 'textarea',
desc: 'ๅช’ไฝ“่ต„ๆบไธ‹่ฝฝไธ“็”จไปฃ็†ๆฑ ๏ผŒๆฏ่กŒไธ€ไธชไปฃ็† URL๏ผ›็•™็ฉบๆ—ถๅ›ž่ฝ่‡ณๅŸบ็ก€ไปฃ็†ๆฑ ใ€‚',
descKey: 'config.schema.fields.resourceProxyPool.desc',
showIf: { path: 'proxy.egress.mode', values: ['proxy_pool'] },
},
{
key: 'skip_ssl_verify',
label: '่ทณ่ฟ‡ SSL ๆ ก้ชŒ',
labelKey: 'config.schema.fields.skipSslVerify.label',
type: 'bool',
desc: '่ทณ่ฟ‡ไปฃ็†ๆœๅŠกๅ™จ TLS ่ฏไนฆๆ ก้ชŒ๏ผŒไป…ๅœจไฝฟ็”จ่‡ช็ญพๅ่ฏไนฆ็š„ไปฃ็†ๆ—ถๅฏ็”จใ€‚',
descKey: 'config.schema.fields.skipSslVerify.desc',
showIf: { path: 'proxy.egress.mode', values: ['single_proxy', 'proxy_pool'] },
},
]
},
{
title: 'Cloudflare Clearance', titleKey: 'config.schema.groups.clearance',
section: 'proxy.clearance',
fields: [
{
key: 'mode',
label: 'Clearance ๆ กๅ‡†',
labelKey: 'config.schema.fields.clearanceMode.label',
type: 'select',
options: [
{ value: 'none', label: 'ๅ…ณ้—ญ', labelKey: 'config.schema.options.clearanceMode.none' },
{ value: 'manual', label: 'ๆ‰‹ๅŠจ', labelKey: 'config.schema.options.clearanceMode.manual' },
{ value: 'flaresolverr', label: 'FlareSolverr', labelKey: 'config.schema.options.clearanceMode.flaresolverr' },
],
desc: 'ๆŽงๅˆถ Cloudflare Clearance ็š„่Žทๅ–ๆ–นๅผใ€‚',
descKey: 'config.schema.fields.clearanceMode.desc',
},
{
key: 'browser',
label: 'ๆต่งˆๅ™จๆŒ‡็บน',
labelKey: 'config.schema.fields.browserFingerprint.label',
type: 'text',
desc: 'curl_cffi ๆต่งˆๅ™จๆŒ‡็บนๆ ‡่ฏ†๏ผŒ้œ€ไธŽ Cloudflare ๆœŸๆœ›็š„ JA3 ๆŒ‡็บนๅŒน้…๏ผŒไพ‹ๅฆ‚ chrome136ใ€‚',
descKey: 'config.schema.fields.browserFingerprint.desc',
showIf: { path: 'proxy.clearance.mode', values: ['manual', 'flaresolverr'] },
},
{
key: 'user_agent',
label: 'User-Agent',
labelKey: 'config.schema.fields.userAgent.label',
type: 'text',
wide: true,
desc: '่ฏทๆฑ‚ๅคดไธญ็š„ User-Agent๏ผŒ้œ€ไธŽ Cloudflare ๆ ก้ชŒ Cookie ๆ—ถๆ‰€ไฝฟ็”จ็š„ๆต่งˆๅ™จไฟๆŒไธ€่‡ดใ€‚',
descKey: 'config.schema.fields.userAgent.desc',
showIf: { path: 'proxy.clearance.mode', values: ['manual'] },
},
{
key: 'cf_cookies',
label: 'Cloudflare Cookies',
labelKey: 'config.schema.fields.cfCookies.label',
type: 'text',
wide: true,
desc: 'Cloudflare ไธ‹ๅ‘็š„ๅฎŒๆ•ด Cookie ๅญ—็ฌฆไธฒ๏ผŒๅบ”ๅŒ…ๅซ cf_clearance ็ญ‰ๅญ—ๆฎตใ€‚',
descKey: 'config.schema.fields.cfCookies.desc',
showIf: { path: 'proxy.clearance.mode', values: ['manual'] },
},
{
key: 'flaresolverr_url',
label: 'FlareSolverr URL',
labelKey: 'config.schema.fields.flaresolverrUrl.label',
type: 'text',
wide: true,
desc: 'FlareSolverr ๅฎžไพ‹็š„ HTTP ๆœๅŠกๅœฐๅ€ใ€‚',
descKey: 'config.schema.fields.flaresolverrUrl.desc',
showIf: { path: 'proxy.clearance.mode', values: ['flaresolverr'] },
},
{
key: 'timeout_sec',
label: 'ๆŒ‘ๆˆ˜่ถ…ๆ—ถ๏ผˆ็ง’๏ผ‰',
labelKey: 'config.schema.fields.challengeTimeout.label',
type: 'number',
desc: 'FlareSolverr ๅฎŒๆˆ Cloudflare ๆŒ‘ๆˆ˜ๅ…่ฎธ็š„ๆœ€้•ฟ็ญ‰ๅพ…ๆ—ถ้•ฟใ€‚',
descKey: 'config.schema.fields.challengeTimeout.desc',
showIf: { path: 'proxy.clearance.mode', values: ['flaresolverr'] },
},
{
key: 'refresh_interval',
label: 'ๅˆทๆ–ฐ้—ด้š”๏ผˆ็ง’๏ผ‰',
labelKey: 'config.schema.fields.refreshInterval.label',
type: 'number',
desc: 'Clearance Cookie ้ข„็ƒญๅˆทๆ–ฐไปปๅŠก็š„่ฐƒๅบฆ้—ด้š”ใ€‚',
descKey: 'config.schema.fields.refreshInterval.desc',
showIf: { path: 'proxy.clearance.mode', values: ['flaresolverr'] },
},
]
},
{
title: '้‡่ฏ•็ญ–็•ฅ', titleKey: 'config.schema.groups.retry',
section: 'retry',
fields: [
{ key: 'max_retries', label: 'ๅบ”็”จๅฑ‚้‡่ฏ•ไธŠ้™', labelKey: 'config.schema.fields.maxRetries.label', type: 'number', desc: '่ฏทๆฑ‚ๅคฑ่ดฅๅŽ็š„ๅบ”็”จๅฑ‚ๆœ€ๅคง้‡่ฏ•ๆฌกๆ•ฐ๏ผ›0 ่กจ็คบไธ้‡่ฏ•ใ€‚้€‚็”จไบŽ chat.completions ไธŽ responses ็ซฏ็‚นใ€‚', descKey: 'config.schema.fields.maxRetries.desc' },
{ key: 'on_codes', label: 'ๅบ”็”จๅฑ‚้‡่ฏ•่งฆๅ‘็Šถๆ€็ ', labelKey: 'config.schema.fields.retryCodes.label', type: 'text', desc: 'ๆ”ถๅˆฐๆŒ‡ๅฎš HTTP ็Šถๆ€็ ๆ—ถ่งฆๅ‘ๅบ”็”จๅฑ‚้‡่ฏ•๏ผŒๅคšไธชๅ€ผ่ฏทไฝฟ็”จ่‹ฑๆ–‡้€—ๅทๅˆ†้š”๏ผŒไพ‹ๅฆ‚ 429,503ใ€‚', descKey: 'config.schema.fields.retryCodes.desc' },
{ key: 'reset_session_status_codes', label: 'ไผ ่พ“ๅฑ‚ไผš่ฏ้‡ๅปบ็Šถๆ€็ ', labelKey: 'config.schema.fields.resetSessionCodes.label', type: 'text', desc: 'ๆ”ถๅˆฐๆŒ‡ๅฎš HTTP ็Šถๆ€็ ๆ—ถ่‡ชๅŠจ้‡ๅปบไผ ่พ“ๅฑ‚ไปฃ็†ไผš่ฏ๏ผˆTransport Session๏ผ‰๏ผŒๅคšไธชๅ€ผ่ฏทไฝฟ็”จ่‹ฑๆ–‡้€—ๅทๅˆ†้š”ใ€‚', descKey: 'config.schema.fields.resetSessionCodes.desc' },
]
},
{
title: '่ฏทๆฑ‚่ถ…ๆ—ถ่ฎพ็ฝฎ', titleKey: 'config.schema.groups.timeouts',
section: 'chat',
fields: [
{ key: 'timeout', section: 'chat', label: 'ๅฏน่ฏ่ฏทๆฑ‚่ถ…ๆ—ถ๏ผˆ็ง’๏ผ‰', labelKey: 'config.schema.fields.chatTimeout.label', type: 'number', desc: 'ๅฏน่ฏไธŽๅ›พๅƒ็ผ–่พ‘่ฏทๆฑ‚็š„ HTTP ่ฟžๆŽฅ่ถ…ๆ—ถๆ—ถ้—ดใ€‚', descKey: 'config.schema.fields.chatTimeout.desc' },
{ key: 'timeout', section: 'image', label: 'ๅ›พๅƒ่ฏทๆฑ‚่ถ…ๆ—ถ๏ผˆ็ง’๏ผ‰', labelKey: 'config.schema.fields.imageTimeout.label', type: 'number', desc: 'ๅ›พๅƒ็”Ÿๆˆ WebSocket ่ฏทๆฑ‚็š„ๆ€ป่ถ…ๆ—ถๆ—ถ้—ดใ€‚', descKey: 'config.schema.fields.imageTimeout.desc' },
{ key: 'stream_timeout', section: 'image', label: 'ๅ›พๅƒๆตๅผ็ฉบ้—ฒ่ถ…ๆ—ถ๏ผˆ็ง’๏ผ‰', type: 'number', desc: 'ๅ›พๅƒ็”Ÿๆˆ WebSocket ๅœจๆตๅผๆŽฅๆ”ถ้˜ถๆฎตๅ…่ฎธ็š„ๆœ€้•ฟ่ฟž็ปญ็ฉบ้—ฒ็ญ‰ๅพ…ๆ—ถ้•ฟใ€‚' },
{ key: 'timeout', section: 'video', label: '่ง†้ข‘่ฏทๆฑ‚่ถ…ๆ—ถ๏ผˆ็ง’๏ผ‰', labelKey: 'config.schema.fields.videoTimeout.label', type: 'number', desc: '่ง†้ข‘็”Ÿๆˆ่ฏทๆฑ‚็š„็ปŸไธ€่ถ…ๆ—ถๆ—ถ้—ดใ€‚', descKey: 'config.schema.fields.videoTimeout.desc' },
{ key: 'timeout', section: 'voice', label: '่ฏญ้Ÿณ่ฏทๆฑ‚่ถ…ๆ—ถ๏ผˆ็ง’๏ผ‰', labelKey: 'config.schema.fields.voiceTimeout.label', type: 'number', desc: '่ฏญ้Ÿณ่ฏทๆฑ‚็š„็ปŸไธ€่ถ…ๆ—ถๆ—ถ้—ดใ€‚', descKey: 'config.schema.fields.voiceTimeout.desc' },
{ key: 'timeout', section: 'nsfw', label: 'NSFW ๅค„็†่ถ…ๆ—ถ๏ผˆ็ง’๏ผ‰', labelKey: 'config.schema.fields.nsfwTimeout.label', type: 'number', desc: 'set_birth_date ไธŽ enable_nsfw ๆŽฅๅฃ่ฐƒ็”จ็š„่ถ…ๆ—ถๆ—ถ้—ดใ€‚', descKey: 'config.schema.fields.nsfwTimeout.desc' },
{ key: 'upload_timeout', section: 'asset', label: '่ต„ไบงไธŠไผ ่ถ…ๆ—ถ๏ผˆ็ง’๏ผ‰', labelKey: 'config.schema.fields.assetUploadTimeout.label', type: 'number', desc: 'Asset ไธŠไผ ๆŽฅๅฃ็š„่ถ…ๆ—ถๆ—ถ้—ดใ€‚', descKey: 'config.schema.fields.assetUploadTimeout.desc' },
{ key: 'download_timeout', section: 'asset', label: '่ต„ไบงไธ‹่ฝฝ่ถ…ๆ—ถ๏ผˆ็ง’๏ผ‰', labelKey: 'config.schema.fields.assetDownloadTimeout.label', type: 'number', desc: 'Asset ไธ‹่ฝฝๆŽฅๅฃ็š„่ถ…ๆ—ถๆ—ถ้—ดใ€‚', descKey: 'config.schema.fields.assetDownloadTimeout.desc' },
{ key: 'list_timeout', section: 'asset', label: '่ต„ไบงๆŸฅ่ฏข่ถ…ๆ—ถ๏ผˆ็ง’๏ผ‰', labelKey: 'config.schema.fields.assetListTimeout.label', type: 'number', desc: 'Asset ๆŸฅ่ฏขๆŽฅๅฃ็š„่ถ…ๆ—ถๆ—ถ้—ดใ€‚', descKey: 'config.schema.fields.assetListTimeout.desc' },
{ key: 'delete_timeout', section: 'asset', label: '่ต„ไบงๅˆ ้™ค่ถ…ๆ—ถ๏ผˆ็ง’๏ผ‰', labelKey: 'config.schema.fields.assetDeleteTimeout.label', type: 'number', desc: 'Asset ๅˆ ้™คๆŽฅๅฃ็š„่ถ…ๆ—ถๆ—ถ้—ดใ€‚', descKey: 'config.schema.fields.assetDeleteTimeout.desc' },
]
},
]
},
{
id: 'cache', label: '็ผ“ๅญ˜ๅญ˜ๅ‚จ', labelKey: 'config.schema.tabs.cache',
groups: [
{
title: 'ๆœฌๅœฐๅช’ไฝ“็ผ“ๅญ˜', titleKey: 'config.schema.groups.localMediaCache',
section: 'cache.local',
fields: [
{
key: 'image_max_mb',
label: 'ๅ›พ็‰‡็ผ“ๅญ˜ไธŠ้™๏ผˆMB๏ผ‰',
labelKey: 'config.schema.fields.imageCacheMaxMb.label',
type: 'number',
desc: '0 ่กจ็คบไธ้™ๅˆถใ€‚ไป…ๅฏนๆœฌๅœฐไปฃ็†่ฝ็›˜็”Ÿๆ•ˆ๏ผ›่ถ…้™ๅŽๆŒ‰ๆœ€ๆ—งๆ–‡ไปถไผ˜ๅ…ˆๆธ…็†๏ผŒๅนถไธ€ๆฌกๆ€งๅ›ž่ฝๅˆฐไธŠ้™็š„ 60%ใ€‚',
descKey: 'config.schema.fields.imageCacheMaxMb.desc',
},
{
key: 'video_max_mb',
label: '่ง†้ข‘็ผ“ๅญ˜ไธŠ้™๏ผˆMB๏ผ‰',
labelKey: 'config.schema.fields.videoCacheMaxMb.label',
type: 'number',
desc: '0 ่กจ็คบไธ้™ๅˆถใ€‚ไป…ๅฏนๆœฌๅœฐไปฃ็†่ฝ็›˜็”Ÿๆ•ˆ๏ผ›่ถ…้™ๅŽๆŒ‰ๆœ€ๆ—งๆ–‡ไปถไผ˜ๅ…ˆๆธ…็†๏ผŒๅนถไธ€ๆฌกๆ€งๅ›ž่ฝๅˆฐไธŠ้™็š„ 60%ใ€‚',
descKey: 'config.schema.fields.videoCacheMaxMb.desc',
},
]
},
]
},
];
// โ”€โ”€ State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
let SCHEMA = [];
let _cfg = {}; // loaded config (nested)
let _dirty = {}; // { "section.key": newValue }
let _activeTab = SCHEMA_DEF[0].id;
let _configLoaded = false;
let _rowRefs = new Map();
let _tabButtons = new Map();
let _tabDirtyCounts = new Map();
let _tabFieldIndex = new Map();
let _conditionalRows = [];
let _selectRefs = [];
let _showIfDeps = new Map();
let _disabledSelectDeps = new Map();
function tr(key, params, fallback) {
if (!key) return fallback ?? '';
const value = t(key, params);
return value === key ? (fallback ?? key) : value;
}
function waitI18n() {
return new Promise((resolve) => I18n.onReady(resolve));
}
function _addDependency(depMap, path, target) {
if (!path || !target) return;
if (!depMap.has(path)) depMap.set(path, []);
depMap.get(path).push(target);
}
function _showIfPaths(showIf) {
if (!showIf) return [];
return [typeof showIf === 'string' ? showIf : showIf.path].filter(Boolean);
}
function _disabledWhenPaths(condition) {
if (condition === 'no_app_url') return ['app.app_url'];
return [];
}
function _collectDependentTargets(paths, depMap) {
if (!Array.isArray(paths) || !paths.length) return [];
const seen = new Set();
const items = [];
paths.forEach((path) => {
(depMap.get(path) || []).forEach((target) => {
if (seen.has(target)) return;
seen.add(target);
items.push(target);
});
});
return items;
}
function _syncTabDirtyState(tabId) {
const btn = _tabButtons.get(tabId);
if (!btn) return;
btn.classList.toggle('dirty', (_tabDirtyCounts.get(tabId) || 0) > 0);
}
function _updateDirtyCount(fp, delta) {
const tabId = _tabFieldIndex.get(fp);
if (!tabId || !delta) return;
const next = Math.max(0, (_tabDirtyCounts.get(tabId) || 0) + delta);
_tabDirtyCounts.set(tabId, next);
_syncTabDirtyState(tabId);
}
function _resetRenderCaches() {
_rowRefs = new Map();
_tabButtons = new Map();
_tabDirtyCounts = new Map();
_tabFieldIndex = new Map();
_conditionalRows = [];
_selectRefs = [];
_showIfDeps = new Map();
_disabledSelectDeps = new Map();
}
function _localizeOption(opt) {
if (typeof opt !== 'object') return opt;
return {
...opt,
label: tr(opt.labelKey, null, opt.label ?? opt.value),
...(opt.disabledTipKey ? { disabledTip: tr(opt.disabledTipKey, null, opt.disabledTip ?? '') } : {}),
};
}
function _localizeField(field) {
return {
...field,
label: field.labelKey ? tr(field.labelKey, null, field.label ?? field.key) : (field.label ?? field.key),
desc: field.descKey ? tr(field.descKey, null, field.desc ?? '') : (field.desc ?? ''),
help: field.helpKey ? tr(field.helpKey, null, field.help ?? '') : (field.help ?? ''),
options: field.options ? field.options.map(_localizeOption) : field.options,
};
}
function buildSchema() {
return SCHEMA_DEF.map((tab) => ({
...tab,
label: tr(tab.labelKey, null, tab.label ?? tab.id),
groups: tab.groups.map((group) => ({
...group,
title: group.titleKey ? tr(group.titleKey, null, group.title ?? '') : (group.title ?? ''),
fields: group.fields.map(_localizeField),
})),
}));
}
function applyConfigI18n() {
document.title = tr('config.pageTitle', null, 'Grok2API - ้…็ฝฎ็ฎก็†');
SCHEMA = buildSchema();
if (!_configLoaded) return;
renderAll();
_refreshDirtyState({ autoNormalizeSelects: false });
}
// โ”€โ”€ API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function _api(method, path, body) {
const key = await adminKey.get();
const r = await fetch(ADMIN_API + path, {
method,
headers: { ...(body != null && { 'Content-Type': 'application/json' }), Authorization: `Bearer ${key}` },
...(body != null && { body: JSON.stringify(body) }),
});
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || r.status); }
return r.json();
}
// โ”€โ”€ Schema helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// Navigate a nested object by dotted path. Returns undefined if any segment is missing.
function _getByPath(obj, dottedPath) {
return dottedPath.split('.').reduce((node, k) =>
(node != null && typeof node === 'object') ? node[k] : undefined, obj);
}
// Set a value at a dotted path, creating intermediate objects as needed.
function _setByPath(obj, dottedPath, value) {
const keys = dottedPath.split('.');
let cur = obj;
for (let i = 0; i < keys.length - 1; i++) {
if (cur[keys[i]] == null || typeof cur[keys[i]] !== 'object') cur[keys[i]] = {};
cur = cur[keys[i]];
}
cur[keys[keys.length - 1]] = value;
}
// Evaluate a disabledWhen condition on a select option.
// Currently supported: 'no_app_url' โ€” true when app.app_url is empty.
function _evalDisabledWhen(condition) {
if (condition === 'no_app_url') {
const v = 'app.app_url' in _dirty ? _dirty['app.app_url'] : _getByPath(_cfg, 'app.app_url');
return !v || String(v).trim() === '';
}
return false;
}
// Evaluate a showIf condition.
// String form: truthy check on config path. "app.webui_enabled"
// Object form: value-in-list check. { path: "proxy.egress.mode", values: ["single_proxy"] }
function _evalShowIf(showIf) {
if (!showIf) return true;
const path = typeof showIf === 'string' ? showIf : showIf.path;
const cur = path in _dirty ? _dirty[path] : _getByPath(_cfg, path);
if (typeof showIf === 'string') return !!cur;
return showIf.values.includes(cur);
}
// Full dotted config path for a field: "proxy.egress.mode"
function _fullPath(section, key) { return `${section}.${key}`; }
// Get the current value of a field from loaded config.
function _getValue(section, key) { return _getByPath(_cfg, _fullPath(section, key)); }
function _getCurrentValue(section, key) {
const fp = _fullPath(section, key);
return fp in _dirty ? _dirty[fp] : _getValue(section, key);
}
// โ”€โ”€ Render โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function _el(tag, attrs, ...children) {
const el = document.createElement(tag);
Object.entries(attrs || {}).forEach(([k, v]) => {
if (k === 'class') el.className = v;
else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else el.setAttribute(k, v);
});
children.flat().forEach(c => {
if (c == null) return;
el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
return el;
}
let _helpTipEl = null;
let _activeHelpButton = null;
function _hideHelpTip() {
if (_activeHelpButton) _activeHelpButton.removeAttribute('aria-describedby');
if (_helpTipEl) _helpTipEl.remove();
_helpTipEl = null;
_activeHelpButton = null;
}
function _positionHelpTip(button, tip) {
const margin = 12;
const rect = button.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const maxWidth = Math.max(120, viewportWidth - margin * 2);
tip.style.maxWidth = `${Math.min(320, maxWidth)}px`;
tip.style.left = '0px';
tip.style.top = '0px';
const tipRect = tip.getBoundingClientRect();
let left = rect.left + rect.width / 2 - tipRect.width / 2;
left = Math.max(margin, Math.min(left, viewportWidth - tipRect.width - margin));
let placement = 'top';
let top = rect.top - tipRect.height - 8;
if (top < margin) {
placement = 'bottom';
top = rect.bottom + 8;
}
if (top + tipRect.height > viewportHeight - margin) {
top = Math.max(margin, viewportHeight - tipRect.height - margin);
}
const arrowLeft = Math.max(12, Math.min(rect.left + rect.width / 2 - left, tipRect.width - 12));
tip.dataset.placement = placement;
tip.style.setProperty('--arrow-left', `${arrowLeft}px`);
tip.style.left = `${left}px`;
tip.style.top = `${top}px`;
}
function _showHelpTip(button) {
const text = button.getAttribute('data-tip');
if (!text) return;
_hideHelpTip();
const tip = _el('div', {
class: 'cfg-help-tip',
id: 'cfg-help-tip',
role: 'tooltip',
}, text);
document.body.appendChild(tip);
button.setAttribute('aria-describedby', 'cfg-help-tip');
_helpTipEl = tip;
_activeHelpButton = button;
_positionHelpTip(button, tip);
requestAnimationFrame(() => tip.classList.add('visible'));
}
window.addEventListener('resize', _hideHelpTip);
window.addEventListener('scroll', _hideHelpTip, true);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') _hideHelpTip();
});
document.addEventListener('click', (event) => {
if (!event.target.closest?.('.cfg-help')) _hideHelpTip();
});
function renderInput(field, section, value) {
const fp = _fullPath(section, field.key);
const onChange = (v) => {
const wasDirty = fp in _dirty;
const orig = _getValue(section, field.key);
const same = String(orig) === String(v) || orig === v;
if (same) delete _dirty[fp]; else _dirty[fp] = v;
const isDirty = fp in _dirty;
if (wasDirty !== isDirty) _updateDirtyCount(fp, isDirty ? 1 : -1);
_refreshRow(section, field.key);
_refreshDirtyState({ changedPaths: [fp] });
};
if (field.type === 'bool') {
const chk = document.createElement('input');
chk.type = 'checkbox';
chk.checked = Boolean(value);
chk.id = `field-${section}-${field.key}`;
chk.addEventListener('change', () => onChange(chk.checked));
const track = _el('span', { class: 'toggle-track' });
const label = _el('label', { class: 'toggle', for: chk.id }, chk, track);
return label;
}
if (field.type === 'select') {
const sel = _el('select', { class: 'cfg-select', id: `field-${section}-${field.key}` });
const deps = new Set();
(field.options || []).forEach(opt => {
const o = document.createElement('option');
o.value = (typeof opt === 'object') ? opt.value : opt;
o.textContent = (typeof opt === 'object') ? opt.label : opt;
if (o.value === value) o.selected = true;
if (typeof opt === 'object' && opt.disabledWhen) {
o.dataset.disabledWhen = opt.disabledWhen;
if (opt.disabledTip) o.dataset.disabledTip = opt.disabledTip;
const isDisabled = _evalDisabledWhen(opt.disabledWhen);
o.disabled = isDisabled;
if (isDisabled && opt.disabledTip) o.title = opt.disabledTip;
_disabledWhenPaths(opt.disabledWhen).forEach((path) => deps.add(path));
}
sel.appendChild(o);
});
if (deps.size) {
_selectRefs.push(sel);
deps.forEach((path) => _addDependency(_disabledSelectDeps, path, sel));
}
sel.addEventListener('change', () => onChange(sel.value));
return sel;
}
if (field.type === 'textarea') {
const ta = _el('textarea', { class: 'cfg-textarea', id: `field-${section}-${field.key}`,
rows: '3', placeholder: '' });
ta.value = value ?? '';
ta.addEventListener('input', () => onChange(ta.value));
return ta;
}
if (field.type === 'number') {
const inp = _el('input', { class: 'cfg-number', type: 'number',
id: `field-${section}-${field.key}`, value: value ?? '' });
inp.addEventListener('input', () => {
const n = inp.value === '' ? '' : Number(inp.value);
onChange(n);
});
return inp;
}
// text / password
const inp = _el('input', {
class: `cfg-text${field.wide ? ' wide' : ''}`,
type: field.type === 'password' ? 'password' : 'text',
id: `field-${section}-${field.key}`,
value: value ?? '',
autocomplete: 'off',
});
inp.addEventListener('input', () => onChange(inp.value));
if (field.rand) {
const btn = _el('button', { class: 'cfg-rand-btn', type: 'button', title: tr('config.randomize', null, '้šๆœบ็”Ÿๆˆ'), onclick: () => {
const uuid = crypto.randomUUID().replace(/-/g, '').slice(0, 24);
inp.value = uuid;
inp.type = 'text';
onChange(uuid);
}});
btn.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="2" y="2" width="20" height="20" rx="3"/><circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none"/><circle cx="16" cy="8" r="1.5" fill="currentColor" stroke="none"/><circle cx="8" cy="16" r="1.5" fill="currentColor" stroke="none"/><circle cx="16" cy="16" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/></svg>';
const wrap = _el('div', { style: 'display:flex;align-items:center;gap:6px;flex:1;min-width:0' }, inp, btn);
return wrap;
}
return inp;
}
function renderRow(field, section, tabId) {
const fp = _fullPath(section, field.key);
const value = _getValue(section, field.key);
const isDirty = fp in _dirty;
const dot = _el('span', { class: 'cfg-modified-dot' });
const input = renderInput(field, section, isDirty ? _dirty[fp] : value);
const labelChildren = [field.label];
if (section === 'app' && field.key === 'webui_enabled') {
const btn = _el('button', {
class: 'cfg-inline-action',
type: 'button',
title: 'ๆ‰“ๅผ€ WebUI',
'aria-label': 'ๆ‰“ๅผ€ WebUI',
'data-webui-link': 'true',
onclick: () => window.open('/webui/login', '_blank', 'noopener,noreferrer'),
});
btn.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="M14 5h5v5"/><path d="M10 14 19 5"/><path d="M19 14v5h-14v-14h5"/></svg>';
btn.disabled = !Boolean(_getCurrentValue(section, field.key));
labelChildren.push(btn);
}
if (field.help) {
labelChildren.push(_el('button', {
class: 'cfg-help',
type: 'button',
'aria-label': field.help,
'data-tip': field.help,
onmouseenter: (event) => _showHelpTip(event.currentTarget),
onmouseleave: _hideHelpTip,
onfocus: (event) => _showHelpTip(event.currentTarget),
onblur: _hideHelpTip,
onclick: (event) => {
event.preventDefault();
event.stopPropagation();
_showHelpTip(event.currentTarget);
},
}, '?'));
}
labelChildren.push(dot);
const labelCol = _el('div', { class: 'cfg-label-col' },
_el('div', { class: 'cfg-label' }, labelChildren),
field.desc ? _el('div', { class: 'cfg-desc' }, field.desc) : null,
);
const inputCol = _el('div', { class: `cfg-input-col${field.type === 'bool' ? ' is-bool' : ''}` }, input);
const row = _el('div', {
class: `cfg-row${isDirty ? ' modified' : ''}`,
'data-section': section,
'data-key': field.key,
}, labelCol, inputCol);
_rowRefs.set(fp, row);
_tabFieldIndex.set(fp, tabId);
if (isDirty) _tabDirtyCounts.set(tabId, (_tabDirtyCounts.get(tabId) || 0) + 1);
// Conditional visibility
if (field.showIf) {
row._showIfSpec = field.showIf;
row.style.display = _evalShowIf(field.showIf) ? '' : 'none';
_conditionalRows.push(row);
_showIfPaths(field.showIf).forEach((path) => _addDependency(_showIfDeps, path, row));
}
return row;
}
function renderGroup(group, tabId) {
const section = group.section;
const rows = group.fields.map(f => renderRow(f, f.section || section, tabId));
const titleEl = _el('div', { class: 'cfg-group-title' }, group.title || '');
const wrap = _el('div', { class: 'cfg-group' }, titleEl, ...rows);
return wrap;
}
function renderPanel(tab) {
const panel = _el('div', { class: `cfg-panel${tab.id === _activeTab ? ' active' : ''}`,
id: `panel-${tab.id}` });
tab.groups.forEach(g => panel.appendChild(renderGroup(g, tab.id)));
return panel;
}
function renderAll() {
const nav = document.getElementById('cfg-nav');
const panels = document.getElementById('cfg-panels');
_resetRenderCaches();
nav.innerHTML = ''; panels.innerHTML = '';
SCHEMA.forEach(tab => {
// Tab button
const btn = _el('button', {
class: `cfg-tab${tab.id === _activeTab ? ' active' : ''}`,
onclick: () => switchTab(tab.id),
'data-tab': tab.id,
}, tab.label, _el('span', { class: 'dot' }));
_tabButtons.set(tab.id, btn);
nav.appendChild(btn);
// Panel
panels.appendChild(renderPanel(tab));
});
_tabButtons.forEach((_, tabId) => _syncTabDirtyState(tabId));
}
function switchTab(id) {
_activeTab = id;
document.querySelectorAll('.cfg-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === id));
document.querySelectorAll('.cfg-panel').forEach(p =>
p.classList.toggle('active', p.id === `panel-${id}`));
}
// โ”€โ”€ Dirty state helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function _refreshRow(section, key) {
const row = _rowRefs.get(_fullPath(section, key));
if (row) row.classList.toggle('modified', _fullPath(section, key) in _dirty);
}
function _refreshConditionalRows(changedPaths = null) {
const rows = changedPaths ? _collectDependentTargets(changedPaths, _showIfDeps) : _conditionalRows;
rows.forEach((row) => {
row.style.display = _evalShowIf(row._showIfSpec) ? '' : 'none';
});
}
function _refreshSelectStates(changedPaths = null, { autoNormalizeSelects = true } = {}) {
const selects = changedPaths ? _collectDependentTargets(changedPaths, _disabledSelectDeps) : _selectRefs;
selects.forEach((sel) => {
let anyChanged = false;
[...sel.options].forEach(o => {
if (!o.dataset.disabledWhen) return;
const isDisabled = _evalDisabledWhen(o.dataset.disabledWhen);
o.disabled = isDisabled;
o.title = isDisabled ? (o.dataset.disabledTip || '') : '';
if (autoNormalizeSelects && isDisabled && o.selected) anyChanged = true;
});
if (anyChanged) {
const firstEnabled = [...sel.options].find(o => !o.disabled);
if (firstEnabled) {
firstEnabled.selected = true;
sel.dispatchEvent(new Event('change'));
}
}
});
}
function _refreshDirtyState({ autoNormalizeSelects = true, changedPaths = null } = {}) {
const n = Object.keys(_dirty).length;
document.getElementById('btn-save').disabled = n === 0;
document.getElementById('btn-reset').disabled = n === 0;
const bar = document.getElementById('save-bar');
bar.classList.toggle('visible', n > 0);
document.getElementById('save-bar-msg').textContent =
n === 0
? tr('config.unsavedDefault', null, 'ๅญ˜ๅœจๆœชไฟๅญ˜็š„ๆ›ดๆ”น')
: n === 1
? tr('config.unsavedSingle', { n }, 'ๅญ˜ๅœจ 1 ้กนๆœชไฟๅญ˜็š„ๆ›ดๆ”น')
: tr('config.unsavedMultiple', { n }, `ๅญ˜ๅœจ ${n} ้กนๆœชไฟๅญ˜็š„ๆ›ดๆ”น`);
const webuiLinkBtn = document.querySelector('[data-webui-link="true"]');
if (webuiLinkBtn) {
const enabled = Boolean(_getCurrentValue('app', 'webui_enabled'));
webuiLinkBtn.disabled = !enabled;
webuiLinkBtn.title = enabled ? 'ๆ‰“ๅผ€ WebUI' : 'ๅฏ็”จ WebUI ๅŽๅฏๆ‰“ๅผ€';
webuiLinkBtn.setAttribute('aria-label', webuiLinkBtn.title);
}
_refreshConditionalRows(changedPaths);
_refreshSelectStates(changedPaths, { autoNormalizeSelects });
if (!changedPaths) _tabButtons.forEach((_, tabId) => _syncTabDirtyState(tabId));
}
// โ”€โ”€ Load config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function load() {
try {
_cfg = await _api('GET', '/config');
_dirty = {};
_configLoaded = true;
if (!SCHEMA.length) SCHEMA = buildSchema();
renderAll();
_refreshDirtyState({ autoNormalizeSelects: false });
} catch (e) {
showToast(tr('config.loadFailed', { message: e.message }, `้…็ฝฎๅŠ ่ฝฝๅคฑ่ดฅ: ${e.message}`), 'error');
}
}
// โ”€โ”€ Save โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
async function doSave() {
if (!Object.keys(_dirty).length) return;
// Build a properly nested patch from full dotted paths.
// "proxy.egress.mode" โ†’ {proxy:{egress:{mode:v}}}
const patch = {};
Object.entries(_dirty).forEach(([fp, v]) => _setByPath(patch, fp, v));
try {
showToast(tr('config.saving', null, 'ๆญฃๅœจไฟๅญ˜ๆ›ดๆ”นโ€ฆ'), 'info');
await _api('POST', '/config', patch);
// Merge into local _cfg so re-render shows correct values.
Object.entries(_dirty).forEach(([fp, v]) => _setByPath(_cfg, fp, v));
_dirty = {};
// Re-render to clear modified state
renderAll();
_refreshDirtyState({ autoNormalizeSelects: false });
showToast(tr('config.saveSuccess', null, 'ๆ›ดๆ”นๅทฒไฟๅญ˜'), 'success');
} catch (e) {
showToast(tr('config.saveFailed', { message: e.message }, `ไฟๅญ˜ๆ›ดๆ”นๅคฑ่ดฅ: ${e.message}`), 'error');
}
}
// โ”€โ”€ Reset โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function doReset() {
if (!Object.keys(_dirty).length) return;
_dirty = {};
renderAll();
_refreshDirtyState({ autoNormalizeSelects: false });
showToast(tr('config.resetSuccess', null, 'ๆœชไฟๅญ˜็š„ๆ›ดๆ”นๅทฒๆ’ค้”€'), 'info');
}
// โ”€โ”€ Boot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
(async () => {
try {
SCHEMA = buildSchema();
waitI18n().then(() => {
applyConfigI18n();
}).catch((e) => {
console.error('i18n init failed', e);
});
await renderAdminHeader?.();
await renderSiteFooter?.();
const key = await adminKey.get();
if (!key || !await verifyKey(ADMIN_API + '/verify', key).catch(() => false))
return location.href = '/admin/login';
await load();
} catch (e) {
console.error('config page boot failed', e);
showToast(tr('config.loadFailed', { message: e.message }, `้…็ฝฎ้กตๅˆๅง‹ๅŒ–ๅคฑ่ดฅ: ${e.message}`), 'error');
}
})();
</script>
</body>
</html>