Compare commits

...
Author SHA1 Message Date
weichen 57fabf1a95 Merge authoritative main into the admin-zone branch 2026-10-12 02:37:49 +00:00
weichen 360d6406e4 Merge pull request 'Refresh me on auth state so the admin entry survives reloads' (#61) from feature/me-refresh-on-auth into main
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/61
2026-10-12 02:35:35 +00:00
weichen 25e76c5e01 Host plugin-declared sections in the admin zone
Why: KTD4 requires the RDM audit partition to be reachable from the admin
zone by RDM domain admins and base superadmins, without hardcoding RDM
into the base AdminZone or merging roles in the frontend.
Evidence: npx vitest run 101/101 green (incl. design-guard), tsc --noEmit
clean, knip 42 <= 43; Dev composite CI is authoritative.
Impact: AdminZone merges admin-only builtin sections with sections
declared by compiled plugins (GET /api/admin-zone/sections, loaded in
refreshPlugins); the entry menu shows when isAdmin OR declared sections
exist; declared sections render via an id-keyed builtin component map
(rdm.audit -> RdmAuditWorkspace) with a version-mismatch placeholder for
unknown ids; unknown icons fall back to the generic grid icon. Token and
color usage unchanged.
Refs: WP3-I3/KTD4; task card feature-adminzone-plugin-sections.
2026-10-12 02:34:04 +00:00
weichen 41f6ce8760 Merge pull request 'Add the workspace task-page container with pin and project aggregation (U6)' (#62) from feature/generative-ui-u6 into main 2026-10-12 02:17:00 +00:00
weichen fdedaa1123 Refresh me on auth state so the admin entry survives reloads
Why: store.me was only written by the forced-change gate's onDone
(refreshMe sole caller), so after any page reload or a normal
re-login me stayed null and the admin menu (me?.isAdmin gate in
TaskSidebar/AdminZone) vanished permanently - reported repeatedly as
the user-management page becoming unavailable.

Evidence: vitest 100/100 pass and tsc+vite build clean after the
change; grep shows refreshMe had exactly one call site before
(App.tsx mustchange onDone).

Impact: admins keep the system-management entry across reloads and
normal logins; no behavior change for non-admins (me loads but
isAdmin=false).

Refs: dev task card feature/fe-me-refresh; companion Dev-repo pin
bump.
2026-10-12 02:09:14 +00:00
6 changed files with 72 additions and 12 deletions
+4
View File
@@ -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
View File
@@ -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}`) }),
+49 -9
View File
@@ -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>
+2 -1
View File
@@ -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>
+7
View File
@@ -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
View File
@@ -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