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
169 lines
7.3 KiB
TypeScript
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
|
|
}
|
|
|