mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-08-29 00:41:25 +08:00
feat(config): scaffold config feature shell (constants, uiState, tabs, header, save bar)
This commit is contained in:
@@ -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 槽位(桌面端为 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<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>>;
|
||||
/** 有待保存修改的 tabs(uiState.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';
|
||||
|
||||
/** 搜索跳转的脉冲高亮 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 (
|
||||
<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>;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 密钥"
|
||||
|
||||
@@ -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 金鑰"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user