feat(desktop): add document upload and markdown editor

This commit is contained in:
2026-07-25 23:46:24 +08:00
parent dbf5c33c99
commit 562c6b1bdb
4 changed files with 57 additions and 12 deletions

View File

@@ -65,6 +65,16 @@ await page.getByRole('option', { name: '森林项目' }).click()
await page.waitForSelector('.channel-rail')
await page.locator('.channel-rail button[title="资料"]').click()
const documents = await page.locator('.detail-row').count()
const documentActions = await page.locator('.document-actions .arco-btn').count()
await page.locator('.document-actions .arco-btn').nth(1).click()
await page.waitForSelector('.markdown-editor-page')
await page.screenshot({ path: 'test-results/app-markdown-new.png', fullPage: true })
const markdownEditor = await page.locator('.markdown-editor-page').count()
const markdownLayout = await page.evaluate(() => {
const rect = (selector) => { const node = document.querySelector(selector); const value = node?.getBoundingClientRect(); return value ? { left: value.left, width: value.width } : null }
return { scrollX: window.scrollX, scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, shell: rect('.app-shell'), main: rect('.app-main'), rail: rect('.channel-rail'), editor: rect('.markdown-editor-page') }
})
await page.locator('.markdown-editor-page .content-heading .arco-btn').click()
await page.locator('.channel-rail button[title="AI"]').click()
await page.waitForSelector('.expert-chips')
const experts = await page.locator('.expert-chips span').count()
@@ -83,8 +93,9 @@ if (home.pending !== 1 || home.completed !== 0) failures.push(`all-project task
if (taskModal !== 1) failures.push('task editor did not open')
if (exploreSources !== 2 || exploreDetail !== 1) failures.push(`explore reader did not render: ${JSON.stringify({ exploreSources, exploreDetail })}`)
if (documents !== 1) failures.push(`expected one document, got ${documents}`)
if (documentActions !== 2 || markdownEditor !== 1) failures.push(`document actions did not render: ${JSON.stringify({ documentActions, markdownEditor })}`)
if (experts !== 1) failures.push(`expected one AI expert, got ${experts}`)
if (profileMenu !== 1) failures.push('profile menu did not open')
if (errors.length) failures.push(`console errors: ${errors.join('; ')}`)
console.log(JSON.stringify({ home, documents, experts, taskModal, exploreSources, exploreDetail, profileMenu, errors }, null, 2))
console.log(JSON.stringify({ home, documents, documentActions, markdownEditor, markdownLayout, experts, taskModal, exploreSources, exploreDetail, profileMenu, errors }, null, 2))
if (failures.length) throw new Error(failures.join('\n'))

View File

@@ -1,13 +1,14 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Alert, Avatar, Button, Dropdown, Empty, Form, Input, Menu, Modal, Select, Spin, Switch, Tag, Typography } from '@arco-design/web-react'
import {
IconApps, IconBook, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconCompass, IconDown, IconFile,
IconFolder, IconLeft, IconList, IconMoon, IconPlus, IconRefresh, IconRight, IconRobot,
IconSun, IconUser,
IconSun, IconUpload, IconUser,
} from '@arco-design/web-react/icon'
import {
ApiError, createProject, fetchProjectWorkspace, fetchProjects, getCurrentUser,
ApiError, createMarkdownDocument, createProject, fetchProjectWorkspace, fetchProjects, getCurrentUser,
listAIExperts, listDatasetItems, listDatasetSources, login, setApiBaseUrl, updateCurrentUser, updateTask,
uploadDocument,
type ApiSession, type CurrentUser, type DatasetItem, type DatasetSource, type Expert, type Project, type Workspace, type WorkspaceTask,
} from './api'
@@ -114,7 +115,7 @@ export function App() {
</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} /> : <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 loadWorkspaces() }} onCreateMarkdown={async (input) => { await createMarkdownDocument(session, projectId, input); await loadWorkspaces() }} /> : <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>)}
@@ -142,10 +143,10 @@ export function App() {
)
}
function Content({ view, title, workspaces, tasks, onEditTask, onLoadAI, experts }: { view: View; title: string; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onLoadAI: () => void; experts: Expert[] }) {
function Content({ view, title, workspaces, tasks, onEditTask, onLoadAI, experts, onUploadDocument, onCreateMarkdown }: { view: View; title: string; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onLoadAI: () => void; experts: Expert[]; onUploadDocument: (file: File) => Promise<void>; onCreateMarkdown: (input: { name: string; markdown: string }) => Promise<void> }) {
let panel: React.ReactNode
if (view === 'tasks') panel = <TaskBoard title="工作计划" subtitle="" tasks={tasks} onEditTask={onEditTask} />
else if (view === 'documents') panel = <DocumentPanel title="" workspaces={workspaces} />
else if (view === 'documents') panel = <DocumentPanel workspaces={workspaces} onUpload={onUploadDocument} onCreateMarkdown={onCreateMarkdown} />
else if (view === 'ai') panel = <AIPanel title="" workspaces={workspaces} experts={experts} onLoad={onLoadAI} />
else panel = <CronPanel title="" workspaces={workspaces} />
return <section className="project-content"><header className="project-page-header"><Title heading={3}>{title}</Title></header>{panel}</section>
@@ -206,9 +207,31 @@ function ExplorePanel({ session, projectName }: { session: ApiSession; projectNa
return <section className="content-page explore-page"><div className="content-heading"><div><Title heading={4}></Title><Text type="secondary"></Text></div><Button type="text" shape="circle" aria-label="刷新探索内容" loading={loading} icon={<IconRefresh />} onClick={() => { void refreshSources(); void refreshItems() }} /></div>{error ? <Alert type="error" content={error} /> : null}<div className="explore-source-strip"><button className={sourceId === 'all' ? 'active' : ''} onClick={() => setSourceId('all')}><IconCompass /><span></span><small>{allCount}</small></button>{sources.map((source) => <button className={sourceId === source.id ? 'active' : ''} key={source.id} onClick={() => setSourceId(source.id)}>{source.iconUrl ? <img src={source.iconUrl} alt="" /> : <IconBook />}<span>{source.name}</span><small>{source.itemCount}</small></button>)}</div><div className="explore-reader">{items.length ? <><div className="explore-item-list">{items.map((item) => <button className={item.id === selected?.id ? 'active' : ''} key={item.id} onClick={() => setSelectedId(item.id)}><span><b>{item.title}</b><small>{item.summary || '暂无摘要'}</small></span><i>{item.starred ? '★' : formatDate(item.publishedAt || item.createdAt)}</i></button>)}</div><article className="explore-detail"><div className="explore-detail-meta"><Tag color="arcoblue">{selectedSource?.name || '全部'}</Tag><small>{formatDate(selected?.publishedAt || selected?.createdAt || '')}</small></div><Title heading={5}>{selected?.title}</Title>{selected?.imageUrl ? <img src={selected.imageUrl} alt="" /> : null}<p>{selected?.content || selected?.summary || '暂无正文'}</p><div className="explore-detail-actions"><Button type="text" icon={<IconBook />} disabled={!projectName}>{projectName || '项目'}</Button>{selected?.url ? <Button type="text" href={selected.url} target="_blank"></Button> : null}</div></article></> : <Empty description="暂无探索内容,请先在 Web 工作台添加或同步数据源" />}</div></section>
}
function DocumentPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) {
function DocumentPanel({ workspaces, onUpload, onCreateMarkdown }: { workspaces: Workspace[]; onUpload: (file: File) => Promise<void>; onCreateMarkdown: (input: { name: string; markdown: string }) => Promise<void> }) {
const documents = workspaces.flatMap((workspace) => workspace.documents.map((item) => ({ ...item, projectName: workspace.project.name })))
return <Panel title="笔记资料" subtitle={title}>{documents.map((item) => <article className="detail-row" key={item.id}><IconFile /><div><strong>{item.name}</strong><p>{item.extension || item.mimeType || item.kind} · {formatDate(item.updatedAt)}</p></div><Tag>{item.projectName}</Tag></article>)}{!documents.length && <Empty description="暂无笔记或资料" />}</Panel>
const inputRef = useRef<HTMLInputElement>(null)
const [creating, setCreating] = useState(false)
const [name, setName] = useState('未命名笔记.md')
const [markdown, setMarkdown] = useState('')
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
async function saveMarkdown() {
if (!name.trim()) return
setSaving(true); setError('')
try { await onCreateMarkdown({ name: name.trim(), markdown }); setCreating(false); setName('未命名笔记.md'); setMarkdown('') } catch (requestError) { setError(errorMessage(requestError)) } finally { setSaving(false) }
}
async function uploadFiles(files: FileList | null) {
if (!files?.length) return
setUploading(true); setError('')
try { for (const file of Array.from(files)) await onUpload(file) } catch (requestError) { setError(errorMessage(requestError)) } finally { setUploading(false); if (inputRef.current) inputRef.current.value = '' }
}
if (creating) return <section className="content-page markdown-editor-page"><div className="content-heading"><div><Text type="secondary"></Text><Title heading={4}> Markdown</Title></div><Button type="text" onClick={() => setCreating(false)}></Button></div>{error ? <Alert type="error" content={error} /> : null}<Form layout="vertical"><Form.Item label="文件名称" required><Input value={name} onChange={setName} placeholder="例如:会议记录.md" /></Form.Item><Form.Item label="Markdown 内容"><Input.TextArea value={markdown} onChange={setMarkdown} placeholder={'# 标题\n\n开始记录…'} autoSize={{ minRows: 14, maxRows: 24 }} /></Form.Item><div className="markdown-editor-actions"><Button onClick={() => setCreating(false)}></Button><Button type="primary" loading={saving} onClick={() => { void saveMarkdown() }}></Button></div></Form></section>
return <section className="content-page documents-page"><div className="content-heading"><div><Title heading={4}></Title></div><div className="document-actions"><input ref={inputRef} className="file-picker" type="file" multiple onChange={(event) => { void uploadFiles(event.target.files) }} /><Button icon={<IconUpload />} loading={uploading} onClick={() => inputRef.current?.click()}></Button><Button type="primary" icon={<IconPlus />} onClick={() => setCreating(true)}> Markdown</Button></div></div>{error ? <Alert type="error" content={error} /> : null}<div className="detail-list">{documents.map((item) => <article className="detail-row" key={item.id}><IconFile /><div><strong>{item.name}</strong><p>{item.extension || item.mimeType || item.kind} · {formatDate(item.updatedAt)}</p></div><Tag>{item.projectName}</Tag></article>)}{!documents.length && <Empty description="暂无笔记或资料" />}</div></section>
}
function AIPanel({ title, workspaces, experts, onLoad }: { title: string; workspaces: Workspace[]; experts: Expert[]; onLoad: () => void }) {

View File

@@ -122,6 +122,11 @@ export function uploadDocument(session: ApiSession, projectId: string, file: Fil
return apiRequest(`/api/v1/projects/${projectId}/documents/uploads`, { method: 'POST', token: session.token, body })
}
export function createMarkdownDocument(session: ApiSession, projectId: string, input: { name: string; markdown?: string; parentId?: string }) {
useSessionBase(session)
return apiRequest(`/api/v1/projects/${projectId}/documents/markdown`, { method: 'POST', token: session.token, body: input })
}
export function listAIExperts(session: ApiSession) {
useSessionBase(session)
return apiRequest<Expert[]>('/api/v1/ai-experts', { token: session.token })

View File

@@ -3,7 +3,7 @@
body { margin: 0; min-width: 300px; min-height: 100vh; background: #f4f5f7; }
button { font: inherit; }
.app-shell { --canvas: #f6f7f9; --surface: #fff; --line: #e5e6eb; --text: #1d2129; --muted: #86909c; --rail: #f2f3f5; display: grid; grid-template: 48px 46px minmax(0, 1fr) / minmax(0, 1fr) 58px; min-height: 100vh; color: var(--text); background: var(--canvas); overflow: hidden; }
.app-shell { --canvas: #f6f7f9; --surface: #fff; --line: #e5e6eb; --text: #1d2129; --muted: #86909c; --rail: #f2f3f5; display: grid; grid-template: 48px 46px minmax(0, 1fr) / minmax(0, 1fr) 58px; min-height: 100vh; color: var(--text); background: var(--canvas); overflow: clip; }
.app-shell.theme-dark { --canvas: #171a1f; --surface: #23272e; --line: #3a3f47; --text: #f2f3f5; --muted: #a9b0bb; --rail: #2b3038; color-scheme: dark; }
.app-header { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; min-width: 0; padding: 0 12px 0 14px; border-bottom: 1px solid var(--line); background: var(--surface); }
.app-brand { display: flex; align-items: center; gap: 7px; min-width: 0; height: 100%; font-weight: 700; letter-spacing: .1px; }
@@ -26,10 +26,16 @@ button { font: inherit; }
.app-main { grid-column: 1; grid-row: 3; min-width: 0; overflow: auto; padding: 18px 15px 24px; }
.app-shell.no-rail .app-main { grid-column: 1 / -1; }
.app-main > .arco-spin { min-height: 100%; }
.content-page { max-width: 650px; margin: 0 auto; }
.content-page { width: 100%; max-width: 650px; min-width: 0; margin: 0 auto; }
.project-content { min-width: 0; }
.project-page-header { width: calc(100% + 30px); margin: -18px -15px 18px; padding: 20px 15px 17px; border-bottom: 1px solid var(--line); background: var(--surface); }
.project-page-header .arco-typography { margin: 0; font-size: 26px; line-height: 1.25; }
.document-actions, .markdown-editor-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.file-picker { display: none; }
.documents-page .arco-alert, .markdown-editor-page .arco-alert { margin-bottom: 12px; }
.markdown-editor-page .arco-form { display: grid; min-width: 0; gap: 2px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }
.markdown-editor-page .arco-form-item, .markdown-editor-page .arco-form-item-wrapper { min-width: 0; }
.markdown-editor-page .arco-textarea { min-height: 280px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.65; }
.content-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.content-heading .arco-typography { margin: 0; }
.content-heading h4 { margin-top: 2px; font-size: 20px; }
@@ -75,4 +81,4 @@ button { font: inherit; }
.login-page { display: grid; min-height: 100vh; place-items: center; padding: 20px; background: #edf2f9; }.login-card { display: grid; width: min(100%, 360px); gap: 12px; padding: 28px; border: 1px solid #e5e6eb; border-radius: 16px; background: #fff; box-shadow: 0 18px 48px rgba(31, 35, 41, .12); }.login-card > img { width: 34px; }.login-card .arco-typography { margin: 0; }.login-card label { display: grid; gap: 5px; color: #4e5969; font-size: 12px; }
.theme-dark .project-strip, .theme-dark .app-header, .theme-dark .task-section, .theme-dark .detail-row, .theme-dark .tag-filter button { color: var(--text); background: var(--surface); }.theme-dark .project-pill { color: var(--muted); }.theme-dark .project-pill.active, .theme-dark .channel-rail button.active { color: #8ac7ff; background: #1d4d77; }.theme-dark .task-row:hover { background: #293847; border-color: #477da7; }.theme-dark .section-label span { color: #8ac7ff; background: #1d4d77; }
@media (max-width: 360px) { .profile-trigger > span { display: none; }.project-pill { max-width: 90px; }.project-strip { gap: 2px; padding-inline: 5px; }.app-main { padding: 13px 9px 20px; }.project-page-header { width: calc(100% + 18px); margin: -13px -9px 15px; padding: 17px 9px 14px; }.task-meta .arco-tag { max-width: 64px; overflow: hidden; }.content-heading h4 { font-size: 18px; }.stat-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 360px) { .profile-trigger > span { display: none; }.project-pill { max-width: 90px; }.project-strip { gap: 2px; padding-inline: 5px; }.app-main { padding: 13px 9px 20px; }.project-page-header { width: calc(100% + 18px); margin: -13px -9px 15px; padding: 17px 9px 14px; }.document-actions { width: 100%; justify-content: flex-start; }.document-actions .arco-btn { padding-inline: 8px; }.task-meta .arco-tag { max-width: 64px; overflow: hidden; }.content-heading h4 { font-size: 18px; }.stat-grid { grid-template-columns: repeat(2, 1fr); } }