Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57fabf1a95 | ||
|
|
360d6406e4 | ||
|
|
25e76c5e01 | ||
|
|
41f6ce8760 | ||
|
|
fdedaa1123 |
@@ -68,6 +68,10 @@ export function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (authState !== 'ok') return
|
||||
// me 回填进 store:管理入口(me?.isAdmin)与侧栏身份都消费它;此前只有
|
||||
// 改密门 onDone 调 refreshMe,登录与刷新回态后 me 恒为 null,管理员菜单
|
||||
// 在任何一次 F5 后永久消失(2026-10-12「用户管理页面不可用」根因)。
|
||||
store.refreshMe()
|
||||
store.refreshProjects()
|
||||
store.refreshProfiles()
|
||||
store.refreshPlugins()
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
|
||||
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
|
||||
import { auth } from './auth'
|
||||
import type {
|
||||
import type { AdminZoneSection,
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
|
||||
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
|
||||
ActivityCard,
|
||||
@@ -113,6 +113,7 @@ export const api = {
|
||||
|
||||
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
|
||||
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
|
||||
adminZoneSections: () => auth.fetch(`${BASE}/api/admin-zone/sections`).then(r => r.json()) as Promise<AdminZoneSection[]>,
|
||||
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
|
||||
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
|
||||
setSessionNote: (id: string, sessionNote: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionNote }) }).then(r => { if (!r.ok) throw new Error(`${r.status}`) }),
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
// 系统管理(重设计方案 阶段②):管理员专属的全屏独立视图(带返回,非弹窗)。
|
||||
// 系统管理(重设计方案 阶段②):全屏独立视图(带返回,非弹窗)。
|
||||
// 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。
|
||||
// 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。
|
||||
// WP3-I3/KTD4:编译插件可经 Registry.AddAdminZone 声明展示分区(服务端
|
||||
// /api/admin-zone/sections 下发);声明分区对全部认证主体开放,区内数据
|
||||
// 能力由分区组件自身的命令面 fail-closed——前端只消费、不合并角色。
|
||||
import { useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { Icon, type IconName } from '@ui'
|
||||
import { EmptyState, Icon, type IconName } from '@ui'
|
||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
import type { AdminZoneSection } from '../contracts'
|
||||
|
||||
interface AdminSectionDef {
|
||||
key: string
|
||||
@@ -27,12 +32,47 @@ const SECTIONS: AdminSectionDef[] = [
|
||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
||||
]
|
||||
|
||||
/** 声明分区 → 内置分区组件(按声明 ID 映射;AdminZone 不硬编码领域逻辑)。 */
|
||||
const DECLARED_COMPONENTS: Record<string, () => React.ReactNode> = {
|
||||
'rdm.audit': () => <RdmAuditWorkspace />,
|
||||
}
|
||||
|
||||
/** icon 走基座 IconName 词表;声明携带未知图标时回退通用图标(不渲染空白)。 */
|
||||
const ICONS: IconName[] = ['plus', 'search', 'panel-left', 'inbox', 'settings', 'sun', 'moon', 'pin', 'archive', 'undo',
|
||||
'chevron-right', 'chevron-down', 'folder', 'message', 'package', 'bot', 'puzzle', 'grid', 'arrow-up', 'paperclip',
|
||||
'more', 'logout', 'key', 'user', 'users', 'shield', 'x', 'check', 'alert', 'send', 'sparkles', 'clock', 'list',
|
||||
'file', 'image', 'download', 'trash', 'eye', 'maximize', 'minimize']
|
||||
function declaredIcon(name?: string): IconName {
|
||||
return (ICONS as string[]).includes(name ?? '') ? (name as IconName) : 'grid'
|
||||
}
|
||||
|
||||
function DeclaredSection({ section }: { section: AdminZoneSection }) {
|
||||
const render = DECLARED_COMPONENTS[section.id]
|
||||
if (!render) {
|
||||
return <EmptyState icon="puzzle">分区「{section.title}」由内置插件声明,但当前前端版本未携带其组件<br />(部署前端与二进制版本不匹配时会出现;升级前端即可)</EmptyState>
|
||||
}
|
||||
return <>{render()}</>
|
||||
}
|
||||
|
||||
export function AdminZone() {
|
||||
const me = useStore(s => s.me)
|
||||
const declared = useStore(s => s.adminSections)
|
||||
const [sectionKey, setSectionKey] = useState('users')
|
||||
// 双保险:入口已在菜单层门控(me?.isAdmin),此处再拦一次(纵深防御)。
|
||||
if (!me?.isAdmin) return null
|
||||
const section = SECTIONS.find(s => s.key === sectionKey) ?? SECTIONS[0]
|
||||
const isAdmin = !!me?.isAdmin
|
||||
// 门控:基座系统分区仅 isAdmin(纵深防御与菜单层一致);声明分区对全部
|
||||
// 认证主体开放(区内能力由组件命令面判定)。两者皆无时不渲染。
|
||||
if (!isAdmin && declared.length === 0) return null
|
||||
const sections: AdminSectionDef[] = [
|
||||
...(isAdmin ? SECTIONS : []),
|
||||
...declared.map(s => ({
|
||||
key: `decl:${s.id}`, label: s.title, icon: declaredIcon(s.icon),
|
||||
desc: s.desc || '插件声明的管理分区(区内数据能力由插件命令面判定)',
|
||||
render: () => <DeclaredSection section={s} />,
|
||||
})),
|
||||
]
|
||||
const fallback = sections[0]?.key ?? ''
|
||||
const section = sections.find(s => s.key === sectionKey) ?? sections[0]
|
||||
if (!section) return null
|
||||
return (
|
||||
<div className="az" role="dialog" aria-label="系统管理">
|
||||
<div className="az-head">
|
||||
@@ -40,14 +80,14 @@ export function AdminZone() {
|
||||
<Icon name="chevron-down" size={14} /> 返回工作台
|
||||
</button>
|
||||
<span className="az-title">系统管理</span>
|
||||
<span className="az-sub">仅管理员可见 · 变更即时生效</span>
|
||||
<span className="az-sub">{isAdmin ? '仅管理员可见' : '按插件声明开放'} · 变更即时生效</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="az-tenant">{me.owner}</span>
|
||||
<span className="az-tenant">{me?.owner ?? ''}</span>
|
||||
</div>
|
||||
<div className="az-body">
|
||||
<nav className="az-side">
|
||||
{SECTIONS.map(s => (
|
||||
<button key={s.key} className={`az-nav-item ${s.key === sectionKey ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
|
||||
{sections.map(s => (
|
||||
<button key={s.key} className={`az-nav-item ${s.key === (section?.key ?? fallback) ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
|
||||
<Icon name={s.icon} size={15} />
|
||||
<span>{s.label}</span>
|
||||
</button>
|
||||
|
||||
@@ -40,6 +40,7 @@ export function TaskSidebar() {
|
||||
const projects = useStore(s => s.projects)
|
||||
const tasks = useStore(s => s.tasks)
|
||||
const me = useStore(s => s.me)
|
||||
const adminSections = useStore(s => s.adminSections)
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const currentConvId = useStore(s => s.currentConvId)
|
||||
const currentProjectId = useStore(s => s.currentProjectId)
|
||||
@@ -265,7 +266,7 @@ export function TaskSidebar() {
|
||||
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
|
||||
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
|
||||
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
|
||||
{me?.isAdmin && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||||
{(me?.isAdmin || adminSections.length > 0) && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||||
{me?.auth && <>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
|
||||
|
||||
@@ -288,6 +288,13 @@ export interface PluginManifest {
|
||||
backend?: { mode?: 'inproc' | 'sidecar'; command?: string; args?: string[]; schemaVersion?: number };
|
||||
}
|
||||
|
||||
// ---- 管理区声明分区(WP3-I3/KTD4)----
|
||||
/** 编译插件经 Registry.AddAdminZone 声明的管理区展示分区;入口不做前端权限合并,
|
||||
* 分区内数据能力由组件自身的命令面 fail-closed。 */
|
||||
export interface AdminZoneSection {
|
||||
id: string; title: string; icon?: string; desc?: string; order?: number;
|
||||
}
|
||||
|
||||
export interface PluginPanel {
|
||||
id: string; title: string; icon?: string; body: UINode[]; refreshTool?: string;
|
||||
}
|
||||
|
||||
+8
-1
@@ -15,7 +15,7 @@ const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
|
||||
export function isFontSizeChoice(value: number): value is FontSizeChoice {
|
||||
return (FONT_SIZE_CHOICES as readonly number[]).includes(value)
|
||||
}
|
||||
import type {
|
||||
import type { AdminZoneSection,
|
||||
ActivityCard,
|
||||
AgentProfile,
|
||||
Artifact,
|
||||
@@ -112,6 +112,8 @@ interface AppState {
|
||||
profiles: AgentProfile[]
|
||||
pluginPanels: PluginPanel[]
|
||||
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
|
||||
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
|
||||
adminSections: AdminZoneSection[]
|
||||
ui: UIState
|
||||
connected: boolean
|
||||
/** 发送请求已经出去,run 记录还没回来。 */
|
||||
@@ -175,6 +177,7 @@ let state: AppState = {
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
serverWorkspaces: [],
|
||||
adminSections: [],
|
||||
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
||||
outbox: [], outboxDraining: false,
|
||||
previewTabs: {}, previewActive: {},
|
||||
@@ -784,6 +787,10 @@ export const store = {
|
||||
const ws = await api.listWorkspaces()
|
||||
set({ serverWorkspaces: ws ?? [] })
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const sections = await api.adminZoneSections()
|
||||
set({ adminSections: sections ?? [] })
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const settings = await api.getSettings()
|
||||
const runtime = settings.user.runtime
|
||||
|
||||
Reference in New Issue
Block a user