feat(config): scaffold config feature shell (constants, uiState, tabs, header, save bar)

This commit is contained in:
Supra4E8C
2026-08-06 02:34:09 +08:00
parent 30478c539c
commit c2feeac1ca
20 changed files with 2720 additions and 0 deletions
@@ -0,0 +1,156 @@
@use '../../../styles/mixins' as *;
/* 配置面板头部:与凭证库/额度页同语汇(紧排标题 / ▍mono 遥测 / ghost 次按钮) */
.header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px 24px;
flex-wrap: wrap;
}
.copy {
display: flex;
flex-direction: column;
gap: 7px;
min-width: 0;
}
.title {
margin: 0;
font-size: clamp(26px, 3.2vw, 30px);
line-height: 1.15;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.meta {
margin: 0;
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 8px;
font-family: $font-mono;
font-size: 13px;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
color: var(--text-secondary);
/* 终端游标:全站页头 meta 行的结构记号 */
&::before {
content: '';
color: var(--viz-success);
font-size: 13px;
line-height: 1;
align-self: center;
margin-right: 1px;
}
}
.metaDot {
color: var(--text-quaternary);
user-select: none;
}
.metaMuted {
color: var(--text-secondary);
}
.metaWarning {
color: var(--amber-text);
}
.metaError {
color: var(--viz-failure);
}
.metaOk {
color: var(--viz-success);
}
/* ---------- 动作区 ---------- */
.actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* 次按钮:安静的文字链接 */
.ghostAction {
display: inline-flex;
align-items: center;
gap: 6px;
border: 0;
cursor: pointer;
background: none;
color: var(--text-secondary);
border-radius: $radius-full;
padding: 10px 14px;
font-size: 13px;
font-weight: 550;
line-height: 1;
transition:
color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
&:disabled {
cursor: not-allowed;
opacity: 0.55;
}
&:active:not(:disabled) {
transform: scale(0.97);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
}
@media (hover: hover) and (pointer: fine) {
.ghostAction:hover:not(:disabled) {
color: var(--text-primary);
}
}
.spinning {
animation: config-header-spin 0.8s linear infinite;
}
@keyframes config-header-spin {
to {
transform: rotate(360deg);
}
}
@include mobile {
.header {
align-items: stretch;
flex-direction: column;
}
.actions {
justify-content: flex-start;
flex-wrap: wrap;
}
}
@media (prefers-reduced-motion: reduce) {
.ghostAction {
transition: none;
}
.ghostAction:active:not(:disabled) {
transform: none;
}
.spinning {
animation: none;
}
}
@@ -0,0 +1,73 @@
import { Fragment, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { IconRefreshCw } from '@/components/ui/icons';
import type { HeaderMetaSegment } from '../uiState';
import styles from './ConfigHeader.module.scss';
export type ConfigHeaderProps = {
/** ▍mono meta 行的段落序列(uiState.buildHeaderMeta 的产物)。 */
meta: HeaderMetaSegment[];
reloadDisabled: boolean;
reloading: boolean;
onReload: () => void;
/** 移动端上移到头部动作行的 ModeSwitch 槽位(桌面端为 nullModeSwitch 在 tabs 行右端)。 */
extraActions?: ReactNode;
};
/**
* 配置面板头部:标题领衔 + ▍mono 遥测 meta 行 + 重载 ghost。
* 保存动作不在头部常驻 —— 由 FloatingSaveBar 在 dirty 时承载。
*/
export function ConfigHeader({
meta,
reloadDisabled,
reloading,
onReload,
extraActions,
}: ConfigHeaderProps) {
const { t } = useTranslation();
const toneClass: Record<HeaderMetaSegment['tone'], string> = {
muted: styles.metaMuted,
warning: styles.metaWarning,
error: styles.metaError,
ok: styles.metaOk,
};
return (
<header className={styles.header}>
<div className={styles.copy}>
<h1 className={styles.title} data-reveal>
{t('config_management.title')}
</h1>
<p className={styles.meta} data-reveal>
{meta.map((segment, index) => (
<Fragment key={segment.key}>
{index > 0 ? (
<span className={styles.metaDot} aria-hidden="true">
·
</span>
) : null}
<span className={toneClass[segment.tone]}>
{segment.count !== undefined
? t(segment.labelKey, { count: segment.count })
: t(segment.labelKey)}
</span>
</Fragment>
))}
</p>
</div>
<div className={styles.actions} data-reveal>
{extraActions}
<button
type="button"
className={styles.ghostAction}
onClick={onReload}
disabled={reloadDisabled}
>
<IconRefreshCw size={14} className={reloading ? styles.spinning : undefined} />
{t('config_management.reload')}
</button>
</div>
</header>
);
}
@@ -0,0 +1,130 @@
@use '../../../styles/mixins' as *;
/* 分区 tabs:安静的下划线式(种子自提供商 tabs),加错误徽章与脏点两个状态通道 */
.tabs {
display: flex;
align-items: stretch;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
min-width: 0;
flex: 1;
&::-webkit-scrollbar {
display: none;
}
}
.tab {
position: relative;
display: inline-flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
border: 0;
background: none;
cursor: pointer;
padding: 8px 12px 11px;
border-radius: 8px 8px 0 0;
font-size: 13px;
font-weight: 550;
color: var(--text-secondary);
white-space: nowrap;
transition:
color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
&::after {
content: '';
position: absolute;
left: 10px;
right: 10px;
bottom: -1px;
height: 2px;
border-radius: $radius-full;
background: transparent;
transition: background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
}
&:disabled {
cursor: not-allowed;
opacity: 0.55;
}
&:active:not(:disabled) {
transform: translateY(0.5px);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
}
@media (hover: hover) and (pointer: fine) {
.tab:hover:not(:disabled) {
color: var(--text-primary);
background: color-mix(in srgb, var(--bg-tertiary) 55%, transparent);
}
}
.tabActive {
color: var(--text-primary);
font-weight: 650;
&::after {
background: var(--text-primary);
}
/* 整体禁用(保存中)时激活 tab 仍保持完整对比度 */
&:disabled {
opacity: 1;
}
}
.tabGlyph {
flex-shrink: 0;
color: var(--text-tertiary);
.tabActive & {
color: var(--text-primary);
}
}
.tabLabel {
line-height: 1.4;
}
/* 校验错误徽章:mono 数字,失败色(数据墨水走 --viz-*) */
.tabBadge {
font-family: $font-mono;
font-size: 10.5px;
font-weight: 650;
font-variant-numeric: tabular-nums;
line-height: 1.6;
color: var(--viz-failure);
padding: 0 6px;
border-radius: $radius-full;
background: color-mix(in srgb, var(--viz-failure) 12%, transparent);
}
/* 待保存脏点:6px 琥珀 */
.tabDirtyDot {
width: 6px;
height: 6px;
border-radius: $radius-full;
background: var(--amber-color);
flex-shrink: 0;
}
@media (prefers-reduced-motion: reduce) {
.tab,
.tab::after {
transition: none;
}
.tab:active:not(:disabled) {
transform: none;
}
}
@@ -0,0 +1,110 @@
import { useEffect, useRef, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { prefersReducedMotion } from '@/hooks/motion';
import {
CONFIG_TAB_ICONS,
CONFIG_TAB_IDS,
configPanelDomId,
configTabDomId,
type ConfigTabId,
} from '../constants';
import styles from './ConfigTabs.module.scss';
export type ConfigTabsProps = {
active: ConfigTabId;
/** 每 tab 校验错误数(uiState.countSectionErrors 的产物),>0 显示失败色徽章。 */
errorCounts: Partial<Record<ConfigTabId, number>>;
/** 有待保存修改的 tabsuiState.resolveDirtyTabs 的产物),显示琥珀脏点。 */
dirtyTabs: ReadonlySet<ConfigTabId>;
disabled?: boolean;
onChange: (id: ConfigTabId) => void;
};
/**
* 分区 tabs:安静的下划线式(与提供商 tabs 同语汇),图标 + 标签 + 错误徽章 + 脏点。
* 「常用」是首 tab;tab 切换是高频操作,零动画。
*/
export function ConfigTabs({
active,
errorCounts,
dirtyTabs,
disabled = false,
onChange,
}: ConfigTabsProps) {
const { t } = useTranslation();
const listRef = useRef<HTMLDivElement | null>(null);
const buttonRefs = useRef<Partial<Record<ConfigTabId, HTMLButtonElement | null>>>({});
// 移动端横滚时把激活 tab 带回视野中央;无溢出时不动,避免无谓的页面滚动。
useEffect(() => {
const scroller = listRef.current;
const button = buttonRefs.current[active];
if (!scroller || !button) return;
if (scroller.scrollWidth <= scroller.clientWidth) return;
button.scrollIntoView({
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
block: 'nearest',
inline: 'center',
});
}, [active]);
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const count = CONFIG_TAB_IDS.length;
const currentIndex = CONFIG_TAB_IDS.indexOf(active);
let nextIndex = -1;
if (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % count;
else if (event.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + count) % count;
else if (event.key === 'Home') nextIndex = 0;
else if (event.key === 'End') nextIndex = count - 1;
if (nextIndex < 0) return;
event.preventDefault();
const nextId = CONFIG_TAB_IDS[nextIndex];
onChange(nextId);
buttonRefs.current[nextId]?.focus();
};
return (
<div
className={styles.tabs}
role="tablist"
aria-label={t('config_management.title')}
ref={listRef}
>
{CONFIG_TAB_IDS.map((id) => {
const Icon = CONFIG_TAB_ICONS[id];
const isActive = active === id;
const errorCount = errorCounts[id] ?? 0;
return (
<button
key={id}
ref={(node) => {
buttonRefs.current[id] = node;
}}
type="button"
role="tab"
id={configTabDomId(id)}
aria-selected={isActive}
aria-controls={configPanelDomId(id)}
tabIndex={isActive ? 0 : -1}
className={`${styles.tab} ${isActive ? styles.tabActive : ''}`}
disabled={disabled}
onClick={() => onChange(id)}
onKeyDown={handleKeyDown}
>
<Icon size={15} className={styles.tabGlyph} />
<span className={styles.tabLabel}>
{t(`config_management.visual.sections.${id}.title`)}
</span>
{errorCount > 0 ? (
<span className={styles.tabBadge} aria-hidden="true">
{errorCount}
</span>
) : null}
{dirtyTabs.has(id) ? <span className={styles.tabDirtyDot} aria-hidden="true" /> : null}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,177 @@
@use '../../../styles/mixins' as *;
/* 悬浮保存栏:底部居中的玻璃工具栏(--content-center-x 对齐内容列中心) */
.container {
position: fixed;
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
left: var(--content-center-x, 50%);
transform: translateX(-50%);
width: min(720px, calc(100vw - 24px));
z-index: $z-dropdown;
pointer-events: none;
}
.bar {
pointer-events: auto;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px 16px;
padding: 10px 14px;
border-radius: 16px;
--glass-blur: 14px;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
backdrop-filter: var(--glass-backdrop-filter);
box-shadow: var(--shadow-lg);
}
/* 状态文案:mono 遥测 */
.status {
font-family: $font-mono;
font-size: 12px;
font-weight: 650;
font-variant-numeric: tabular-nums;
padding-right: 4px;
min-width: 0;
overflow-wrap: anywhere;
}
.statusWarning {
color: var(--amber-text);
}
.statusError {
color: var(--viz-failure);
}
.statusBusy,
.statusMuted {
color: var(--text-secondary);
}
.statusOk {
color: var(--viz-success);
}
.actionsGroup {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* 保存:墨色药丸主按钮 */
.savePill {
display: inline-flex;
align-items: center;
gap: 7px;
border: 0;
cursor: pointer;
background: var(--text-primary);
color: var(--bg-secondary);
border-radius: $radius-full;
padding: 9px 16px;
font-size: 13px;
font-weight: 600;
line-height: 1;
transition:
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out),
background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
box-shadow var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
&:disabled {
cursor: not-allowed;
opacity: 0.55;
}
&:active:not(:disabled) {
transform: translateY(0) scale(0.97);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
}
@media (hover: hover) and (pointer: fine) {
.savePill:hover:not(:disabled) {
transform: translateY(-1px);
background: color-mix(in srgb, var(--text-primary) 86%, var(--bg-secondary));
box-shadow: 0 12px 26px color-mix(in srgb, var(--text-primary) 22%, transparent);
}
}
/* 放弃更改:安静的文字链接 */
.ghostAction {
display: inline-flex;
align-items: center;
gap: 6px;
border: 0;
cursor: pointer;
background: none;
color: var(--text-secondary);
border-radius: $radius-full;
padding: 9px 12px;
font-size: 12.5px;
font-weight: 550;
line-height: 1;
transition:
color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
&:disabled {
cursor: not-allowed;
opacity: 0.55;
}
&:active:not(:disabled) {
transform: scale(0.97);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
}
@media (hover: hover) and (pointer: fine) {
.ghostAction:hover:not(:disabled) {
color: var(--text-primary);
}
}
@include mobile {
.container {
width: calc(100vw - 16px);
}
.bar {
flex-direction: column;
align-items: stretch;
}
.actionsGroup {
justify-content: stretch;
> * {
flex: 1;
justify-content: center;
}
}
}
@media (prefers-reduced-motion: reduce) {
.savePill,
.ghostAction {
transition: none;
}
.savePill:active:not(:disabled),
.ghostAction:active:not(:disabled) {
transform: none;
}
}
@@ -0,0 +1,170 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { animate } from 'motion/mini';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconCheck } from '@/components/ui/icons';
import { prefersReducedMotion } from '@/hooks/motion';
import { useActionBarHeightVar } from '@/hooks/useActionBarHeightVar';
import type { ConfigStatusTone } from '../uiState';
import styles from './FloatingSaveBar.module.scss';
const easeOutQuart = (progress: number) => 1 - (1 - progress) ** 4;
const easeInCubic = (progress: number) => progress ** 3;
const BASE_TRANSFORM = 'translateX(-50%)';
const HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
export type FloatingSaveBarProps = {
/** 有未保存修改时可见(与未保存离开守卫的 block 条件一致)。 */
visible: boolean;
statusText: string;
statusTone: ConfigStatusTone;
saving: boolean;
saveDisabled: boolean;
discardDisabled: boolean;
onSave: () => void;
onDiscard: () => void;
};
/**
* 悬浮保存栏:portal 到 body 的玻璃工具栏,仅在 dirty 时出现。
* - 上浮入场 0.28s 强减速,退场 0.22s 加速后卸载;
* - reduced-motion 只做透明度淡入淡出(保留 translateX(-50%),防止错位半宽);
* - 实时高度写入 --config-action-bar-height 供页面底部留白。
*/
export function FloatingSaveBar(props: FloatingSaveBarProps) {
const {
visible,
statusText,
statusTone,
saving,
saveDisabled,
discardDisabled,
onSave,
onDiscard,
} = props;
const { t } = useTranslation();
const [mounted, setMounted] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const animationRef = useRef<ReturnType<typeof animate> | null>(null);
const visibleRef = useRef(visible);
const previousVisibleRef = useRef(false);
useActionBarHeightVar(containerRef, '--config-action-bar-height', mounted);
useEffect(() => {
visibleRef.current = visible;
if (visible) setMounted(true);
}, [visible]);
useLayoutEffect(() => {
if (!mounted) return;
const el = containerRef.current;
if (!el) return;
const wasVisible = previousVisibleRef.current;
animationRef.current?.stop();
animationRef.current = null;
const reduced = prefersReducedMotion();
if (visible && !wasVisible) {
if (reduced) {
el.style.transform = BASE_TRANSFORM;
animationRef.current = animate(
el,
{ opacity: [0, 1] },
{
duration: 0.15,
ease: 'linear',
onComplete: () => {
el.style.opacity = '1';
},
}
);
} else {
animationRef.current = animate(
el,
{ transform: [HIDDEN_TRANSFORM, BASE_TRANSFORM], opacity: [0, 1] },
{
duration: 0.28,
ease: easeOutQuart,
onComplete: () => {
el.style.transform = BASE_TRANSFORM;
el.style.opacity = '1';
},
}
);
}
} else if (!visible && wasVisible) {
const finishExit = () => {
if (!visibleRef.current) setMounted(false);
};
if (reduced) {
el.style.transform = BASE_TRANSFORM;
animationRef.current = animate(
el,
{ opacity: [1, 0] },
{ duration: 0.12, ease: 'linear', onComplete: finishExit }
);
} else {
animationRef.current = animate(
el,
{ transform: [BASE_TRANSFORM, HIDDEN_TRANSFORM], opacity: [1, 0] },
{ duration: 0.22, ease: easeInCubic, onComplete: finishExit }
);
}
}
previousVisibleRef.current = visible;
}, [mounted, visible]);
useEffect(
() => () => {
animationRef.current?.stop();
animationRef.current = null;
},
[]
);
if (!mounted || typeof document === 'undefined') return null;
const toneClass: Record<ConfigStatusTone, string> = {
error: styles.statusError,
warning: styles.statusWarning,
busy: styles.statusBusy,
muted: styles.statusMuted,
ok: styles.statusOk,
};
return createPortal(
<div className={styles.container} ref={containerRef}>
<div className={styles.bar} role="group" aria-label={t('config_management.status_dirty')}>
<span className={`${styles.status} ${toneClass[statusTone]}`} aria-live="polite">
{statusText}
</span>
<div className={styles.actionsGroup}>
<button
type="button"
className={styles.ghostAction}
onClick={onDiscard}
disabled={discardDisabled}
>
{t('config_management.actions.discard')}
</button>
<button
type="button"
className={styles.savePill}
onClick={onSave}
disabled={saveDisabled}
>
{saving ? <LoadingSpinner size={14} /> : <IconCheck size={15} />}
{t('config_management.actions.save')}
</button>
</div>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,73 @@
@use '../../../styles/mixins' as *;
/* 可视化/源码 segmented:与认证文件工具栏分段控件同语汇 */
.segmented {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
flex-shrink: 0;
}
.segment {
border: 0;
background: none;
cursor: pointer;
padding: 6px 12px;
border-radius: $radius-full;
font-size: 12px;
font-weight: 550;
line-height: 1.3;
color: var(--text-secondary);
white-space: nowrap;
transition:
color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
&:disabled {
cursor: not-allowed;
opacity: 0.55;
}
&:active:not(:disabled) {
transform: scale(0.96);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -1px;
}
}
@media (hover: hover) and (pointer: fine) {
.segment:hover:not(:disabled) {
color: var(--text-primary);
}
}
.segmentActive {
color: var(--text-primary);
font-weight: 650;
background: var(--bg-primary);
box-shadow: var(--shadow);
/* 整体禁用(保存/加载中)时激活段仍保持完整对比度 —— 当前模式本身是有效信息 */
&:disabled {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.segment {
transition: none;
}
.segment:active:not(:disabled) {
transform: none;
}
}
@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next';
import type { ConfigEditorMode } from '../constants';
import styles from './ModeSwitch.module.scss';
export type ModeSwitchProps = {
mode: ConfigEditorMode;
/** YAML 解析失败:锁定源码模式,可视化段禁用并给出 tooltip。 */
locked: boolean;
disabled?: boolean;
onChange: (mode: ConfigEditorMode) => void;
};
/**
* 可视化 / 源码 segmented 切换。源码模式是整份文档的另一种表示(不是第 9 个分区),
* 所以它不进 tabs,常驻 tabs 行右端(移动端上移到头部动作行)。
*/
export function ModeSwitch({ mode, locked, disabled = false, onChange }: ModeSwitchProps) {
const { t } = useTranslation();
return (
<div className={styles.segmented} role="group" aria-label={t('config_management.mode.label')}>
<button
type="button"
className={`${styles.segment} ${mode === 'visual' ? styles.segmentActive : ''}`}
aria-pressed={mode === 'visual'}
disabled={disabled || locked}
title={locked ? t('config_management.mode.locked_tooltip') : undefined}
onClick={() => onChange('visual')}
>
{t('config_management.mode.visual')}
</button>
<button
type="button"
className={`${styles.segment} ${mode === 'source' ? styles.segmentActive : ''}`}
aria-pressed={mode === 'source'}
disabled={disabled}
onClick={() => onChange('source')}
>
{t('config_management.mode.source')}
</button>
</div>
);
}
@@ -0,0 +1,123 @@
@use '../../../styles/mixins' as *;
/* 分区卡片:自然高度,无固定高度/无滚动吸附(旧轮播已退役) */
.card {
display: flex;
flex-direction: column;
gap: clamp(16px, 2vw, 22px);
min-width: 0;
box-sizing: border-box;
padding: clamp(20px, 2.4vw, 28px);
border: 1px solid var(--border-color);
border-radius: 14px;
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
@include mobile {
gap: 14px;
padding: 16px;
}
}
/* 首载入场:0.45s 强减速上浮(与全站卡片入场同拍),仅挂载时播一次 */
.cardEnter {
animation: config-card-in 0.45s var(--ease-out-strong, ease-out) 0.28s backwards;
}
@keyframes config-card-in {
from {
opacity: 0;
transform: translate3d(0, 24px, 0);
}
}
.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);
@include mobile {
grid-template-columns: minmax(0, 1fr);
padding-bottom: 12px;
}
}
.badges {
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: 8px;
color: var(--text-secondary);
font-family: $font-mono;
font-size: 11px;
font-weight: 650;
font-variant-numeric: tabular-nums;
letter-spacing: 0.08em;
}
.iconBadge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
color: var(--text-secondary);
flex: 0 0 auto;
}
.heading {
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.01em;
}
.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;
}
@media (prefers-reduced-motion: reduce) {
.cardEnter {
animation: none;
}
}
@@ -0,0 +1,43 @@
import type { ReactNode } from 'react';
import { FIELDS_ROOT_CLASS } from './fields/FieldPrimitives';
import styles from './SectionCard.module.scss';
export type SectionCardProps = {
/** 分区序号(01–07)。常用 tab 是别名视图,不传即不显示。 */
indexLabel?: string;
icon?: ReactNode;
title: ReactNode;
description?: ReactNode;
/** 仅首载入场为 true(页面挂载时用 useState 捕获),tab 切换零动画不重播。 */
animateIn?: boolean;
children: ReactNode;
};
/**
* 分区卡片:自然高度纵向流(替代旧的固定高度滚动吸附轮播)。
* 表面配方与全站卡片一致:14px 圆角 / 1px 描边 / 82% color-mix。
*/
export function SectionCard({
indexLabel,
icon,
title,
description,
animateIn = false,
children,
}: SectionCardProps) {
return (
<section className={`${styles.card} ${animateIn ? styles.cardEnter : ''}`}>
<header className={styles.header}>
<div className={styles.badges}>
{indexLabel ? <span className={styles.indexBadge}>{indexLabel}</span> : null}
{icon ? <span className={styles.iconBadge}>{icon}</span> : null}
</div>
<div className={styles.heading}>
<h2 className={styles.title}>{title}</h2>
{description ? <p className={styles.description}>{description}</p> : null}
</div>
</header>
<div className={`${styles.content} ${FIELDS_ROOT_CLASS}`}>{children}</div>
</section>
);
}
@@ -0,0 +1,281 @@
@use '../../../../styles/mixins' as *;
/* 字段原语:表单控件的 scoped 覆盖 + 开关行 / 网格 / 字段组 / 搜索锚点。
交互反馈一律引用全局动效 tokens--dur-* / --ease-out-strong)。 */
/* ---------- 表单控件宿主(原 .visualEditor 的 :global 覆盖收编于此) ---------- */
.fieldsRoot {
:global(.form-group) {
gap: 7px;
margin-bottom: 0;
}
:global(.form-group > label) {
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
}
:global(.input) {
min-height: 42px;
border-radius: 10px;
background: var(--bg-secondary);
border-color: var(--border-color);
box-shadow: none;
transition:
border-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
box-shadow var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
}
:global(.input:focus) {
background: var(--bg-primary);
border-color: var(--text-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-primary) 12%, transparent);
}
:global(textarea.input) {
min-height: 112px;
}
:global(.hint) {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.55;
}
:global(.error-box) {
border-radius: 10px;
}
:global(.item-list) {
gap: 8px;
margin-top: 8px;
}
:global(.item-row) {
border-radius: 10px;
padding: 12px;
background: transparent;
border-color: var(--border-color);
}
:global(.item-row .item-meta) {
gap: 4px;
}
:global(.item-row .item-actions) {
flex-wrap: wrap;
}
:global(.pill) {
border: 1px solid var(--border-color);
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
}
}
/* ---------- 开关行 ---------- */
.toggleRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
min-height: 74px;
padding: 14px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: transparent;
transition:
border-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
@include mobile {
grid-template-columns: minmax(0, 1fr);
}
}
@media (hover: hover) and (pointer: fine) {
.toggleRow:hover {
border-color: var(--border-hover);
background: color-mix(in srgb, var(--bg-tertiary) 40%, transparent);
}
}
.toggleCopy {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.toggleTitle {
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
line-height: 1.25;
}
.toggleDescription {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.55;
}
/* ---------- 布局原语 ---------- */
.fieldGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
@include mobile {
grid-template-columns: minmax(0, 1fr);
}
}
.fieldStack {
display: flex;
flex-direction: column;
gap: 14px;
}
.divider {
height: 1px;
background: var(--border-color);
}
/* ---------- 搜索锚点与脉冲高亮 ---------- */
.fieldAnchor {
display: block;
min-width: 0;
scroll-margin-top: calc(var(--header-height, 64px) + 16px);
}
.fieldHighlightActive {
border-radius: 10px;
animation: config-field-highlight 1.8s ease-out;
}
@keyframes config-field-highlight {
0% {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--text-primary) 55%, transparent);
}
55% {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--text-primary) 32%, transparent);
}
100% {
box-shadow: 0 0 0 3px transparent;
}
}
/* ---------- 字段组(原 subsection ---------- */
.group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: transparent;
}
.groupHeader {
display: flex;
flex-direction: column;
gap: 5px;
}
.groupTitle {
margin: 0;
color: var(--text-primary);
font-size: 15px;
font-weight: 700;
line-height: 1.25;
}
.groupDescription {
margin: 0;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.6;
}
/* ---------- 字段外壳 ---------- */
.fieldShell {
display: flex;
flex-direction: column;
gap: 7px;
min-width: 0;
}
.fieldLabel {
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
}
.fieldControl {
position: relative;
}
.fieldHint {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.55;
}
.inlinePill {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
}
/* ---------- 响应式 ---------- */
@include mobile {
.group,
.toggleRow {
padding: 14px;
}
}
@media (max-width: 380px) {
.group,
.toggleRow {
padding: 12px;
}
}
/* ---------- 无障碍 ---------- */
@media (prefers-reduced-motion: reduce) {
.fieldsRoot :global(.input),
.toggleRow {
transition: none;
}
.fieldHighlightActive {
animation: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--text-primary) 32%, transparent);
}
}
@@ -0,0 +1,136 @@
import type { ReactNode } from 'react';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { configFieldDomId } from '../../searchIndex';
import styles from './Field.module.scss';
/** 搜索跳转的脉冲高亮 classuseFieldJump 命令式挂载/移除)。 */
export const FIELD_HIGHLIGHT_CLASS: string = styles.fieldHighlightActive;
/**
* 表单控件宿主 class:收编旧 VisualConfigEditor 的 :global(.form-group/.input/...)
* 覆盖的作用域根。SectionCard 的内容区自动挂载;脱离卡片渲染表单块(如 Modal 内容)时手动挂。
*/
export const FIELDS_ROOT_CLASS: string = styles.fieldsRoot;
export type ToggleRowProps = {
title: string;
description?: string;
checked: boolean;
disabled?: boolean;
onChange: (value: boolean) => void;
};
export function ToggleRow({ title, description, checked, disabled, onChange }: ToggleRowProps) {
return (
<div className={styles.toggleRow}>
<div className={styles.toggleCopy}>
<div className={styles.toggleTitle}>{title}</div>
{description ? <div className={styles.toggleDescription}>{description}</div> : null}
</div>
<ToggleSwitch checked={checked} onChange={onChange} disabled={disabled} ariaLabel={title} />
</div>
);
}
export function FieldGrid({ children }: { children: ReactNode }) {
return <div className={styles.fieldGrid}>{children}</div>;
}
export function FieldStack({ children }: { children: ReactNode }) {
return <div className={styles.fieldStack}>{children}</div>;
}
export function Divider() {
return <div className={styles.divider} />;
}
// Stable, stateless anchor around a searchable field. Search jumps target its DOM id
// (see searchIndex.ts) and the highlight pulse is applied to it imperatively.
export function FieldAnchor({ fieldId, children }: { fieldId: string; children: ReactNode }) {
return (
<div id={configFieldDomId(fieldId)} className={styles.fieldAnchor}>
{children}
</div>
);
}
/** 带描边容器的字段组(原 SectionSubsection / .subsection)。title 可省略只留容器。 */
export function FieldGroup({
title,
description,
children,
}: {
title?: string;
description?: string;
children: ReactNode;
}) {
return (
<div className={styles.group}>
{title ? (
<div className={styles.groupHeader}>
<h3 className={styles.groupTitle}>{title}</h3>
{description ? <p className={styles.groupDescription}>{description}</p> : null}
</div>
) : null}
{children}
</div>
);
}
/** 独立的小组标题行(如 Claude / Codex 请求头小节标题)。 */
export function FieldGroupHeading({ title }: { title: string }) {
return (
<div className={styles.groupHeader}>
<h3 className={styles.groupTitle}>{title}</h3>
</div>
);
}
export function FieldShell({
label,
labelId,
htmlFor,
hint,
hintId,
error,
errorId,
children,
}: {
label: string;
labelId?: string;
htmlFor?: string;
hint?: string;
hintId?: string;
error?: string;
errorId?: string;
children: ReactNode;
}) {
return (
<div className={styles.fieldShell}>
<label id={labelId} htmlFor={htmlFor} className={styles.fieldLabel}>
{label}
</label>
{children}
{error ? (
<div id={errorId} className="error-box">
{error}
</div>
) : null}
{hint ? (
<div id={hintId} className={styles.fieldHint}>
{hint}
</div>
) : null}
</div>
);
}
/** 数字输入右侧的「已禁用」pill 宿主(流式 keepalive 的 0/空 提示)。 */
export function FieldControl({ children }: { children: ReactNode }) {
return <div className={styles.fieldControl}>{children}</div>;
}
/** FieldControl 内的内联 pill。 */
export function InlinePill({ children }: { children: ReactNode }) {
return <span className={styles.inlinePill}>{children}</span>;
}
+170
View File
@@ -0,0 +1,170 @@
import type { ComponentType } from 'react';
import {
IconCode,
IconKey,
IconNetwork,
IconSatellite,
IconScrollText,
IconShield,
IconSlidersHorizontal,
IconTimer,
type IconProps,
} from '@/components/ui/icons';
import type { VisualConfigFieldPath } from '@/types/visualConfig';
import type { VisualSectionId } from './searchIndex';
/** 编辑模式:可视化表单 or YAML 源码。 */
export type ConfigEditorMode = 'visual' | 'source';
/** 顶部 tabs'common'(常用,原简单模式的继任者)+ 7 个正典分区。 */
export type ConfigTabId = 'common' | VisualSectionId;
export const CONFIG_SECTION_IDS = [
'connectivity',
'network',
'logging',
'quota',
'streaming',
'advanced',
'payload',
] as const satisfies readonly VisualSectionId[];
export const CONFIG_TAB_IDS: readonly ConfigTabId[] = ['common', ...CONFIG_SECTION_IDS];
/** 分区序号(01–07)。常用 tab 是别名视图,不占序号。 */
export const SECTION_INDEX_LABELS: Record<VisualSectionId, string> = {
connectivity: '01',
network: '02',
logging: '03',
quota: '04',
streaming: '05',
advanced: '06',
payload: '07',
};
export const CONFIG_TAB_ICONS: Record<ConfigTabId, ComponentType<IconProps>> = {
common: IconSlidersHorizontal,
connectivity: IconKey,
network: IconNetwork,
logging: IconScrollText,
quota: IconTimer,
streaming: IconSatellite,
advanced: IconShield,
payload: IconCode,
};
/** 常用 tab 的 8 个字段(原简单模式),渲染源与正典分区共享(fields/sharedFields.tsx)。 */
export const COMMON_FIELD_IDS = [
'host',
'port',
'apiKeys',
'proxyUrl',
'debug',
'loggingToFile',
'quotaSwitchProject',
'quotaSwitchPreviewModel',
] as const;
/**
* 每个分区承载的校验字段路径(tab 错误徽章的分桶依据)。
* payload 的校验不走字段路径,由 hasPayloadValidationErrors 旗标补记。
*/
export const SECTION_VALIDATION_FIELDS: Record<
VisualSectionId,
readonly VisualConfigFieldPath[]
> = {
connectivity: ['port'],
network: ['requestRetry', 'maxRetryCredentials', 'maxRetryInterval', 'authAutoRefreshWorkers'],
logging: ['errorLogsMaxFiles', 'logsMaxTotalSizeMb', 'redisUsageQueueRetentionSeconds'],
quota: [],
streaming: [
'streaming.keepaliveSeconds',
'streaming.bootstrapRetries',
'streaming.nonstreamKeepaliveInterval',
],
advanced: [],
payload: [],
};
/**
* fieldId → useVisualConfig dirtyFields 的键(= VisualConfigValues 叶值键,streaming 用点号叶)。
* 与搜索索引 58 条一一对应;三方对账由 tests/configFieldParity.test.ts 守护 ——
* 增删字段时漏改任何一边(索引 / 本表 / 分区 JSX)都会红。
*/
export const FIELD_VALUE_KEYS: Record<string, readonly string[]> = {
// ── connectivity ──────────────────────────────────────────────────────────
host: ['host'],
port: ['port'],
authDir: ['authDir'],
apiKeys: ['apiKeysText'],
tlsEnable: ['tlsEnable'],
tlsCert: ['tlsCert'],
tlsKey: ['tlsKey'],
rmAllowRemote: ['rmAllowRemote'],
rmDisableControlPanel: ['rmDisableControlPanel'],
rmDisableAutoUpdatePanel: ['rmDisableAutoUpdatePanel'],
rmSecretKey: ['rmSecretKey'],
rmPanelRepo: ['rmPanelRepo'],
// ── network ───────────────────────────────────────────────────────────────
proxyUrl: ['proxyUrl'],
requestRetry: ['requestRetry'],
maxRetryCredentials: ['maxRetryCredentials'],
maxRetryInterval: ['maxRetryInterval'],
authAutoRefreshWorkers: ['authAutoRefreshWorkers'],
routingStrategy: ['routingStrategy'],
disableImageGeneration: ['disableImageGeneration'],
gptImage2BaseModel: ['gptImage2BaseModel'],
routingSessionAffinityTTL: ['routingSessionAffinityTTL'],
forceModelPrefix: ['forceModelPrefix'],
passthroughHeaders: ['passthroughHeaders'],
disableCooling: ['disableCooling'],
routingSessionAffinity: ['routingSessionAffinity'],
wsAuth: ['wsAuth'],
// ── logging ───────────────────────────────────────────────────────────────
debug: ['debug'],
commercialMode: ['commercialMode'],
loggingToFile: ['loggingToFile'],
logsMaxTotalSizeMb: ['logsMaxTotalSizeMb'],
errorLogsMaxFiles: ['errorLogsMaxFiles'],
redisUsageQueueRetentionSeconds: ['redisUsageQueueRetentionSeconds'],
usageStatisticsEnabled: ['usageStatisticsEnabled'],
// ── quota ─────────────────────────────────────────────────────────────────
quotaSwitchProject: ['quotaSwitchProject'],
quotaSwitchPreviewModel: ['quotaSwitchPreviewModel'],
quotaAntigravityCredits: ['quotaAntigravityCredits'],
// ── streaming ─────────────────────────────────────────────────────────────
streamingKeepaliveSeconds: ['streaming.keepaliveSeconds'],
streamingBootstrapRetries: ['streaming.bootstrapRetries'],
streamingNonstreamKeepalive: ['streaming.nonstreamKeepaliveInterval'],
// ── advanced ──────────────────────────────────────────────────────────────
pluginsEnabled: ['pluginsEnabled'],
pluginStoreSources: ['pluginStoreSources'],
pluginStoreAuth: ['pluginStoreAuth'],
antigravitySignatureCacheEnabled: ['antigravitySignatureCacheEnabled'],
antigravitySignatureBypassStrict: ['antigravitySignatureBypassStrict'],
claudeHeaderUserAgent: ['claudeHeaderUserAgent'],
claudeHeaderPackageVersion: ['claudeHeaderPackageVersion'],
claudeHeaderRuntimeVersion: ['claudeHeaderRuntimeVersion'],
claudeHeaderOs: ['claudeHeaderOs'],
claudeHeaderArch: ['claudeHeaderArch'],
claudeHeaderTimeout: ['claudeHeaderTimeout'],
claudeHeaderStabilizeDeviceProfile: ['claudeHeaderStabilizeDeviceProfile'],
codexHeaderUserAgent: ['codexHeaderUserAgent'],
codexHeaderBetaFeatures: ['codexHeaderBetaFeatures'],
// ── payload ───────────────────────────────────────────────────────────────
payloadDefaultRules: ['payloadDefaultRules'],
payloadDefaultRawRules: ['payloadDefaultRawRules'],
payloadOverrideRules: ['payloadOverrideRules'],
payloadOverrideRawRules: ['payloadOverrideRawRules'],
payloadFilterRules: ['payloadFilterRules'],
};
/** tab / tabpanel 的 DOM id:单点定义,ConfigTabs 与页面侧面板用同一函数生成 aria 关联。 */
export const configTabDomId = (id: ConfigTabId) => `config-tab-${id}`;
export const configPanelDomId = (id: ConfigTabId) => `config-panel-${id}`;
/** localStorage 键:mode 沿用旧键('visual' | 'source' 值域不变);section 为新键。 */
export const CONFIG_MODE_STORAGE_KEY = 'config-management:tab';
export const CONFIG_SECTION_STORAGE_KEY = 'config-management:section';
/** 旧「简单/完整」双模式的持久化键,模式轴已删除;挂载时清理。 */
export const LEGACY_EDITOR_MODE_STORAGE_KEY = 'config-management:editor-mode';
+488
View File
@@ -0,0 +1,488 @@
// 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
// components/sections/*.tsx (or fields/sharedFields.tsx), update the matching
// entry here, wrap the field's JSX in <FieldAnchor fieldId="..."> with the same
// `fieldId`, and map it in constants.ts FIELD_VALUE_KEYS.
// tests/configFieldParity.test.ts enforces the three-way parity — a missing or
// extra entry anywhere fails CI.
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);
}
+259
View File
@@ -0,0 +1,259 @@
// 配置页 UI 状态的纯函数层:状态机、徽章分桶、脏字段归属、localStorage 读取。
// 全部无副作用,由 tests/configUiState.test.ts 覆盖。
import type { VisualConfigValidationErrors } from '@/types/visualConfig';
import {
COMMON_FIELD_IDS,
CONFIG_SECTION_IDS,
CONFIG_TAB_IDS,
FIELD_VALUE_KEYS,
SECTION_VALIDATION_FIELDS,
type ConfigEditorMode,
type ConfigTabId,
} from './constants';
import { CONFIG_FIELD_SEARCH_INDEX, type VisualSectionId } from './searchIndex';
/** 叶值键(= useVisualConfig dirtyFields 的键)→ fieldId 反查表。 */
const VALUE_KEY_TO_FIELD_ID: ReadonlyMap<string, string> = (() => {
const map = new Map<string, string>();
for (const [fieldId, valueKeys] of Object.entries(FIELD_VALUE_KEYS)) {
for (const valueKey of valueKeys) map.set(valueKey, fieldId);
}
return map;
})();
const FIELD_ID_TO_SECTION: ReadonlyMap<string, VisualSectionId> = new Map(
CONFIG_FIELD_SEARCH_INDEX.map((entry) => [entry.fieldId, entry.sectionId])
);
const COMMON_FIELD_ID_SET: ReadonlySet<string> = new Set<string>(COMMON_FIELD_IDS);
/** 常用 tab 渲染的字段对应的叶值键集合(校验错误归属常用 tab 时用)。 */
const COMMON_VALUE_KEYS: ReadonlySet<string> = new Set(
COMMON_FIELD_IDS.flatMap((fieldId) => [...(FIELD_VALUE_KEYS[fieldId] ?? [])])
);
/** 脏字段集合 → 点亮脏点的 tabs。常用字段同时点亮 common 与其正典分区(两处都渲染它)。 */
export function resolveDirtyTabs(dirtyFields: ReadonlySet<string>): ReadonlySet<ConfigTabId> {
const tabs = new Set<ConfigTabId>();
for (const valueKey of dirtyFields) {
const fieldId = VALUE_KEY_TO_FIELD_ID.get(valueKey);
if (!fieldId) continue;
const sectionId = FIELD_ID_TO_SECTION.get(fieldId);
if (sectionId) tabs.add(sectionId);
if (COMMON_FIELD_ID_SET.has(fieldId)) tabs.add('common');
}
return tabs;
}
/** 每个 tab 的校验错误数(错误徽章)。payload 的校验以旗标计 1。 */
export function countSectionErrors(
validationErrors: VisualConfigValidationErrors | undefined,
hasPayloadValidationErrors: boolean
): Record<ConfigTabId, number> {
const counts = Object.fromEntries(CONFIG_TAB_IDS.map((tabId) => [tabId, 0])) as Record<
ConfigTabId,
number
>;
for (const sectionId of CONFIG_SECTION_IDS) {
counts[sectionId] = SECTION_VALIDATION_FIELDS[sectionId].reduce(
(total, field) => total + (validationErrors?.[field] ? 1 : 0),
0
);
}
if (hasPayloadValidationErrors) counts.payload += 1;
counts.common = Object.entries(validationErrors ?? {}).reduce(
(total, [field, error]) => total + (error && COMMON_VALUE_KEYS.has(field) ? 1 : 0),
0
);
return counts;
}
/** 全页校验错误总数(头部 meta 行)。 */
export function countTotalErrors(
validationErrors: VisualConfigValidationErrors | undefined,
hasPayloadValidationErrors: boolean
): number {
const fieldErrors = Object.values(validationErrors ?? {}).filter(Boolean).length;
return fieldErrors + (hasPayloadValidationErrors ? 1 : 0);
}
export type ConfigStatusKey =
| 'disconnected'
| 'loading'
| 'load_failed'
| 'yaml_error'
| 'validation_blocked'
| 'saving'
| 'dirty'
| 'synced';
export type ConfigStatusTone = 'error' | 'warning' | 'busy' | 'muted' | 'ok';
export type ConfigStatus = {
key: ConfigStatusKey;
/** 完整状态文案的 i18n 键。 */
labelKey: string;
/** 移动端短文案的 i18n 键。validation_blocked 的短键在 config_management 顶层(历史路径 bug 的修正)。 */
shortLabelKey: string;
tone: ConfigStatusTone;
};
export type ConfigStatusInput = {
disconnected: boolean;
loading: boolean;
loadFailed: boolean;
yamlError: boolean;
validationBlocked: boolean;
saving: boolean;
dirty: boolean;
};
/** 悬浮保存栏 / 状态文案的状态机。优先级自上而下,与旧页 getStatusText 分支序一致。 */
export function resolveStatus(input: ConfigStatusInput): ConfigStatus {
if (input.disconnected) {
return {
key: 'disconnected',
labelKey: 'config_management.status_disconnected',
shortLabelKey: 'config_management.status_disconnected_short',
tone: 'muted',
};
}
if (input.loading) {
return {
key: 'loading',
labelKey: 'config_management.status_loading',
shortLabelKey: 'config_management.status_loading_short',
tone: 'busy',
};
}
if (input.loadFailed) {
return {
key: 'load_failed',
labelKey: 'config_management.status_load_failed',
shortLabelKey: 'config_management.status_load_failed_short',
tone: 'error',
};
}
if (input.yamlError) {
return {
key: 'yaml_error',
labelKey: 'config_management.visual_mode_unavailable',
shortLabelKey: 'config_management.visual_mode_unavailable_short',
tone: 'error',
};
}
if (input.validationBlocked) {
return {
key: 'validation_blocked',
labelKey: 'config_management.visual.validation.validation_blocked',
shortLabelKey: 'config_management.validation_blocked_short',
tone: 'error',
};
}
if (input.saving) {
return {
key: 'saving',
labelKey: 'config_management.status_saving',
shortLabelKey: 'config_management.status_saving_short',
tone: 'busy',
};
}
if (input.dirty) {
return {
key: 'dirty',
labelKey: 'config_management.status_dirty',
shortLabelKey: 'config_management.status_dirty_short',
tone: 'warning',
};
}
return {
key: 'synced',
labelKey: 'config_management.status_loaded',
shortLabelKey: 'config_management.status_loaded_short',
tone: 'ok',
};
}
export type HeaderMetaSegment = {
key: 'fields' | 'loading' | 'yaml_error' | 'dirty' | 'dirty_source' | 'errors' | 'synced';
labelKey: string;
count?: number;
tone: 'muted' | 'warning' | 'error' | 'ok';
};
export type HeaderMetaInput = {
fieldCount: number;
loading: boolean;
yamlError: boolean;
dirtyCount: number;
sourceDirty: boolean;
errorCount: number;
};
/**
* 头部 ▍mono meta 行的段落序列:字段总数常驻,之后按状态追加
* (加载中 / YAML 错误 / 待保存 / 校验错误),全部干净时以「已同步」收尾。
*/
export function buildHeaderMeta(input: HeaderMetaInput): HeaderMetaSegment[] {
const segments: HeaderMetaSegment[] = [
{
key: 'fields',
labelKey: 'config_management.meta_fields',
count: input.fieldCount,
tone: 'muted',
},
];
if (input.loading) {
segments.push({
key: 'loading',
labelKey: 'config_management.status_loading',
tone: 'muted',
});
return segments;
}
if (input.yamlError) {
segments.push({
key: 'yaml_error',
labelKey: 'config_management.visual_mode_unavailable_short',
tone: 'error',
});
}
if (input.sourceDirty) {
segments.push({
key: 'dirty_source',
labelKey: 'config_management.meta_dirty_source',
tone: 'warning',
});
} else if (input.dirtyCount > 0) {
segments.push({
key: 'dirty',
labelKey: 'config_management.meta_dirty',
count: input.dirtyCount,
tone: 'warning',
});
}
if (input.errorCount > 0) {
segments.push({
key: 'errors',
labelKey: 'config_management.meta_errors',
count: input.errorCount,
tone: 'error',
});
}
if (segments.length === 1 && !input.yamlError) {
segments.push({ key: 'synced', labelKey: 'config_management.meta_synced', tone: 'ok' });
}
return segments;
}
/** localStorage 读取:非法/陈旧值回退默认。 */
export function readSavedMode(raw: string | null): ConfigEditorMode {
return raw === 'source' ? 'source' : 'visual';
}
export function readSavedSection(raw: string | null): ConfigTabId {
return raw !== null && (CONFIG_TAB_IDS as readonly string[]).includes(raw)
? (raw as ConfigTabId)
: 'common';
}
+19
View File
@@ -831,6 +831,21 @@
},
"config_management": {
"title": "Config Panel",
"meta_fields": "{{count}} settings",
"meta_dirty": "{{count}} unsaved",
"meta_dirty_source": "Unsaved source edits",
"meta_errors": "{{count}} validation errors",
"meta_synced": "In sync",
"actions": {
"discard": "Discard changes",
"save": "Save changes"
},
"mode": {
"label": "Editor mode",
"visual": "Visual",
"source": "Source",
"locked_tooltip": "YAML parse error — locked to source mode"
},
"reload": "Reload",
"reload_confirm_message": "Reloading will discard your unsaved changes. Do you want to continue?",
"save": "Save",
@@ -884,6 +899,10 @@
"no_results": "No matching settings"
},
"sections": {
"common": {
"title": "Common",
"description": "The most-used settings, backed by the same data as the full sections"
},
"connectivity": {
"title": "Access & Authentication",
"description": "Server address, port, auth directory, and API keys"
+19
View File
@@ -818,6 +818,21 @@
},
"config_management": {
"title": "Панель конфигурации",
"meta_fields": "Настроек: {{count}}",
"meta_dirty": "Не сохранено: {{count}}",
"meta_dirty_source": "Несохранённые правки исходника",
"meta_errors": "Ошибок валидации: {{count}}",
"meta_synced": "Синхронизировано",
"actions": {
"discard": "Отменить изменения",
"save": "Сохранить изменения"
},
"mode": {
"label": "Режим редактора",
"visual": "Визуальный",
"source": "Исходный код",
"locked_tooltip": "Ошибка разбора YAML — доступен только исходный код"
},
"reload": "Перезагрузить",
"reload_confirm_message": "Перезагрузка отбросит ваши несохранённые изменения. Продолжить?",
"save": "Сохранить",
@@ -871,6 +886,10 @@
"no_results": "Нет подходящих настроек"
},
"sections": {
"common": {
"title": "Основные",
"description": "Самые востребованные настройки — те же данные, что и в полных разделах"
},
"connectivity": {
"title": "Доступ и аутентификация",
"description": "Адрес сервера, порт, каталог аутентификации и ключи API"
+19
View File
@@ -831,6 +831,21 @@
},
"config_management": {
"title": "配置面板",
"meta_fields": "{{count}} 项配置",
"meta_dirty": "{{count}} 项待保存",
"meta_dirty_source": "源码有未保存修改",
"meta_errors": "{{count}} 项校验错误",
"meta_synced": "已同步",
"actions": {
"discard": "放弃更改",
"save": "保存更改"
},
"mode": {
"label": "编辑模式",
"visual": "可视化",
"source": "源码",
"locked_tooltip": "YAML 解析失败,已锁定源码模式"
},
"reload": "重新加载",
"reload_confirm_message": "重新加载将丢弃你当前未保存的修改,确定继续吗?",
"save": "保存",
@@ -884,6 +899,10 @@
"no_results": "没有匹配的配置项"
},
"sections": {
"common": {
"title": "常用",
"description": "最常调整的配置项,与完整分区共享同一份数据"
},
"connectivity": {
"title": "接入与认证",
"description": "服务地址、端口、认证目录与 API 密钥"
+19
View File
@@ -857,6 +857,21 @@
},
"config_management": {
"title": "設定面板",
"meta_fields": "{{count}} 項設定",
"meta_dirty": "{{count}} 項待儲存",
"meta_dirty_source": "原始碼有未儲存修改",
"meta_errors": "{{count}} 項驗證錯誤",
"meta_synced": "已同步",
"actions": {
"discard": "放棄變更",
"save": "儲存變更"
},
"mode": {
"label": "編輯模式",
"visual": "視覺化",
"source": "原始碼",
"locked_tooltip": "YAML 解析失敗,已鎖定原始碼模式"
},
"reload": "重新載入",
"reload_confirm_message": "重新載入將捨棄你目前未儲存的修改,確定繼續嗎?",
"save": "儲存",
@@ -910,6 +925,10 @@
"no_results": "沒有符合的設定項"
},
"sections": {
"common": {
"title": "常用",
"description": "最常調整的設定項,與完整分區共享同一份資料"
},
"connectivity": {
"title": "接入與認證",
"description": "服務位址、連接埠、認證目錄與 API 金鑰"
+212
View File
@@ -0,0 +1,212 @@
import { describe, expect, test } from 'bun:test';
import {
buildHeaderMeta,
countSectionErrors,
countTotalErrors,
readSavedMode,
readSavedSection,
resolveDirtyTabs,
resolveStatus,
type ConfigStatusInput,
} from '@/features/config/uiState';
import type { VisualConfigValidationErrors } from '@/types/visualConfig';
const statusInput = (overrides: Partial<ConfigStatusInput> = {}): ConfigStatusInput => ({
disconnected: false,
loading: false,
loadFailed: false,
yamlError: false,
validationBlocked: false,
saving: false,
dirty: false,
...overrides,
});
describe('resolveStatus', () => {
test('follows the legacy precedence chain top-down', () => {
// disconnected > loading > load_failed > yaml_error > validation_blocked > saving > dirty > synced
expect(resolveStatus(statusInput({ disconnected: true, loading: true })).key).toBe(
'disconnected'
);
expect(resolveStatus(statusInput({ loading: true, loadFailed: true })).key).toBe('loading');
expect(resolveStatus(statusInput({ loadFailed: true, yamlError: true })).key).toBe(
'load_failed'
);
expect(resolveStatus(statusInput({ yamlError: true, validationBlocked: true })).key).toBe(
'yaml_error'
);
expect(resolveStatus(statusInput({ validationBlocked: true, saving: true })).key).toBe(
'validation_blocked'
);
expect(resolveStatus(statusInput({ saving: true, dirty: true })).key).toBe('saving');
expect(resolveStatus(statusInput({ dirty: true })).key).toBe('dirty');
expect(resolveStatus(statusInput()).key).toBe('synced');
});
test('validation_blocked short key lives at config_management top level (regression: old .visual. path bug)', () => {
const status = resolveStatus(statusInput({ validationBlocked: true }));
expect(status.shortLabelKey).toBe('config_management.validation_blocked_short');
expect(status.labelKey).toBe('config_management.visual.validation.validation_blocked');
expect(status.tone).toBe('error');
});
test('every status resolves label keys that exist in all four locales', async () => {
const locales = ['en', 'zh-CN', 'zh-TW', 'ru'];
const inputs: Partial<ConfigStatusInput>[] = [
{ disconnected: true },
{ loading: true },
{ loadFailed: true },
{ yamlError: true },
{ validationBlocked: true },
{ saving: true },
{ dirty: true },
{},
];
for (const locale of locales) {
const json = (await Bun.file(`src/i18n/locales/${locale}.json`).json()) as Record<
string,
unknown
>;
const resolveKey = (path: string): unknown =>
path.split('.').reduce<unknown>((node, part) => {
if (node && typeof node === 'object') return (node as Record<string, unknown>)[part];
return undefined;
}, json);
for (const overrides of inputs) {
const status = resolveStatus(statusInput(overrides));
expect(typeof resolveKey(status.labelKey)).toBe('string');
expect(typeof resolveKey(status.shortLabelKey)).toBe('string');
}
}
});
});
describe('countSectionErrors', () => {
test('buckets field errors by section and mirrors common-tab fields', () => {
const errors: VisualConfigValidationErrors = {
port: 'port_range',
requestRetry: 'non_negative_integer',
'streaming.keepaliveSeconds': 'non_negative_integer',
};
const counts = countSectionErrors(errors, false);
expect(counts.connectivity).toBe(1);
expect(counts.network).toBe(1);
expect(counts.streaming).toBe(1);
expect(counts.logging).toBe(0);
expect(counts.quota).toBe(0);
expect(counts.advanced).toBe(0);
expect(counts.payload).toBe(0);
// port 由常用 tab 渲染,同一错误在两个 tab 都要可见
expect(counts.common).toBe(1);
});
test('payload flag adds one to the payload tab only', () => {
const counts = countSectionErrors(undefined, true);
expect(counts.payload).toBe(1);
expect(counts.common).toBe(0);
expect(counts.connectivity).toBe(0);
});
test('undefined error entries do not count', () => {
const errors: VisualConfigValidationErrors = { port: undefined };
const counts = countSectionErrors(errors, false);
expect(counts.connectivity).toBe(0);
expect(counts.common).toBe(0);
});
});
describe('countTotalErrors', () => {
test('sums field errors plus the payload flag', () => {
const errors: VisualConfigValidationErrors = {
port: 'port_range',
maxRetryInterval: 'non_negative_integer',
logsMaxTotalSizeMb: undefined,
};
expect(countTotalErrors(errors, false)).toBe(2);
expect(countTotalErrors(errors, true)).toBe(3);
expect(countTotalErrors(undefined, false)).toBe(0);
});
});
describe('resolveDirtyTabs', () => {
test('maps dirty value keys to their canonical sections', () => {
const tabs = resolveDirtyTabs(new Set(['rmSecretKey', 'streaming.bootstrapRetries']));
expect(tabs.has('connectivity')).toBe(true);
expect(tabs.has('streaming')).toBe(true);
expect(tabs.has('common')).toBe(false);
expect(tabs.size).toBe(2);
});
test('a common field lights both the common tab and its canonical section', () => {
const tabs = resolveDirtyTabs(new Set(['apiKeysText']));
expect(tabs.has('common')).toBe(true);
expect(tabs.has('connectivity')).toBe(true);
expect(tabs.size).toBe(2);
const quotaTabs = resolveDirtyTabs(new Set(['quotaSwitchProject']));
expect(quotaTabs.has('common')).toBe(true);
expect(quotaTabs.has('quota')).toBe(true);
});
test('unknown keys are ignored instead of crashing', () => {
const tabs = resolveDirtyTabs(new Set(['not-a-real-key']));
expect(tabs.size).toBe(0);
});
});
describe('buildHeaderMeta', () => {
const base = {
fieldCount: 58,
loading: false,
yamlError: false,
dirtyCount: 0,
sourceDirty: false,
errorCount: 0,
};
test('loading short-circuits after the field count', () => {
const meta = buildHeaderMeta({ ...base, loading: true, dirtyCount: 3 });
expect(meta.map((segment) => segment.key)).toEqual(['fields', 'loading']);
});
test('clean state ends with a synced segment', () => {
const meta = buildHeaderMeta(base);
expect(meta.map((segment) => segment.key)).toEqual(['fields', 'synced']);
expect(meta[0].count).toBe(58);
});
test('dirty and errors stack after the field count', () => {
const meta = buildHeaderMeta({ ...base, dirtyCount: 3, errorCount: 2 });
expect(meta.map((segment) => segment.key)).toEqual(['fields', 'dirty', 'errors']);
expect(meta[1].count).toBe(3);
expect(meta[1].tone).toBe('warning');
expect(meta[2].count).toBe(2);
expect(meta[2].tone).toBe('error');
});
test('source dirty supersedes the visual dirty count', () => {
const meta = buildHeaderMeta({ ...base, dirtyCount: 3, sourceDirty: true });
expect(meta.map((segment) => segment.key)).toEqual(['fields', 'dirty_source']);
});
test('yaml error shows without a synced tail', () => {
const meta = buildHeaderMeta({ ...base, yamlError: true });
expect(meta.map((segment) => segment.key)).toEqual(['fields', 'yaml_error']);
});
});
describe('localStorage readers', () => {
test('readSavedMode falls back to visual on unknown values', () => {
expect(readSavedMode('source')).toBe('source');
expect(readSavedMode('visual')).toBe('visual');
expect(readSavedMode('full')).toBe('visual'); // 旧「简单/完整」值域不再合法
expect(readSavedMode(null)).toBe('visual');
});
test('readSavedSection falls back to common on stale values', () => {
expect(readSavedSection('payload')).toBe('payload');
expect(readSavedSection('common')).toBe('common');
expect(readSavedSection('server')).toBe('common'); // 历史分区 id 不再存在
expect(readSavedSection(null)).toBe('common');
});
});