fix(desktop): harden workspace interactions

This commit is contained in:
2026-07-26 00:57:39 +08:00
parent 93b671c746
commit 076be30146
2 changed files with 28 additions and 17 deletions

View File

@@ -42,6 +42,7 @@ export function App() {
const [projectModalOpen, setProjectModalOpen] = useState(false)
const [profileModalOpen, setProfileModalOpen] = useState(false)
const [experts, setExperts] = useState<Expert[]>([])
const workspaceRequestRef = useRef(0)
useEffect(() => {
if (!session) return
@@ -51,21 +52,29 @@ export function App() {
async function loadWorkspaces(activeSession = session) {
if (!activeSession) return
const requestId = ++workspaceRequestRef.current
setLoading(true)
setError('')
try {
const nextProjects = await fetchProjects(activeSession)
const items = await Promise.all(nextProjects.map(async (project) => [project.id, await fetchProjectWorkspace(activeSession, project.id)] as const))
if (requestId !== workspaceRequestRef.current) return
setProjects(nextProjects)
setWorkspaces(Object.fromEntries(items))
setProjectId((current) => current && nextProjects.some((project) => project.id === current) ? current : '')
} catch (requestError) {
setError(errorMessage(requestError))
if (requestId === workspaceRequestRef.current) setError(errorMessage(requestError))
} finally {
setLoading(false)
if (requestId === workspaceRequestRef.current) setLoading(false)
}
}
async function refreshProjectWorkspace(targetProjectId: string, activeSession = session) {
if (!activeSession || !targetProjectId) return
const workspace = await fetchProjectWorkspace(activeSession, targetProjectId)
setWorkspaces((current) => ({ ...current, [targetProjectId]: workspace }))
}
async function loadAI() {
if (!session || experts.length) return
try { setExperts(await listAIExperts(session)) } catch (requestError) { setError(errorMessage(requestError)) }
@@ -85,12 +94,13 @@ export function App() {
}
function logout() {
workspaceRequestRef.current += 1
localStorage.removeItem(SESSION_KEY)
localStorage.removeItem('senlin-mini-session')
setSession(null); setUser(null); setProjects([]); setWorkspaces({}); setProjectId(''); setView('tasks')
}
if (!session) return <Login onLogin={(next) => { setSession(next); setUser(next.user); persistSession(next) }} />
if (!session) return <Login onLogin={(next) => { workspaceRequestRef.current += 1; setProjects([]); setWorkspaces({}); setProjectId(''); setSession(next); setUser(next.user); persistSession(next) }} />
return (
<div className={`app-shell ${dark ? 'theme-dark' : ''} ${!projectId || workspaceMode === 'explore' ? 'no-rail' : ''}`}>
@@ -109,13 +119,13 @@ export function App() {
<Select.Option value="all"></Select.Option>
{projects.map((project) => <Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>)}
</Select>
<Button type="text" size="mini" shape="circle" aria-label="上一个项目" icon={<IconLeft />} onClick={() => cycleProject(-1)} />
<Button type="text" size="mini" shape="circle" aria-label="下一个项目" icon={<IconRight />} onClick={() => cycleProject(1)} />
<Button className="project-cycle" type="text" size="mini" shape="circle" aria-label="上一个项目" icon={<IconLeft />} onClick={() => cycleProject(-1)} />
<Button className="project-cycle" type="text" size="mini" shape="circle" aria-label="下一个项目" icon={<IconRight />} onClick={() => cycleProject(1)} />
<Button type="primary" size="mini" shape="circle" aria-label="新建项目" icon={<IconPlus />} onClick={() => setProjectModalOpen(true)} />
</section>
{error ? <Alert className="app-alert" type="error" content={error} closable onClose={() => setError('')} /> : null}
<main className="app-main"><Spin loading={loading} block>{workspaceMode === 'explore' ? <ExplorePanel session={session} projectName={activeProject?.name ?? ''} /> : !projectId ? <AllProjectsHome projects={projects} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onCreateProject={() => setProjectModalOpen(true)} /> : visibleWorkspaces.length ? <Content view={view} title={title} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onLoadAI={loadAI} experts={experts} onUploadDocument={async (file) => { await uploadDocument(session, projectId, file); await loadWorkspaces() }} onCreateMarkdown={async (input) => { await createMarkdownDocument(session, projectId, input); await loadWorkspaces() }} onLoadMarkdown={(documentId) => getDocument(session, projectId, documentId)} onUpdateMarkdown={async (input) => { await updateDocument(session, projectId, input.id, input); await loadWorkspaces() }} onOpenExternalDocument={async (documentId) => { const preview = window.open('', '_blank'); const blob = await fetchDocumentBlob(session, projectId, documentId); const url = URL.createObjectURL(blob); if (preview) { preview.opener = null; preview.location.href = url } else { const link = document.createElement('a'); link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.click() } window.setTimeout(() => URL.revokeObjectURL(url), 60_000) }} onCreateAISession={async (input) => { await createAISession(session, projectId, input); await loadWorkspaces() }} /> : <Empty description="项目加载失败,请刷新后重试" />}</Spin></main>
<main className="app-main"><Spin loading={loading} block>{workspaceMode === 'explore' ? <ExplorePanel session={session} projectName={activeProject?.name ?? ''} /> : !projectId ? <AllProjectsHome projects={projects} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onCreateProject={() => setProjectModalOpen(true)} /> : visibleWorkspaces.length ? <Content view={view} title={title} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onLoadAI={loadAI} experts={experts} onUploadDocument={async (file) => { await uploadDocument(session, projectId, file); await refreshProjectWorkspace(projectId) }} onCreateMarkdown={async (input) => { await createMarkdownDocument(session, projectId, input); await refreshProjectWorkspace(projectId) }} onLoadMarkdown={(documentId) => getDocument(session, projectId, documentId)} onUpdateMarkdown={async (input) => { await updateDocument(session, projectId, input.id, input); await refreshProjectWorkspace(projectId) }} onOpenExternalDocument={async (documentId) => { const preview = window.open('', '_blank'); try { const blob = await fetchDocumentBlob(session, projectId, documentId); const url = URL.createObjectURL(blob); if (preview) { preview.opener = null; preview.location.href = url } else { const link = document.createElement('a'); link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.click() } window.setTimeout(() => URL.revokeObjectURL(url), 60_000) } catch (requestError) { preview?.close(); throw requestError } }} onCreateAISession={async (input) => { await createAISession(session, projectId, input); await refreshProjectWorkspace(projectId) }} /> : <Empty description="项目加载失败,请刷新后重试" />}</Spin></main>
{projectId && workspaceMode === 'workbench' ? <aside className="channel-rail" aria-label="项目子菜单">
{navItems.map((item) => <button key={item.id} title={item.label} className={view === item.id ? 'active' : ''} onClick={() => { setView(item.id); if (item.id === 'ai') void loadAI() }}>{item.icon}<span>{item.label}</span></button>)}
@@ -124,7 +134,7 @@ export function App() {
<TaskEditor task={taskEditor} tags={visibleWorkspaces.flatMap((workspace) => workspace.tags.map((tag) => tag.name))} onClose={() => setTaskEditor(null)} onSave={async (draft) => {
if (!taskEditor) return
await updateTask(session, taskEditor.projectId, taskEditor.id, { title: draft.title, description: draft.summary, tag: draft.tag, completed: draft.completed })
await loadWorkspaces()
await refreshProjectWorkspace(taskEditor.projectId)
setTaskEditor(null)
}} />
<ProjectModal visible={projectModalOpen} onClose={() => setProjectModalOpen(false)} onSave={async (name) => {
@@ -271,24 +281,25 @@ function Panel({ title, subtitle, children }: { title: string; subtitle: string;
function ProfileMenu({ user, onProfile, onLogout }: { user: CurrentUser | null; onProfile: () => void; onLogout: () => void }) {
const menu = <Menu><Menu.Item key="profile" onClick={onProfile}></Menu.Item><Menu.Item key="logout" onClick={onLogout}>退</Menu.Item></Menu>
return <Dropdown droplist={menu} position="br" trigger="click"><button className="profile-trigger"><Avatar size={28}>{user?.displayName.slice(0, 1) || <IconUser />}</Avatar><span>{user?.displayName || '用户'}</span><IconDown /></button></Dropdown>
return <Dropdown droplist={menu} position="br" trigger="click"><button className="profile-trigger" aria-label={`用户菜单,${user?.displayName || '用户'}`}><Avatar size={28}>{user?.displayName.slice(0, 1) || <IconUser />}</Avatar><span>{user?.displayName || '用户'}</span><IconDown /></button></Dropdown>
}
function TaskEditor({ task, tags, onClose, onSave }: { task: TaskWithProject | null; tags: string[]; onClose: () => void; onSave: (draft: { title: string; summary: string; tag: string; completed: boolean }) => Promise<void> }) {
const [title, setTitle] = useState(''); const [summary, setSummary] = useState(''); const [tag, setTag] = useState(''); const [completed, setCompleted] = useState(false); const [saving, setSaving] = useState(false)
useEffect(() => { if (task) { setTitle(task.title); setSummary(task.summary); setTag(task.tag); setCompleted(task.completed) } }, [task])
return <Modal title="编辑任务" visible={Boolean(task)} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!title.trim()) return; setSaving(true); try { await onSave({ title: title.trim(), summary: summary.trim(), tag, completed }) } finally { setSaving(false) } }}><Form layout="vertical"><Form.Item label="标题" required><Input value={title} onChange={setTitle} /></Form.Item><Form.Item label="说明"><Input.TextArea value={summary} onChange={setSummary} autoSize={{ minRows: 3, maxRows: 6 }} /></Form.Item><Form.Item label="标签"><Select value={tag || undefined} allowClear onChange={(value) => setTag(value ?? '')}>{[...new Set(tags.filter(Boolean))].map((item) => <Select.Option key={item} value={item}>{item}</Select.Option>)}</Select></Form.Item><Form.Item label="完成状态"><Switch checked={completed} checkedText="已完成" uncheckedText="待办" onChange={setCompleted} /></Form.Item></Form></Modal>
const [title, setTitle] = useState(''); const [summary, setSummary] = useState(''); const [tag, setTag] = useState(''); const [completed, setCompleted] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState('')
useEffect(() => { if (task) { setTitle(task.title); setSummary(task.summary); setTag(task.tag); setCompleted(task.completed); setError('') } }, [task])
return <Modal title="编辑任务" visible={Boolean(task)} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!title.trim()) return; setSaving(true); setError(''); try { await onSave({ title: title.trim(), summary: summary.trim(), tag, completed }) } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) } }}><Form layout="vertical">{error ? <Alert type="error" content={error} /> : null}<Form.Item label="标题" required><Input value={title} onChange={setTitle} /></Form.Item><Form.Item label="说明"><Input.TextArea value={summary} onChange={setSummary} autoSize={{ minRows: 3, maxRows: 6 }} /></Form.Item><Form.Item label="标签"><Select value={tag || undefined} allowClear onChange={(value) => setTag(value ?? '')}>{[...new Set(tags.filter(Boolean))].map((item) => <Select.Option key={item} value={item}>{item}</Select.Option>)}</Select></Form.Item><Form.Item label="完成状态"><Switch checked={completed} checkedText="已完成" uncheckedText="待办" onChange={completed => setCompleted(completed)} /></Form.Item></Form></Modal>
}
function ProjectModal({ visible, onClose, onSave }: { visible: boolean; onClose: () => void; onSave: (name: string) => Promise<void> }) {
const [name, setName] = useState(''); const [saving, setSaving] = useState(false)
return <Modal title="新建项目" visible={visible} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!name.trim()) return; setSaving(true); try { await onSave(name.trim()); setName('') } finally { setSaving(false) } }}><Input autoFocus placeholder="项目名称" value={name} onChange={setName} /></Modal>
const [name, setName] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState('')
useEffect(() => { if (visible) setError('') }, [visible])
return <Modal title="新建项目" visible={visible} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!name.trim()) return; setSaving(true); setError(''); try { await onSave(name.trim()); setName('') } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) } }}>{error ? <Alert type="error" content={error} /> : null}<Input autoFocus placeholder="项目名称" value={name} onChange={setName} /></Modal>
}
function ProfileModal({ user, visible, onClose, onSave }: { user: CurrentUser | null; visible: boolean; onClose: () => void; onSave: (input: { displayName: string; currentPassword?: string; newPassword?: string }) => Promise<void> }) {
const [displayName, setDisplayName] = useState(user?.displayName ?? ''); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [saving, setSaving] = useState(false)
useEffect(() => setDisplayName(user?.displayName ?? ''), [user])
return <Modal title="修改资料" visible={visible} onCancel={onClose} confirmLoading={saving} onOk={async () => { setSaving(true); try { await onSave({ displayName, currentPassword: currentPassword || undefined, newPassword: newPassword || undefined }); setCurrentPassword(''); setNewPassword('') } finally { setSaving(false) } }}><Form layout="vertical"><Form.Item label="名称" required><Input value={displayName} onChange={setDisplayName} /></Form.Item><Form.Item label="邮箱"><Input disabled value={user?.email} /></Form.Item><Form.Item label="当前密码"><Input.Password value={currentPassword} onChange={setCurrentPassword} placeholder="修改密码时必填" /></Form.Item><Form.Item label="新密码"><Input.Password value={newPassword} onChange={setNewPassword} placeholder="不修改可留空" /></Form.Item></Form></Modal>
const [displayName, setDisplayName] = useState(user?.displayName ?? ''); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState('')
useEffect(() => { setDisplayName(user?.displayName ?? ''); if (visible) setError('') }, [user, visible])
return <Modal title="修改资料" visible={visible} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!displayName.trim()) return; setSaving(true); setError(''); try { await onSave({ displayName: displayName.trim(), currentPassword: currentPassword || undefined, newPassword: newPassword || undefined }); setCurrentPassword(''); setNewPassword('') } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) } }}><Form layout="vertical">{error ? <Alert type="error" content={error} /> : null}<Form.Item label="名称" required><Input value={displayName} onChange={setDisplayName} /></Form.Item><Form.Item label="邮箱"><Input disabled value={user?.email} /></Form.Item><Form.Item label="当前密码"><Input.Password value={currentPassword} onChange={setCurrentPassword} placeholder="修改密码时必填" /></Form.Item><Form.Item label="新密码"><Input.Password value={newPassword} onChange={setNewPassword} placeholder="不修改可留空" /></Form.Item></Form></Modal>
}
function Login({ onLogin }: { onLogin: (session: ApiSession & { user: CurrentUser }) => void }) {