mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-08-29 00:41:25 +08:00
feat(config)!: route /config to the redesigned page and retire the legacy editor
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Коммерческий режим",
|
||||
|
||||
@@ -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": "商业模式",
|
||||
|
||||
@@ -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": "商業模式",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user