// 设置中心(4.9 → UI 能力平台迁移):@ui SettingsShell + SettingRow 统一行语义, // 保存按钮进固定 actions 底栏(不随内容滚动);schema 驱动 + 生效时机标注 + 密钥掩码。 // 重设计方案 阶段②(IA):设置瘦身——只留个人偏好(常规);智能体画像/插件管理/ // 插件工作区迁「系统管理」(AdminZone,管理员专属全屏视图)。 import { useEffect, useMemo, useState } from 'react' import { api } from '../api' import { isFontSizeChoice, store, useStore } from '../store' import type { ThemeChoice } from '../theme' import type { SettingDesc } from '../contracts' import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui' import { BorrowPanels } from './BorrowPanels' import { MyAccountSettings } from './MyAccountSettings' type Section = 'settings' | 'account' // 分组显示名(项目设置面板共用)。 export const groupNames: Record = { models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索', ui: '界面', plugins: '插件目录', crossSession: '会话协作', tools: '工具', } export function SettingsModal() { const [section, setSection] = useState
('settings') const [schema, setSchema] = useState([]) const [, setUser] = useState>({}) const [draft, setDraft] = useState>({}) const [saved, setSaved] = useState(false) const theme = useStore(s => s.ui.theme) const fontSize = useStore(s => s.ui.fontSize) useEffect(() => { api.getSettings().then(s => { setSchema(s.schema) setUser(flatten(s.user)) setDraft(flatten(s.user)) }).catch(() => undefined) }, []) const groups = useMemo(() => { const g: Record = {} for (const d of schema) (g[d.group] ??= []).push(d) return g }, [schema]) const save = async () => { const values = unflatten(draft) await api.putUserSettings(values) setUser(draft) setSaved(true) setTimeout(() => setSaved(false), 1500) // hot 类设置即时生效:主题。 if (draft['ui.theme']) store.setUI({ theme: draft['ui.theme'] }) store.refreshPlugins() } return ( setSection(key as Section)} onClose={() => store.openSettings(false)} sections={[ { key: 'settings', label: '常规', icon: 'settings' }, { key: 'account', label: '账号', icon: 'user' }, ]} footer={saved ? ✓ 已保存 : 'Agent-System · 设置中心'} actions={section === 'settings' && schema.length > 0 ? ( <> {saved && ✓ 已保存} 保存设置 ) : undefined} > {section === 'settings' && ( )} {section === 'settings' && Object.entries(groups).map(([g, descs]) => { const rows = descs.filter(d => d.key !== 'ui.theme') if (rows.length === 0) return null return ( {rows.map(d => ( setDraft(x => ({ ...x, [d.key]: v }))} /> ))} ) })} {section === 'settings' && } {section === 'account' && } ) } // 输入框语义(2026-10-12 增量 3):仅秘密字段用密码框;普通文本用 text—— // 无条件 type="password" 会把整排设置项当凭据表单(浏览器「password 不在 form 中/ // 缺少 username」警告来源之一),也把普通文本无谓遮蔽。 export function settingInputAttrs(desc: SettingDesc): { type: 'text' | 'password'; autoComplete: string } { const isSecret = desc.secret || desc.type === 'secret' return isSecret ? { type: 'password', autoComplete: 'new-password' } : { type: 'text', autoComplete: 'off' } } export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) { if (desc.type === 'boolean') { return onChange(e.target.checked)} /> } if (desc.type === 'enum') { return } const isSecret = desc.secret || desc.type === 'secret' const attrs = settingInputAttrs(desc) return onChange(e.target.value)} /> } // 点路径扁平化/还原。 function flat(m: Record, prefix: string, out: Record) { for (const [k, v] of Object.entries(m)) { const key = prefix ? `${prefix}.${k}` : k if (v && typeof v === 'object' && !Array.isArray(v)) flat(v, key, out) else out[key] = v } } export function flatten(m: Record): Record { const out: Record = {} flat(m, '', out) return out } export function unflatten(flatMap: Record): Record { const out: Record = {} for (const [k, v] of Object.entries(flatMap)) { if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改) const parts = k.split('.') let cur = out for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]] ??= {} cur[parts[parts.length - 1]] = v } return out }