Files
Agent-System-Frontend/src/components/SettingsModal.tsx
T
weichen e895a4f6a8 Close the account chain UI against the current backend contract
Why: the forced password gate still submitted an empty current password
after the backend began requiring it (current_required), locking every
account seeded with an initial password out of the workbench, and the
admin user panel had no client for the owner-confirmed recovery
proposal contract that replaced one-time key issuing (HTTP 410).
Evidence: npm test 300 passed; tsc -b && vite build ok; knip issues
54 <= baseline 55 (SELF).
Impact: mode-aware ChangePasswordGate with current-password input,
admin recovery proposal panel, owner-side account settings section,
recovery proposal client methods; no backend changes.
Refs: backend 8dfc223, b9c0a34; frontend #94
2026-10-16 07:49:25 +00:00

169 lines
7.3 KiB
TypeScript

// 设置中心(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<string, string> = {
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
ui: '界面', plugins: '插件目录', crossSession: '会话协作', tools: '工具',
}
export function SettingsModal() {
const [section, setSection] = useState<Section>('settings')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [, setUser] = useState<Record<string, any>>({})
const [draft, setDraft] = useState<Record<string, any>>({})
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<string, SettingDesc[]> = {}
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 (
<SettingsShell
title="设置" active={section} onSelect={key => setSection(key as Section)}
onClose={() => store.openSettings(false)}
sections={[
{ key: 'settings', label: '常规', icon: 'settings' },
{ key: 'account', label: '账号', icon: 'user' },
]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
actions={section === 'settings' && schema.length > 0 ? (
<>
{saved && <span style={{ color: 'var(--ok)', fontSize: 'var(--fs-ui-sm)', marginRight: 8 }}>✓ 已保存</span>}
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
</>
) : undefined}
>
{section === 'settings' && (
<SettingsGroup title="外观">
<SettingRow label="主题" desc="墨绿是可选身份皮肤。跟随系统只在深色与浅色之间切换。深色和浅色会随设置保存" effect="hot">
<select value={theme} onChange={e => {
const next = e.target.value as ThemeChoice
store.setUI({ theme: next })
if (next === 'dark' || next === 'light') setDraft(x => ({ ...x, 'ui.theme': next }))
}}>
<option value="dark">深色</option>
<option value="light">浅色</option>
<option value="molv">墨绿</option>
<option value="system">跟随系统</option>
</select>
</SettingRow>
<SettingRow label="界面字号" desc="小、标准、大、特大。只保存在这台设备上,图标和间距不跟随缩放" effect="hot">
<select value={fontSize} onChange={e => {
const next = Number(e.target.value)
if (isFontSizeChoice(next)) store.setUI({ fontSize: next })
}}>
<option value={13}>小</option>
<option value={14}>标准</option>
<option value={16}>大</option>
<option value={18}>特大</option>
</select>
</SettingRow>
</SettingsGroup>
)}
{section === 'settings' && Object.entries(groups).map(([g, descs]) => {
const rows = descs.filter(d => d.key !== 'ui.theme')
if (rows.length === 0) return null
return (
<SettingsGroup key={g} title={groupNames[g] ?? g}>
{rows.map(d => (
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
</SettingRow>
))}
</SettingsGroup>
)
})}
{section === 'settings' && <BorrowPanels />}
{section === 'account' && <MyAccountSettings />}
</SettingsShell>
)
}
// 输入框语义(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 <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
}
if (desc.type === 'enum') {
return <select value={value} onChange={e => onChange(e.target.value)}>
{(desc.options ?? []).map(o => <option key={o} value={o}>{o}</option>)}
</select>
}
const isSecret = desc.secret || desc.type === 'secret'
const attrs = settingInputAttrs(desc)
return <input type={attrs.type} autoComplete={attrs.autoComplete} style={{ width: '100%' }} value={typeof value === 'string' && value === '••••••' ? '' : (value ?? '')}
placeholder={isSecret ? '(已配置,输入以覆盖)' : String(desc.default ?? '')}
onChange={e => onChange(e.target.value)} />
}
// 点路径扁平化/还原。
function flat(m: Record<string, any>, prefix: string, out: Record<string, any>) {
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<string, any>): Record<string, any> {
const out: Record<string, any> = {}
flat(m, '', out)
return out
}
export function unflatten(flatMap: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
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
}