feat(config)!: route /config to the redesigned page and retire the legacy editor

This commit is contained in:
Supra4E8C
2026-08-06 02:58:13 +08:00
parent 77ec68bd97
commit 38ee01c0e0
16 changed files with 1 additions and 6960 deletions
@@ -1,113 +0,0 @@
@use '../../styles/mixins' as *;
.section {
display: flex;
flex-direction: column;
gap: clamp(16px, 2vw, 22px);
height: clamp(520px, calc(100dvh - var(--header-height, 64px) - 250px), 780px);
min-width: 0;
box-sizing: border-box;
overflow-y: auto;
overscroll-behavior: auto;
padding: clamp(20px, 2.4vw, 28px);
border: 1px solid var(--border-color);
border-radius: 8px;
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
scroll-margin-top: 104px;
scroll-snap-align: start;
scroll-snap-stop: always;
scrollbar-width: thin;
@include mobile {
gap: 14px;
height: clamp(420px, calc(100dvh - var(--header-height, 64px) - 260px), 680px);
padding: 16px;
scroll-margin-top: 92px;
}
}
.header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: start;
padding-bottom: 14px;
border-bottom: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-primary) 88%, transparent);
@include mobile {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
padding-bottom: 12px;
background: transparent;
}
}
.titleRow {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.indexBadge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 28px;
padding: 0 8px;
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
font-size: 11px;
font-weight: 750;
letter-spacing: 0.08em;
}
.iconBadge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-secondary);
flex: 0 0 auto;
}
.headingGroup {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.title {
margin: 0;
color: var(--text-primary);
font-size: clamp(18px, 1.6vw, 22px);
font-weight: 680;
line-height: 1.18;
letter-spacing: 0;
}
.description {
margin: 0;
max-width: 72ch;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.65;
@include mobile {
max-width: none;
}
}
.content {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
min-width: 0;
}
-34
View File
@@ -1,34 +0,0 @@
import { forwardRef, type HTMLAttributes, type PropsWithChildren, type ReactNode } from 'react';
import styles from './ConfigSection.module.scss';
interface ConfigSectionProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
title: ReactNode;
description?: ReactNode;
indexLabel?: ReactNode;
icon?: ReactNode;
}
export const ConfigSection = forwardRef<HTMLElement, PropsWithChildren<ConfigSectionProps>>(
function ConfigSection(
{ title, description, indexLabel, icon, className, children, ...rest },
ref
) {
const sectionClassName = [styles.section, className].filter(Boolean).join(' ');
return (
<section ref={ref} className={sectionClassName} {...rest}>
<header className={styles.header}>
<div className={styles.titleRow}>
{indexLabel ? <span className={styles.indexBadge}>{indexLabel}</span> : null}
{icon ? <span className={styles.iconBadge}>{icon}</span> : null}
</div>
<div className={styles.headingGroup}>
<h2 className={styles.title}>{title}</h2>
{description ? <p className={styles.description}>{description}</p> : null}
</div>
</header>
<div className={styles.content}>{children}</div>
</section>
);
}
);
@@ -1,62 +0,0 @@
import { useMemo, type Ref } from 'react';
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { keymap } from '@codemirror/view';
type ConfigSourceEditorProps = {
value: string;
onChange: (value: string) => void;
editorRef?: Ref<ReactCodeMirrorRef>;
theme: 'light' | 'dark';
editable: boolean;
placeholder: string;
};
export default function ConfigSourceEditor({
value,
onChange,
editorRef,
theme,
editable,
placeholder,
}: ConfigSourceEditorProps) {
const extensions = useMemo(
() => [yaml(), search(), highlightSelectionMatches(), keymap.of(searchKeymap)],
[]
);
return (
<CodeMirror
ref={editorRef}
value={value}
onChange={onChange}
extensions={extensions}
theme={theme}
editable={editable}
placeholder={placeholder}
height="100%"
style={{ height: '100%' }}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightActiveLine: true,
foldGutter: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: false,
rectangularSelection: true,
crosshairCursor: false,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
searchKeymap: true,
foldKeymap: true,
completionKeymap: false,
lintKeymap: true,
}}
/>
);
}
-299
View File
@@ -1,299 +0,0 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
$diff-mono: 'Consolas', 'Monaco', 'Menlo', 'SF Mono', monospace;
$diff-font-size: 12px;
$diff-line-height: 20px;
$diff-gutter-width: 50px;
$diff-prefix-width: 20px;
// GitHub-inspired diff colors (theme-adaptive via color-mix)
$diff-add-color: #3fb950;
$diff-del-color: #f85149;
$diff-hunk-color: #388bfd;
.diffModal {
:global(.modal-body) {
padding: 0;
max-height: none;
overflow: hidden;
}
}
.content {
display: flex;
flex-direction: column;
height: 70vh;
min-height: 420px;
}
.emptyState {
flex: 1;
border: 1px dashed var(--border-color);
border-radius: $radius-md;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 14px;
display: grid;
place-items: center;
margin: $spacing-md;
}
// ── File container ──────────────────────────────────────
.diffContainer {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
// ── File header ─────────────────────────────────────────
.fileHeader {
display: flex;
align-items: center;
gap: $spacing-sm;
padding: 10px $spacing-md;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.fileIcon {
flex-shrink: 0;
color: var(--text-tertiary);
}
.fileName {
font-size: 13px;
font-weight: 600;
font-family: $diff-mono;
color: var(--text-primary);
}
.fileStats {
margin-left: auto;
display: flex;
align-items: center;
gap: $spacing-sm;
font-size: 12px;
font-weight: 700;
font-family: $diff-mono;
}
.statAdditions {
color: $diff-add-color;
}
.statDeletions {
color: $diff-del-color;
}
.statBar {
display: inline-flex;
gap: 2px;
margin-left: 2px;
}
.statBlock {
width: 8px;
height: 8px;
border-radius: 2px;
}
.statBlockAdd {
background: $diff-add-color;
}
.statBlockDel {
background: $diff-del-color;
}
// ── Diff body (scrollable) ──────────────────────────────
.diffBody {
flex: 1;
min-height: 0;
overflow: auto;
font-family: $diff-mono;
font-size: $diff-font-size;
line-height: $diff-line-height;
}
// ── Hunk ────────────────────────────────────────────────
.hunk + .hunk {
border-top: 1px solid var(--border-color);
}
.hunkHeader {
display: flex;
align-items: center;
background: color-mix(in srgb, $diff-hunk-color 8%, var(--bg-primary));
border-bottom: 1px solid color-mix(in srgb, $diff-hunk-color 12%, var(--border-color));
color: color-mix(in srgb, $diff-hunk-color 75%, var(--text-secondary));
min-height: $diff-line-height;
}
.hunkGutter {
width: $diff-gutter-width;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
border-right: 1px solid color-mix(in srgb, $diff-hunk-color 12%, var(--border-color));
}
.hunkExpandIcon {
color: color-mix(in srgb, $diff-hunk-color 70%, var(--text-tertiary));
opacity: 0.7;
}
.hunkText {
padding: 4px $spacing-sm 4px ($diff-prefix-width + $spacing-sm);
font-size: $diff-font-size;
white-space: nowrap;
}
// ── Diff line ───────────────────────────────────────────
.diffLine {
display: flex;
min-height: $diff-line-height;
}
// ── Line number gutters ─────────────────────────────────
.lineNum {
width: $diff-gutter-width;
flex-shrink: 0;
padding: 0 8px;
text-align: right;
color: var(--text-tertiary);
user-select: none;
font-variant-numeric: tabular-nums;
border-right: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
box-sizing: border-box;
}
.lineNumEmpty {
color: transparent;
}
// ── Prefix column (+/-/space) ───────────────────────────
.linePrefix {
width: $diff-prefix-width;
flex-shrink: 0;
text-align: center;
user-select: none;
font-weight: 700;
}
// ── Code text ───────────────────────────────────────────
.lineText {
flex: 1;
min-width: 0;
padding-right: 12px;
white-space: pre-wrap;
word-break: break-word;
}
// ── Context lines ───────────────────────────────────────
.context {
background: var(--bg-primary);
.linePrefix {
color: var(--text-tertiary);
}
.lineText {
color: var(--text-primary);
}
}
// ── Deletion lines ──────────────────────────────────────
.deletion {
background: color-mix(in srgb, $diff-del-color 8%, var(--bg-primary));
.lineNum {
background: color-mix(in srgb, $diff-del-color 12%, var(--bg-primary));
border-right-color: color-mix(in srgb, $diff-del-color 18%, var(--border-color));
color: color-mix(in srgb, $diff-del-color 60%, var(--text-tertiary));
}
.linePrefix {
color: $diff-del-color;
}
.lineText {
color: var(--text-primary);
}
}
// ── Addition lines ──────────────────────────────────────
.addition {
background: color-mix(in srgb, $diff-add-color 8%, var(--bg-primary));
.lineNum {
background: color-mix(in srgb, $diff-add-color 12%, var(--bg-primary));
border-right-color: color-mix(in srgb, $diff-add-color 18%, var(--border-color));
color: color-mix(in srgb, $diff-add-color 60%, var(--text-tertiary));
}
.linePrefix {
color: $diff-add-color;
}
.lineText {
color: var(--text-primary);
}
}
// ── Mobile responsive ───────────────────────────────────
@include mobile {
.content {
height: 65vh;
min-height: 360px;
}
.lineNum {
width: 36px;
padding: 0 4px;
font-size: 10px;
}
.linePrefix {
width: 16px;
font-size: 11px;
}
.hunkGutter {
width: 36px;
}
.hunkText {
padding-left: 20px;
}
.diffBody {
font-size: 11px;
line-height: 18px;
}
.fileName {
font-size: 12px;
}
.fileStats {
font-size: 11px;
}
}
-309
View File
@@ -1,309 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Text } from '@codemirror/state';
import { Chunk } from '@codemirror/merge';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import styles from './DiffModal.module.scss';
type DiffModalProps = {
open: boolean;
original: string;
modified: string;
onConfirm: () => void;
onCancel: () => void;
loading?: boolean;
};
type UnifiedLineType = 'context' | 'addition' | 'deletion';
type UnifiedLine = {
type: UnifiedLineType;
oldNum: number | null;
newNum: number | null;
text: string;
};
type Hunk = {
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
lines: UnifiedLine[];
};
type DiffResult = {
hunks: Hunk[];
additions: number;
deletions: number;
};
const DIFF_CONTEXT_LINES = 3;
const clampPos = (doc: Text, pos: number) => Math.max(0, Math.min(pos, doc.length));
function computeUnifiedDiff(original: string, modified: string): DiffResult {
const oldDoc = Text.of(original.split('\n'));
const newDoc = Text.of(modified.split('\n'));
const chunks = Chunk.build(oldDoc, newDoc);
let totalAdditions = 0;
let totalDeletions = 0;
const hunks: Hunk[] = chunks.map((chunk: Chunk) => {
const lines: UnifiedLine[] = [];
const hasDel = chunk.fromA < chunk.toA;
const hasAdd = chunk.fromB < chunk.toB;
// Collect deleted lines from old doc
const delLines: { num: number; text: string }[] = [];
if (hasDel) {
const startLine = oldDoc.lineAt(chunk.fromA).number;
const endLine = oldDoc.lineAt(chunk.toA - 1).number;
for (let i = startLine; i <= endLine; i++) {
delLines.push({ num: i, text: oldDoc.line(i).text });
}
}
// Collect added lines from new doc
const addLines: { num: number; text: string }[] = [];
if (hasAdd) {
const startLine = newDoc.lineAt(chunk.fromB).number;
const endLine = newDoc.lineAt(chunk.toB - 1).number;
for (let i = startLine; i <= endLine; i++) {
addLines.push({ num: i, text: newDoc.line(i).text });
}
}
totalDeletions += delLines.length;
totalAdditions += addLines.length;
// Compute context boundaries
let ctxBeforeEndOld: number;
let ctxAfterStartOld: number;
let ctxBeforeEndNew: number;
let ctxAfterStartNew: number;
if (hasDel) {
ctxBeforeEndOld = delLines[0].num - 1;
ctxAfterStartOld = delLines[delLines.length - 1].num + 1;
} else {
const anchorPos = clampPos(oldDoc, chunk.fromA);
const lineInfo = oldDoc.lineAt(anchorPos);
if (chunk.fromA === lineInfo.from) {
ctxBeforeEndOld = lineInfo.number - 1;
ctxAfterStartOld = lineInfo.number;
} else {
ctxBeforeEndOld = lineInfo.number;
ctxAfterStartOld = lineInfo.number + 1;
}
}
if (hasAdd) {
ctxBeforeEndNew = addLines[0].num - 1;
ctxAfterStartNew = addLines[addLines.length - 1].num + 1;
} else {
const anchorPos = clampPos(newDoc, chunk.fromB);
const lineInfo = newDoc.lineAt(anchorPos);
if (chunk.fromB === lineInfo.from) {
ctxBeforeEndNew = lineInfo.number - 1;
ctxAfterStartNew = lineInfo.number;
} else {
ctxBeforeEndNew = lineInfo.number;
ctxAfterStartNew = lineInfo.number + 1;
}
}
// Context before
const ctxBeforeCount = Math.min(
DIFF_CONTEXT_LINES,
Math.max(0, ctxBeforeEndOld),
Math.max(0, ctxBeforeEndNew)
);
for (let i = ctxBeforeCount; i > 0; i--) {
const oldNum = ctxBeforeEndOld - i + 1;
const newNum = ctxBeforeEndNew - i + 1;
if (oldNum >= 1 && newNum >= 1 && oldNum <= oldDoc.lines) {
lines.push({
type: 'context',
oldNum,
newNum,
text: oldDoc.line(oldNum).text,
});
}
}
// Deletions
for (const del of delLines) {
lines.push({ type: 'deletion', oldNum: del.num, newNum: null, text: del.text });
}
// Additions
for (const add of addLines) {
lines.push({ type: 'addition', oldNum: null, newNum: add.num, text: add.text });
}
// Context after
const ctxAfterCountOld = Math.max(
0,
Math.min(DIFF_CONTEXT_LINES, oldDoc.lines - ctxAfterStartOld + 1)
);
const ctxAfterCountNew = Math.max(
0,
Math.min(DIFF_CONTEXT_LINES, newDoc.lines - ctxAfterStartNew + 1)
);
const ctxAfterCount = Math.min(ctxAfterCountOld, ctxAfterCountNew);
for (let i = 0; i < ctxAfterCount; i++) {
const oldNum = ctxAfterStartOld + i;
const newNum = ctxAfterStartNew + i;
if (oldNum >= 1 && oldNum <= oldDoc.lines && newNum >= 1 && newNum <= newDoc.lines) {
lines.push({
type: 'context',
oldNum,
newNum,
text: oldDoc.line(oldNum).text,
});
}
}
// Compute hunk header values
const firstOld = lines.find((l) => l.oldNum !== null)?.oldNum ?? 1;
const firstNew = lines.find((l) => l.newNum !== null)?.newNum ?? 1;
const oldCount = lines.filter((l) => l.type !== 'addition').length;
const newCount = lines.filter((l) => l.type !== 'deletion').length;
return { oldStart: firstOld, oldCount, newStart: firstNew, newCount, lines };
});
return { hunks, additions: totalAdditions, deletions: totalDeletions };
}
const STAT_BLOCKS = 5;
function StatBar({ additions, deletions }: { additions: number; deletions: number }) {
const total = additions + deletions;
if (total === 0) return null;
const addBlocks = Math.round((additions / total) * STAT_BLOCKS);
return (
<span className={styles.statBar}>
{Array.from({ length: STAT_BLOCKS }, (_, i) => (
<span
key={i}
className={`${styles.statBlock} ${i < addBlocks ? styles.statBlockAdd : styles.statBlockDel}`}
/>
))}
</span>
);
}
export function DiffModal({
open,
original,
modified,
onConfirm,
onCancel,
loading = false,
}: DiffModalProps) {
const { t } = useTranslation();
const diff = useMemo<DiffResult>(
() => computeUnifiedDiff(original, modified),
[original, modified]
);
return (
<Modal
open={open}
title={t('config_management.diff.title')}
onClose={onCancel}
width="min(1200px, 90vw)"
className={styles.diffModal}
closeDisabled={loading}
footer={
<>
<Button variant="secondary" onClick={onCancel} disabled={loading}>
{t('common.cancel')}
</Button>
<Button onClick={onConfirm} loading={loading} disabled={loading}>
{t('config_management.diff.confirm')}
</Button>
</>
}
>
<div className={styles.content}>
{diff.hunks.length === 0 ? (
<div className={styles.emptyState}>{t('config_management.diff.no_changes')}</div>
) : (
<div className={styles.diffContainer}>
<div className={styles.fileHeader}>
<svg className={styles.fileIcon} viewBox="0 0 16 16" width="16" height="16">
<path
fillRule="evenodd"
d="M3.75 1.5a.25.25 0 00-.25.25v11.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V6H9.75A1.75 1.75 0 018 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0112.25 15h-8.5A1.75 1.75 0 012 13.25V1.75z"
fill="currentColor"
/>
</svg>
<span className={styles.fileName}>config.yaml</span>
<span className={styles.fileStats}>
<span className={styles.statAdditions}>+{diff.additions}</span>
<span className={styles.statDeletions}>-{diff.deletions}</span>
<StatBar additions={diff.additions} deletions={diff.deletions} />
</span>
</div>
<div className={styles.diffBody}>
{diff.hunks.map((hunk, hunkIdx) => (
<div key={hunkIdx} className={styles.hunk}>
<div className={styles.hunkHeader}>
<span className={styles.hunkGutter}>
<svg
className={styles.hunkExpandIcon}
viewBox="0 0 16 16"
width="12"
height="12"
>
<path
d="M8.177 1.677l2.896 2.896a.25.25 0 01-.177.427H8.75v1.25a.75.75 0 01-1.5 0V5H5.104a.25.25 0 01-.177-.427l2.896-2.896a.25.25 0 01.354 0zM7.25 11.75a.75.75 0 011.5 0V13h2.146a.25.25 0 01.177.427l-2.896 2.896a.25.25 0 01-.354 0l-2.896-2.896A.25.25 0 015.104 13H7.25v-1.25z"
fill="currentColor"
/>
</svg>
</span>
<span className={styles.hunkGutter} />
<span className={styles.hunkText}>
@@ -{hunk.oldStart},{hunk.oldCount} +{hunk.newStart},{hunk.newCount} @@
</span>
</div>
{hunk.lines.map((line, lineIdx) => (
<div
key={`${hunkIdx}-${lineIdx}`}
className={`${styles.diffLine} ${styles[line.type]}`}
>
<span
className={`${styles.lineNum} ${line.oldNum === null ? styles.lineNumEmpty : ''}`}
>
{line.oldNum ?? ''}
</span>
<span
className={`${styles.lineNum} ${line.newNum === null ? styles.lineNumEmpty : ''}`}
>
{line.newNum ?? ''}
</span>
<span className={styles.linePrefix}>
{line.type === 'deletion' ? '-' : line.type === 'addition' ? '+' : ' '}
</span>
<code className={styles.lineText}>{line.text || ' '}</code>
</div>
))}
</div>
))}
</div>
</div>
)}
</div>
</Modal>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-485
View File
@@ -1,485 +0,0 @@
// Search index for the visual config editor's global "jump to field" search.
//
// IMPORTANT: this index is maintained by hand and is NOT what drives field
// rendering — it only powers search. When you add, remove, or move a field in
// VisualConfigEditor.tsx, update the matching entry here (and wrap the field's
// JSX in <FieldAnchor fieldId="..."> using the same `fieldId`).
export type VisualSectionId =
'connectivity' | 'network' | 'logging' | 'quota' | 'streaming' | 'advanced' | 'payload';
export interface ConfigFieldSearchEntry {
/** Stable anchor id; matches FieldAnchor's `fieldId` and the rendered DOM id. */
fieldId: string;
sectionId: VisualSectionId;
/** i18n key resolved with t() at search time so matching follows the active language. */
labelKey: string;
/** Optional secondary i18n key shown next to the label to disambiguate duplicates
* (e.g. Claude vs Codex "User-Agent"). Also searchable. */
qualifierKey?: string;
/** Optional hint i18n key — searchable but not shown in results. */
hintKey?: string;
/** Backend YAML key aliases, e.g. ['proxy-url']. Static strings (language-agnostic). */
yamlKeys?: string[];
/** Extra synonyms to match against (language-agnostic, lowercase). */
keywords?: string[];
}
/** DOM id for a field anchor — kept in one place so the index and the anchors agree. */
export const configFieldDomId = (fieldId: string) => `cfg-field-${fieldId}`;
type Translate = (key: string) => string;
// Compact helper: every label/hint key lives under config_management.visual.
const L = (key: string) => `config_management.visual.${key}`;
export const CONFIG_FIELD_SEARCH_INDEX: ConfigFieldSearchEntry[] = [
// ── connectivity ──────────────────────────────────────────────────────────
{
fieldId: 'host',
sectionId: 'connectivity',
labelKey: L('sections.server.host'),
yamlKeys: ['host'],
},
{
fieldId: 'port',
sectionId: 'connectivity',
labelKey: L('sections.server.port'),
yamlKeys: ['port'],
},
{
fieldId: 'authDir',
sectionId: 'connectivity',
labelKey: L('sections.auth.auth_dir'),
hintKey: L('sections.auth.auth_dir_hint'),
yamlKeys: ['auth-dir'],
},
{
fieldId: 'apiKeys',
sectionId: 'connectivity',
labelKey: L('api_keys.label'),
yamlKeys: ['api-keys'],
keywords: ['api key', 'apikey', 'token'],
},
{
fieldId: 'tlsEnable',
sectionId: 'connectivity',
labelKey: L('sections.tls.enable'),
hintKey: L('sections.tls.enable_desc'),
yamlKeys: ['tls'],
keywords: ['tls', 'ssl', 'https'],
},
{
fieldId: 'tlsCert',
sectionId: 'connectivity',
labelKey: L('sections.tls.cert'),
yamlKeys: ['tls', 'cert'],
keywords: ['tls', 'ssl', 'certificate'],
},
{
fieldId: 'tlsKey',
sectionId: 'connectivity',
labelKey: L('sections.tls.key'),
yamlKeys: ['tls', 'key'],
keywords: ['tls', 'ssl', 'private key'],
},
{
fieldId: 'rmAllowRemote',
sectionId: 'connectivity',
labelKey: L('sections.remote.allow_remote'),
hintKey: L('sections.remote.allow_remote_desc'),
yamlKeys: ['remote-management', 'allow-remote'],
},
{
fieldId: 'rmDisableControlPanel',
sectionId: 'connectivity',
labelKey: L('sections.remote.disable_panel'),
yamlKeys: ['remote-management', 'disable-control-panel'],
},
{
fieldId: 'rmDisableAutoUpdatePanel',
sectionId: 'connectivity',
labelKey: L('sections.remote.disable_auto_update_panel'),
yamlKeys: ['remote-management', 'disable-auto-update-panel'],
},
{
fieldId: 'rmSecretKey',
sectionId: 'connectivity',
labelKey: L('sections.remote.secret_key'),
yamlKeys: ['remote-management', 'secret-key'],
},
{
fieldId: 'rmPanelRepo',
sectionId: 'connectivity',
labelKey: L('sections.remote.panel_repo'),
yamlKeys: ['remote-management', 'panel-github-repository'],
},
// ── network ───────────────────────────────────────────────────────────────
{
fieldId: 'proxyUrl',
sectionId: 'network',
labelKey: L('sections.network.proxy_url'),
yamlKeys: ['proxy-url'],
},
{
fieldId: 'requestRetry',
sectionId: 'network',
labelKey: L('sections.network.request_retry'),
yamlKeys: ['request-retry'],
},
{
fieldId: 'maxRetryCredentials',
sectionId: 'network',
labelKey: L('sections.network.max_retry_credentials'),
hintKey: L('sections.network.max_retry_credentials_hint'),
yamlKeys: ['max-retry-credentials'],
},
{
fieldId: 'maxRetryInterval',
sectionId: 'network',
labelKey: L('sections.network.max_retry_interval'),
yamlKeys: ['max-retry-interval'],
},
{
fieldId: 'authAutoRefreshWorkers',
sectionId: 'network',
labelKey: L('sections.network.auth_auto_refresh_workers'),
hintKey: L('sections.network.auth_auto_refresh_workers_hint'),
yamlKeys: ['auth-auto-refresh-workers'],
},
{
fieldId: 'routingStrategy',
sectionId: 'network',
labelKey: L('sections.network.routing_strategy'),
hintKey: L('sections.network.routing_strategy_hint'),
yamlKeys: ['routing', 'strategy'],
keywords: ['round-robin', 'weighted-round-robin', 'wrr', 'fill-first'],
},
{
fieldId: 'disableImageGeneration',
sectionId: 'network',
labelKey: L('sections.network.disable_image_generation'),
hintKey: L('sections.network.disable_image_generation_hint'),
yamlKeys: ['disable-image-generation'],
keywords: ['false', 'true', 'chat', 'passthrough'],
},
{
fieldId: 'gptImage2BaseModel',
sectionId: 'network',
labelKey: L('sections.network.gpt_image_2_base_model'),
hintKey: L('sections.network.gpt_image_2_base_model_hint'),
yamlKeys: ['gpt-image-2-base-model'],
},
{
fieldId: 'routingSessionAffinityTTL',
sectionId: 'network',
labelKey: L('sections.network.session_affinity_ttl'),
yamlKeys: ['routing', 'session-affinity-ttl'],
},
{
fieldId: 'forceModelPrefix',
sectionId: 'network',
labelKey: L('sections.network.force_model_prefix'),
hintKey: L('sections.network.force_model_prefix_desc'),
yamlKeys: ['force-model-prefix'],
},
{
fieldId: 'passthroughHeaders',
sectionId: 'network',
labelKey: L('sections.network.passthrough_headers'),
hintKey: L('sections.network.passthrough_headers_desc'),
yamlKeys: ['passthrough-headers'],
},
{
fieldId: 'disableCooling',
sectionId: 'network',
labelKey: L('sections.network.disable_cooling'),
hintKey: L('sections.network.disable_cooling_desc'),
yamlKeys: ['disable-cooling'],
},
{
fieldId: 'routingSessionAffinity',
sectionId: 'network',
labelKey: L('sections.network.session_affinity'),
yamlKeys: ['routing', 'session-affinity'],
},
{
fieldId: 'wsAuth',
sectionId: 'network',
labelKey: L('sections.network.ws_auth'),
hintKey: L('sections.network.ws_auth_desc'),
yamlKeys: ['ws-auth'],
keywords: ['websocket'],
},
// ── logging ───────────────────────────────────────────────────────────────
{
fieldId: 'debug',
sectionId: 'logging',
labelKey: L('sections.system.debug'),
hintKey: L('sections.system.debug_desc'),
yamlKeys: ['debug'],
},
{
fieldId: 'commercialMode',
sectionId: 'logging',
labelKey: L('sections.system.commercial_mode'),
hintKey: L('sections.system.commercial_mode_desc'),
yamlKeys: ['commercial-mode'],
},
{
fieldId: 'loggingToFile',
sectionId: 'logging',
labelKey: L('sections.system.logging_to_file'),
hintKey: L('sections.system.logging_to_file_desc'),
yamlKeys: ['logging-to-file'],
},
{
fieldId: 'logsMaxTotalSizeMb',
sectionId: 'logging',
labelKey: L('sections.system.logs_max_size'),
yamlKeys: ['logs-max-total-size-mb'],
},
{
fieldId: 'errorLogsMaxFiles',
sectionId: 'logging',
labelKey: L('sections.system.error_logs_max_files'),
yamlKeys: ['error-logs-max-files'],
},
{
fieldId: 'redisUsageQueueRetentionSeconds',
sectionId: 'logging',
labelKey: L('sections.system.redis_usage_retention'),
hintKey: L('sections.system.redis_usage_retention_hint'),
yamlKeys: ['redis-usage-queue-retention-seconds'],
},
{
fieldId: 'usageStatisticsEnabled',
sectionId: 'logging',
labelKey: L('sections.system.usage_statistics_enabled'),
hintKey: L('sections.system.usage_statistics_enabled_desc'),
yamlKeys: ['usage-statistics-enabled'],
},
// ── quota ─────────────────────────────────────────────────────────────────
{
fieldId: 'quotaSwitchProject',
sectionId: 'quota',
labelKey: L('sections.quota.switch_project'),
hintKey: L('sections.quota.switch_project_desc'),
yamlKeys: ['quota-exceeded', 'switch-project'],
},
{
fieldId: 'quotaSwitchPreviewModel',
sectionId: 'quota',
labelKey: L('sections.quota.switch_preview_model'),
hintKey: L('sections.quota.switch_preview_model_desc'),
yamlKeys: ['quota-exceeded', 'switch-preview-model'],
},
{
fieldId: 'quotaAntigravityCredits',
sectionId: 'quota',
labelKey: L('sections.quota.antigravity_credits'),
yamlKeys: ['quota-exceeded', 'antigravity-credits'],
},
// ── streaming ─────────────────────────────────────────────────────────────
{
fieldId: 'streamingKeepaliveSeconds',
sectionId: 'streaming',
labelKey: L('sections.streaming.keepalive_seconds'),
hintKey: L('sections.streaming.keepalive_hint'),
yamlKeys: ['streaming', 'keepalive-seconds'],
},
{
fieldId: 'streamingBootstrapRetries',
sectionId: 'streaming',
labelKey: L('sections.streaming.bootstrap_retries'),
hintKey: L('sections.streaming.bootstrap_hint'),
yamlKeys: ['streaming', 'bootstrap-retries'],
},
{
fieldId: 'streamingNonstreamKeepalive',
sectionId: 'streaming',
labelKey: L('sections.streaming.nonstream_keepalive'),
hintKey: L('sections.streaming.nonstream_keepalive_hint'),
yamlKeys: ['streaming', 'nonstream-keepalive-interval'],
},
// ── advanced ──────────────────────────────────────────────────────────────
{
fieldId: 'pluginsEnabled',
sectionId: 'advanced',
labelKey: L('sections.system.plugins_enabled'),
hintKey: L('sections.system.plugins_enabled_desc'),
yamlKeys: ['plugins'],
},
{
fieldId: 'pluginStoreSources',
sectionId: 'advanced',
labelKey: L('sections.system.plugin_store_sources'),
hintKey: L('sections.system.plugin_store_sources_hint'),
yamlKeys: ['plugins', 'store-sources'],
},
{
fieldId: 'pluginStoreAuth',
sectionId: 'advanced',
labelKey: L('sections.system.plugin_store_auth'),
hintKey: L('sections.system.plugin_store_auth_hint'),
yamlKeys: ['plugins', 'store-auth'],
},
{
fieldId: 'antigravitySignatureCacheEnabled',
sectionId: 'advanced',
labelKey: L('sections.system.antigravity_signature_cache'),
hintKey: L('sections.system.antigravity_signature_cache_desc'),
yamlKeys: ['antigravity-signature-cache-enabled'],
},
{
fieldId: 'antigravitySignatureBypassStrict',
sectionId: 'advanced',
labelKey: L('sections.system.antigravity_signature_strict'),
hintKey: L('sections.system.antigravity_signature_strict_desc'),
yamlKeys: ['antigravity-signature-bypass-strict'],
},
// Claude header defaults — qualifierKey disambiguates the shared "User-Agent" label.
{
fieldId: 'claudeHeaderUserAgent',
sectionId: 'advanced',
labelKey: L('sections.headers.user_agent'),
qualifierKey: L('sections.headers.claude_title'),
yamlKeys: ['claude-header-defaults', 'user-agent'],
keywords: ['claude'],
},
{
fieldId: 'claudeHeaderPackageVersion',
sectionId: 'advanced',
labelKey: L('sections.headers.package_version'),
qualifierKey: L('sections.headers.claude_title'),
yamlKeys: ['claude-header-defaults', 'package-version'],
keywords: ['claude'],
},
{
fieldId: 'claudeHeaderRuntimeVersion',
sectionId: 'advanced',
labelKey: L('sections.headers.runtime_version'),
qualifierKey: L('sections.headers.claude_title'),
yamlKeys: ['claude-header-defaults', 'runtime-version'],
keywords: ['claude'],
},
{
fieldId: 'claudeHeaderOs',
sectionId: 'advanced',
labelKey: L('sections.headers.os'),
qualifierKey: L('sections.headers.claude_title'),
yamlKeys: ['claude-header-defaults', 'os'],
keywords: ['claude'],
},
{
fieldId: 'claudeHeaderArch',
sectionId: 'advanced',
labelKey: L('sections.headers.arch'),
qualifierKey: L('sections.headers.claude_title'),
yamlKeys: ['claude-header-defaults', 'arch'],
keywords: ['claude'],
},
{
fieldId: 'claudeHeaderTimeout',
sectionId: 'advanced',
labelKey: L('sections.headers.timeout'),
qualifierKey: L('sections.headers.claude_title'),
yamlKeys: ['claude-header-defaults', 'timeout'],
keywords: ['claude'],
},
{
fieldId: 'claudeHeaderStabilizeDeviceProfile',
sectionId: 'advanced',
labelKey: L('sections.headers.stabilize_device'),
qualifierKey: L('sections.headers.claude_title'),
hintKey: L('sections.headers.stabilize_device_desc'),
yamlKeys: ['claude-header-defaults', 'stabilize-device-profile'],
keywords: ['claude'],
},
// Codex header defaults.
{
fieldId: 'codexHeaderUserAgent',
sectionId: 'advanced',
labelKey: L('sections.headers.user_agent'),
qualifierKey: L('sections.headers.codex_title'),
yamlKeys: ['codex-header-defaults', 'user-agent'],
keywords: ['codex'],
},
{
fieldId: 'codexHeaderBetaFeatures',
sectionId: 'advanced',
labelKey: L('sections.headers.beta_features'),
qualifierKey: L('sections.headers.codex_title'),
yamlKeys: ['codex-header-defaults', 'beta-features'],
keywords: ['codex'],
},
// ── payload (coarse: one entry per rule group) ──────────────────────────────
{
fieldId: 'payloadDefaultRules',
sectionId: 'payload',
labelKey: L('sections.payload.default_rules'),
hintKey: L('sections.payload.default_rules_desc'),
keywords: ['payload', 'rule'],
},
{
fieldId: 'payloadDefaultRawRules',
sectionId: 'payload',
labelKey: L('sections.payload.default_raw_rules'),
hintKey: L('sections.payload.default_raw_rules_desc'),
keywords: ['payload', 'rule', 'json'],
},
{
fieldId: 'payloadOverrideRules',
sectionId: 'payload',
labelKey: L('sections.payload.override_rules'),
hintKey: L('sections.payload.override_rules_desc'),
keywords: ['payload', 'rule'],
},
{
fieldId: 'payloadOverrideRawRules',
sectionId: 'payload',
labelKey: L('sections.payload.override_raw_rules'),
hintKey: L('sections.payload.override_raw_rules_desc'),
keywords: ['payload', 'rule', 'json'],
},
{
fieldId: 'payloadFilterRules',
sectionId: 'payload',
labelKey: L('sections.payload.filter_rules'),
hintKey: L('sections.payload.filter_rules_desc'),
keywords: ['payload', 'rule', 'filter'],
},
];
const MAX_RESULTS = 8;
/**
* Lowercase substring search over label + qualifier + hint + YAML keys + keywords.
* Returns the best ~8 matches, label/qualifier hits ranked above alias-only hits.
*/
export function searchConfigFields(query: string, t: Translate): ConfigFieldSearchEntry[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const scored: { entry: ConfigFieldSearchEntry; score: number }[] = [];
for (const entry of CONFIG_FIELD_SEARCH_INDEX) {
const label = t(entry.labelKey).toLowerCase();
const qualifier = entry.qualifierKey ? t(entry.qualifierKey).toLowerCase() : '';
const hint = entry.hintKey ? t(entry.hintKey).toLowerCase() : '';
const yaml = (entry.yamlKeys ?? []).join(' ').toLowerCase();
const keywords = (entry.keywords ?? []).join(' ').toLowerCase();
let score = Number.POSITIVE_INFINITY;
if (label.startsWith(q)) score = 0;
else if (label.includes(q)) score = 1;
else if (qualifier.includes(q) || keywords.includes(q)) score = 2;
else if (yaml.includes(q)) score = 3;
else if (hint.includes(q)) score = 4;
if (Number.isFinite(score)) scored.push({ entry, score });
}
scored.sort((a, b) => a.score - b.score);
return scored.slice(0, MAX_RESULTS).map((item) => item.entry);
}
-20
View File
@@ -879,21 +879,7 @@
"confirm": "Confirm Save",
"no_changes": "No changes detected"
},
"tabs": {
"visual": "Visual Editor",
"source": "Source File Editor"
},
"visual": {
"notice": "Visual mode covers common fields. Review or edit unsupported config.yaml entries in source mode.",
"quick_jump": "Quick Jump",
"mode": {
"simple": "Simple",
"full": "Full",
"label": "Editor mode",
"more_settings": "Switch to full mode to see all {{total}} sections",
"validation_banner": "Some advanced settings have validation errors and are blocking save. Switch to full mode to fix them.",
"switch_to_full": "Switch to full mode"
},
"search": {
"placeholder": "Search settings (label or YAML key)",
"no_results": "No matching settings"
@@ -918,8 +904,6 @@
"signature_title": "Antigravity Signature"
},
"server": {
"title": "Server Configuration",
"description": "Basic server settings",
"host": "Host Address",
"port": "Port"
},
@@ -945,14 +929,10 @@
"panel_repo": "Panel Repository"
},
"auth": {
"title": "Authentication Configuration",
"description": "API keys and authentication directory settings",
"auth_dir": "Auth Directory (auth-dir)",
"auth_dir_hint": "Directory path for authentication files (supports ~)"
},
"system": {
"title": "System Configuration",
"description": "Debug, logging, statistics, and performance settings",
"debug": "Debug Mode",
"debug_desc": "Enable verbose debug logging",
"commercial_mode": "Commercial Mode",
-20
View File
@@ -866,21 +866,7 @@
"confirm": "Подтвердить",
"no_changes": "Изменений не обнаружено"
},
"tabs": {
"visual": "Визуальный редактор",
"source": "Редактор файла"
},
"visual": {
"notice": "Визуальный режим охватывает основные поля. Остальные параметры config.yaml по-прежнему нужно проверять или редактировать в режиме исходника.",
"quick_jump": "Быстрый переход",
"mode": {
"simple": "Простой",
"full": "Полный",
"label": "Режим редактора",
"more_settings": "Переключитесь в полный режим, чтобы увидеть все разделы ({{total}})",
"validation_banner": "В некоторых дополнительных параметрах есть ошибки проверки, сохранение заблокировано. Переключитесь в полный режим, чтобы исправить их.",
"switch_to_full": "Перейти в полный режим"
},
"search": {
"placeholder": "Поиск настроек (по названию или ключу YAML)",
"no_results": "Нет подходящих настроек"
@@ -905,8 +891,6 @@
"signature_title": "Подпись Antigravity"
},
"server": {
"title": "Настройки сервера",
"description": "Базовые параметры сервера",
"host": "Адрес хоста",
"port": "Порт"
},
@@ -932,14 +916,10 @@
"panel_repo": "Репозиторий панели"
},
"auth": {
"title": "Настройки аутентификации",
"description": "API-ключи и каталог аутентификации",
"auth_dir": "Каталог auth-dir",
"auth_dir_hint": "Путь к каталогу с файлами аутентификации (поддерживает ~)"
},
"system": {
"title": "Системные настройки",
"description": "Отладка, журналирование, статистика и производительность",
"debug": "Режим отладки",
"debug_desc": "Включить подробные отладочные журналы",
"commercial_mode": "Коммерческий режим",
-20
View File
@@ -879,21 +879,7 @@
"confirm": "确认保存",
"no_changes": "未检测到变更"
},
"tabs": {
"visual": "可视化编辑",
"source": "源文件编辑"
},
"visual": {
"notice": "可视化模式覆盖常用字段,未覆盖的配置仍需在源文件模式中查看或编辑。",
"quick_jump": "快速跳转",
"mode": {
"simple": "简单",
"full": "完整",
"label": "编辑模式",
"more_settings": "切换到完整模式,查看全部 {{total}} 个配置分区",
"validation_banner": "部分高级配置项存在校验错误,已阻止保存。请切换到完整模式进行修复。",
"switch_to_full": "切换完整模式"
},
"search": {
"placeholder": "搜索配置项(标签或 YAML 键名)",
"no_results": "没有匹配的配置项"
@@ -918,8 +904,6 @@
"signature_title": "Antigravity 签名"
},
"server": {
"title": "服务器配置",
"description": "基础服务器设置",
"host": "主机地址",
"port": "端口"
},
@@ -945,14 +929,10 @@
"panel_repo": "面板仓库"
},
"auth": {
"title": "认证配置",
"description": "API 密钥与认证文件目录设置",
"auth_dir": "认证文件目录 (auth-dir)",
"auth_dir_hint": "存放认证文件的目录路径(支持 ~)"
},
"system": {
"title": "系统配置",
"description": "调试、日志、统计与性能调试设置",
"debug": "调试模式",
"debug_desc": "启用详细的调试日志",
"commercial_mode": "商业模式",
-20
View File
@@ -905,21 +905,7 @@
"confirm": "確認儲存",
"no_changes": "未偵測到變更"
},
"tabs": {
"visual": "視覺化編輯",
"source": "原始檔編輯"
},
"visual": {
"notice": "視覺化模式涵蓋常用欄位,未涵蓋的設定仍需在原始檔模式中查看或編輯。",
"quick_jump": "快速跳轉",
"mode": {
"simple": "簡單",
"full": "完整",
"label": "編輯模式",
"more_settings": "切換到完整模式,檢視全部 {{total}} 個設定分區",
"validation_banner": "部分進階設定項存在驗證錯誤,已阻止儲存。請切換到完整模式進行修正。",
"switch_to_full": "切換完整模式"
},
"search": {
"placeholder": "搜尋設定項(標籤或 YAML 鍵名)",
"no_results": "沒有符合的設定項"
@@ -944,8 +930,6 @@
"signature_title": "Antigravity 簽章"
},
"server": {
"title": "伺服器設定",
"description": "基本伺服器設定",
"host": "主機位址",
"port": "連接埠"
},
@@ -971,14 +955,10 @@
"panel_repo": "面板儲存庫"
},
"auth": {
"title": "驗證設定",
"description": "API 金鑰與驗證檔案目錄設定",
"auth_dir": "驗證檔案目錄(auth-dir",
"auth_dir_hint": "存放驗證檔案的目錄路徑(支援 ~)"
},
"system": {
"title": "系統設定",
"description": "除錯、記錄、統計與效能調整設定",
"debug": "除錯模式",
"debug_desc": "啟用詳細的除錯記錄",
"commercial_mode": "商業模式",
-395
View File
@@ -1,395 +0,0 @@
@use '../styles/mixins' as *;
@use '../styles/variables' as *;
.container {
width: min(100%, 1480px);
min-height: 100%;
display: flex;
flex-direction: column;
gap: clamp(18px, 2.4vw, 28px);
margin: 0 auto;
overflow-y: auto;
padding-bottom: calc(
var(--config-action-bar-height, 0px) + 16px + env(safe-area-inset-bottom) + #{$spacing-md}
);
}
.pageHeader {
display: flex;
align-items: flex-start;
}
.pageHeaderCopy {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
width: min(100%, 360px);
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.tabBar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px;
padding: 2px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: color-mix(in srgb, var(--bg-primary) 72%, transparent);
}
.tabItem {
@include button-reset;
min-height: 38px;
padding: 0 12px;
border-radius: 6px;
border: 1px solid transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 650;
line-height: 1.25;
white-space: nowrap;
transition:
color 0.15s ease,
background-color 0.15s ease,
border-color 0.15s ease;
&:hover:not(:disabled) {
color: var(--text-primary);
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
}
&:disabled {
opacity: 0.58;
cursor: not-allowed;
}
}
.tabActive {
color: var(--bg-primary);
background: var(--text-primary);
border-color: var(--text-primary);
}
.workspaceShell {
display: flex;
flex-direction: column;
gap: $spacing-lg;
min-width: 0;
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
min-height: 0;
}
.sourceWorkspace {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.sourceToolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: color-mix(in srgb, var(--bg-primary) 76%, transparent);
@include mobile {
grid-template-columns: minmax(0, 1fr);
}
}
.searchInputWrapper {
min-width: 0;
position: relative;
display: flex;
align-items: center;
:global(.form-group) {
margin-bottom: 0;
width: 100%;
}
}
.searchInput {
min-height: 38px !important;
border-radius: 6px !important;
padding-right: 128px !important;
background: var(--bg-secondary) !important;
}
.searchRight {
display: inline-flex;
align-items: center;
gap: 6px;
}
.searchCount {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid var(--border-color);
color: var(--text-secondary);
font-size: 12px;
font-weight: 650;
pointer-events: none;
white-space: nowrap;
}
.searchButton {
@include button-reset;
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 6px;
border: 1px solid var(--text-primary);
background: var(--text-primary);
color: var(--bg-primary);
transition:
background-color $transition-fast,
border-color $transition-fast,
opacity $transition-fast;
&:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.searchActions {
display: flex;
gap: 6px;
flex-shrink: 0;
button {
min-width: 38px;
width: 38px;
height: 38px;
padding: 0 !important;
border-radius: 6px;
}
@include mobile {
justify-content: stretch;
button {
flex: 1;
width: auto;
}
}
}
.editorWrapper {
width: 100%;
flex: 0 0 auto;
height: clamp(500px, 70vh, 1040px);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
background: var(--bg-primary);
@supports (height: 100dvh) {
height: clamp(500px, 70dvh, 1040px);
}
:global {
.cm-editor {
height: 100%;
font-size: 13px;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
background: transparent;
}
.cm-scroller {
overflow: auto;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pan-y;
overscroll-behavior: contain;
}
.cm-gutters {
border-right: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-secondary) 86%, transparent);
}
.cm-lineNumbers .cm-gutterElement {
padding: 0 8px 0 12px;
min-width: 40px;
color: var(--text-muted);
}
.cm-activeLine,
.cm-activeLineGutter {
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
}
.cm-selectionMatch {
background: rgba(224, 170, 20, 0.24);
}
.cm-searchMatch {
background: rgba(224, 170, 20, 0.32);
outline: 1px solid rgba(224, 170, 20, 0.48);
}
.cm-searchMatch-selected {
background: rgba(198, 87, 70, 0.32);
}
}
}
.modified {
color: var(--warning-text);
background: var(--warning-bg);
border-color: var(--warning-border);
}
.saved {
color: var(--success-color);
background: color-mix(in srgb, var(--success-color) 10%, transparent);
border-color: color-mix(in srgb, var(--success-color) 34%, var(--border-color));
}
.error {
color: var(--warning-text);
background: var(--warning-bg);
border-color: var(--warning-border);
}
.floatingActionContainer {
position: fixed;
left: var(--content-center-x, 50%);
bottom: calc(16px + env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 50;
pointer-events: auto;
width: fit-content;
max-width: calc(100vw - 24px);
}
.floatingActionList {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px;
max-width: inherit;
overflow-x: auto;
border: 1px solid var(--border-color);
border-radius: 8px;
background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
box-shadow: var(--shadow-lg);
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.floatingStatus {
display: inline-flex;
align-items: center;
min-width: 0;
min-height: 34px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid transparent;
color: var(--text-primary);
font-size: 11px;
font-weight: 700;
line-height: 1.2;
text-align: center;
max-width: min(300px, 46vw);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.floatingStatusCompact {
max-width: 112px;
padding: 0 8px;
font-size: 10px;
}
.floatingActionButton {
@include button-reset;
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease,
opacity 0.15s ease;
&:hover:not(:disabled) {
background: var(--text-primary);
color: var(--bg-primary);
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.dirtyDot {
position: absolute;
top: 8px;
right: 8px;
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--warning-color);
box-shadow: 0 0 0 2px var(--bg-primary);
}
@media (max-width: 1200px) {
.floatingActionContainer {
bottom: calc(12px + env(safe-area-inset-bottom));
max-width: calc(100vw - 16px);
}
.floatingStatus {
max-width: min(180px, 40vw);
}
}
@media (max-width: 480px) {
.floatingStatus {
max-width: min(132px, 38vw);
}
.searchInput {
padding-right: 108px !important;
}
}
-691
View File
@@ -1,691 +0,0 @@
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import type { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { parse as parseYaml, parseDocument } from 'yaml';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import {
IconCheck,
IconChevronDown,
IconChevronUp,
IconRefreshCw,
IconSearch,
} from '@/components/ui/icons';
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
import { DiffModal } from '@/components/config/DiffModal';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useActionBarHeightVar } from '@/hooks/useActionBarHeightVar';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { useVisualConfig } from '@/hooks/useVisualConfig';
import { useNotificationStore, useAuthStore, useThemeStore, useConfigStore } from '@/stores';
import { configFileApi } from '@/services/api/configFile';
import styles from './ConfigPage.module.scss';
type ConfigEditorTab = 'visual' | 'source';
const LazyConfigSourceEditor = lazy(() => import('@/components/config/ConfigSourceEditor'));
function readCommercialModeFromYaml(yamlContent: string): boolean {
try {
const parsed = parseYaml(yamlContent);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
return Boolean((parsed as Record<string, unknown>)['commercial-mode']);
} catch {
return false;
}
}
function normalizeYamlForVisualDiff(yamlContent: string): string {
try {
const doc = parseDocument(yamlContent);
return doc.toString({ indent: 2, lineWidth: 120, minContentWidth: 0 });
} catch {
return yamlContent;
}
}
export function ConfigPage() {
const { t } = useTranslation();
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.isCurrentLayer : true;
const showNotification = useNotificationStore((state) => state.showNotification);
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isMobile = useMediaQuery('(max-width: 768px)');
const {
visualValues,
visualDirty,
visualParseError,
visualValidationErrors,
visualHasPayloadValidationErrors,
loadVisualValuesFromYaml,
applyVisualChangesToYaml,
setVisualValues,
} = useVisualConfig();
const [activeTab, setActiveTab] = useState<ConfigEditorTab>(() => {
const saved = localStorage.getItem('config-management:tab');
if (saved === 'visual' || saved === 'source') return saved;
return 'visual';
});
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [dirty, setDirty] = useState(false);
const [diffModalOpen, setDiffModalOpen] = useState(false);
const [serverYaml, setServerYaml] = useState('');
const [mergedYaml, setMergedYaml] = useState('');
const [previewServerYaml, setPreviewServerYaml] = useState('');
const [previewTab, setPreviewTab] = useState<ConfigEditorTab>('visual');
// Search state
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<{ current: number; total: number }>({
current: 0,
total: 0,
});
const [lastSearchedQuery, setLastSearchedQuery] = useState('');
const editorRef = useRef<ReactCodeMirrorRef | null>(null);
const floatingActionsRef = useRef<HTMLDivElement>(null);
const disableControls = connectionStatus !== 'connected';
const isDirty = dirty || visualDirty;
const shouldRenderFloatingActions = isCurrentLayer;
const hasVisualModeError = !!visualParseError;
const hasVisualValidationErrors =
activeTab === 'visual' &&
(Object.values(visualValidationErrors).some(Boolean) || visualHasPayloadValidationErrors);
const unsavedChangesDialog = useMemo(
() => ({
title: t('common.unsaved_changes_title'),
message: t('common.unsaved_changes_message'),
confirmText: t('common.confirm'),
cancelText: t('common.cancel'),
}),
[t]
);
useUnsavedChangesGuard({
enabled: isCurrentLayer,
shouldBlock: isDirty,
dialog: unsavedChangesDialog,
});
const loadConfig = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await configFileApi.fetchConfigYaml();
setContent(data);
setDirty(false);
setDiffModalOpen(false);
setServerYaml(data);
setMergedYaml(data);
setPreviewServerYaml(data);
loadVisualValuesFromYaml(data);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('notification.refresh_failed');
setError(message);
} finally {
setLoading(false);
}
}, [loadVisualValuesFromYaml, t]);
useEffect(() => {
loadConfig();
}, [loadConfig]);
useEffect(() => {
if (activeTab !== 'visual' || !visualParseError) return;
setActiveTab('source');
localStorage.setItem('config-management:tab', 'source');
showNotification(
t('config_management.visual_mode_unavailable_detail', { message: visualParseError }),
'error'
);
}, [activeTab, showNotification, t, visualParseError]);
const handleConfirmSave = async () => {
setSaving(true);
try {
const latestServerYaml = await configFileApi.fetchConfigYaml();
if (latestServerYaml !== previewServerYaml) {
const nextMergedYaml =
previewTab === 'visual' && !dirty
? applyVisualChangesToYaml(latestServerYaml)
: mergedYaml;
const nextServerYaml =
previewTab === 'visual' ? normalizeYamlForVisualDiff(latestServerYaml) : latestServerYaml;
setPreviewServerYaml(latestServerYaml);
setServerYaml(nextServerYaml);
setMergedYaml(nextMergedYaml);
if (nextServerYaml === nextMergedYaml) {
setDirty(false);
setDiffModalOpen(false);
setContent(latestServerYaml);
loadVisualValuesFromYaml(latestServerYaml);
showNotification(t('config_management.diff.no_changes'), 'info');
}
return;
}
const previousCommercialMode = readCommercialModeFromYaml(latestServerYaml);
const nextCommercialMode = readCommercialModeFromYaml(mergedYaml);
const commercialModeChanged = previousCommercialMode !== nextCommercialMode;
await configFileApi.saveConfigYaml(mergedYaml);
const latestContent = await configFileApi.fetchConfigYaml();
setDirty(false);
setDiffModalOpen(false);
setContent(latestContent);
setServerYaml(latestContent);
setMergedYaml(latestContent);
setPreviewServerYaml(latestContent);
loadVisualValuesFromYaml(latestContent);
// Keep the global config store in sync so sidebar / other pages reflect YAML changes immediately.
try {
useConfigStore.getState().clearCache();
await useConfigStore.getState().fetchConfig(true);
} catch (refreshError: unknown) {
const message =
refreshError instanceof Error
? refreshError.message
: typeof refreshError === 'string'
? refreshError
: '';
showNotification(
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
'error'
);
}
showNotification(t('config_management.save_success'), 'success');
if (commercialModeChanged) {
showNotification(t('notification.commercial_mode_restart_required'), 'warning');
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(`${t('notification.save_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
};
const handleSave = async () => {
if (activeTab === 'visual' && visualParseError) {
showNotification(t('config_management.visual_mode_save_blocked'), 'error');
return;
}
setSaving(true);
try {
const latestServerYaml = await configFileApi.fetchConfigYaml();
const visualBaseYaml = dirty ? content : latestServerYaml;
if (activeTab !== 'source') {
const latestDocument = parseDocument(latestServerYaml);
if (latestDocument.errors.length > 0) {
showNotification(
t('config_management.visual_mode_latest_yaml_invalid', {
message:
latestDocument.errors[0]?.message ??
t('config_management.visual_mode_save_blocked'),
}),
'error'
);
return;
}
if (visualBaseYaml !== latestServerYaml) {
const visualBaseDocument = parseDocument(visualBaseYaml);
if (visualBaseDocument.errors.length > 0) {
showNotification(
t('config_management.visual_mode_latest_yaml_invalid', {
message:
visualBaseDocument.errors[0]?.message ??
t('config_management.visual_mode_save_blocked'),
}),
'error'
);
return;
}
}
}
// In source mode, save exactly what the user edited. In visual mode, preserve the
// local source draft when it has unsaved edits so source-only backend fields are not dropped.
const nextMergedYaml =
activeTab === 'source' ? content : applyVisualChangesToYaml(visualBaseYaml);
// In visual mode, applyVisualChangesToYaml re-serializes YAML via parseDocument → toString,
// which may reformat comments/whitespace. Normalize the server YAML through the same pipeline
// so the diff only shows actual value changes, not cosmetic reformatting.
let diffOriginal = latestServerYaml;
if (activeTab !== 'source') {
diffOriginal = normalizeYamlForVisualDiff(latestServerYaml);
}
if (diffOriginal === nextMergedYaml) {
setDirty(false);
setContent(latestServerYaml);
setServerYaml(latestServerYaml);
setMergedYaml(nextMergedYaml);
setPreviewServerYaml(latestServerYaml);
loadVisualValuesFromYaml(latestServerYaml);
showNotification(t('config_management.diff.no_changes'), 'info');
return;
}
setServerYaml(diffOriginal);
setMergedYaml(nextMergedYaml);
setPreviewServerYaml(latestServerYaml);
setPreviewTab(activeTab);
setDiffModalOpen(true);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(`${t('notification.save_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
};
const handleChange = useCallback((value: string) => {
setContent(value);
setDirty(true);
}, []);
const handleTabChange = useCallback(
(tab: ConfigEditorTab) => {
if (tab === activeTab) return;
if (tab === 'source') {
// Only rewrite YAML when there are pending visual changes; otherwise preserve raw YAML + comments.
if (visualDirty) {
const nextContent = applyVisualChangesToYaml(content);
if (nextContent !== content) {
setContent(nextContent);
setDirty(true);
}
}
} else {
const result = loadVisualValuesFromYaml(content);
if (!result.ok) {
showNotification(
t('config_management.visual_mode_unavailable_detail', { message: result.error }),
'error'
);
return;
}
}
setActiveTab(tab);
localStorage.setItem('config-management:tab', tab);
},
[
activeTab,
applyVisualChangesToYaml,
content,
loadVisualValuesFromYaml,
showNotification,
t,
visualDirty,
]
);
// Search functionality
const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => {
if (!query || !editorRef.current?.view) return;
const view = editorRef.current.view;
const doc = view.state.doc.toString();
const matches: number[] = [];
const lowerQuery = query.toLowerCase();
const lowerDoc = doc.toLowerCase();
let pos = 0;
while (pos < lowerDoc.length) {
const index = lowerDoc.indexOf(lowerQuery, pos);
if (index === -1) break;
matches.push(index);
pos = index + 1;
}
if (matches.length === 0) {
setSearchResults({ current: 0, total: 0 });
return;
}
// Find current match based on cursor position
const selection = view.state.selection.main;
const cursorPos = direction === 'prev' ? selection.from : selection.to;
let currentIndex = 0;
if (direction === 'next') {
// Find next match after cursor
for (let i = 0; i < matches.length; i++) {
if (matches[i] > cursorPos) {
currentIndex = i;
break;
}
// If no match after cursor, wrap to first
if (i === matches.length - 1) {
currentIndex = 0;
}
}
} else {
// Find previous match before cursor
for (let i = matches.length - 1; i >= 0; i--) {
if (matches[i] < cursorPos) {
currentIndex = i;
break;
}
// If no match before cursor, wrap to last
if (i === 0) {
currentIndex = matches.length - 1;
}
}
}
const matchPos = matches[currentIndex];
setSearchResults({ current: currentIndex + 1, total: matches.length });
// Scroll to and select the match
view.dispatch({
selection: { anchor: matchPos, head: matchPos + query.length },
scrollIntoView: true,
});
view.focus();
}, []);
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
// Do not auto-search on each keystroke. Clear previous results when query changes.
if (!value) {
setSearchResults({ current: 0, total: 0 });
setLastSearchedQuery('');
} else {
setSearchResults({ current: 0, total: 0 });
}
}, []);
const executeSearch = useCallback(
(direction: 'next' | 'prev' = 'next') => {
if (!searchQuery) return;
setLastSearchedQuery(searchQuery);
performSearch(searchQuery, direction);
},
[searchQuery, performSearch]
);
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
executeSearch(e.shiftKey ? 'prev' : 'next');
}
},
[executeSearch]
);
const handlePrevMatch = useCallback(() => {
if (!lastSearchedQuery) return;
performSearch(lastSearchedQuery, 'prev');
}, [lastSearchedQuery, performSearch]);
const handleNextMatch = useCallback(() => {
if (!lastSearchedQuery) return;
performSearch(lastSearchedQuery, 'next');
}, [lastSearchedQuery, performSearch]);
// Keep bottom floating actions from covering page content by syncing its height to a CSS variable.
useActionBarHeightVar(
floatingActionsRef,
'--config-action-bar-height',
shouldRenderFloatingActions
);
// Status text
const getStatusText = () => {
if (disableControls) return t('config_management.status_disconnected');
if (loading) return t('config_management.status_loading');
if (error) return t('config_management.status_load_failed');
if (hasVisualModeError) return t('config_management.visual_mode_unavailable');
if (hasVisualValidationErrors)
return t('config_management.visual.validation.validation_blocked');
if (saving) return t('config_management.status_saving');
if (isDirty) return t('config_management.status_dirty');
return t('config_management.status_loaded');
};
const getStatusClass = () => {
if (error || hasVisualModeError || hasVisualValidationErrors) return styles.error;
if (isDirty) return styles.modified;
if (!loading && !saving) return styles.saved;
return '';
};
const getFloatingStatusText = () => {
if (!isMobile) return getStatusText();
if (disableControls)
return t('config_management.status_disconnected_short', { defaultValue: 'Disconnected' });
if (loading) return t('config_management.status_loading_short', { defaultValue: 'Loading' });
if (error) return t('config_management.status_load_failed_short', { defaultValue: 'Failed' });
if (hasVisualModeError)
return t('config_management.visual_mode_unavailable_short', { defaultValue: 'YAML issue' });
if (hasVisualValidationErrors)
return t('config_management.visual.validation_blocked_short', { defaultValue: 'Fix errors' });
if (saving) return t('config_management.status_saving_short', { defaultValue: 'Saving' });
if (isDirty) return t('config_management.status_dirty_short', { defaultValue: 'Unsaved' });
return t('config_management.status_loaded_short', { defaultValue: 'Loaded' });
};
const handleReload = useCallback(() => {
if (!isDirty) {
void loadConfig();
return;
}
showConfirmation({
title: t('common.unsaved_changes_title'),
message: t('config_management.reload_confirm_message'),
confirmText: t('config_management.reload'),
cancelText: t('common.cancel'),
variant: 'danger',
onConfirm: async () => {
await loadConfig();
},
});
}, [isDirty, loadConfig, showConfirmation, t]);
const floatingActions = (
<div className={styles.floatingActionContainer} ref={floatingActionsRef}>
<div className={styles.floatingActionList}>
<div
className={`${styles.floatingStatus} ${
isMobile ? styles.floatingStatusCompact : ''
} ${getStatusClass()}`}
>
{getFloatingStatusText()}
</div>
<button
type="button"
className={styles.floatingActionButton}
onClick={handleReload}
disabled={loading || saving}
title={t('config_management.reload')}
aria-label={t('config_management.reload')}
>
<IconRefreshCw size={16} />
</button>
<button
type="button"
className={styles.floatingActionButton}
onClick={handleSave}
disabled={
disableControls ||
loading ||
saving ||
!isDirty ||
diffModalOpen ||
hasVisualModeError ||
hasVisualValidationErrors
}
title={t('config_management.save')}
aria-label={t('config_management.save')}
>
<IconCheck size={16} />
{isDirty && <span className={styles.dirtyDot} aria-hidden="true" />}
</button>
</div>
</div>
);
return (
<div className={styles.container}>
<div className={styles.pageHeader}>
<div className={styles.pageHeaderCopy}>
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
<div className={styles.tabBar}>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'visual' ? styles.tabActive : ''}`}
onClick={() => handleTabChange('visual')}
disabled={saving || loading}
>
{t('config_management.tabs.visual', { defaultValue: '可视化编辑' })}
</button>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'source' ? styles.tabActive : ''}`}
onClick={() => handleTabChange('source')}
disabled={saving || loading}
>
{t('config_management.tabs.source', { defaultValue: '源代码编辑' })}
</button>
</div>
</div>
</div>
<div className={styles.workspaceShell}>
<div className={styles.content}>
{error && <div className="error-box">{error}</div>}
{!error && visualParseError && (
<div className="error-box">
{t('config_management.visual_mode_unavailable_detail', { message: visualParseError })}
</div>
)}
{activeTab === 'visual' ? (
<VisualConfigEditor
values={visualValues}
validationErrors={visualValidationErrors}
hasPayloadValidationErrors={visualHasPayloadValidationErrors}
disabled={disableControls || loading}
onChange={setVisualValues}
/>
) : (
<div className={styles.sourceWorkspace}>
<div className={styles.sourceToolbar}>
<div className={styles.searchInputWrapper}>
<Input
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder={t('config_management.search_placeholder', {
defaultValue: '搜索配置内容...',
})}
disabled={disableControls || loading}
className={styles.searchInput}
rightElement={
<div className={styles.searchRight}>
{searchQuery && lastSearchedQuery === searchQuery && (
<span className={styles.searchCount}>
{searchResults.total > 0
? `${searchResults.current} / ${searchResults.total}`
: t('config_management.search_no_results', {
defaultValue: '无结果',
})}
</span>
)}
<button
type="button"
className={styles.searchButton}
onClick={() => executeSearch('next')}
disabled={!searchQuery || disableControls || loading}
title={t('config_management.search_button', { defaultValue: '搜索' })}
>
<IconSearch size={16} />
</button>
</div>
}
/>
</div>
<div className={styles.searchActions}>
<Button
variant="secondary"
size="sm"
onClick={handlePrevMatch}
disabled={
!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0
}
title={t('config_management.search_prev', { defaultValue: '上一个' })}
>
<IconChevronUp size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleNextMatch}
disabled={
!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0
}
title={t('config_management.search_next', { defaultValue: '下一个' })}
>
<IconChevronDown size={16} />
</Button>
</div>
</div>
<div className={styles.editorWrapper}>
<Suspense fallback={null}>
<LazyConfigSourceEditor
editorRef={editorRef}
value={content}
onChange={handleChange}
theme={resolvedTheme}
editable={!disableControls && !loading}
placeholder={t('config_management.editor_placeholder')}
/>
</Suspense>
</div>
</div>
)}
</div>
</div>
{shouldRenderFloatingActions && typeof document !== 'undefined'
? createPortal(floatingActions, document.body)
: null}
<DiffModal
open={diffModalOpen}
original={serverYaml}
modified={mergedYaml}
onConfirm={handleConfirmSave}
onCancel={() => setDiffModalOpen(false)}
loading={saving}
/>
</div>
);
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { QuotaPage } from '@/features/quota/QuotaPage';
import { PluginResourcePage } from '@/features/plugins/PluginResourcePage';
import { PluginsPage } from '@/features/plugins/PluginsPage';
import { PluginStorePage } from '@/features/plugins/PluginStorePage';
import { ConfigPage } from '@/pages/ConfigPage';
import { ConfigPage } from '@/features/config/ConfigPage';
import { LogsPage } from '@/pages/LogsPage';
import { SystemPage } from '@/pages/SystemPage';
import { useAuthStore } from '@/stores';