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.
195 lines
9.0 KiB
TypeScript
195 lines
9.0 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { auth } from './auth'
|
||
import { api } from './api'
|
||
import { LoginPage, ChangePasswordGate } from './components/LoginPage'
|
||
import { ChangePasswordModal } from './components/AdminUsersModal'
|
||
import { AdminZone } from './components/AdminZone'
|
||
import { Icon } from '@ui'
|
||
import { store, useStore } from './store'
|
||
import { TaskSidebar } from './components/TaskSidebar'
|
||
import { Conversation } from './components/Conversation'
|
||
import { previewTabsKey } from './previewtabs'
|
||
import { Workbench } from './components/Workbench'
|
||
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
|
||
import { AgentTracker } from './components/AgentTracker'
|
||
import { SettingsModal } from './components/SettingsModal'
|
||
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
|
||
import { TopBar } from './components/TopBar'
|
||
import { workspaceEntries } from './components/Workspaces'
|
||
|
||
// 布局(三栏工作台):左(项目与会话)/ 中(对话)/ 第三栏(工作|文件双入口)。
|
||
// 文件预览不再是独立第四栏——预览在第三栏「文件」入口内,展开态铺满主区。
|
||
const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
|
||
const RIGHT_MIN = 280, RIGHT_MAX = 560
|
||
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
|
||
|
||
export function App() {
|
||
useEffect(() => {
|
||
const h = (e: KeyboardEvent) => workspaceShortcuts(e)
|
||
window.addEventListener('keydown', h, true)
|
||
return () => window.removeEventListener('keydown', h, true)
|
||
}, [])
|
||
|
||
const ui = useStore(s => s.ui)
|
||
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
|
||
// 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。
|
||
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
|
||
const rightTab = useStore(s => s.ui.rightTab)
|
||
const toast = useStore(s => s.toast)
|
||
const trackerRunId = useStore(s => s.ui.trackerRunId)
|
||
// 文件入口 + 预览标签在场时,第三栏用「阅读档」宽度(420–1100),不受工作档上限约束。
|
||
const filesWide = rightTab === 'files' && previewOpen
|
||
const rightPanelWidth = filesWide ? ui.previewWidth : ui.rightWidth
|
||
const previewExpanded = filesWide && ui.previewExpanded
|
||
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
|
||
// authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。
|
||
const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking')
|
||
const [mustChangeOwner, setMustChangeOwner] = useState('')
|
||
|
||
useEffect(() => {
|
||
;(async () => {
|
||
try {
|
||
const r = await auth.fetch('/api/health')
|
||
if (r.status === 401) { setAuthState('login'); return }
|
||
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
|
||
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
|
||
// key 持有者清 key 回登录页,改走密码登录完成改密。
|
||
const me = await api.me().catch(() => null)
|
||
if (me?.mustChangePassword) {
|
||
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
|
||
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
|
||
}
|
||
setAuthState('ok')
|
||
} catch {
|
||
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
|
||
}
|
||
})()
|
||
}, [])
|
||
|
||
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()
|
||
}, [authState])
|
||
|
||
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
|
||
|
||
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
|
||
e.preventDefault()
|
||
// 右侧分隔条按当前入口选宽度档:文件+预览在场走阅读档,否则工作档。
|
||
const wide = side === 'right' && rightTab === 'files' && previewOpen
|
||
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
|
||
drag.current = {
|
||
side, startX: e.clientX, startW: ui[key], overshoot: false,
|
||
min: side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN,
|
||
max: side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX,
|
||
key,
|
||
}
|
||
const move = (ev: MouseEvent) => {
|
||
const d = drag.current
|
||
if (!d) return
|
||
if (d.side === 'left') {
|
||
const w = d.startW + (ev.clientX - d.startX)
|
||
if (w < d.min - LEFT_OVERSHOOT) { d.overshoot = true; return }
|
||
d.overshoot = false
|
||
store.setUI({ leftCollapsed: false, leftWidth: Math.min(d.max, Math.max(d.min, w)) })
|
||
} else {
|
||
const w = d.startW - (ev.clientX - d.startX)
|
||
if (w < d.min) return
|
||
store.setUI({ rightCollapsed: false, [d.key]: Math.min(d.max, w) } as Partial<typeof ui>)
|
||
}
|
||
}
|
||
const up = () => {
|
||
const d = drag.current
|
||
if (d?.side === 'left' && d.overshoot) store.setUI({ leftCollapsed: true })
|
||
drag.current = null
|
||
window.removeEventListener('mousemove', move)
|
||
window.removeEventListener('mouseup', up)
|
||
}
|
||
window.addEventListener('mousemove', move)
|
||
window.addEventListener('mouseup', up)
|
||
}
|
||
|
||
if (authState === 'login') {
|
||
return <LoginPage
|
||
onAuthed={() => setAuthState('ok')}
|
||
onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} />
|
||
}
|
||
if (authState === 'mustchange') {
|
||
return <ChangePasswordGate
|
||
owner={mustChangeOwner}
|
||
onDone={async () => { await store.refreshMe().catch(() => undefined); setAuthState('ok') }}
|
||
onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} />
|
||
}
|
||
if (authState !== 'ok') {
|
||
return <div className="login-gate"><div className="login-card"><p>连接服务中…</p></div></div>
|
||
}
|
||
|
||
const tools = workspaceEntries()
|
||
return (
|
||
<div className="app">
|
||
<TopBar />
|
||
<div className="app-body">
|
||
{!ui.leftCollapsed ? (
|
||
<>
|
||
<div className="left-nav" style={{ width: ui.leftWidth }}>
|
||
<TaskSidebar />
|
||
</div>
|
||
<div className="divider" onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft} title="拖动调整宽度;继续向左超过最小宽度后松开可收起;双击切换" />
|
||
</>
|
||
) : (
|
||
<div className="left-nav collapsed" style={{ width: 52 }}>
|
||
<div className="icon-only">
|
||
<button title="展开侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
||
<button title="新建任务(⌘N)" onClick={() => store.createTask()}><Icon name="plus" /></button>
|
||
<button title="收件箱" onClick={() => store.focusPending()}><Icon name="inbox" />
|
||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
||
</button>
|
||
{tools.map(entry => (
|
||
<button key={entry.key} title={`${entry.title}(${entry.shortcut})`} onClick={() => store.openWorkspace(entry.key)}>{entry.icon}</button>
|
||
))}
|
||
<button title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" /></button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="main" hidden={previewExpanded}>
|
||
<Conversation />
|
||
</div>
|
||
|
||
{!ui.rightCollapsed ? (
|
||
<>
|
||
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
|
||
<div className={`right-panel mk-panel-grow${previewExpanded ? ' expanded' : ''}`}
|
||
style={previewExpanded ? undefined : { width: rightPanelWidth }}>
|
||
<Workbench />
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="right-panel collapsed" style={{ width: 44 }}>
|
||
<div className="icon-only">
|
||
<button title="展开右栏" onClick={store.toggleRight}>◧
|
||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
||
</button>
|
||
<button title="文件(搜索、预览、编辑、下载)" onClick={() => store.openFiles()}><Icon name="folder" size={16} /></button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<Workspaces />
|
||
{trackerRunId && <AgentTracker />}
|
||
{ui.settingsOpen && <SettingsModal />}
|
||
{ui.projectSettingsId && <ProjectSettingsModal />}
|
||
{ui.adminZoneOpen && <AdminZone />}
|
||
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
|
||
{toast && <div className="toast">{toast}</div>}
|
||
</div>
|
||
)
|
||
}
|