feat(config): support CLIProxyAPI v8.0.10 template settings

- Add structured editors for request, OAuth, plugin, and concurrency settings
- Expand advanced configuration for all eight provider families and credentials
- Preserve YAML comments, secrets, unknown fields, and explicit overrides
- Add validation, conflict detection, default restoration, and restart notices
- Improve responsive layouts and localize configuration controls
- Fix provider health checks and extend mocks and integration tests
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent 4d1afc82ef
commit c89e2fb020
46 files changed
+3839 -235

No files matched your search

+10
View File
@@ -6,6 +6,8 @@ import { managementApi } from '../services/managementApi';
import { loadAuthFileSettings, saveAuthFileSettings, type AuthFileSettingsDraft, type BooleanOverride } from '../services/authFileSettings';
import { modelMatchesRule, normalizeOAuthExcludedRules, oauthModelCandidates, oauthModelsFromPayload, setOAuthModelsExcluded, type OAuthModelDefinition } from '../services/oauthModels';
import './AuthFileSettingsDialog.css';
import { StructuredConfigEditor, useConfigText } from './StructuredConfigEditor';
import { credentialAdvancedShape } from '../services/credentialAdvancedSettings';
export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
name: string;
@@ -13,6 +15,7 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
onSaved: () => void;
}) {
const { t } = useI18n();
const tx = useConfigText();
const id = useId();
const dialogRef = useRef<HTMLDialogElement>(null);
const savingRef = useRef(false);
@@ -166,6 +169,13 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
</div>
</details>
</section>
<section className="credential-settings-section">
<details>
<summary>{tx({ zh: '模型别名与 Claude 兼容设置', en: 'Model aliases and Claude compatibility', ja: 'モデル別名と Claude 互換性' })}</summary>
<p><small>{tx({ zh: '这里的别名仅作用于此凭据,优先于全局别名。伪装与请求指纹适用于 Claude 及兼容委派凭据;未设置的字段继续使用默认行为。', en: 'Aliases apply only to this credential and take precedence over global aliases. Cloaking and fingerprints apply to Claude and compatible delegated credentials. Unset fields retain default behavior.', ja: '別名はこの認証情報にのみ適用され、全体の別名より優先されます。偽装とフィンガープリントは Claude と互換の委譲認証情報に適用します。' })}</small></p>
<StructuredConfigEditor shape={credentialAdvancedShape} value={draft.advanced} onChange={value => update('advanced', value as Record<string, unknown>)} disabled={saving} id={`${id}-advanced`} />
</details>
</section>
<section className="credential-settings-section">
<h3>{t('authFiles.settings.additional')}</h3>
<label className="credential-settings-field">
+9 -7
View File
@@ -5,6 +5,7 @@ import type { MessageKey } from '../i18n/resources';
import { isRecord, maskSecret, readString } from '../services/managementApi';
import { effectiveProviderKey, providerKeyDraft, providerKeyOverrides, type ProviderKeyDraft } from '../services/providerGroups';
import '../styles/provider-groups.css';
import { ProviderKeyTemplateFields, ProviderModelFields } from './ProviderTemplateFields';
type Props = {
keys: ProviderKeyDraft[];
@@ -60,10 +61,10 @@ export function ProviderGroupKeysEditor({ keys, shared, section, disabled, onCha
</div>
<details className="provider-key-settings">
<summary>{t('apiAccess.groups.keySettings')} · {overrideCount ? t('apiAccess.groups.overrides', { count: overrideCount }) : t('apiAccess.groups.inherits')}</summary>
<label><span>{t('apiAccess.groups.weight')}</span><input type="number" min="0" max="1000000" step="1" value={key.weight == null ? '' : String(key.weight)}
<label><span>{t('apiAccess.groups.weight')}</span><input type="number" max="1000000" step="1" value={key.weight == null ? '' : String(key.weight)}
placeholder="1" onChange={(event) => set(draft.id, 'weight', event.currentTarget.value === '' ? undefined : Number(event.currentTarget.value))} /></label>
<p className="provider-group-hint">{t('apiAccess.groups.overrideHint')}</p>
{fields.map(({ field, label, kind, initial }) => {
{fields.filter(({ field }) => section !== 'openai-compatibility' || field === 'proxy-url').map(({ field, label, kind, initial }) => {
const overridden = key[field] != null;
const content = draft.text?.[field] ?? (field === 'headers' && isRecord(key[field])
? Object.entries(key[field]).map(([name, value]) => `${name}: ${value}`).join('\n')
@@ -79,7 +80,7 @@ export function ProviderGroupKeysEditor({ keys, shared, section, disabled, onCha
onChange={(event) => set(draft.id, field, kind === 'number' ? Number(event.currentTarget.value) : event.currentTarget.value)} /> : null}
</div>;
})}
<div className="provider-key-override">
{section !== 'openai-compatibility' ? <div className="provider-key-override">
<label className="provider-key-override-toggle"><input type="checkbox" checked={key.models != null}
onChange={(event) => set(draft.id, 'models', event.currentTarget.checked ? structuredClone(effective.models ?? []) : undefined)} />
<span>{t('apiAccess.groups.overrideModels')}</span></label>
@@ -87,16 +88,17 @@ export function ProviderGroupKeysEditor({ keys, shared, section, disabled, onCha
{key.models.map((raw, modelIndex) => {
const model = isRecord(raw) ? raw : { name: String(raw) };
const change = (patch: Record<string, string>) => set(draft.id, 'models', (key.models as unknown[]).map((item, i) => i === modelIndex ? { ...model, ...patch } : item));
return <div className="model-config-entry" key={modelIndex}>
return <div className="provider-model-config" key={modelIndex}><div className="model-config-entry">
<input aria-label={t('apiAccess.models.namePlaceholder')} value={readString(model, 'name')} onChange={(event) => change({ name: event.currentTarget.value })} />
<input aria-label={t('apiAccess.models.aliasPlaceholder')} value={readString(model, 'alias')} onChange={(event) => change({ alias: event.currentTarget.value })} />
<button type="button" className="icon-button quiet danger" aria-label={t('apiAccess.models.remove')} onClick={() => set(draft.id, 'models', (key.models as unknown[]).filter((_, i) => i !== modelIndex))}><Trash2 size={14} /></button>
</div>;
</div><ProviderModelFields value={model} section={section} onChange={(next) => set(draft.id, 'models', (key.models as unknown[]).map((item, i) => i === modelIndex ? next : item))} /></div>;
})}
<button type="button" className="secondary-button compact-button" onClick={() => set(draft.id, 'models', [...key.models as unknown[], { name: '', alias: '' }])}><Plus size={14} />{t('apiAccess.models.add')}</button>
</div> : null}
</div>
{section === 'codex-api-key' ? <label><span>WebSocket</span><select value={key.websockets == null ? '' : String(key.websockets)} onChange={(event) => set(draft.id, 'websockets', event.currentTarget.value === '' ? undefined : event.currentTarget.value === 'true')}>
</div> : null}
<ProviderKeyTemplateFields value={key} section={section} inherited={shared} onChange={(value) => update(draft.id, (current) => ({ ...current, value }))} />
{section === 'codex-api-key' || section === 'xai-api-key' ? <label><span>WebSocket</span><select value={key.websockets == null ? '' : String(key.websockets)} onChange={(event) => set(draft.id, 'websockets', event.currentTarget.value === '' ? undefined : event.currentTarget.value === 'true')}>
<option value="">{t('apiAccess.option.inherit')}</option><option value="true">{t('common.enabled')}</option><option value="false">{t('common.disabled')}</option>
</select></label> : null}
{section === 'claude-api-key' ? <div className="provider-cloak-settings">
+135
View File
@@ -0,0 +1,135 @@
import type { ReactNode } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { useI18n } from '../i18n';
import { providerText, type ProviderTemplateMessage } from '../i18n/providerTemplate';
import { isRecord, readString } from '../services/managementApi';
import '../styles/provider-template.css';
type Value = Record<string, unknown>;
type Change = (value: Value) => void;
const changed = (value: Value, field: string, next: unknown): Value => {
const result = { ...value };
if (next === undefined) delete result[field]; else result[field] = next;
return result;
};
const lines = (value: unknown) => Array.isArray(value) ? value.map(String).join('\n') : '';
export function ProviderField({ value, onChange, field, label, kind = 'boolean', initial, hint, choices }: {
value: Value; onChange: Change; field: string; label: ProviderTemplateMessage;
kind?: 'boolean' | 'text' | 'number' | 'lines' | 'select'; initial?: unknown; hint?: ProviderTemplateMessage;
choices?: { value: string; label: string }[];
}) {
const { locale } = useI18n();
const p = (key: ProviderTemplateMessage) => providerText(key, locale);
const current = value[field];
const enabled = current != null;
const set = (next: unknown) => onChange(changed(value, field, next));
return <div className="provider-template-field">
{kind === 'boolean' ? <label><span>{p(label)}</span>
<select aria-label={p(label)} value={enabled ? String(current) : ''} onChange={(event) => set(event.currentTarget.value === '' ? undefined : event.currentTarget.value === 'true')}>
<option value="">{p('inherit')}</option><option value="true">{p('enabled')}</option><option value="false">{p('disabled')}</option>
</select></label> : <>
<label className="provider-template-toggle"><input type="checkbox" checked={enabled} onChange={(event) => set(event.currentTarget.checked
? structuredClone(initial ?? (kind === 'number' ? 0 : kind === 'lines' ? [] : '')) : undefined)} /><span>{p(label)}</span></label>
{enabled ? kind === 'lines' ? <textarea aria-label={p(label)} rows={3} value={lines(current)} onChange={(event) => set(event.currentTarget.value.split(/\r?\n/))} onBlur={(event) => set(event.currentTarget.value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))} />
: kind === 'select' ? <select aria-label={p(label)} value={String(current)} onChange={(event) => set(event.currentTarget.value)}>
{!choices?.some((choice) => choice.value === current) ? <option value={String(current)}>{String(current)}</option> : null}
{choices?.map((choice) => <option key={choice.value} value={choice.value}>{choice.label}</option>)}
</select> : <input aria-label={p(label)} type={kind} step={kind === 'number' ? '1' : undefined} value={String(current)} onChange={(event) => set(kind === 'number' && event.currentTarget.value !== '' ? Number(event.currentTarget.value) : event.currentTarget.value)} /> : null}
</>}
{hint ? <small>{p(hint)}</small> : null}
</div>;
}
export function ProviderErrorRules({ value, onChange, inherited }: { value: Value; onChange: Change; inherited?: unknown }) {
const { locale } = useI18n();
const p = (key: ProviderTemplateMessage) => providerText(key, locale);
const field = 'request-scoped-errors';
const rules = Array.isArray(value[field]) ? value[field] as unknown[] : [];
const set = (next: unknown) => onChange(changed(value, field, next));
const actions = ['stop', 'stop-and-cooldown', 'continue', 'continue-and-cooldown'] as const;
return <details className="provider-template-section">
<summary>{p('errors')}{value[field] != null ? ` · ${rules.length}` : ''}</summary>
<p className="provider-group-hint">{p('errorsHint')}</p>
<label className="provider-template-toggle"><input type="checkbox" checked={value[field] != null}
onChange={(event) => set(event.currentTarget.checked ? structuredClone(inherited ?? []) : undefined)} /><span>{p('configure')}</span></label>
{value[field] != null ? <div className="provider-template-rules">
{rules.map((raw, index) => {
const rule = isRecord(raw) ? raw : {};
const update = (next: Value) => set(rules.map((item, i) => i === index ? next : item));
return <fieldset key={index} className="provider-template-rule"><legend>{index + 1}</legend>
<div className="provider-template-grid">
<label><span>{p('status')}</span><input aria-label={p('status')} type="number" min="100" max="599" step="1" value={String(rule.status ?? '')} onChange={(event) => update({ ...rule, status: event.currentTarget.value === '' ? '' : Number(event.currentTarget.value) })} /></label>
<label><span>{p('action')}</span><select aria-label={p('action')} value={readString(rule, 'action')} onChange={(event) => update({ ...rule, action: event.currentTarget.value })}>
{!actions.some((action) => action === rule.action) ? <option value={readString(rule, 'action')}>{readString(rule, 'action')}</option> : null}
{actions.map((action) => <option key={action} value={action}>{p(action)}</option>)}
</select></label>
<ProviderField value={rule} onChange={update} field="match" label="match" kind="lines" />
<ProviderField value={rule} onChange={update} field="match-regexr" label="regex" kind="lines" />
</div>
<button type="button" className="secondary-button compact-button" onClick={() => set(rules.filter((_, i) => i !== index))}><Trash2 size={14} />{p('remove')}</button>
</fieldset>;
})}
<button type="button" className="secondary-button compact-button" onClick={() => set([...rules, { status: 400, action: 'stop' }])}><Plus size={14} />{p('addRule')}</button>
</div> : null}
</details>;
}
export function ProviderGroupTemplateFields({ value, onChange, section }: { value: Value; onChange: Change; section: string }) {
return <div className="provider-template-fields">
<ProviderField value={value} onChange={onChange} field="request-retry" label="retry" kind="number" initial={0} hint="retryHint" />
{section === 'openai-compatibility' ? <ProviderField value={value} onChange={onChange} field="support-prompt-cache-key" label="promptCache" /> : null}
{section !== 'vertex-api-key' ? <ProviderErrorRules value={value} onChange={onChange} /> : null}
</div>;
}
export function ProviderKeyTemplateFields({ value, onChange, section, inherited }: { value: Value; onChange: Change; section: string; inherited: Value }) {
const { locale } = useI18n();
return <div className="provider-template-fields">
{!['vertex-api-key', 'openai-compatibility'].includes(section) ? <ProviderErrorRules value={value} onChange={onChange} inherited={inherited['request-scoped-errors']} /> : null}
{section === 'codex-api-key' ? <div className="provider-template-grid">
<ProviderField value={value} onChange={onChange} field="disable-codex-cloaking" label="noCloak" />
<ProviderField value={value} onChange={onChange} field="alpha-search" label="alpha" />
</div> : null}
{section === 'claude-api-key' ? <div className="provider-template-grid">
<ProviderField value={value} onChange={onChange} field="rebuild-mid-system-message" label="rebuild" />
<ProviderField value={value} onChange={onChange} field="fingerprint-profile" label="fingerprint" kind="select" choices={[
{ value: '', label: providerText('caller', locale) }, { value: 'claude-code-cli', label: 'Claude Code CLI' },
]} />
<ProviderField value={value} onChange={onChange} field="experimental-cch-signing" label="cch" hint="deprecated" />
</div> : null}
</div>;
}
export function ProviderModelFields({ value, onChange, section, children }: { value: Value; onChange: Change; section: string; children?: ReactNode }) {
const { locale } = useI18n();
const thinking = isRecord(value.thinking) ? value.thinking : {};
const setThinking = (next: Value) => onChange({ ...value, thinking: next });
return <details className="provider-template-section provider-model-options">
<summary>{providerText('options', locale)}</summary>
<p className="provider-group-hint">{providerText('modelHint', locale)}</p>
<div className="provider-template-grid">
<ProviderField value={value} onChange={onChange} field="display-name" label="display" kind="text" />
{section !== 'vertex-api-key' ? <>
<ProviderField value={value} onChange={onChange} field="max-context-length" label="context" kind="number" />
<ProviderField value={value} onChange={onChange} field="is-compat" label="compat" />
</> : null}
<ProviderField value={value} onChange={onChange} field="force-mapping" label="force" />
{['codex-api-key', 'xai-api-key', 'meta-api-key'].includes(section) ? <ProviderField value={value} onChange={onChange} field="support-configuration-update" label="configuration" /> : null}
{section === 'openai-compatibility' ? <>
<ProviderField value={value} onChange={onChange} field="image" label="image" hint="imageHint" />
<ProviderField value={value} onChange={onChange} field="input-modalities" label="input" kind="lines" hint="linesHint" />
<ProviderField value={value} onChange={onChange} field="output-modalities" label="output" kind="lines" hint="linesHint" />
<ProviderField value={value} onChange={onChange} field="use-max-completion-tokens" label="completion" />
</> : null}
</div>
<label className="provider-template-toggle"><input type="checkbox" checked={value.thinking != null} onChange={(event) => onChange(changed(value, 'thinking', event.currentTarget.checked ? {} : undefined))} /><span>{providerText('thinking', locale)}</span></label>
{value.thinking != null ? <div className="provider-template-grid">
<ProviderField value={thinking} onChange={setThinking} field="levels" label="levels" kind="lines" hint="linesHint" />
<ProviderField value={thinking} onChange={setThinking} field="min" label="min" kind="number" />
<ProviderField value={thinking} onChange={setThinking} field="max" label="max" kind="number" />
<ProviderField value={thinking} onChange={setThinking} field="zero-allowed" label="zero" />
<ProviderField value={thinking} onChange={setThinking} field="dynamic-allowed" label="dynamic" />
</div> : null}{children}
</details>;
}
+32
View File
@@ -0,0 +1,32 @@
.structured-list, .structured-map { display: grid; gap: 12px; min-width: 0; }
.structured-list-item, .structured-map-item { padding: 14px; border: 1px solid var(--border-color); border-radius: 12px; background: var(--bg-card); min-width: 0; }
.structured-list-toolbar, .structured-map-key, .structured-label { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
.structured-list-toolbar { margin-bottom: 10px; color: var(--text-tertiary); font-size: 12px; }
.structured-list-toolbar > div { display: flex; gap: 2px; }
.structured-map-key { margin-bottom: 12px; overflow-wrap: anywhere; }
.structured-object { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 14px; min-width: 0; }
.structured-field { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
.structured-field-wide { grid-column: 1 / -1; }
.structured-label { font-size: 13px; font-weight: 600; }
.structured-label label { overflow-wrap: anywhere; }
.structured-field small { font-size: 12px; color: var(--text-tertiary); line-height: 1.5; }
.structured-field .text-input, .structured-field .select-input, .structured-map-add .text-input, .structured-any .select-input, .structured-list-item > .text-input { width: 100%; min-width: 0; }
.structured-unset { justify-content: flex-start; color: var(--text-tertiary); white-space: normal; text-align: start; }
.structured-add { justify-self: start; }
.structured-map-add { display: flex; gap: 8px; flex-wrap: wrap; }
.structured-map-add .text-input { flex: 1 1 180px; width: auto; }
.structured-map-add .secondary-button { flex: 0 0 auto; }
.structured-any { display: grid; gap: 8px; min-width: 0; }
.structured-any > .select-input { max-width: 160px; }
.structured-error { color: var(--failure-badge-text); font-size: 12px; }
.structured-list .text-input, .structured-list .select-input,
.structured-map .text-input, .structured-map .select-input,
.structured-object .text-input, .structured-object .select-input,
.structured-any > .text-input, .structured-any > .select-input {
box-sizing: border-box; min-width: 0; width: 100%; padding: 9px 11px;
border: 1px solid var(--border-color); border-radius: 8px;
background: var(--bg-primary); color: var(--text-primary); font: inherit; font-size: 13px; line-height: 1.5;
}
.structured-list :is(input, select):focus-visible, .structured-map :is(input, select):focus-visible,
.structured-object :is(input, select):focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
.structured-list button, .structured-map button, .structured-object button { max-width: 100%; }
+116
View File
@@ -0,0 +1,116 @@
import { useId, useState } from 'react';
import { ArrowDown, ArrowUp, Plus, Trash2, Undo2 } from 'lucide-react';
import { useI18n } from '../i18n';
import { templateText } from '../i18n/templateConfig';
import { configRecord, defaultStructuredValue, type ConfigShape, type ConfigText } from '../services/structuredConfig';
import './StructuredConfigEditor.css';
export function useConfigText() {
const { locale } = useI18n();
return (value: ConfigText | undefined): string => {
if (!value) return '';
if (typeof value === 'string') return value;
return templateText(value, locale);
};
}
type Props = { shape: ConfigShape; value: unknown; onChange: (value: unknown) => void; disabled?: boolean; id?: string };
export function StructuredConfigEditor({ shape, value, onChange, disabled = false, id: providedId }: Props) {
const generatedId = useId();
const id = providedId ?? generatedId;
const tx = useConfigText();
const [newKey, setNewKey] = useState('');
const [keyError, setKeyError] = useState('');
const addText = tx({ zh: '添加', en: 'Add', ja: '追加' });
const removeText = tx({ zh: '删除', en: 'Remove', ja: '削除' });
const inheritText = tx({ zh: '继承 / 未设置', en: 'Inherit / not set', ja: '継承 / 未設定' });
const label = tx(shape.label) || id;
if (shape.type === 'any') {
const current = Array.isArray(value) ? 'array' : configRecord(value) ? 'map' : value === null ? 'null' : typeof value;
const types = {
string: { zh: '文本', en: 'Text', ja: 'テキスト' },
number: { zh: '数值', en: 'Number', ja: '数値' },
boolean: { zh: '布尔值', en: 'Boolean', ja: '真偽値' },
null: { zh: '空值', en: 'Null', ja: '空値' },
array: { zh: '列表', en: 'List', ja: 'リスト' },
map: { zh: '对象', en: 'Object', ja: 'オブジェクト' },
};
return <div className="structured-any">
<select className="select-input" aria-label={`${label} ${tx({ zh: '类型', en: 'type', ja: 'の型' })}`} value={current} disabled={disabled}
onChange={event => onChange(({ string: '', number: 0, boolean: false, null: null, array: [], map: {} } as Record<string, unknown>)[event.currentTarget.value])}>
{Object.entries(types).map(([type, text]) => <option key={type} value={type}>{tx(text)}</option>)}
</select>
{current !== 'null' && <StructuredConfigEditor shape={{ type: current as ConfigShape['type'], item: { type: 'any' }, label: shape.label, integer: false }} value={value} onChange={onChange} disabled={disabled} id={id} />}
</div>;
}
if (shape.type === 'array') {
const rows = Array.isArray(value) ? value : [];
const item = shape.item ?? { type: 'any' };
const update = (index: number, next: unknown) => onChange(rows.map((row, i) => i === index ? next : row));
const move = (index: number, delta: number) => {
const next = [...rows];
[next[index], next[index + delta]] = [next[index + delta], next[index]];
onChange(next);
};
return <div className="structured-list">
{rows.map((row, index) => <div className="structured-list-item" key={index}>
<div className="structured-list-toolbar"><span>{index + 1}</span><div>
<button type="button" className="icon-button quiet" disabled={disabled || index === 0} onClick={() => move(index, -1)} aria-label={tx({ zh: '上移', en: 'Move up', ja: '上へ' })}><ArrowUp size={14} /></button>
<button type="button" className="icon-button quiet" disabled={disabled || index === rows.length - 1} onClick={() => move(index, 1)} aria-label={tx({ zh: '下移', en: 'Move down', ja: '下へ' })}><ArrowDown size={14} /></button>
<button type="button" className="icon-button quiet" disabled={disabled} onClick={() => onChange(rows.filter((_, i) => i !== index))} aria-label={`${removeText} ${index + 1}`}><Trash2 size={14} /></button>
</div></div>
<StructuredConfigEditor shape={{ ...item, label: item.label ?? `${label} ${index + 1}` }} value={row} onChange={next => update(index, next)} disabled={disabled} id={`${id}-${index}`} />
</div>)}
<button type="button" className="secondary-button structured-add" disabled={disabled} onClick={() => onChange([...rows, defaultStructuredValue(item)])}><Plus size={14} />{addText}</button>
</div>;
}
if (shape.type === 'object') {
const record = configRecord(value) ? value : {};
const update = (key: string, next: unknown) => {
const result = { ...record };
if (next === undefined) delete result[key]; else result[key] = next;
onChange(result);
};
return <div className="structured-object">
{Object.entries(shape.fields ?? {}).map(([key, field]) => <div key={key} className={`structured-field${['array', 'map', 'object', 'any'].includes(field.type) ? ' structured-field-wide' : ''}`}>
<div className="structured-label"><label htmlFor={`${id}-${key}`}>{tx(field.label) || key}</label>
{field.optional && record[key] !== undefined && <button type="button" className="icon-button quiet" disabled={disabled} onClick={() => update(key, undefined)} title={inheritText} aria-label={`${inheritText}: ${tx(field.label) || key}`}><Undo2 size={13} /></button>}
</div>
{field.optional && record[key] == null ? <button type="button" className="secondary-button structured-unset" disabled={disabled} aria-label={`${tx(field.label) || key}: ${inheritText}`} onClick={() => update(key, defaultStructuredValue(field))}>{inheritText} · {tx({ zh: '设置', en: 'Set', ja: '設定' })}</button>
: <StructuredConfigEditor shape={field} value={record[key]} onChange={next => update(key, next)} disabled={disabled} id={`${id}-${key}`} />}
{field.hint && <small>{tx(field.hint)}</small>}
</div>)}
</div>;
}
if (shape.type === 'map') {
const record = configRecord(value) ? value : {};
const item = shape.item ?? { type: 'any' };
return <div className="structured-map">
{Object.entries(record).map(([key, entry]) => <div className="structured-map-item" key={key}>
<div className="structured-map-key"><strong>{key}</strong><button type="button" className="icon-button quiet" disabled={disabled} aria-label={`${removeText} ${key}`} onClick={() => {
const next = { ...record }; delete next[key]; onChange(next);
}}><Trash2 size={14} /></button></div>
<StructuredConfigEditor shape={{ ...item, label: item.label ?? key }} value={entry} onChange={next => onChange({ ...record, [key]: next })} disabled={disabled} id={`${id}-${key}`} />
</div>)}
<div className="structured-map-add"><input className="text-input" aria-label={tx({ zh: '新字段名称', en: 'New field name', ja: '新しいフィールド名' })} value={newKey} disabled={disabled} placeholder={tx({ zh: '字段 / 通道名称', en: 'Field / channel name', ja: 'フィールド / チャンネル名' })}
onChange={event => { setNewKey(event.currentTarget.value); setKeyError(''); }} />
<button type="button" className="secondary-button" disabled={disabled || !newKey.trim()} onClick={() => {
const key = newKey.trim();
if (Object.prototype.hasOwnProperty.call(record, key) || ['__proto__', 'prototype', 'constructor'].includes(key)) { setKeyError(tx({ zh: '名称已存在或不可使用', en: 'Name already exists or is not allowed', ja: 'この名前は使用できません' })); return; }
onChange({ ...record, [key]: defaultStructuredValue(item) }); setNewKey('');
}}><Plus size={14} />{addText}</button></div>
{keyError && <p role="alert" className="structured-error">{keyError}</p>}
</div>;
}
if (shape.type === 'boolean' || shape.type === 'select') {
const options = shape.type === 'boolean' ? [true, false] : shape.options ?? [];
return <select id={id} className="select-input" aria-label={label} disabled={disabled} value={String(value)} onChange={event => onChange(options.find(option => String(option) === event.currentTarget.value))}>
{!options.some(option => option === value) && <option value={String(value)}>{String(value ?? '')}</option>}
{options.map(option => <option value={String(option)} key={String(option)}>{option === true ? tx({ zh: '启用', en: 'Enabled', ja: '有効' }) : option === false ? tx({ zh: '禁用', en: 'Disabled', ja: '無効' }) : String(option)}</option>)}
</select>;
}
return <input id={id} aria-label={label} className="text-input" type={shape.secret ? 'password' : shape.type === 'number' ? 'number' : 'text'} autoComplete="off" spellCheck={false} disabled={disabled}
min={shape.min} max={shape.max} step={shape.type === 'number' ? shape.integer === false ? 'any' : 1 : undefined} value={typeof value === 'string' || typeof value === 'number' ? value : ''}
onChange={event => { const text = event.currentTarget.value; onChange(shape.type === 'number' && text !== '' && Number.isFinite(Number(text)) ? Number(text) : text); }} />;
}
+186
View File
@@ -0,0 +1,186 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open } from '@tauri-apps/plugin-dialog';
import { RotateCcw, Save, Settings2 } from 'lucide-react';
import { useI18n } from '../i18n';
import { templateMessages, templateText } from '../i18n/templateConfig';
import { useCoreRuntime, type CoreStatus } from '../coreRuntime';
import {
applyTemplateChanges, draftTemplateField, readTemplatePath, templateFieldKey, templateFieldValidation, templateFieldSaveValue,
type TemplateConfigChange, type TemplateConfigDraft, type TemplateConfigField, type TemplateConfigGroup, type TemplateConfigSaveResult,
} from '../services/templateConfig';
import '../styles/template-config.css';
export function TemplateConfigSection({ groups, visibleGroups }: {
groups: readonly TemplateConfigGroup[];
visibleGroups?: readonly string[];
}) {
const { locale } = useI18n();
const { status, publishStatus, refreshStatus } = useCoreRuntime();
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
const [drafts, setDrafts] = useState<Record<string, TemplateConfigDraft>>({});
const [loadError, setLoadError] = useState('');
const [groupErrors, setGroupErrors] = useState<Record<string, string>>({});
const [saved, setSaved] = useState<Record<string, boolean>>({});
const [restartNeeded, setRestartNeeded] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const mounted = useRef(true);
const revision = useRef(0);
const tr = (message: keyof typeof templateMessages) => templateText(templateMessages[message], locale);
const reload = useCallback(async () => {
const request = ++revision.current;
try {
const value = await invoke<Record<string, unknown>>('get_extended_core_config');
if (mounted.current && request === revision.current) { setConfig(value); setLoadError(''); }
} catch (error) {
if (mounted.current && request === revision.current) setLoadError(String(error));
}
}, []);
useEffect(() => {
mounted.current = true;
let disposed = false;
let stop: (() => void) | undefined;
void reload();
void listen('config-files-changed', () => { if (!disposed) void reload(); }).then((unlisten) => {
if (!disposed) stop = unlisten;
else unlisten();
}).catch((error) => { if (!disposed) setLoadError(String(error)); });
return () => { disposed = true; mounted.current = false; ++revision.current; stop?.(); };
}, [reload]);
const changeField = (group: TemplateConfigGroup, field: TemplateConfigField, value: unknown, remove = false) => {
if (!config) return;
const key = templateFieldKey(field.path);
setDrafts((previous) => {
const next = { ...previous };
const draft = draftTemplateField(field, config, previous[key], value, remove);
if (draft) next[key] = draft;
else delete next[key];
return next;
});
setGroupErrors((previous) => ({ ...previous, [group.id]: '' }));
setSaved((previous) => ({ ...previous, [group.id]: false }));
};
const discardGroup = (group: TemplateConfigGroup) => {
setDrafts((previous) => {
const next = { ...previous };
for (const field of group.fields) delete next[templateFieldKey(field.path)];
return next;
});
setGroupErrors((previous) => ({ ...previous, [group.id]: '' }));
setSaved((previous) => ({ ...previous, [group.id]: false }));
};
const saveGroup = async (group: TemplateConfigGroup) => {
if (!config || busy) return;
const changes: TemplateConfigChange[] = [];
for (const field of group.fields) {
const draft = drafts[templateFieldKey(field.path)];
if (!draft) continue;
const error = !draft.remove && templateFieldValidation(field, draft.value);
if (error) {
setGroupErrors((previous) => ({ ...previous, [group.id]: `${templateText(field.label, locale)}: ${templateText(error, locale)}` }));
return;
}
changes.push({ ...draft, path: [...field.path], value: draft.remove ? null : templateFieldSaveValue(field, draft.value) });
}
if (!changes.length) return;
const groupError = group.validate?.(applyTemplateChanges(config, changes));
if (groupError) { setGroupErrors((previous) => ({ ...previous, [group.id]: templateText(groupError, locale) })); return; }
setBusy(group.id);
setGroupErrors((previous) => ({ ...previous, [group.id]: '' }));
try {
const result = await invoke<TemplateConfigSaveResult>('save_extended_core_config', { changes });
++revision.current;
setConfig(result.config);
setLoadError('');
setDrafts((previous) => {
const next = { ...previous };
for (const change of changes) delete next[templateFieldKey(change.path)];
return next;
});
setSaved((previous) => ({ ...previous, [group.id]: true }));
if (result.restartRequired) setRestartNeeded(true);
} catch (error) {
setGroupErrors((previous) => ({ ...previous, [group.id]: String(error) }));
// Refresh untouched values while retaining both the draft and its original conflict check.
await reload();
} finally { if (mounted.current) setBusy(null); }
};
const restart = async () => {
setBusy('restart');
try { publishStatus(await invoke<CoreStatus>('restart_core_process')); setRestartNeeded(false); setLoadError(''); }
catch (error) { setLoadError(String(error)); await refreshStatus(); }
finally { setBusy(null); }
};
const chooseDirectory = async (group: TemplateConfigGroup, field: TemplateConfigField) => {
setBusy('directory');
try {
const selected = await open({ directory: true, multiple: false, title: templateText(field.label, locale) });
if (mounted.current && typeof selected === 'string') changeField(group, field, selected);
} catch (error) {
if (mounted.current) setGroupErrors((previous) => ({ ...previous, [group.id]: String(error) }));
} finally { if (mounted.current) setBusy(null); }
};
const control = (group: TemplateConfigGroup, field: TemplateConfigField, value: unknown, id: string, invalid: boolean) => {
const disabled = busy !== null || config === null;
const onChange = (next: unknown) => changeField(group, field, next);
const common = { id, disabled, 'aria-invalid': invalid || undefined, 'aria-describedby': `${id}-description` };
if (field.type === 'custom' && field.render) return field.render({ value, onChange, disabled, id });
if (field.type === 'boolean') return <label className="switch-control"><input {...common} type="checkbox" checked={value === true} onChange={(event) => onChange(event.currentTarget.checked)} /><span className="switch-track" /></label>;
if (field.type === 'select') {
const selected = field.options?.findIndex((option) => Object.is(option.value, value)) ?? -1;
return <select {...common} className="config-network-input" value={String(selected)} onChange={(event) => onChange(field.options?.[Number(event.currentTarget.value)]?.value)}>
{selected < 0 ? <option value="-1">{String(value ?? '')}</option> : null}
{field.options?.map((option, index) => <option key={index} value={String(index)}>{templateText(option.label, locale)}</option>)}
</select>;
}
if (field.type === 'string-list' || field.type === 'json') return <textarea {...common} className="config-network-input template-config-textarea" rows={field.rows ?? (field.type === 'json' ? 6 : 3)}
value={field.type === 'string-list' ? Array.isArray(value) ? value.join('\n') : '' : typeof value === 'string' ? value : JSON.stringify(value ?? {}, null, 2)}
placeholder={field.placeholder}
onChange={(event) => onChange(field.type === 'string-list' ? event.currentTarget.value.split('\n') : event.currentTarget.value)}
onBlur={() => { if (field.type === 'string-list' && Array.isArray(value)) { const clean = value.map(String).map((item) => item.trim()).filter(Boolean); if (JSON.stringify(clean) !== JSON.stringify(value)) onChange(clean); } }} />;
const input = <input {...common} className="config-network-input" type={field.type === 'number' ? 'number' : field.sensitive ? 'password' : 'text'} autoComplete={field.sensitive ? 'new-password' : undefined}
min={field.min} max={field.max} step={field.step ?? 1} placeholder={field.placeholder} value={typeof value === 'string' || typeof value === 'number' ? value : ''}
onChange={(event) => onChange(field.type === 'number' && event.currentTarget.value !== '' ? Number(event.currentTarget.value) : event.currentTarget.value)} />;
return field.directory ? <div className="template-config-directory">{input}<button type="button" className="secondary-button compact-button" disabled={disabled} onClick={() => void chooseDirectory(group, field)} aria-label={`${tr('chooseDirectory')}: ${templateText(field.label, locale)}`}>{tr('chooseDirectory')}</button></div> : input;
};
const visible = groups.filter((group) => !visibleGroups || visibleGroups.includes(group.id));
return <div className="template-config-section" hidden={visible.length === 0}>
{loadError ? <div className="template-config-notice template-config-error" role="alert"><span>{loadError}</span><button type="button" className="secondary-button compact-button" disabled={busy !== null} onClick={() => void reload()}>{tr('reload')}</button></div> : null}
{!config && !loadError ? <p role="status">{tr('loading')}</p> : null}
{restartNeeded ? <div className="template-config-notice" role="status"><span>{tr('restart')}</span>{status?.running ? <button type="button" className="secondary-button compact-button" disabled={busy !== null} onClick={() => void restart()}>{tr('restartNow')}</button> : null}</div> : null}
{groups.map((group) => {
const dirty = group.fields.some((field) => drafts[templateFieldKey(field.path)]);
return <section key={group.id} className="config-card template-config-card" hidden={!visible.includes(group)} aria-labelledby={`template-group-${group.id}`}>
<div className="template-config-heading"><div><h3 id={`template-group-${group.id}`}><Settings2 size={18} aria-hidden="true" />{templateText(group.title, locale)}</h3>{group.description ? <p>{templateText(group.description, locale)}</p> : null}</div></div>
<div className="template-config-fields">{group.fields.map((field, index) => {
const key = templateFieldKey(field.path);
const original = readTemplatePath(config, field.path);
const draft = drafts[key];
const exists = draft ? !draft.remove : original.exists;
const value = draft && !draft.remove ? draft.value : draft?.remove || !original.exists ? field.defaultValue : original.value;
const validation = draft && !draft.remove ? templateFieldValidation(field, value) : null;
const id = `template-${group.id}-${index}`;
return <div key={key} className={`template-config-field${field.type === 'custom' || field.type === 'json' || field.type === 'string-list' ? ' template-config-field-wide' : ''}${field.type === 'boolean' ? ' template-config-toggle' : ''}`}>
<div className="template-config-label">{field.type === 'custom' ? <span id={`${id}-label`}>{templateText(field.label, locale)}</span> : <label htmlFor={id}>{templateText(field.label, locale)}</label>}<span className={`template-config-origin${draft ? ' template-config-origin-dirty' : ''}`}>{draft ? tr('dirty') : exists ? tr('explicit') : tr('inherited')}</span></div>
{field.type === 'custom' ? <details className="template-config-custom"><summary aria-labelledby={`${id}-label ${id}-edit`} aria-describedby={`${id}-description`}><span id={`${id}-edit`}>{tr('editDetails')}</span></summary><div className="template-config-control" role="group" aria-labelledby={`${id}-label`}>{control(group, field, value, id, Boolean(validation))}</div></details> : <div className="template-config-control">{control(group, field, value, id, Boolean(validation))}</div>}
<small id={`${id}-description`}>{templateText(field.description, locale)}{field.type === 'string-list' ? ` ${tr('list')}` : ''}</small>
{validation ? <small className="template-config-error" role="alert">{templateText(validation, locale)}</small> : null}
{exists ? <button type="button" className="template-config-reset" disabled={busy !== null || !config} aria-label={`${tr('default')}: ${templateText(field.label, locale)}`} onClick={() => changeField(group, field, null, true)}><RotateCcw size={12} aria-hidden="true" />{tr('default')}</button> : null}
</div>;
})}</div>
{groupErrors[group.id] ? <p className="template-config-error" role="alert">{groupErrors[group.id]}</p> : null}
<div className="template-config-actions"><span role="status">{dirty ? tr('dirty') : saved[group.id] ? tr('saved') : ''}</span><div>{dirty ? <button type="button" className="secondary-button compact-button" disabled={busy !== null} onClick={() => discardGroup(group)}>{tr('discard')}</button> : null}<button type="button" className="primary-button compact-button" disabled={!dirty || busy !== null || !config} onClick={() => void saveGroup(group)}><Save size={15} aria-hidden="true" />{busy === group.id ? tr('saving') : tr('save')}</button></div></div>
</section>;
})}
</div>;
}