diff --git a/apps/desktop/design-qa.md b/apps/desktop/design-qa.md new file mode 100644 index 0000000..e795300 --- /dev/null +++ b/apps/desktop/design-qa.md @@ -0,0 +1,44 @@ +# Desktop App design QA + +## Comparison target + +- Source visual truth: `C:\Users\david\AppData\Local\Temp\codex-clipboard-72c4b101-58e8-44b0-81b4-01abc35a29e6.png` +- Implementation screenshot: `test-results/app-home.png` +- Full-view viewport: 420 × 820 CSS px at device scale factor 1; implementation image is 420 × 820 px. +- Source image: 144 × 343 px. It shows only a narrow icon rail, whereas the implementation contains the requested complete desktop workbench. +- State: logged-in light theme, default “全部” project scope, one pending task and one completed task. +- Interaction checks: default task board, task edit modal,资料 panel, AI panel, and profile dropdown. Console errors: none. + +## Evidence + +- Full implementation: `test-results/app-home.png` +- Focused icon-rail comparison: `test-results/design-qa-rail-comparison.png` + - Left: the source’s 75 × 343 px rail crop. + - Right: the implementation’s 58 × 820 px right rail, normalized to 75 × 343 px. + +The focused comparison is intentionally rail-only because the source does not provide a full workbench layout. The implementation places the rail on the right as requested, rather than copying the source’s left placement. + +## Findings + +No actionable P0, P1, or P2 findings. + +- Fonts and typography: the application uses the existing Inter / Microsoft YaHei stack. Small rail labels remain legible at the 420 px target width and task titles retain stronger hierarchy. +- Spacing and layout rhythm: the 48 px header, 46 px project strip, scrollable content column, and 58 px rail leave the task content unobscured. No horizontal overflow was observed. +- Colors and visual tokens: neutral rail, blue selection, green completion, and muted metadata provide equivalent compact navigation contrast to the source while preserving the product’s existing Arco blue token. +- Image and icon fidelity: the existing product icon is retained; all navigation controls use the installed Arco icon system. No placeholder or hand-drawn visual assets were introduced. +- Copy and content: project scope, task state, profile menu, and submenus use the requested Chinese product language. + +## Follow-up polish + +- [P3] The source has four tall unlabeled icon slots; the implementation has six compact labeled slots because it must expose the requested project views. If the list grows further, group secondary views under “更多”. + +## Implementation checklist + +- [x] Align desktop API fields and request payloads with `web_v1`. +- [x] Add theme toggle and user dropdown with profile editing and logout. +- [x] Add project scope strip with 全部, previous/next, and create controls. +- [x] Move project submenu navigation to the right rail. +- [x] Make pending and completed tasks the default editable content. +- [x] Verify primary panels and interaction states. + +final result: passed diff --git a/apps/desktop/scripts/visual-check.mjs b/apps/desktop/scripts/visual-check.mjs index 55e461e..249ce82 100644 --- a/apps/desktop/scripts/visual-check.mjs +++ b/apps/desktop/scripts/visual-check.mjs @@ -4,110 +4,72 @@ import { chromium } from 'playwright' const port = Number(process.env.MINI_VISUAL_PORT ?? 4180) const server = await createServer({ root: process.cwd(), server: { host: '127.0.0.1', port, strictPort: true } }) await server.listen() - const browser = await chromium.launch({ headless: true }) const page = await browser.newPage({ viewport: { width: 420, height: 820 }, deviceScaleFactor: 1 }) const errors = [] page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()) }) const projectId = '019b0000-0000-7000-8000-000000000101' -const expert = { - id: '019b0000-0000-7000-8000-000000000102', slug: 'product-manager', category: 'product', categoryName: '产品', - name: '产品经理', description: '负责需求分析、路线规划和产品交付。', emoji: '🧭', color: '#165DFF', -} +const user = { email: 'demo@senlin.ai', displayName: '演示用户' } const workspace = { - project: { id: projectId, name: '森林项目', identifier: 'forest', icon: 'folder', background: '#165DFF', description: '让项目计划、资料和 AI 协作保持在同一条线上。', initials: '森林', unreadCount: 0 }, - channels: [], - tags: [{ id: 'tag-product', name: '产品' }, { id: 'tag-design', name: '设计' }], - recentSessions: [], - inbox: [], + project: { id: projectId, name: '森林项目', identifier: 'forest', icon: 'folder', background: '#165DFF', description: '项目协作空间', initials: '森林', unreadCount: 0 }, + channels: [], tags: [{ id: 'tag-product', name: '产品' }, { id: 'tag-design', name: '设计' }], recentSessions: [], inbox: [{ id: 'inbox-1', projectId, source: 'manual', title: '客户反馈', summary: '整理下周的反馈内容', status: 'open', tag: '产品', time: '2026-07-22T10:00:00Z' }], tasks: [ - { id: 'task-1', projectId, title: '整理产品路线', summary: '确认下一阶段交付范围。', completed: false, owner: '张明', due: null, createdAt: '今天 09:00', completedAt: null, tagId: 'tag-product', tag: '产品' }, - { id: 'task-2', projectId, title: '检查迷你布局', summary: '验证窄屏下的导航和滚动。', completed: false, owner: '张明', due: null, createdAt: '今天 10:00', completedAt: null, tagId: 'tag-design', tag: '设计' }, - ], - aiSessions: [{ id: 'old-session', projectId, title: '梳理版本计划', summary: '项目会话', updatedAt: '今天', references: [] }], - documents: [ - { id: 'note-1', projectId, kind: 'markdown', name: '版本规划', extension: '.md', mimeType: 'text/markdown', updatedAt: '今天' }, - { id: 'source-1', projectId, kind: 'file', name: '需求说明.pdf', extension: '.pdf', mimeType: 'application/pdf', updatedAt: '昨天' }, + { id: 'task-1', projectId, title: '整理产品路线', summary: '确认下一阶段交付范围。', completed: false, owner: '张明', due: null, createdAt: '2026-07-22T09:00:00Z', completedAt: null, tagId: 'tag-product', tag: '产品' }, + { id: 'task-2', projectId, title: '完成迷你布局', summary: '验证窄屏下的导航和滚动。', completed: true, owner: '张明', due: null, createdAt: '2026-07-21T10:00:00Z', completedAt: '2026-07-22T10:00:00Z', tagId: 'tag-design', tag: '设计' }, ], + aiSessions: [{ id: 'session-1', projectId, title: '梳理版本计划', summary: '下一步怎么安排', updatedAt: '2026-07-22T10:00:00Z', references: [] }], + documents: [{ id: 'doc-1', projectId, kind: 'markdown', name: '版本规划', extension: '.md', mimeType: 'text/markdown', updatedAt: '2026-07-22T10:00:00Z' }], cronPlans: [{ id: 'cron-1', projectId, title: '每周复盘', schedule: '0 17 * * 5', nextRun: null, enabled: true, lastResult: '', owner: '张明' }], } +const expert = { id: 'expert-1', slug: 'product-manager', category: 'product', categoryName: '产品', name: '产品经理', description: '负责需求分析', emoji: '📘', color: '#165DFF' } await page.route('http://localhost:9150/api/v1/**', async (route) => { - const request = route.request() - const path = new URL(request.url()).pathname - if (path === '/api/v1/status') return route.fulfill({ json: { status: 'unavailable' } }) - if (path === '/api/v1/auth/login') return route.fulfill({ json: { token: 'mini-token' } }) - if (path === '/api/v1/projects') { - if (request.method() === 'POST') return route.fulfill({ status: 201, json: workspace.project }) - return route.fulfill({ json: [workspace.project] }) - } + const request = route.request(); const path = new URL(request.url()).pathname + if (path === '/api/v1/auth/login') return route.fulfill({ json: { token: 'app-token', user } }) + if (path === '/api/v1/auth/me') return route.fulfill({ json: user }) + if (path === '/api/v1/projects') return route.fulfill({ json: [workspace.project] }) if (path === `/api/v1/projects/${projectId}/workspace`) return route.fulfill({ json: workspace }) - if (path === '/api/v1/ai-experts') return route.fulfill({ json: [expert, { ...expert, id: 'expert-2', name: 'UI 设计师', category: 'design', categoryName: '设计', emoji: '🎨', color: '#722ED1' }] }) - if (path === `/api/v1/projects/${projectId}/ai-sessions`) { - if (request.method() === 'POST') { - const input = request.postDataJSON() - return route.fulfill({ status: 201, json: { id: 'session-new', projectId, title: input.title, context: input.context, status: 'ready', expert, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } }) - } - return route.fulfill({ json: [{ id: 'session-1', projectId, title: '梳理版本计划', context: '下一步怎么安排', status: 'ready', expert, createdAt: '2026-07-22T10:00:00Z', updatedAt: '2026-07-22T10:00:00Z' }] }) - } + if (path === '/api/v1/ai-experts') return route.fulfill({ json: [expert] }) if (path.includes('/tasks/') && request.method() === 'PATCH') return route.fulfill({ json: {} }) - if (path.endsWith('/tasks') && request.method() === 'POST') return route.fulfill({ status: 201, json: {} }) - if (path.endsWith('/cron-plans') && request.method() === 'POST') return route.fulfill({ status: 201, json: {} }) - return route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '未配置的视觉检查接口' } } }) + return route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '未配置的检查接口' } } }) }) await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle' }) -await page.waitForSelector('.mini-connection.offline') -const offlineConnection = await page.locator('.mini-connection').evaluate((element) => ({ - text: element.textContent, - borderColor: getComputedStyle(element).borderColor, -})) -await page.screenshot({ path: 'test-results/app-login.png', fullPage: true }) -const loginDefaults = await page.locator('.login-note input').evaluateAll((inputs) => inputs.map((input) => input.value)) -await page.locator('.login-note input').nth(2).press('Enter') -await page.waitForSelector('.mini-shell') -await page.waitForTimeout(400) +await page.locator('.login-card input').nth(2).press('Enter') +await page.waitForSelector('.app-shell') +await page.waitForSelector('.task-row') await page.screenshot({ path: 'test-results/app-home.png', fullPage: true }) - const home = await page.evaluate(() => ({ - width: document.querySelector('.mini-shell')?.getBoundingClientRect().width, - navCount: document.querySelectorAll('.mini-nav button').length, - overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth, - title: document.querySelector('.page-intro h4')?.textContent ?? '', + width: document.querySelector('.app-shell')?.getBoundingClientRect().width, + selectedAll: document.querySelector('.project-pill.active span')?.textContent, + navCount: document.querySelectorAll('.channel-rail button').length, + pending: document.querySelectorAll('.task-row:not(.done)').length, + completed: document.querySelectorAll('.task-row.done').length, })) - -await page.getByRole('button', { name: '计划', exact: true }).click() -await page.getByRole('button', { name: '设计', exact: true }).click() -const tasks = await page.evaluate(() => ({ - count: document.querySelectorAll('.task-note').length, - title: document.querySelector('.task-note strong')?.textContent ?? '', -})) -await page.screenshot({ path: 'test-results/app-tasks.png', fullPage: true }) - -await page.getByRole('button', { name: '资料', exact: true }).click() -const notes = await page.locator('.source-note').count() - -await page.getByRole('button', { name: 'AI', exact: true }).click() -await page.getByRole('button', { name: /产品经理/ }).click() -await page.locator('.mini-composer textarea').fill('请给出下一步计划') -const sendEnabled = await page.getByRole('button', { name: '开始会话' }).isEnabled() +await page.locator('.task-row').first().click() +const taskModal = await page.getByText('编辑任务').count() +await page.getByRole('button', { name: '取消' }).click() +await page.locator('.channel-rail button[title="资料"]').click() +const documents = await page.locator('.detail-row').count() +await page.locator('.channel-rail button[title="AI"]').click() +await page.waitForSelector('.expert-chips') +const experts = await page.locator('.expert-chips span').count() +await page.locator('.profile-trigger').click() +await page.getByText('修改资料').waitFor() +const profileMenu = await page.getByText('修改资料').count() await page.screenshot({ path: 'test-results/app-ai.png', fullPage: true }) - -await browser.close() -await server.close() +await browser.close(); await server.close() const failures = [] -if (!offlineConnection.text?.includes('连接异常') || offlineConnection.borderColor !== 'rgb(245, 63, 63)') failures.push(`offline connection must be red: ${JSON.stringify(offlineConnection)}`) -if (loginDefaults[1] !== 'demo@senlin.ai' || loginDefaults[2] !== 'password123') failures.push(`login defaults failed: ${JSON.stringify(loginDefaults)}`) -if (errors.length) failures.push(`console errors: ${errors.join('; ')}`) if (home.width !== 420) failures.push(`expected 420px shell, got ${home.width}`) -if (home.navCount !== 5) failures.push(`expected five top navigation items, got ${home.navCount}`) -if (home.overflowX) failures.push('mini client has horizontal overflow') -if (home.title !== '森林项目') failures.push(`project overview missing, got ${home.title}`) -if (tasks.count !== 1 || tasks.title !== '检查迷你布局') failures.push(`task tag filtering failed: ${JSON.stringify(tasks)}`) -if (notes !== 2) failures.push(`expected two note cards, got ${notes}`) -if (!sendEnabled) failures.push('AI composer must enable after selecting an expert and entering a prompt') - -console.log(JSON.stringify({ home, tasks, notes, sendEnabled, errors }, null, 2)) +if (home.selectedAll !== '全部') failures.push(`default project scope must be 全部, got ${home.selectedAll}`) +if (home.navCount !== 6) failures.push(`expected six right rail menus, got ${home.navCount}`) +if (home.pending !== 1 || home.completed !== 1) failures.push(`task sections incorrect: ${JSON.stringify(home)}`) +if (taskModal !== 1) failures.push('task editor did not open') +if (documents !== 1) failures.push(`expected one document, got ${documents}`) +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, profileMenu, errors }, null, 2)) if (failures.length) throw new Error(failures.join('\n')) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f4207b2..aba0f5d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,325 +1,63 @@ -import { useEffect, useRef, useState } from 'react' -import { Alert, Avatar, Button, Empty, Input, Message, Modal, Select, Spin, Tag, Typography } from '@arco-design/web-react' +import { useEffect, useMemo, 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, IconCheckCircle, IconClockCircle, - IconFile, IconFolder, IconPlus, IconRefresh, IconRobot, IconSearch, IconSettings, + IconApps, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconDown, IconEmail, IconFile, + IconFolder, IconLeft, IconList, IconMoon, IconPlus, IconRight, IconRobot, + IconSun, IconUser, } from '@arco-design/web-react/icon' import { - ApiError, checkServerConnection, createAISession, createCron, createProject, createTask, fetchAISessions, fetchExperts, - fetchProjects, fetchWorkspace, login, updateTask, uploadSource, - type AISession, type ApiSession, type Expert, type Project, type Workspace, + ApiError, createProject, fetchProjectWorkspace, fetchProjects, getCurrentUser, + listAIExperts, login, setApiBaseUrl, updateCurrentUser, updateTask, + type ApiSession, type CurrentUser, type Expert, type Project, type Workspace, type WorkspaceTask, } from './api' const { Text, Title } = Typography -type View = 'home' | 'tasks' | 'notes' | 'ai' | 'more' -type Action = 'project' | 'task' | 'cron' | null -type ConnectionStatus = 'checking' | 'online' | 'offline' +const SESSION_KEY = 'senlin-app-session' +type View = 'overview' | 'tasks' | 'inbox' | 'documents' | 'ai' | 'cron' +type SavedSession = ApiSession & { user?: CurrentUser } +type TaskWithProject = WorkspaceTask & { projectName: string } const navItems: Array<{ id: View; label: string; icon: React.ReactNode }> = [ - { id: 'home', label: '项目', icon: }, - { id: 'tasks', label: '计划', icon: }, - { id: 'notes', label: '资料', icon: }, + { id: 'overview', label: '概况', icon: }, + { id: 'tasks', label: '计划', icon: }, + { id: 'inbox', label: '收集箱', icon: }, + { id: 'documents', label: '资料', icon: }, { id: 'ai', label: 'AI', icon: }, - { id: 'more', label: '更多', icon: }, + { id: 'cron', label: '周期计划', icon: }, ] export function App() { - const [session, setSession] = useState(() => readStoredSession()) + const stored = useMemo(readStoredSession, []) + const [session, setSession] = useState(stored) + const [user, setUser] = useState(stored?.user ?? null) const [projects, setProjects] = useState([]) + const [workspaces, setWorkspaces] = useState>({}) const [projectId, setProjectId] = useState('') - const [workspace, setWorkspace] = useState(null) - const [experts, setExperts] = useState([]) - const [aiSessions, setAISessions] = useState([]) - const [view, setView] = useState('home') - const [loading, setLoading] = useState(Boolean(session)) + const [view, setView] = useState('overview') + const [dark, setDark] = useState(false) + const [loading, setLoading] = useState(Boolean(stored)) const [error, setError] = useState('') - const [action, setAction] = useState(null) - const refreshGeneration = useRef(0) + const [taskEditor, setTaskEditor] = useState(null) + const [projectModalOpen, setProjectModalOpen] = useState(false) + const [profileModalOpen, setProfileModalOpen] = useState(false) + const [experts, setExperts] = useState([]) useEffect(() => { if (!session) return - let cancelled = false - setLoading(true) - void fetchProjects(session) - .then((items) => { - if (cancelled) return - setProjects(items) - setProjectId((current) => items.some((item) => item.id === current) ? current : items[0]?.id ?? '') - }) - .catch((requestError) => !cancelled && setError(errorMessage(requestError))) - .finally(() => !cancelled && setLoading(false)) - return () => { cancelled = true } + if (!user) void getCurrentUser(session).then(setUser).catch((requestError) => setError(errorMessage(requestError))) + void loadWorkspaces(session) }, [session]) - useEffect(() => { - if (!session || !projectId) return - void refreshProject(session, projectId) - }, [projectId, session]) - - async function refreshProject(activeSession = session, activeProject = projectId) { - if (!activeSession || !activeProject) return - const generation = ++refreshGeneration.current + async function loadWorkspaces(activeSession = session) { + if (!activeSession) return setLoading(true) setError('') try { - const [nextWorkspace, nextExperts, nextSessions] = await Promise.all([ - fetchWorkspace(activeSession, activeProject), - experts.length ? Promise.resolve(experts) : fetchExperts(activeSession), - fetchAISessions(activeSession, activeProject), - ]) - if (generation !== refreshGeneration.current) return - setWorkspace(nextWorkspace) - setExperts(nextExperts) - setAISessions(nextSessions) - } catch (requestError) { - if (generation === refreshGeneration.current) setError(errorMessage(requestError)) - } finally { - if (generation === refreshGeneration.current) setLoading(false) - } - } - - if (!session) return { localStorage.setItem('senlin-mini-session', JSON.stringify(next)); setSession(next) }} /> - - const activeProject = projects.find((project) => project.id === projectId) ?? projects[0] - const content = !workspace || !activeProject - ? - : renderView(view, { - session, workspace, experts, aiSessions, - refresh: () => refreshProject(), - openAction: setAction, - }) - - return ( -
-
-
- - 森林AI - App 1.0 -
-
- -
- -
- - - - {error ? setError('')} /> : null} -
{content}
- -
- 服务已连接 - -
- - setAction(null)} - onProject={async (name) => { - const project = await createProject(session, name) - const next = await fetchProjects(session) - setProjects(next) - setProjectId(project.id) - }} - onTask={async (title, tag) => { - if (!projectId) return - await createTask(session, projectId, title, tag) - await refreshProject() - }} - onCron={async (title, schedule) => { - if (!projectId) return - await createCron(session, projectId, title, schedule) - await refreshProject() - }} - /> -
- ) -} - -function renderView(view: View, props: ViewProps) { - if (view === 'tasks') return - if (view === 'notes') return - if (view === 'ai') return - if (view === 'more') return - return -} - -type ViewProps = { - session: ApiSession - workspace: Workspace - experts: Expert[] - aiSessions: AISession[] - refresh: () => void | Promise - openAction: (action: Action) => void -} - -function HomeView({ workspace, openAction }: ViewProps) { - const pending = workspace.tasks.filter((task) => !task.completed) - return ( -
-
- 当前项目 - {workspace.project.name} -

{workspace.project.description || '把计划、资料与 AI 会话放进同一个项目上下文。'}

-
-
- - - - -
-
快速操作
-
- - -
-
接下来{pending.length}
-
- {pending.slice(0, 4).map((task) =>
{task.title}{task.tag || '未分类'}
)} - {!pending.length && } -
-
- ) -} - -function Metric({ value, label, color }: { value: number; label: string; color: string }) { - return
{value}{label}
-} - -function TasksView({ session, workspace, refresh, openAction }: ViewProps) { - const [tag, setTag] = useState('全部') - const tags = workspace.tags.filter((item) => item.name !== '全部' && item.name !== 'all') - const tasks = tag === '全部' ? workspace.tasks : workspace.tasks.filter((task) => task.tag === tag) - return ( -
-
工作计划轻点圆圈即可更新状态
-
- {['全部', ...tags.map((item) => item.name)].map((item) => )} -
-
- {tasks.map((task) => ( -
- -
{task.title}

{task.summary || '暂无说明'}

{task.tag || '未分类'} · {task.createdAt}
-
- ))} - {!tasks.length && } -
-
- ) -} - -function NotesView({ session, workspace, refresh }: ViewProps) { - const fileInput = useRef(null) - return ( -
-
笔记资料项目资料随手可取
- { const file = event.target.files?.[0]; if (!file) return; await uploadSource(session, workspace.project.id, file); await refresh(); event.target.value = '' }} /> -
- {workspace.documents.map((document, index) =>
{document.name}

{document.extension || document.mimeType || document.kind}

{document.kind || '资料'} · {document.updatedAt}
)} - {!workspace.documents.length && } -
-
- ) -} - -function AIView({ session, workspace, experts, aiSessions, refresh }: ViewProps) { - const [query, setQuery] = useState('') - const [category, setCategory] = useState('全部') - const [expertId, setExpertId] = useState('') - const [prompt, setPrompt] = useState('') - const [sending, setSending] = useState(false) - const categories = [...new Set(experts.map((expert) => expert.categoryName))] - const visible = experts.filter((expert) => (category === '全部' || expert.categoryName === category) && `${expert.name}${expert.description}`.toLowerCase().includes(query.toLowerCase())) - const selected = experts.find((expert) => expert.id === expertId) - return ( -
-
AI 会话先选择专家,再开始会话
{experts.length} 位
- } value={query} onChange={setQuery} allowClear placeholder="搜索专家" /> -
- {['全部', ...categories].map((item) => )} -
-
- {visible.slice(0, 30).map((expert) => )} -
-
-
{selected ? <>{selected.emoji}{selected.name}{selected.description} : 选择一位专家进行会话}
- - -
-
最近会话
-
{aiSessions.slice(0, 5).map((item) =>
{item.expert?.emoji || '🤖'}{item.title}{item.expert?.name || '森林AI'}
)}
-
- ) -} - -function MoreView({ workspace, openAction }: ViewProps) { - return ( -
-
更多项目周期安排与客户端信息
-
计划任务{workspace.cronPlans.map((plan) =>
{plan.title}{plan.schedule} · {plan.enabled ? '已启用' : '已停用'}
)}{!workspace.cronPlans.length && 暂无周期计划}
-
停靠说明

顶部箭头可将桌面窗口停靠至当前屏幕左侧或右侧。Web 模式会记住布局偏好,桌面模式会同时移动实际窗口。

-
森林AI Appv1.0 · 停靠客户端
-
- ) -} - -function Login({ onLogin }: { onLogin: (session: ApiSession) => void }) { - const [server, setServer] = useState('http://localhost:9150') - const [email, setEmail] = useState('demo@senlin.ai') - const [password, setPassword] = useState('password123') - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') - const [connection, setConnection] = useState('checking') - - useEffect(() => { - const controller = new AbortController() - const timer = window.setTimeout(() => void checkConnection(controller.signal), 300) - return () => { - controller.abort() - window.clearTimeout(timer) - } - }, [server]) - - async function checkConnection(signal?: AbortSignal) { - setConnection('checking') - try { - const online = await checkServerConnection(server, signal) - if (!signal?.aborted) setConnection(online ? 'online' : 'offline') - } catch (requestError) { - if (!(requestError instanceof DOMException && requestError.name === 'AbortError')) setConnection('offline') - } - } - - async function submitLogin() { - if (loading) return - setLoading(true) - setError('') - try { - onLogin(await login(server, email.trim(), password)) + const nextProjects = await fetchProjects(activeSession) + const items = await Promise.all(nextProjects.map(async (project) => [project.id, await fetchProjectWorkspace(activeSession, project.id)] as const)) + setProjects(nextProjects) + setWorkspaces(Object.fromEntries(items)) + setProjectId((current) => current && nextProjects.some((project) => project.id === current) ? current : '') } catch (requestError) { setError(errorMessage(requestError)) } finally { @@ -327,72 +65,159 @@ function Login({ onLogin }: { onLogin: (session: ApiSession) => void }) { } } + async function loadAI() { + if (!session || experts.length) return + try { setExperts(await listAIExperts(session)) } catch (requestError) { setError(errorMessage(requestError)) } + } + + const visibleWorkspaces = projectId ? [workspaces[projectId]].filter(Boolean) : Object.values(workspaces) + const activeProject = projectId ? projects.find((project) => project.id === projectId) : undefined + const tasks = visibleWorkspaces.flatMap((workspace) => workspace.tasks.map((task) => ({ ...task, projectName: workspace.project.name }))) + const title = activeProject?.name ?? '全部项目' + + function cycleProject(direction: number) { + const index = projects.findIndex((project) => project.id === projectId) + const next = projectId ? (index + direction + projects.length) % projects.length : direction > 0 ? 0 : projects.length - 1 + setProjectId(projects[next]?.id ?? '') + } + + function logout() { + localStorage.removeItem(SESSION_KEY) + localStorage.removeItem('senlin-mini-session') + setSession(null); setUser(null); setProjects([]); setWorkspaces({}); setProjectId(''); setView('overview') + } + + if (!session) return { setSession(next); setUser(next.user); persistSession(next) }} /> + return ( -
-
{ event.preventDefault(); void submitLogin() }}> - - 森林AI App - 停靠在屏幕一侧,随时记录与推进项目 - -
- - {connection === 'checking' ? '正在检查' : connection === 'online' ? '连接正常' : '连接异常'} - +
+
+
森林AI
+
+
- - - {error ? : null} - - v1.0 完整版 - +
+ +
+ +
+ {projects.map((project) => )} +
+
+ + {error ? setError('')} /> : null} +
{visibleWorkspaces.length ? : }
+ + + + 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() + setTaskEditor(null) + }} /> + setProjectModalOpen(false)} onSave={async (name) => { + const project = await createProject(session, { name, identifier: slugify(name) || `project-${Date.now().toString(36)}`, icon: 'folder', background: '#165DFF', description: '' }) + setProjectModalOpen(false) + await loadWorkspaces() + setProjectId(project.id) + }} /> + setProfileModalOpen(false)} onSave={async (input) => { + const nextUser = await updateCurrentUser(session, input) + setUser(nextUser) + persistSession({ ...session, user: nextUser }) + setProfileModalOpen(false) + }} />
) } -function ActionModal({ action, workspace, onClose, onProject, onTask, onCron }: { - action: Action - workspace: Workspace | null - onClose: () => void - onProject: (name: string) => Promise - onTask: (title: string, tag: string) => Promise - onCron: (title: string, schedule: string) => Promise -}) { - const [title, setTitle] = useState('') - const [tag, setTag] = useState('') - const [schedule, setSchedule] = useState('0 9 * * 1-5') - const [loading, setLoading] = useState(false) - useEffect(() => { if (action) { setTitle(''); setTag(''); setSchedule('0 9 * * 1-5') } }, [action]) - const labels = action === 'project' ? ['新建项目', '项目名称'] : action === 'task' ? ['新建计划', '计划标题'] : ['新建计划任务', '任务名称'] - return ( - -
- - {action === 'task' ? : null} - {action === 'cron' ? : null} - -
-
- ) +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[] }) { + if (view === 'tasks' || view === 'overview') return + if (view === 'inbox') return + if (view === 'documents') return + if (view === 'ai') return + return } -function readStoredSession(): ApiSession | null { - try { - const value = JSON.parse(localStorage.getItem('senlin-mini-session') || 'null') as ApiSession | null - return value?.baseUrl && value?.token ? value : null - } catch { - return null - } +function TaskBoard({ title, subtitle, tasks, onEditTask }: { title: string; subtitle: string; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void }) { + const [tag, setTag] = useState('全部') + const tags = [...new Set(tasks.map((task) => task.tag).filter(Boolean))] + const visible = tag === '全部' ? tasks : tasks.filter((task) => task.tag === tag) + const pending = visible.filter((task) => !task.completed) + const completed = visible.filter((task) => task.completed) + return
+
{subtitle}{title}
{pending.length} 项待办
+
{tags.map((item) => )}
+
待办任务{pending.length}
{pending.map((task) => onEditTask(task)} />)}{!pending.length && }
+
已完成{completed.length}
{completed.map((task) => onEditTask(task)} />)}{!completed.length && }
+
} -function projectMark(project?: Project) { - if (!project) return - if (project.icon && !['folder', 'project'].includes(project.icon)) return project.icon - const chars = Array.from(project.name.trim()) - const chinese = chars.some((char) => /[\u3400-\u9fff]/.test(char)) - return chars.slice(0, chinese ? 2 : 4).join('').toUpperCase() +function TaskRow({ task, onClick }: { task: TaskWithProject; onClick: () => void }) { + return } -function errorMessage(error: unknown) { - if (error instanceof ApiError || error instanceof Error) return error.message - return '操作失败,请稍后重试' +function InboxPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) { + const items = workspaces.flatMap((workspace) => workspace.inbox.map((item) => ({ ...item, projectName: workspace.project.name }))) + return {items.map((item) =>
{item.title}

{item.summary || item.source}

{item.projectName}
)}{!items.length && }
} + +function DocumentPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) { + const documents = workspaces.flatMap((workspace) => workspace.documents.map((item) => ({ ...item, projectName: workspace.project.name }))) + return {documents.map((item) =>
{item.name}

{item.extension || item.mimeType || item.kind} · 更新于 {formatDate(item.updatedAt)}

{item.projectName}
)}{!documents.length && }
+} + +function AIPanel({ title, workspaces, experts, onLoad }: { title: string; workspaces: Workspace[]; experts: Expert[]; onLoad: () => void }) { + useEffect(() => { onLoad() }, [onLoad]) + const sessions = workspaces.flatMap((workspace) => workspace.aiSessions.map((item) => ({ ...item, projectName: workspace.project.name }))) + return
{experts.slice(0, 6).map((expert) => {expert.emoji}{expert.name})}
{sessions.map((item) =>
{item.title}

{item.summary || 'AI 会话'}

{item.projectName}
)}{!sessions.length && }
+} + +function CronPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) { + const plans = workspaces.flatMap((workspace) => workspace.cronPlans.map((item) => ({ ...item, projectName: workspace.project.name }))) + return {plans.map((item) =>
{item.title}

{item.schedule} · {item.enabled ? '已启用' : '已停用'}

{item.projectName}
)}{!plans.length && }
+} + +function Panel({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) { return
{subtitle}{title}
{children}
} + +function ProfileMenu({ user, onProfile, onLogout }: { user: CurrentUser | null; onProfile: () => void; onLogout: () => void }) { + const menu = 修改资料退出登录 + return +} + +function TaskEditor({ task, tags, onClose, onSave }: { task: TaskWithProject | null; tags: string[]; onClose: () => void; onSave: (draft: { title: string; summary: string; tag: string; completed: boolean }) => Promise }) { + 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 { if (!title.trim()) return; setSaving(true); try { await onSave({ title: title.trim(), summary: summary.trim(), tag, completed }) } finally { setSaving(false) } }}>
+} + +function ProjectModal({ visible, onClose, onSave }: { visible: boolean; onClose: () => void; onSave: (name: string) => Promise }) { + const [name, setName] = useState(''); const [saving, setSaving] = useState(false) + return { if (!name.trim()) return; setSaving(true); try { await onSave(name.trim()); setName('') } finally { setSaving(false) } }}> +} + +function ProfileModal({ user, visible, onClose, onSave }: { user: CurrentUser | null; visible: boolean; onClose: () => void; onSave: (input: { displayName: string; currentPassword?: string; newPassword?: string }) => Promise }) { + 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 { setSaving(true); try { await onSave({ displayName, currentPassword: currentPassword || undefined, newPassword: newPassword || undefined }); setCurrentPassword(''); setNewPassword('') } finally { setSaving(false) } }}>
+} + +function Login({ onLogin }: { onLogin: (session: ApiSession & { user: CurrentUser }) => void }) { + const [server, setServer] = useState('http://localhost:9150'); const [email, setEmail] = useState('demo@senlin.ai'); const [password, setPassword] = useState('password123'); const [loading, setLoading] = useState(false); const [error, setError] = useState('') + return
{ event.preventDefault(); void submit() }}>森林AI App连接你的私有工作台{error && }
+ async function submit() { setLoading(true); setError(''); try { onLogin(await login(server, email.trim(), password)) } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) } } +} + +function readStoredSession(): SavedSession | null { try { const value = JSON.parse(localStorage.getItem(SESSION_KEY) || localStorage.getItem('senlin-mini-session') || 'null') as SavedSession | null; if (!value?.baseUrl || !value.token) return null; setApiBaseUrl(value.baseUrl); return value } catch { return null } } +function persistSession(session: SavedSession) { localStorage.setItem(SESSION_KEY, JSON.stringify(session)) } +function projectLabel(project: Project) { if (project.icon && !['folder', 'project'].includes(project.icon)) return Array.from(project.icon).slice(0, 2).join(''); return Array.from(project.name.trim()).slice(0, 2).join('').toUpperCase() || } +function formatDate(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `${date.getMonth() + 1}/${date.getDate()}` } +function slugify(value: string) { return value.toLowerCase().trim().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').replace(/^-|-$/g, '') } +function errorMessage(error: unknown) { return error instanceof ApiError || error instanceof Error ? error.message : '操作失败,请稍后重试' } diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index ae5f784..767209f 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -1,5 +1,7 @@ export type ApiSession = { baseUrl: string; token: string } +export type CurrentUser = { email: string; displayName: string } + export type Project = { id: string name: string @@ -9,6 +11,17 @@ export type Project = { description: string } +export type WorkspaceChannel = { + id: string + projectId: string + type: string + title: string + icon: string + count: number + url: string + sortOrder: number +} + export type WorkspaceTask = { id: string projectId: string @@ -23,57 +36,27 @@ export type WorkspaceTask = { tag: string } -export type WorkspaceDocument = { - id: string - projectId: string - kind: string - name: string - extension: string - mimeType: string - updatedAt: string -} - -export type CronPlan = { - id: string - projectId: string - title: string - schedule: string - nextRun: string | null - enabled: boolean - lastResult: string - owner: string -} +export type WorkspaceDocument = { id: string; projectId: string; kind: string; name: string; extension: string; mimeType: string; updatedAt: string } +export type WorkspaceAISession = { id: string; projectId: string; title: string; summary: string; updatedAt: string; references: string[] } +export type WorkspaceInbox = { id: string; projectId: string; source: string; title: string; summary: string; status: string; tag: string; time: string } +export type WorkspaceCronPlan = { id: string; projectId: string; title: string; schedule: string; nextRun: string | null; enabled: boolean; lastResult: string; owner: string } export type Workspace = { project: Project & { initials: string; unreadCount: number } + channels: WorkspaceChannel[] tags: Array<{ id: string; name: string }> + recentSessions: WorkspaceAISession[] + inbox: WorkspaceInbox[] tasks: WorkspaceTask[] + aiSessions: WorkspaceAISession[] documents: WorkspaceDocument[] - aiSessions: Array<{ id: string; projectId: string; title: string; summary: string; updatedAt: string; references: string[] }> - cronPlans: CronPlan[] + cronPlans: WorkspaceCronPlan[] } -export type Expert = { - id: string - slug: string - category: string - categoryName: string - name: string - description: string - emoji: string - color: string -} - -export type AISession = { - id: string - projectId: string - title: string - context: string - status: string - expert: Expert | null - createdAt: string - updatedAt: string -} +export type Expert = { id: string; slug: string; category: string; categoryName: string; name: string; description: string; emoji: string; color: string } +export type AISession = { id: string; projectId: string; title: string; context: string; status: string; expert: Expert | null; createdAt: string; updatedAt: string } +export type CreateTaskInput = { title: string; description?: string; status?: string; dueAt?: string; tag?: string } +export type UpdateTaskInput = { title: string; description?: string; status?: string; completed: boolean; nextProjectId?: string; tag?: string } export class ApiError extends Error { constructor(public status: number, public code: string, message: string) { @@ -84,131 +67,101 @@ export class ApiError extends Error { let baseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9150') -export function configureApi(value: string) { - baseUrl = normalizeBaseUrl(value) +export function setApiBaseUrl(value: string) { baseUrl = normalizeBaseUrl(value) } +export function getApiBaseUrl() { return baseUrl } + +export async function login(server: string, email: string, password: string): Promise { + setApiBaseUrl(server) + const response = await apiRequest<{ token: string; user: CurrentUser }>('/api/v1/auth/login', { method: 'POST', body: { email, password } }) + return { baseUrl, token: response.token, user: response.user } } -export async function login(server: string, email: string, password: string): Promise { - configureApi(server) - const response = await request<{ token: string }>('/api/v1/auth/login', { method: 'POST', body: { email, password } }) - return { baseUrl, token: response.token } +export function getCurrentUser(session: ApiSession) { + useSessionBase(session) + return apiRequest('/api/v1/auth/me', { token: session.token }) +} + +export function updateCurrentUser(session: ApiSession, input: { displayName: string; currentPassword?: string; newPassword?: string }) { + useSessionBase(session) + return apiRequest('/api/v1/auth/me', { method: 'PATCH', token: session.token, body: input }) +} + +export function fetchProjects(session: ApiSession) { + useSessionBase(session) + return apiRequest('/api/v1/projects', { token: session.token }) +} + +export function fetchProjectWorkspace(session: ApiSession, projectId: string) { + useSessionBase(session) + return apiRequest(`/api/v1/projects/${projectId}/workspace`, { token: session.token }) +} + +export function createProject(session: ApiSession, input: Pick) { + useSessionBase(session) + return apiRequest('/api/v1/projects', { method: 'POST', token: session.token, body: input }) +} + +export function createTask(session: ApiSession, projectId: string, input: CreateTaskInput) { + useSessionBase(session) + return apiRequest(`/api/v1/projects/${projectId}/tasks`, { method: 'POST', token: session.token, body: input }) +} + +export function updateTask(session: ApiSession, projectId: string, taskId: string, input: UpdateTaskInput) { + useSessionBase(session) + return apiRequest(`/api/v1/projects/${projectId}/tasks/${taskId}`, { method: 'PATCH', token: session.token, body: input }) +} + +export function listAIExperts(session: ApiSession) { + useSessionBase(session) + return apiRequest('/api/v1/ai-experts', { token: session.token }) +} + +export function listAISessions(session: ApiSession, projectId: string) { + useSessionBase(session) + return apiRequest(`/api/v1/projects/${projectId}/ai-sessions`, { token: session.token }) +} + +export function createAISession(session: ApiSession, projectId: string, input: { title: string; context: string; expertId?: string }) { + useSessionBase(session) + return apiRequest(`/api/v1/projects/${projectId}/ai-sessions`, { method: 'POST', token: session.token, body: input }) } export async function checkServerConnection(server: string, signal?: AbortSignal) { try { const response = await fetch(`${normalizeBaseUrl(server)}/api/v1/status`, { signal }) - if (!response.ok) return false - const payload = await response.json() as { timestamp?: unknown } - return typeof payload.timestamp === 'string' + return response.ok } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') throw error return false } } -export async function fetchProjects(session: ApiSession) { - useSessionBase(session) - return request('/api/v1/projects', { token: session.token }) -} +type RequestOptions = { method?: string; token?: string; body?: unknown; responseType?: 'void'; signal?: AbortSignal } -export async function fetchWorkspace(session: ApiSession, projectId: string) { - useSessionBase(session) - return request(`/api/v1/projects/${projectId}/workspace`, { token: session.token }) -} - -export async function createProject(session: ApiSession, name: string) { - useSessionBase(session) - const identifier = `${slugify(name) || 'project'}-${Date.now().toString(36)}` - return request('/api/v1/projects', { - method: 'POST', token: session.token, - body: { name, identifier, icon: 'folder', background: '#165DFF', description: '' }, - }) -} - -export async function createTask(session: ApiSession, projectId: string, title: string, tag?: string) { - useSessionBase(session) - return request(`/api/v1/projects/${projectId}/tasks`, { - method: 'POST', token: session.token, body: { title, description: '', status: 'open', tag }, - }) -} - -export async function updateTask(session: ApiSession, projectId: string, task: WorkspaceTask) { - useSessionBase(session) - return request(`/api/v1/projects/${projectId}/tasks/${task.id}`, { - method: 'PATCH', token: session.token, - body: { title: task.title, description: task.summary, completed: !task.completed, tag: task.tag }, - }) -} - -export async function uploadSource(session: ApiSession, projectId: string, file: File) { - useSessionBase(session) - const body = new FormData() - body.append('file', file) - body.append('title', file.name) - return request(`/api/v1/projects/${projectId}/sources`, { method: 'POST', token: session.token, body }) -} - -export async function createCron(session: ApiSession, projectId: string, title: string, schedule: string) { - useSessionBase(session) - return request(`/api/v1/projects/${projectId}/cron-plans`, { - method: 'POST', token: session.token, body: { title, schedule, enabled: true }, - }) -} - -export async function fetchExperts(session: ApiSession) { - useSessionBase(session) - return request('/api/v1/ai-experts', { token: session.token }) -} - -export async function fetchAISessions(session: ApiSession, projectId: string) { - useSessionBase(session) - return request(`/api/v1/projects/${projectId}/ai-sessions`, { token: session.token }) -} - -export async function createAISession(session: ApiSession, projectId: string, expertId: string, context: string) { - useSessionBase(session) - return request(`/api/v1/projects/${projectId}/ai-sessions`, { - method: 'POST', token: session.token, - body: { title: Array.from(context).slice(0, 36).join(''), context, expertId }, - }) -} - -type RequestOptions = { method?: string; token?: string; body?: unknown; signal?: AbortSignal } - -async function request(path: string, options: RequestOptions = {}): Promise { - const isForm = options.body instanceof FormData +export async function apiRequest(path: string, options: RequestOptions = {}): Promise { + const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData let response: Response try { response = await fetch(`${baseUrl}${path}`, { method: options.method ?? 'GET', - headers: { - ...(!isForm && options.body !== undefined ? { 'Content-Type': 'application/json' } : {}), - ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}), - }, - body: options.body === undefined ? undefined : isForm ? options.body as FormData : JSON.stringify(options.body), + headers: { ...(options.body !== undefined && !isFormData ? { 'Content-Type': 'application/json' } : {}), ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}) }, + body: options.body === undefined ? undefined : isFormData ? options.body as FormData : JSON.stringify(options.body), signal: options.signal, }) } catch { - throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络') + throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试') } if (!response.ok) { const payload = await response.json().catch(() => null) as { error?: { code?: string; message?: string } } | null - throw new ApiError(response.status, payload?.error?.code ?? 'request_failed', payload?.error?.message ?? '请求失败,请稍后重试') + throw new ApiError(response.status, payload?.error?.code ?? fallbackCode(response.status), payload?.error?.message ?? fallbackMessage(response.status)) } - if (response.status === 204) return undefined as T - return response.json() as Promise + if (response.status === 204 || options.responseType === 'void') return undefined as T + const text = await response.text() + if (!text.trim()) throw new ApiError(response.status, 'invalid_response', '服务器返回了无法识别的数据') + try { return JSON.parse(text) as T } catch { throw new ApiError(response.status, 'invalid_response', '服务器返回了无法识别的数据') } } -function useSessionBase(session: ApiSession) { - baseUrl = normalizeBaseUrl(session.baseUrl) -} - -function normalizeBaseUrl(value: string) { - const normalized = value.trim().replace(/\/+$/, '') - if (!normalized || /^https?:\/\//i.test(normalized)) return normalized - return `http://${normalized}` -} - -function slugify(value: string) { - return value.toLowerCase().trim().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').replace(/^-|-$/g, '') -} +function useSessionBase(session: ApiSession) { baseUrl = normalizeBaseUrl(session.baseUrl) } +function normalizeBaseUrl(value: string) { const normalized = value.trim().replace(/\/+$/, ''); return !normalized || /^https?:\/\//i.test(normalized) ? normalized : `http://${normalized}` } +function fallbackCode(status: number) { return status === 401 ? 'unauthorized' : status === 403 ? 'forbidden' : status === 404 ? 'not_found' : status === 409 ? 'conflict' : status >= 500 ? 'internal_error' : 'request_failed' } +function fallbackMessage(status: number) { return status === 401 ? '登录已失效,请重新登录' : status === 403 ? '没有权限执行此操作' : status === 404 ? '请求的内容不存在' : status === 409 ? '操作冲突,请刷新后重试' : status >= 500 ? '服务器暂时无法处理请求,请稍后重试' : '请求失败,请稍后重试' } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index b7ad72e..78a4d63 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1,235 +1,70 @@ -:root { - font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; - color: #1d2129; - background: #e8edf4; - font-synthesis: none; - --blue: #165dff; - --line: #e5e8ef; - --muted: #86909c; - --panel: #fff; - --canvas: #f5f7fa; -} - +:root { font-family: Inter, "Microsoft YaHei", sans-serif; color: #1d2129; background: #f4f5f7; } * { box-sizing: border-box; } -html, body, #root { width: 100%; min-width: 320px; height: 100%; margin: 0; } -body { overflow: hidden; background: #e8edf4; } -button, input, textarea { font: inherit; } -button { color: inherit; } +body { margin: 0; min-width: 300px; min-height: 100vh; background: #f4f5f7; } +button { font: inherit; } -.mini-shell { - width: 100%; - height: 100vh; - display: grid; - grid-template-rows: 44px 50px 58px minmax(0, 1fr) 28px; - overflow: hidden; - background: var(--canvas); - border: 0; - box-shadow: none; -} +.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.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; } +.app-brand img { width: 23px; height: 23px; } +.header-actions { display: flex; align-items: center; gap: 5px; } +.profile-trigger { display: inline-flex; align-items: center; gap: 6px; max-width: 144px; border: 0; padding: 4px; color: var(--text); background: transparent; border-radius: 8px; cursor: pointer; } +.profile-trigger:hover { background: color-mix(in srgb, var(--text) 7%, transparent); } +.profile-trigger > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; } +.profile-trigger > svg { flex: 0 0 auto; color: var(--muted); font-size: 13px; } -.mini-header, -.project-switcher, -.mini-nav, -.mini-footer { - background: rgba(255, 255, 255, 0.96); -} +.project-strip { grid-column: 1 / -1; display: flex; align-items: center; gap: 4px; min-width: 0; padding: 5px 9px; border-bottom: 1px solid var(--line); background: var(--surface); } +.project-scroller { display: flex; align-items: center; gap: 4px; min-width: 0; overflow: hidden; flex: 1; } +.project-pill { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 5px; max-width: 122px; min-height: 30px; padding: 4px 8px; border: 1px solid transparent; border-radius: 8px; color: #4e5969; background: transparent; cursor: pointer; } +.project-pill:hover, .project-pill.active { color: #165dff; background: #e8f3ff; border-color: #d3e7ff; } +.project-pill i { display: grid; place-items: center; width: 19px; height: 19px; border-radius: 6px; color: #fff; font-style: normal; font-size: 9px; font-weight: 700; } +.project-pill span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; } -.mini-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 10px 0 12px; - border-bottom: 1px solid var(--line); - user-select: none; -} +.app-alert { grid-column: 1 / 2; z-index: 2; margin: 8px 12px -46px; align-self: start; } +.app-main { grid-column: 1; grid-row: 3; min-width: 0; overflow: auto; padding: 18px 15px 24px; } +.app-main > .arco-spin { min-height: 100%; } +.content-page { max-width: 650px; margin: 0 auto; } +.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; } +.tag-filter { display: flex; gap: 6px; margin-bottom: 16px; overflow-x: auto; padding-bottom: 2px; } +.tag-filter button { flex: 0 0 auto; padding: 5px 10px; border: 0; border-radius: 999px; color: var(--muted); background: var(--surface); cursor: pointer; font-size: 12px; } +.tag-filter button.active { color: #fff; background: #165dff; } +.task-section { margin-bottom: 18px; padding: 13px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); } +.completed-section { opacity: .82; } +.section-label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px; font-size: 13px; } +.section-label span { display: grid; place-items: center; min-width: 21px; height: 21px; border-radius: 50%; color: #165dff; background: #e8f3ff; font-size: 11px; font-weight: 700; } +.task-list { display: grid; gap: 7px; } +.task-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; padding: 10px 8px; border: 1px solid transparent; border-radius: 9px; color: var(--text); background: transparent; text-align: left; cursor: pointer; } +.task-row:hover { border-color: #c9d7ee; background: #f7faff; } +.task-row.done .task-copy strong { color: var(--muted); text-decoration: line-through; } +.task-check { color: #165dff; font-size: 21px; line-height: 0; } +.task-row.done .task-check { color: #00b42a; } +.task-copy { display: grid; min-width: 0; gap: 3px; } +.task-copy strong, .task-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.task-copy strong { font-size: 13px; } +.task-copy small { color: var(--muted); font-size: 11px; } +.task-meta { display: grid; justify-items: end; gap: 4px; } +.task-meta small { color: var(--muted); font-size: 10px; } -.mini-brand, -.project-trigger, -.mini-footer, -.section-title, -.page-title, -.selected-expert, -.session-list > div, -.settings-card > div, -.version-card { - display: flex; - align-items: center; -} +.channel-rail { grid-column: 2; grid-row: 3; display: flex; flex-direction: column; align-items: center; gap: 7px; padding: 10px 6px; border-left: 1px solid var(--line); background: var(--rail); overflow-y: auto; } +.channel-rail button { display: grid; place-items: center; width: 42px; min-height: 43px; gap: 2px; border: 0; border-radius: 10px; color: #6b7077; background: transparent; cursor: pointer; } +.channel-rail button:hover { color: #165dff; background: #e8f3ff; } +.channel-rail button.active { color: #165dff; background: #dbeeff; box-shadow: inset 0 0 0 1px #c9e2ff; } +.channel-rail svg { font-size: 20px; } +.channel-rail span { font-size: 9px; line-height: 1; } -.mini-brand { gap: 7px; font-weight: 800; } -.mini-brand img { width: 25px; height: 25px; } -.mini-brand .arco-tag { margin-left: 2px; } +.detail-list { display: grid; gap: 8px; } +.detail-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 9px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); } +.detail-row > svg { color: #165dff; font-size: 18px; } +.detail-row div { min-width: 0; } +.detail-row strong, .detail-row p { display: block; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.detail-row strong { font-size: 13px; }.detail-row p { margin-top: 3px; color: var(--muted); font-size: 11px; } +.expert-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; } +.expert-chips span { display: inline-flex; align-items: center; gap: 4px; padding: 4px 7px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); font-size: 11px; }.expert-chips i { display: grid; place-items: center; width: 17px; height: 17px; border-radius: 50%; font-style: normal; } -.project-switcher { - display: grid; - grid-template-columns: minmax(0, 1fr) 32px 32px; - align-items: center; - gap: 4px; - padding: 6px 8px; - border-bottom: 1px solid var(--line); -} +.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; } -.project-switcher > .arco-select { width: 100%; } -.project-trigger { - width: 100%; - min-width: 0; - height: 36px; - gap: 8px; - border: 0; - border-radius: 9px; - background: transparent; - padding: 3px 6px; - text-align: left; - cursor: pointer; -} -.project-trigger:hover { background: #f2f5fa; } -.project-trigger > span:nth-of-type(1) { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; } -.project-trigger small { margin-left: auto; color: var(--muted); font-size: 10px; } - -.mini-nav { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - border-bottom: 1px solid var(--line); -} -.mini-nav button { - position: relative; - display: grid; - place-items: center; - align-content: center; - gap: 2px; - border: 0; - background: transparent; - color: #6b778c; - cursor: pointer; - font-size: 11px; -} -.mini-nav button::after { content: ""; position: absolute; left: 22%; right: 22%; bottom: 0; height: 2px; border-radius: 99px; background: transparent; } -.mini-nav svg { font-size: 18px; } -.mini-nav button:hover, -.mini-nav button.active { color: var(--blue); background: linear-gradient(180deg, transparent, #f3f7ff); } -.mini-nav button.active::after { background: var(--blue); } - -.mini-alert { position: fixed; z-index: 30; top: 102px; left: 50%; width: min(388px, calc(100% - 24px)); transform: translateX(-50%); } -.mini-content { min-height: 0; overflow: hidden; } -.mini-content > .arco-spin { width: 100%; height: 100%; } -.mini-content > .arco-spin > .arco-spin-children { height: 100%; } -.mini-page { height: 100%; overflow-x: hidden; overflow-y: auto; padding: 14px 14px 24px; scrollbar-width: thin; scrollbar-color: #c9cdd4 transparent; } - -.mini-footer { justify-content: space-between; padding: 0 10px; border-top: 1px solid var(--line); color: var(--muted); font-size: 10px; } -.mini-footer span { display: inline-flex; align-items: center; gap: 5px; } -.mini-footer i { width: 6px; height: 6px; border-radius: 50%; background: #00b42a; box-shadow: 0 0 0 2px #e8ffea; } -.mini-footer button { border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: 10px; } - -.page-intro { - position: relative; - overflow: hidden; - border-radius: 16px; - background: linear-gradient(135deg, #165dff, #6aa1ff); - color: white; - padding: 18px; - box-shadow: 0 12px 28px rgba(22, 93, 255, 0.2); -} -.page-intro::after { content: ""; position: absolute; width: 130px; height: 130px; right: -45px; top: -52px; border: 24px solid rgba(255,255,255,.1); border-radius: 50%; } -.page-intro .arco-typography { color: rgba(255,255,255,.78); } -.page-intro h4 { margin: 4px 0 5px; color: white; } -.page-intro p { position: relative; z-index: 1; margin: 0; color: rgba(255,255,255,.86); font-size: 12px; line-height: 1.6; } - -.metric-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 12px; } -.metric { display: grid; gap: 2px; border: 1px solid var(--line); border-radius: 12px; background: white; padding: 12px; } -.metric strong { font-size: 22px; } -.metric span { color: var(--muted); font-size: 11px; } -.metric.blue strong { color: #165dff; }.metric.green strong { color: #00b42a; }.metric.purple strong { color: #722ed1; }.metric.orange strong { color: #ff7d00; } - -.section-title { justify-content: space-between; gap: 8px; margin: 18px 2px 9px; font-size: 13px; } -.quick-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 9px; } -.quick-actions button { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); border-radius: 12px; background: white; padding: 12px; text-align: left; cursor: pointer; } -.quick-actions button:hover { border-color: #94bfff; box-shadow: 0 5px 16px rgba(22,93,255,.1); } -.quick-actions svg { color: var(--blue); font-size: 20px; } -.quick-actions span, .compact-list span, .session-list span, .settings-card span { min-width: 0; display: grid; gap: 2px; } -.quick-actions small, .compact-list small, .session-list small, .settings-card small { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } - -.compact-list { overflow: hidden; border: 1px solid var(--line); border-radius: 12px; background: white; } -.compact-list > div { display: grid; grid-template-columns: 18px minmax(0,1fr); gap: 9px; align-items: center; padding: 10px 12px; border-bottom: 1px solid #f0f1f4; } -.compact-list > div:last-child { border-bottom: 0; } -.compact-list svg { color: var(--blue); } - -.page-title { justify-content: space-between; gap: 10px; margin-bottom: 13px; } -.page-title h5 { margin: 0 0 2px; } -.page-title .arco-typography { font-size: 11px; } -.filter-strip { display: flex; gap: 6px; overflow-x: auto; margin: 10px 0 12px; padding-bottom: 2px; scrollbar-width: none; } -.filter-strip button { flex: 0 0 auto; border: 1px solid var(--line); border-radius: 999px; background: white; color: var(--muted); padding: 4px 10px; cursor: pointer; font-size: 11px; } -.filter-strip button.active { border-color: #8eb7ff; background: #edf3ff; color: var(--blue); } - -.sticky-list, .note-grid { display: grid; gap: 9px; } -.task-note { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 9px; border: 1px solid #eadfac; border-radius: 4px 13px 13px 13px; background: #fffbea; padding: 13px 12px 12px 9px; box-shadow: 0 4px 14px rgba(78, 89, 105, .08); } -.task-note:nth-child(3n+2) { border-color: #cde5d7; background: #f0fff5; } -.task-note:nth-child(3n+3) { border-color: #d7def4; background: #f2f5ff; } -.task-note.completed { opacity: .64; } -.task-note.completed strong { text-decoration: line-through; } -.task-check { width: 27px; height: 27px; display: grid; place-items: center; border: 0; border-radius: 50%; background: rgba(255,255,255,.8); color: #8b9aab; cursor: pointer; } -.task-note:not(.completed) .task-check:hover, .task-note.completed .task-check { color: #00b42a; } -.task-note strong { font-size: 13px; } -.task-note p { margin: 5px 0; color: #4e5969; font-size: 11px; line-height: 1.5; } -.task-note span { color: var(--muted); font-size: 10px; } - -.hidden-file { display: none; } -.note-grid { grid-template-columns: repeat(2, minmax(0,1fr)); } -.source-note { min-height: 135px; display: flex; flex-direction: column; border: 1px solid #eadfac; border-radius: 4px 14px 14px 14px; background: #fff9d8; padding: 13px; } -.source-note.tone-1 { background: #eaf8ff; border-color: #c7e6f5; }.source-note.tone-2 { background: #f3edff; border-color: #ded0f7; }.source-note.tone-3 { background: #edfff3; border-color: #caead4; } -.source-note svg { color: #ff9a2e; font-size: 18px; } -.source-note strong { margin-top: 9px; font-size: 12px; } -.source-note p { flex: 1; margin: 5px 0; color: #4e5969; font-size: 10px; } -.source-note span { color: var(--muted); font-size: 9px; } - -.ai-view > .arco-input-wrapper { border-radius: 10px; } -.expert-strip { max-height: 210px; display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; overflow-y: auto; padding: 1px; } -.expert-strip button { min-width: 0; display: grid; grid-template-columns: 34px minmax(0,1fr); align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 10px; background: white; padding: 8px; text-align: left; cursor: pointer; } -.expert-strip button.active { border-color: #75a8ff; background: #f2f6ff; box-shadow: 0 0 0 1px #c8dcff; } -.expert-strip i, .selected-expert i, .session-list i { display: grid; place-items: center; border-radius: 9px; color: white; font-style: normal; } -.expert-strip i { width: 34px; height: 34px; font-size: 17px; } -.expert-strip span { min-width: 0; display: grid; } -.expert-strip b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; } -.expert-strip small { color: var(--muted); font-size: 9px; } - -.mini-composer { display: grid; gap: 8px; margin-top: 12px; border: 1px solid var(--line); border-radius: 14px; background: white; padding: 11px; box-shadow: 0 7px 22px rgba(29,33,41,.08); } -.selected-expert { min-height: 38px; gap: 9px; } -.selected-expert i { width: 36px; height: 36px; flex: 0 0 36px; } -.selected-expert span { min-width: 0; display: grid; } -.selected-expert b { font-size: 12px; } -.selected-expert small { overflow: hidden; color: var(--muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } -.mini-composer .arco-textarea-wrapper { border-radius: 9px; background: #f6f7f9; } -.session-list { display: grid; gap: 7px; } -.session-list > div { gap: 9px; border: 1px solid var(--line); border-radius: 10px; background: white; padding: 9px; } -.session-list i { width: 31px; height: 31px; } - -.settings-card { display: grid; gap: 10px; margin-bottom: 10px; border: 1px solid var(--line); border-radius: 13px; background: white; padding: 13px; } -.settings-card > strong { font-size: 13px; } -.settings-card > div { gap: 9px; border-top: 1px solid #f0f1f4; padding-top: 9px; } -.settings-card svg { color: var(--blue); } -.settings-card p { margin: 0; color: #4e5969; font-size: 11px; line-height: 1.65; } -.version-card { grid-template-columns: 34px minmax(0,1fr); } -.version-card img { width: 34px; } - -.mini-login { width: 100%; height: 100%; display: grid; place-items: center; overflow: auto; padding: 22px; background: linear-gradient(160deg, #edf4ff 0%, #e6ebf2 52%, #dce4ef 100%); } -.login-note { width: min(360px, 100%); display: grid; justify-items: stretch; gap: 13px; border: 1px solid rgba(255,255,255,.85); border-radius: 4px 22px 22px 22px; background: #fffbea; padding: 26px 22px 20px; box-shadow: 0 24px 60px rgba(29,33,41,.16); } -.login-note > img { width: 52px; } -.login-note h3 { margin: 0; } -.login-note label, .mini-form label { display: grid; gap: 5px; color: #4e5969; font-size: 11px; } -.login-note .arco-input-wrapper, .mini-form .arco-input-wrapper { border-radius: 9px; background: rgba(255,255,255,.82); } -.login-note > small { color: var(--muted); text-align: center; } -.mini-connection { display: grid; grid-template-columns: 9px 1fr auto; align-items: center; gap: 8px; margin-top: -5px; border: 1px solid #bedaff; border-radius: 9px; background: #f2f8ff; padding: 8px 10px; color: #165dff; font-size: 11px; } -.mini-connection > i { width: 8px; height: 8px; border-radius: 50%; background: currentColor; } -.mini-connection > strong { font-weight: 600; } -.mini-connection > button { border: 0; background: transparent; padding: 0; color: inherit; cursor: pointer; font-size: 11px; } -.mini-connection.online { border-color: #7be188; background: #f0fff4; color: #168a2f; } -.mini-connection.offline { border-color: #f53f3f; background: #fff2f0; color: #d91d35; } - -.mini-modal .arco-modal { width: min(380px, calc(100vw - 24px)); } -.mini-modal .arco-modal-content { border-radius: 15px; } -.mini-form { display: grid; gap: 13px; } -.mini-form .arco-select { width: 100%; } - -@media (max-width: 359px) { - .mini-brand .arco-tag { display: none; } - .note-grid, .expert-strip { grid-template-columns: 1fr; } -} +@media (max-width: 360px) { .profile-trigger > span { display: none; }.project-pill { max-width: 90px; }.app-main { padding: 13px 9px 20px; }.task-meta .arco-tag { max-width: 64px; overflow: hidden; }.content-heading h4 { font-size: 18px; } }