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.waitForSelector('.channel-rail')
await page.locator('.channel-rail button[title="资料"]').click() await page.locator('.channel-rail button[title="资料"]').click()
const documents = await page.locator('.detail-row').count() 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.locator('.channel-rail button[title="AI"]').click()
await page.waitForSelector('.expert-chips') await page.waitForSelector('.expert-chips')
const experts = await page.locator('.expert-chips span').count() 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 (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 (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 (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 (experts !== 1) failures.push(`expected one AI expert, got ${experts}`)
if (profileMenu !== 1) failures.push('profile menu did not open') if (profileMenu !== 1) failures.push('profile menu did not open')
if (errors.length) failures.push(`console errors: ${errors.join('; ')}`) 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')) 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 { Alert, Avatar, Button, Dropdown, Empty, Form, Input, Menu, Modal, Select, Spin, Switch, Tag, Typography } from '@arco-design/web-react'
import { import {
IconApps, IconBook, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconCompass, IconDown, IconFile, IconApps, IconBook, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconCompass, IconDown, IconFile,
IconFolder, IconLeft, IconList, IconMoon, IconPlus, IconRefresh, IconRight, IconRobot, IconFolder, IconLeft, IconList, IconMoon, IconPlus, IconRefresh, IconRight, IconRobot,
IconSun, IconUser, IconSun, IconUpload, IconUser,
} from '@arco-design/web-react/icon' } from '@arco-design/web-react/icon'
import { import {
ApiError, createProject, fetchProjectWorkspace, fetchProjects, getCurrentUser, ApiError, createMarkdownDocument, createProject, fetchProjectWorkspace, fetchProjects, getCurrentUser,
listAIExperts, listDatasetItems, listDatasetSources, login, setApiBaseUrl, updateCurrentUser, updateTask, listAIExperts, listDatasetItems, listDatasetSources, login, setApiBaseUrl, updateCurrentUser, updateTask,
uploadDocument,
type ApiSession, type CurrentUser, type DatasetItem, type DatasetSource, type Expert, type Project, type Workspace, type WorkspaceTask, type ApiSession, type CurrentUser, type DatasetItem, type DatasetSource, type Expert, type Project, type Workspace, type WorkspaceTask,
} from './api' } from './api'
@@ -114,7 +115,7 @@ export function App() {
</section> </section>
{error ? <Alert className="app-alert" type="error" content={error} closable onClose={() => setError('')} /> : null} {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="项目子菜单"> {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>)} {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 let panel: React.ReactNode
if (view === 'tasks') panel = <TaskBoard title="工作计划" subtitle="" tasks={tasks} onEditTask={onEditTask} /> 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 if (view === 'ai') panel = <AIPanel title="" workspaces={workspaces} experts={experts} onLoad={onLoadAI} />
else panel = <CronPanel title="" workspaces={workspaces} /> else panel = <CronPanel title="" workspaces={workspaces} />
return <section className="project-content"><header className="project-page-header"><Title heading={3}>{title}</Title></header>{panel}</section> 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> 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 }))) 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 }) { 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 }) 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) { export function listAIExperts(session: ApiSession) {
useSessionBase(session) useSessionBase(session)
return apiRequest<Expert[]>('/api/v1/ai-experts', { token: session.token }) 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; } body { margin: 0; min-width: 300px; min-height: 100vh; background: #f4f5f7; }
button { font: inherit; } 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-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-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; } .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-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-shell.no-rail .app-main { grid-column: 1 / -1; }
.app-main > .arco-spin { min-height: 100%; } .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-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 { 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; } .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 { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.content-heading .arco-typography { margin: 0; } .content-heading .arco-typography { margin: 0; }
.content-heading h4 { margin-top: 2px; font-size: 20px; } .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; } .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; } .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); } }