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).
234 lines
12 KiB
TypeScript
234 lines
12 KiB
TypeScript
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
|
||
// 全部收进这里,由 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>
|
||
)
|
||
}
|