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.
278 lines
16 KiB
TypeScript
278 lines
16 KiB
TypeScript
// 左栏(找上下文):顶部切换项目,中间放会话与搜索,底部放文件、工作区和账户。
|
||
// 创建、搜索、置顶与归档仍在会话段里;领域工具由服务端工作区声明,没有时装上说明行。
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { store, useStore } from '../store'
|
||
import { auth } from '../auth'
|
||
import { api } from '../api'
|
||
import { Icon } from '@ui'
|
||
import type { Conversation } from '../contracts'
|
||
import { relativeTime, filterTasks } from '../tasklist'
|
||
import { projectStage } from '../projectmeta'
|
||
import { buildWorkspaceEntries } from '../workspaces'
|
||
|
||
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
|
||
function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClose: () => void; children: React.ReactNode }) {
|
||
const ref = useRef<HTMLDivElement>(null)
|
||
useEffect(() => {
|
||
if (!anchor || !ref.current) return
|
||
const r = anchor.getBoundingClientRect()
|
||
const el = ref.current
|
||
let x = r.left, y = r.bottom + 6
|
||
if (x + el.offsetWidth > window.innerWidth - 8) x = window.innerWidth - el.offsetWidth - 8
|
||
if (y + el.offsetHeight > window.innerHeight - 8) y = r.top - el.offsetHeight - 6
|
||
el.style.left = `${Math.max(8, x)}px`
|
||
el.style.top = `${y}px`
|
||
const h = (e: MouseEvent) => { if (!el.contains(e.target as Node)) onClose() }
|
||
window.addEventListener('mousedown', h)
|
||
return () => window.removeEventListener('mousedown', h)
|
||
}, [anchor])
|
||
if (!anchor) return null
|
||
return <div ref={ref} className="tsb-menu" onClick={e => e.stopPropagation()}>{children}</div>
|
||
}
|
||
|
||
function MenuItem({ onClick, danger, icon, children }: { onClick: () => void; danger?: boolean; icon?: Parameters<typeof Icon>[0]['name']; children: React.ReactNode }) {
|
||
return <button className={`tsb-mi ${danger ? 'danger' : ''}`} onClick={onClick}>
|
||
{icon && <Icon name={icon} size={15} />}{children}
|
||
</button>
|
||
}
|
||
|
||
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)
|
||
const showArchived = useStore(s => s.ui.showArchived)
|
||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||
const theme = useStore(s => s.ui.theme)
|
||
const pendingBadge = useStore(s =>
|
||
s.cards.filter(c => c.status === 'action_required').length +
|
||
s.projectCards.filter(c => c.status === 'action_required').length)
|
||
|
||
const [q, setQ] = useState('')
|
||
const [menu, setMenu] = useState<{ kind: 'new' | 'account' | 'project' | `row:${string}`; anchor: HTMLElement } | null>(null)
|
||
const [creatingProject, setCreatingProject] = useState(false)
|
||
const [newProjectName, setNewProjectName] = useState('')
|
||
|
||
// ⌘N/Ctrl+N 新建任务(P6:创建入口唯一 + 快捷键)。
|
||
useEffect(() => {
|
||
const h = (e: KeyboardEvent) => {
|
||
const k = e.key.toLowerCase()
|
||
if ((e.metaKey || e.ctrlKey) && k === 'n') { e.preventDefault(); store.createTask() }
|
||
if ((e.metaKey || e.ctrlKey) && k === 'b') { e.preventDefault(); store.toggleLeft() }
|
||
}
|
||
window.addEventListener('keydown', h, true)
|
||
return () => window.removeEventListener('keydown', h, true)
|
||
}, [])
|
||
|
||
const tools = buildWorkspaceEntries(serverWorkspaces)
|
||
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
|
||
const pinned = visible.filter(t => t.pinned)
|
||
const rest = visible.filter(t => !t.pinned)
|
||
const currentProject = projects.find(p => p.id === currentProjectId)
|
||
const stage = projectStage(currentProject?.settings)
|
||
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
|
||
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
|
||
|
||
const rowActions = (t: Conversation) => (
|
||
<span className="tsb-row-acts">
|
||
<button className="tsb-row-act" title="置顶 / 取消置顶" onClick={e => { e.stopPropagation(); store.pinTask(t.id, !t.pinned) }}><Icon name="pin" size={13} /></button>
|
||
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
|
||
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
|
||
</button>
|
||
<button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
|
||
onClick={e => { e.stopPropagation(); store.setCrossFlags(t.id, { crossPrivate: !t.crossPrivate }) }}>
|
||
<Icon name="shield" size={13} />
|
||
</button>
|
||
</span>
|
||
)
|
||
|
||
/** 切换项目:优先恢复该项目下正在看的会话,否则进第一个未归档会话。 */
|
||
const switchProject = (pid: string) => {
|
||
const next = tasks.find(t => t.projectId === pid && t.id === currentConvId)
|
||
?? tasks.find(t => t.projectId === pid && !t.archived)
|
||
if (next) store.openTask(next.id)
|
||
else store.openProject(pid)
|
||
}
|
||
|
||
const taskRow = (t: Conversation) => (
|
||
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
|
||
onClick={() => store.openTask(t.id)}
|
||
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
|
||
<span className="tsb-task-t" title={t.title}>{t.title || '未命名任务'}</span>
|
||
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
|
||
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
|
||
{rowActions(t)}
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<>
|
||
{/* 项目身份唯一落点:顶部切换项目(方案:项目导航置顶)。 */}
|
||
<div className="tsb-brand">
|
||
<button className="tsb-projrow" title="切换项目"
|
||
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'project' ? null : { kind: 'project', anchor: el }) }}>
|
||
<Icon name="folder" size={15} />
|
||
<span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span>
|
||
{stage && <span className="tsb-projrow-stage">{stage}</span>}
|
||
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||
</button>
|
||
<span style={{ flex: 1 }} />
|
||
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
||
</div>
|
||
|
||
{/* 单一主创建入口(P6):新建任务 + 次级菜单(新建项目/指定项目新建) */}
|
||
<div className="tsb-actions">
|
||
<div className="tsb-newtask">
|
||
<button className="tsb-newtask-main" title="在当前项目新建任务(⌘N)" onClick={() => store.createTask()}>
|
||
<Icon name="plus" size={15} />新建任务{currentProject ? <span className="tsb-newtask-proj">{currentProject.name}</span> : null}
|
||
</button>
|
||
<button className="tsb-newtask-caret" title="更多创建选项"
|
||
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'new' ? null : { kind: 'new', anchor: el }) }}><Icon name="chevron-down" size={14} /></button>
|
||
</div>
|
||
<div className="tsb-search">
|
||
<Icon name="search" size={14} />
|
||
<input placeholder="搜索任务…" value={q} onChange={e => setQ(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="tsb-orgrow">
|
||
<span className="tsb-seclabel" style={{ padding: 0 }}>本项目会话</span>
|
||
<span style={{ flex: 1 }} />
|
||
<button className={`tsb-iconbtn ${showArchived ? 'active' : ''}`} title={showArchived ? '隐藏归档' : '查看归档'}
|
||
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
|
||
</div>
|
||
|
||
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
|
||
<div className="tsb-scroll">
|
||
{creatingProject && (
|
||
<div className="tsb-newproject">
|
||
<input className="tsb-search" autoFocus value={newProjectName}
|
||
onChange={e => setNewProjectName(e.target.value)}
|
||
onKeyDown={async e => {
|
||
if (e.key === 'Enter' && newProjectName.trim()) {
|
||
await store.createProject(newProjectName.trim())
|
||
setCreatingProject(false); setNewProjectName('')
|
||
}
|
||
if (e.key === 'Escape') setCreatingProject(false)
|
||
}}
|
||
placeholder="项目名称,回车创建" />
|
||
</div>
|
||
)}
|
||
|
||
{pinned.length > 0 && (
|
||
<>
|
||
<div className="tsb-seclabel">置顶</div>
|
||
{pinned.map(t => taskRow(t))}
|
||
</>
|
||
)}
|
||
|
||
{rest.length === 0 && pinned.length === 0 && (
|
||
<div className="tsb-empty">这个项目还没有会话——点击「新建任务」开始</div>
|
||
)}
|
||
{rest.map(t => taskRow(t))}
|
||
</div>
|
||
|
||
{/* 底部(方案:文件、工作区、账户沉底)。 */}
|
||
<div className="tsb-bottom">
|
||
<button className="tsb-tool" title="文件搜索、预览、编辑与下载(第三栏「文件」入口)" onClick={() => store.openFiles()}>
|
||
<span className="tsb-tool-icon"><Icon name="folder" size={14} /></span>
|
||
<span className="tsb-tool-name">文件</span>
|
||
{artifacts.length > 0 && <span className="tsb-group-cnt">{artifacts.length}</span>}
|
||
</button>
|
||
{tools.map(entry => (
|
||
<button key={entry.key} className="tsb-tool" title={entry.shortcut} onClick={() => store.openWorkspace(entry.key)}>
|
||
<span className="tsb-tool-icon">{entry.icon}</span>
|
||
<span className="tsb-tool-name">{entry.title}</span>
|
||
<kbd>{entry.shortcut}</kbd>
|
||
</button>
|
||
))}
|
||
{serverWorkspaces.length === 0 && <div className="tsb-tool-note">领域工具由系统级插件提供</div>}
|
||
{currentProject && (
|
||
<button className="tsb-tool" onClick={() => store.openProjectSettings(currentProject.id)}>
|
||
<span className="tsb-tool-icon"><Icon name="settings" size={14} /></span>
|
||
<span className="tsb-tool-name">项目设置</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */}
|
||
<div className="tsb-footer">
|
||
<button className="tsb-user" onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'account' ? null : { kind: 'account', anchor: el }) }}>
|
||
<span className="tsb-avatar">{(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()}</span>
|
||
<span className="tsb-user-info">
|
||
<span className="tsb-user-name">{me?.name || me?.owner || '未连接'}</span>
|
||
<span className="tsb-user-sub">{me ? (me.auth ? (me.isAdmin ? '管理员 · 已认证' : '已认证') : '本地模式 · 默认租户') : '…'}</span>
|
||
</span>
|
||
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||
</button>
|
||
<button className="tsb-iconbtn" title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" size={15} /></button>
|
||
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.focusPending()}><Icon name="inbox" size={15} />{pendingBadge}</button>}
|
||
</div>
|
||
|
||
{/* 弹出菜单们 */}
|
||
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||
{projects.map(p => {
|
||
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
||
const pstage = projectStage(p.settings)
|
||
return (
|
||
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
|
||
{p.name}
|
||
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
|
||
</MenuItem>
|
||
)
|
||
})}
|
||
{projects.length === 0 && <div className="tsb-tool-note">还没有项目</div>}
|
||
<div className="tsb-menu-div" />
|
||
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||
</Menu>
|
||
|
||
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||
<MenuItem icon="plus" onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
|
||
<div className="tsb-menu-div" />
|
||
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
|
||
<MenuItem key={p.id} onClick={() => { store.createTask(p.id); setMenu(null) }}>在「{p.name}」新建任务</MenuItem>
|
||
))}
|
||
</Menu>
|
||
|
||
<Menu anchor={rowMenuAnchor} onClose={() => setMenu(null)}>
|
||
{rowTask && <>
|
||
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
|
||
<MenuItem icon={rowTask.archived ? 'undo' : 'archive'} onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
|
||
<div className="tsb-menu-div" />
|
||
<MenuItem icon="shield" onClick={() => { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'}</MenuItem>
|
||
<MenuItem icon="users" onClick={() => { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}</MenuItem>
|
||
<div className="tsb-menu-div" />
|
||
<MenuItem icon="settings" onClick={() => { if (rowTask) store.openProjectSettings(rowTask.projectId); setMenu(null) }}>项目设置</MenuItem>
|
||
</>}
|
||
</Menu>
|
||
|
||
<Menu anchor={menu?.kind === 'account' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||
<div className="tsb-acc-head">
|
||
<span className="tsb-avatar">{(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()}</span>
|
||
<span>
|
||
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
|
||
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
|
||
</span>
|
||
</div>
|
||
<div className="tsb-menu-div" />
|
||
<MenuItem icon="settings" onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
|
||
<MenuItem icon="inbox" onClick={() => { store.focusPending(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
|
||
<MenuItem icon="key" onClick={() => { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码…</MenuItem>
|
||
<MenuItem icon="moon" onClick={() => { store.setUI({ theme: 'dark' }); setMenu(null) }}>深色主题{theme === 'dark' ? ' · 当前' : ''}</MenuItem>
|
||
<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 || 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>
|
||
</>}
|
||
</Menu>
|
||
</>
|
||
)
|
||
}
|