From c2feeac1ca1ca5c0ae28b22159b3d4e95f3f2a31 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Thu, 6 Aug 2026 02:34:09 +0800 Subject: [PATCH] feat(config): scaffold config feature shell (constants, uiState, tabs, header, save bar) --- .../components/ConfigHeader.module.scss | 156 ++++++ .../config/components/ConfigHeader.tsx | 73 +++ .../config/components/ConfigTabs.module.scss | 130 +++++ src/features/config/components/ConfigTabs.tsx | 110 ++++ .../components/FloatingSaveBar.module.scss | 177 +++++++ .../config/components/FloatingSaveBar.tsx | 170 ++++++ .../config/components/ModeSwitch.module.scss | 73 +++ src/features/config/components/ModeSwitch.tsx | 43 ++ .../config/components/SectionCard.module.scss | 123 +++++ .../config/components/SectionCard.tsx | 43 ++ .../components/fields/Field.module.scss | 281 ++++++++++ .../components/fields/FieldPrimitives.tsx | 136 +++++ src/features/config/constants.ts | 170 ++++++ src/features/config/searchIndex.ts | 488 ++++++++++++++++++ src/features/config/uiState.ts | 259 ++++++++++ src/i18n/locales/en.json | 19 + src/i18n/locales/ru.json | 19 + src/i18n/locales/zh-CN.json | 19 + src/i18n/locales/zh-TW.json | 19 + tests/configUiState.test.ts | 212 ++++++++ 20 files changed, 2720 insertions(+) create mode 100644 src/features/config/components/ConfigHeader.module.scss create mode 100644 src/features/config/components/ConfigHeader.tsx create mode 100644 src/features/config/components/ConfigTabs.module.scss create mode 100644 src/features/config/components/ConfigTabs.tsx create mode 100644 src/features/config/components/FloatingSaveBar.module.scss create mode 100644 src/features/config/components/FloatingSaveBar.tsx create mode 100644 src/features/config/components/ModeSwitch.module.scss create mode 100644 src/features/config/components/ModeSwitch.tsx create mode 100644 src/features/config/components/SectionCard.module.scss create mode 100644 src/features/config/components/SectionCard.tsx create mode 100644 src/features/config/components/fields/Field.module.scss create mode 100644 src/features/config/components/fields/FieldPrimitives.tsx create mode 100644 src/features/config/constants.ts create mode 100644 src/features/config/searchIndex.ts create mode 100644 src/features/config/uiState.ts create mode 100644 tests/configUiState.test.ts diff --git a/src/features/config/components/ConfigHeader.module.scss b/src/features/config/components/ConfigHeader.module.scss new file mode 100644 index 00000000..3522cf29 --- /dev/null +++ b/src/features/config/components/ConfigHeader.module.scss @@ -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; + } +} diff --git a/src/features/config/components/ConfigHeader.tsx b/src/features/config/components/ConfigHeader.tsx new file mode 100644 index 00000000..7a9c265c --- /dev/null +++ b/src/features/config/components/ConfigHeader.tsx @@ -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 槽位(桌面端为 null,ModeSwitch 在 tabs 行右端)。 */ + extraActions?: ReactNode; +}; + +/** + * 配置面板头部:标题领衔 + ▍mono 遥测 meta 行 + 重载 ghost。 + * 保存动作不在头部常驻 —— 由 FloatingSaveBar 在 dirty 时承载。 + */ +export function ConfigHeader({ + meta, + reloadDisabled, + reloading, + onReload, + extraActions, +}: ConfigHeaderProps) { + const { t } = useTranslation(); + const toneClass: Record = { + muted: styles.metaMuted, + warning: styles.metaWarning, + error: styles.metaError, + ok: styles.metaOk, + }; + + return ( +
+
+

+ {t('config_management.title')} +

+

+ {meta.map((segment, index) => ( + + {index > 0 ? ( + + ) : null} + + {segment.count !== undefined + ? t(segment.labelKey, { count: segment.count }) + : t(segment.labelKey)} + + + ))} +

+
+
+ {extraActions} + +
+
+ ); +} diff --git a/src/features/config/components/ConfigTabs.module.scss b/src/features/config/components/ConfigTabs.module.scss new file mode 100644 index 00000000..dcdd79d0 --- /dev/null +++ b/src/features/config/components/ConfigTabs.module.scss @@ -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; + } +} diff --git a/src/features/config/components/ConfigTabs.tsx b/src/features/config/components/ConfigTabs.tsx new file mode 100644 index 00000000..e9d3f04a --- /dev/null +++ b/src/features/config/components/ConfigTabs.tsx @@ -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>; + /** 有待保存修改的 tabs(uiState.resolveDirtyTabs 的产物),显示琥珀脏点。 */ + dirtyTabs: ReadonlySet; + 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(null); + const buttonRefs = useRef>>({}); + + // 移动端横滚时把激活 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) => { + 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 ( +
+ {CONFIG_TAB_IDS.map((id) => { + const Icon = CONFIG_TAB_ICONS[id]; + const isActive = active === id; + const errorCount = errorCounts[id] ?? 0; + + return ( + + ); + })} +
+ ); +} diff --git a/src/features/config/components/FloatingSaveBar.module.scss b/src/features/config/components/FloatingSaveBar.module.scss new file mode 100644 index 00000000..dfbd0bf6 --- /dev/null +++ b/src/features/config/components/FloatingSaveBar.module.scss @@ -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; + } +} diff --git a/src/features/config/components/FloatingSaveBar.tsx b/src/features/config/components/FloatingSaveBar.tsx new file mode 100644 index 00000000..51badf00 --- /dev/null +++ b/src/features/config/components/FloatingSaveBar.tsx @@ -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(null); + const animationRef = useRef | 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 = { + error: styles.statusError, + warning: styles.statusWarning, + busy: styles.statusBusy, + muted: styles.statusMuted, + ok: styles.statusOk, + }; + + return createPortal( +
+
+ + {statusText} + +
+ + +
+
+
, + document.body + ); +} diff --git a/src/features/config/components/ModeSwitch.module.scss b/src/features/config/components/ModeSwitch.module.scss new file mode 100644 index 00000000..3c124b6f --- /dev/null +++ b/src/features/config/components/ModeSwitch.module.scss @@ -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; + } +} diff --git a/src/features/config/components/ModeSwitch.tsx b/src/features/config/components/ModeSwitch.tsx new file mode 100644 index 00000000..03848805 --- /dev/null +++ b/src/features/config/components/ModeSwitch.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/features/config/components/SectionCard.module.scss b/src/features/config/components/SectionCard.module.scss new file mode 100644 index 00000000..2b6dd96c --- /dev/null +++ b/src/features/config/components/SectionCard.module.scss @@ -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; + } +} diff --git a/src/features/config/components/SectionCard.tsx b/src/features/config/components/SectionCard.tsx new file mode 100644 index 00000000..a2e3be56 --- /dev/null +++ b/src/features/config/components/SectionCard.tsx @@ -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 ( +
+
+
+ {indexLabel ? {indexLabel} : null} + {icon ? {icon} : null} +
+
+

{title}

+ {description ?

{description}

: null} +
+
+
{children}
+
+ ); +} diff --git a/src/features/config/components/fields/Field.module.scss b/src/features/config/components/fields/Field.module.scss new file mode 100644 index 00000000..2d8a98a4 --- /dev/null +++ b/src/features/config/components/fields/Field.module.scss @@ -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); + } +} diff --git a/src/features/config/components/fields/FieldPrimitives.tsx b/src/features/config/components/fields/FieldPrimitives.tsx new file mode 100644 index 00000000..bbcdee85 --- /dev/null +++ b/src/features/config/components/fields/FieldPrimitives.tsx @@ -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'; + +/** 搜索跳转的脉冲高亮 class(useFieldJump 命令式挂载/移除)。 */ +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 ( +
+
+
{title}
+ {description ?
{description}
: null} +
+ +
+ ); +} + +export function FieldGrid({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function FieldStack({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function Divider() { + return
; +} + +// 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 ( +
+ {children} +
+ ); +} + +/** 带描边容器的字段组(原 SectionSubsection / .subsection)。title 可省略只留容器。 */ +export function FieldGroup({ + title, + description, + children, +}: { + title?: string; + description?: string; + children: ReactNode; +}) { + return ( +
+ {title ? ( +
+

{title}

+ {description ?

{description}

: null} +
+ ) : null} + {children} +
+ ); +} + +/** 独立的小组标题行(如 Claude / Codex 请求头小节标题)。 */ +export function FieldGroupHeading({ title }: { title: string }) { + return ( +
+

{title}

+
+ ); +} + +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 ( +
+ + {children} + {error ? ( +
+ {error} +
+ ) : null} + {hint ? ( +
+ {hint} +
+ ) : null} +
+ ); +} + +/** 数字输入右侧的「已禁用」pill 宿主(流式 keepalive 的 0/空 提示)。 */ +export function FieldControl({ children }: { children: ReactNode }) { + return
{children}
; +} + +/** FieldControl 内的内联 pill。 */ +export function InlinePill({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/features/config/constants.ts b/src/features/config/constants.ts new file mode 100644 index 00000000..bcbb0536 --- /dev/null +++ b/src/features/config/constants.ts @@ -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 = { + connectivity: '01', + network: '02', + logging: '03', + quota: '04', + streaming: '05', + advanced: '06', + payload: '07', +}; + +export const CONFIG_TAB_ICONS: Record> = { + 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 = { + // ── 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'; diff --git a/src/features/config/searchIndex.ts b/src/features/config/searchIndex.ts new file mode 100644 index 00000000..28cfc30f --- /dev/null +++ b/src/features/config/searchIndex.ts @@ -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 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); +} diff --git a/src/features/config/uiState.ts b/src/features/config/uiState.ts new file mode 100644 index 00000000..d2a45ebc --- /dev/null +++ b/src/features/config/uiState.ts @@ -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 = (() => { + const map = new Map(); + 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 = new Map( + CONFIG_FIELD_SEARCH_INDEX.map((entry) => [entry.fieldId, entry.sectionId]) +); + +const COMMON_FIELD_ID_SET: ReadonlySet = new Set(COMMON_FIELD_IDS); + +/** 常用 tab 渲染的字段对应的叶值键集合(校验错误归属常用 tab 时用)。 */ +const COMMON_VALUE_KEYS: ReadonlySet = new Set( + COMMON_FIELD_IDS.flatMap((fieldId) => [...(FIELD_VALUE_KEYS[fieldId] ?? [])]) +); + +/** 脏字段集合 → 点亮脏点的 tabs。常用字段同时点亮 common 与其正典分区(两处都渲染它)。 */ +export function resolveDirtyTabs(dirtyFields: ReadonlySet): ReadonlySet { + const tabs = new Set(); + 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 { + 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'; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7be35450..14d1fe20 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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" diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 28aaf7f2..b3b43ba1 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 784cbdce..8d26f51b 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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 密钥" diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index b2d0f8ff..9f63bf2d 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -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 金鑰" diff --git a/tests/configUiState.test.ts b/tests/configUiState.test.ts new file mode 100644 index 00000000..55a410d9 --- /dev/null +++ b/tests/configUiState.test.ts @@ -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 => ({ + 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[] = [ + { 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((node, part) => { + if (node && typeof node === 'object') return (node as Record)[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'); + }); +});