Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fac99939fa | ||
|
|
c1225bb880 | ||
|
|
57fabf1a95 | ||
|
|
360d6406e4 | ||
|
|
25e76c5e01 | ||
|
|
41f6ce8760 | ||
|
|
0f860c0743 |
@@ -9,6 +9,11 @@ import type {
|
||||
|
||||
const BASE = ''
|
||||
|
||||
/** 管理区声明分区(WP3-I3/KTD4,backend 专属端点类型):编译插件经
|
||||
* Registry.AddAdminZone 声明;入口不做前端权限合并,区内数据能力由组件
|
||||
* 自身命令面 fail-closed。 */
|
||||
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
@@ -33,6 +38,7 @@ export const api = {
|
||||
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(r)),
|
||||
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
|
||||
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
|
||||
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<any[]>(r)),
|
||||
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(r)),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
@@ -112,6 +118,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 '../api'
|
||||
|
||||
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>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { store, useStore } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
|
||||
import { Icon } from '@ui'
|
||||
import { Icon, SurfaceView } from '@ui'
|
||||
import { previewTabsKey } from '../previewtabs'
|
||||
import { ArtifactsPanel } from './ArtifactsPanel'
|
||||
import { PreviewPane } from './PreviewPane'
|
||||
@@ -115,6 +115,7 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
||||
{inboxScope === 'project'
|
||||
? <ProjectInbox cards={projectCards} filter={filter} />
|
||||
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
|
||||
<WorkspaceSurfaces />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@@ -172,3 +173,33 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// U6 工作区任务页容器:会话内页面在"工作"入口的常驻紧凑视图——默认显示最新
|
||||
// 一页,固定(pin)后不随新页面抢占;关闭插件后此处自然消失(surfaces 为空),
|
||||
// 收件箱/任务卡等原有内容不受影响(禁用回退完整性)。点击标题去文件预览全屏。
|
||||
function WorkspaceSurfaces() {
|
||||
const surfaces = useStore(s => s.surfaces)
|
||||
const pin = useStore(s => s.ui.workspaceSurfacePin)
|
||||
if (surfaces.length === 0) return null
|
||||
const shown = pin ? surfaces.find(x => String(x.identity.surfaceId) === pin) : surfaces[surfaces.length - 1]
|
||||
if (!shown) return null
|
||||
return (
|
||||
<div className="workspace-surfaces" data-workspace-surfaces>
|
||||
<div className="ws-head">
|
||||
<span className="ws-title">任务页面</span>
|
||||
<span className="ws-meta">{shown.spec?.title || '(未命名)'} · 修订 {shown.revision}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="icon-btn" title={pin ? '取消固定(跟随最新)' : '固定此页(新页面不抢占)'}
|
||||
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : String(shown.identity.surfaceId) })}>
|
||||
{pin ? '📍' : '📌'}
|
||||
</button>
|
||||
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(String(shown.identity.surfaceId))}>
|
||||
↗
|
||||
</button>
|
||||
</div>
|
||||
<div className="ws-body">
|
||||
<SurfaceView snapshot={shown} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+17
-4
@@ -1,7 +1,7 @@
|
||||
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
||||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { api, connectEvents } from './api'
|
||||
import { type AdminZoneSection, api, connectEvents } from './api'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||||
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
|
||||
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
Project,
|
||||
Run,
|
||||
StreamEvent,
|
||||
SurfaceMeta,
|
||||
SurfaceSnapshot,
|
||||
TodoList
|
||||
} from './contracts'
|
||||
@@ -58,6 +59,8 @@ export interface UIState {
|
||||
rightTab: 'work' | 'files'
|
||||
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
|
||||
filesView: 'list' | 'preview'
|
||||
/** U6:工作区任务页固定(surfaceId;null=跟随最新;固定后新页面不抢占显示)。 */
|
||||
workspaceSurfacePin: string | null
|
||||
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
|
||||
inboxFilter: 'all' | 'todo'
|
||||
theme: ThemeChoice
|
||||
@@ -99,6 +102,8 @@ interface AppState {
|
||||
artifacts: Artifact[]
|
||||
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
|
||||
surfaces: SurfaceSnapshot[]
|
||||
/** generative-ui U6:项目级跨会话页面聚合(打开项目时权威装载)。 */
|
||||
projectSurfaces: SurfaceMeta[]
|
||||
cards: ActivityCard[]
|
||||
projectCards: ActivityCard[]
|
||||
inboxScope: 'conv' | 'project'
|
||||
@@ -107,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 记录还没回来。 */
|
||||
@@ -125,7 +132,7 @@ const DEFAULT_UI: UIState = {
|
||||
rightWidth: 360,
|
||||
rightCollapsed: false,
|
||||
rightTab: 'work',
|
||||
filesView: 'list',
|
||||
filesView: 'list', workspaceSurfacePin: null,
|
||||
inboxFilter: 'all',
|
||||
activeWorkspace: null,
|
||||
theme: 'dark',
|
||||
@@ -168,8 +175,9 @@ let state: AppState = {
|
||||
contextObs: {},
|
||||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
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: {},
|
||||
@@ -374,7 +382,7 @@ export const store = {
|
||||
|
||||
async openConversation(convId: string) {
|
||||
if (sse) { sse.close(); sse = null }
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||
persistConvId(convId)
|
||||
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
|
||||
if ((state.previewTabs['_'] ?? []).length > 0) {
|
||||
@@ -385,6 +393,7 @@ export const store = {
|
||||
})
|
||||
}
|
||||
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
|
||||
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
|
||||
api.surfaceList(convId).then(list => {
|
||||
if (state.currentConvId !== convId) return
|
||||
set({ surfaces: list ?? [] })
|
||||
@@ -778,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
|
||||
|
||||
@@ -57,3 +57,21 @@ describe('surface.updated 可见性联动', () => {
|
||||
expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('U6 工作区任务页', () => {
|
||||
it('pin 固定后新页面事件不改变固定对象;取消后跟随最新', () => {
|
||||
store.setUI({ workspaceSurfacePin: null })
|
||||
store.applyEvent(surfEvent(201, 'w1', 1, '第一页'))
|
||||
const first = store.getState().ui.workspaceSurfacePin
|
||||
expect(first).toBeNull() // 默认跟随(不自动 pin)
|
||||
// 用户固定 w1
|
||||
store.setUI({ workspaceSurfacePin: 'w1' })
|
||||
store.applyEvent(surfEvent(202, 'w2', 1, '第二页'))
|
||||
// pin 不变,且 surfaces 数组两页并存(禁用回退时容器随空数组自然消失)
|
||||
expect(store.getState().ui.workspaceSurfacePin).toBe('w1')
|
||||
const w2 = store.getState().surfaces.find(x => String(x.identity.surfaceId) === 'w2')
|
||||
expect(w2?.spec.title).toBe('第二页')
|
||||
store.setUI({ workspaceSurfacePin: null })
|
||||
expect(store.getState().ui.workspaceSurfacePin).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user