Files
Agent-System-Frontend/src/components/adminSections.tsx
T
weichen 48a14fb4d2 Move system capabilities into an admin-only full-screen zone
Why: Approved redesign phase 2 (IA): normal users run projects entirely
through conversation; system capabilities (user management, agent profiles,
plugin management, plugin-declared workspaces) belong behind a dedicated
admin entry, not in the user-facing sidebar or settings. The sidebar
workspace strip is removed; artifacts stay reachable via the right-rail
tab and conversation chips.
Evidence: npx tsc -b clean; npm test -> 38 passed (design-guard ratchet
still green). Full combined run recorded in the Dev task card.
Impact: new AdminZone full-screen view with a declarative section registry
(users/profiles/plugins/plugin-workspaces via UIRenderer); sections moved
out of SettingsModal (now personal preferences only) and AdminUsersModal
(now password change only); store ui.adminUsersOpen renamed adminZoneOpen;
workspaceEntries narrowed by role so plugin entries no longer take Cmd1-9
for regular users; theme toggle moved into the account menu.
Refs: Agent-System-Dev task feature/ui-tokens-v3 (phase 2).
2026-09-30 19:11:20 +00:00

234 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
import { useEffect, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile } from '../contracts'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { randomPassword } from '../passwords'
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制)----
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
export function UsersPanel() {
const me = useStore(s => s.me)
const [users, setUsers] = useState<AdminUserRow[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [owner, setOwner] = useState('')
const [name, setName] = useState('')
const [pw, setPw] = useState('')
const [asAdmin, setAsAdmin] = useState(false)
const [busy, setBusy] = useState(false)
const [resetFor, setResetFor] = useState<string | null>(null)
const [resetPw, setResetPw] = useState('')
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const create = async () => {
setErr(''); setNotice('')
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
setBusy(true)
try {
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
setOwner(''); setName(''); setPw('')
await refresh()
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const reset = async () => {
if (!resetFor) return
setErr('')
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.resetAdminUserPassword(resetFor, resetPw)
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
setResetFor(null); setResetPw('')
await refresh()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
{me?.isAdmin && (
<div className="ad-create">
<div className="ad-row">
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
</div>
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码。</p>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{resetFor ? (
<div className="ad-reset">
<span>重置 <b>{resetFor}</b> 的密码:</span>
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
<button className="lg-submit ad-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
<button type="button" className="ad-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
</div>
) : (
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (
<button className="ad-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
)}</td>
</tr>
))}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
)}
</div>
)
}
// ---- 智能体画像(原 SettingsModal ProfilesSection:数据化导入导出 + 编辑)----
export function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
const [editing, setEditing] = useState<AgentProfile | null>(null)
const [text, setText] = useState('')
return (
<div>
<div className="doc-toolbar">
<Btn variant="primary" icon="plus" onClick={() => { setEditing(newProfile()); setText('') }}>新建画像</Btn>
<label className="btn ghost" style={{ cursor: 'pointer' }}>
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
const f = e.target.files?.[0]
if (!f) return
try {
const p = JSON.parse(await f.text())
p.id = p.id || 'prof_imported'
p.builtin = false
await api.saveProfile(p)
store.refreshProfiles()
store.toast('画像已导入')
} catch (err) { store.toast(`导入失败:${err}`) }
}} />
</label>
</div>
{profiles.map(p => (
<SettingRow key={p.id}
label={<>{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-xs)' }}>{p.id}{p.builtin ? ' · 内置' : ''}</span></>}
desc={p.description}>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<Btn variant="ghost" icon="download" onClick={() => {
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob); a.download = `${p.id}.json`; a.click()
}}>导出</Btn>
{!p.builtin && <Btn variant="ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</Btn>}
{!p.builtin && <Btn variant="ghost" icon="trash" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</Btn>}
</div>
</SettingRow>
))}
{editing && (
<SettingsGroup title={`编辑画像:${editing.name}`}>
<div className="ui-form">
<label>名称<input value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} /></label>
<label>系统提示<textarea className="search-input" rows={4} style={{ height: 'auto' }} value={text} onChange={e => setText(e.target.value)} /></label>
<label>允许工具(逗号分隔,* 全部)<input value={editing.tools.allow.join(',')} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, allow: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>执行前确认(前缀. 结尾表示族)<input value={editing.tools.confirm?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, confirm: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>拒绝工具<input value={editing.tools.deny?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, deny: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
</div>
<Btn variant="primary" icon="check" onClick={async () => {
try {
await api.saveProfile({ ...editing, builtin: false, source: 'user', systemPrompt: text })
await store.refreshProfiles()
setEditing(null)
store.toast('画像已保存')
} catch (e) { store.toast(`保存失败:${e}`) }
}}>保存画像</Btn>
</SettingsGroup>
)}
</div>
)
}
function newProfile(): AgentProfile {
return {
id: '', name: '新画像', systemPrompt: '', model: {}, tools: { allow: ['*'] },
context: { maxHistoryMessages: 40 }, hitl: { askOnAmbiguity: true }, budget: {},
} as AgentProfile
}
// ---- 插件管理(原 SettingsModal PluginsSection:清单 + 热重载)----
export function PluginsSection() {
const [plugins, setPlugins] = useState<any[]>([])
const refresh = () => api.listPlugins().then(setPlugins).catch(() => undefined)
useEffect(() => { refresh() }, [])
return (
<div>
<div className="doc-toolbar">
<Btn variant="ghost" icon="undo" onClick={async () => {
await api.reloadPlugins()
await refresh()
store.refreshPlugins()
store.toast('插件已热重载')
}}>热重载</Btn>
<span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-sm)' }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
</div>
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
{plugins.map(p => (
<SettingRow key={p.manifest.id}
label={`${p.manifest.name} v${p.manifest.version}`}
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
<span className="effect-badge">api {p.manifest.apiVersion}</span>
</SettingRow>
))}
</div>
)
}
// ---- 插件工作区(插件声明面板 + 服务端声明工作区;声明体经 UIRenderer 内联渲染)----
export function PluginWorkspacesPanel() {
const pluginPanels = useStore(s => s.pluginPanels)
const serverWorkspaces = useStore(s => s.serverWorkspaces)
const [selected, setSelected] = useState<string | null>(null)
const entries = [
...pluginPanels.map(p => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', body: p.body ?? [] })),
...serverWorkspaces.map(w => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', body: (w.body ?? []) as any[] })),
]
const current = entries.find(e => e.key === selected) ?? entries[0]
if (entries.length === 0) {
return <EmptyState icon="grid">暂无插件声明工作区<br />(插件可在 manifest 里声明 panels / workspaces)</EmptyState>
}
return (
<div>
<div className="doc-toolbar" style={{ flexWrap: 'wrap' }}>
{entries.map(e => (
<button key={e.key} className={`kind-pill ${current?.key === e.key ? 'on' : ''}`} onClick={() => setSelected(e.key)}>
{e.icon} {e.title}
</button>
))}
<span style={{ flex: 1 }} />
<Btn variant="ghost" icon="undo" onClick={() => store.refreshPlugins()}>刷新</Btn>
</div>
{current && <UIRenderer nodes={current.body} docBridge={docBridge as DocBridge} />}
</div>
)
}