feat(desktop): align docked workspace with web client

This commit is contained in:
2026-07-25 21:52:19 +08:00
parent b2afa4edf8
commit 4b3f0d9167
5 changed files with 426 additions and 807 deletions

44
apps/desktop/design-qa.md Normal file
View File

@@ -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 sources 75 × 343 px rail crop.
- Right: the implementations 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 sources 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 products 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

View File

@@ -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'))

View File

@@ -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: <IconApps /> },
{ id: 'tasks', label: '计划', icon: <IconCheckCircle /> },
{ id: 'notes', label: '资料', icon: <IconFile /> },
{ id: 'overview', label: '概况', icon: <IconApps /> },
{ id: 'tasks', label: '计划', icon: <IconList /> },
{ id: 'inbox', label: '收集箱', icon: <IconEmail /> },
{ id: 'documents', label: '资料', icon: <IconFile /> },
{ id: 'ai', label: 'AI', icon: <IconRobot /> },
{ id: 'more', label: '更多', icon: <IconSettings /> },
{ id: 'cron', label: '周期计划', icon: <IconClockCircle /> },
]
export function App() {
const [session, setSession] = useState<ApiSession | null>(() => readStoredSession())
const stored = useMemo(readStoredSession, [])
const [session, setSession] = useState<ApiSession | null>(stored)
const [user, setUser] = useState<CurrentUser | null>(stored?.user ?? null)
const [projects, setProjects] = useState<Project[]>([])
const [workspaces, setWorkspaces] = useState<Record<string, Workspace>>({})
const [projectId, setProjectId] = useState('')
const [workspace, setWorkspace] = useState<Workspace | null>(null)
const [experts, setExperts] = useState<Expert[]>([])
const [aiSessions, setAISessions] = useState<AISession[]>([])
const [view, setView] = useState<View>('home')
const [loading, setLoading] = useState(Boolean(session))
const [view, setView] = useState<View>('overview')
const [dark, setDark] = useState(false)
const [loading, setLoading] = useState(Boolean(stored))
const [error, setError] = useState('')
const [action, setAction] = useState<Action>(null)
const refreshGeneration = useRef(0)
const [taskEditor, setTaskEditor] = useState<TaskWithProject | null>(null)
const [projectModalOpen, setProjectModalOpen] = useState(false)
const [profileModalOpen, setProfileModalOpen] = useState(false)
const [experts, setExperts] = useState<Expert[]>([])
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 <Login onLogin={(next) => { localStorage.setItem('senlin-mini-session', JSON.stringify(next)); setSession(next) }} />
const activeProject = projects.find((project) => project.id === projectId) ?? projects[0]
const content = !workspace || !activeProject
? <Empty description="还没有项目,点击右上角新建" />
: renderView(view, {
session, workspace, experts, aiSessions,
refresh: () => refreshProject(),
openAction: setAction,
})
return (
<div className="mini-shell">
<header className="mini-header" data-tauri-drag-region>
<div className="mini-brand" data-tauri-drag-region>
<img src="/senlinai-icon.svg" alt="" />
<span>AI</span>
<Tag size="small" color="arcoblue">App 1.0</Tag>
</div>
</header>
<div className="project-switcher">
<Select
value={projectId || undefined}
placeholder="选择项目"
onChange={setProjectId}
triggerElement={() => (
<button className="project-trigger">
<Avatar size={26} style={{ background: activeProject?.background || '#165DFF' }}>{projectMark(activeProject)}</Avatar>
<span>{activeProject?.name || '选择项目'}</span>
<small>{activeProject?.identifier || ''}</small>
</button>
)}
>
{projects.map((project) => <Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>)}
</Select>
<Button aria-label="刷新" type="text" shape="circle" icon={<IconRefresh />} loading={loading} onClick={() => void refreshProject()} />
<Button aria-label="新建项目" type="text" shape="circle" icon={<IconPlus />} onClick={() => setAction('project')} />
</div>
<nav className="mini-nav" aria-label="主要导航">
{navItems.map((item) => (
<button className={view === item.id ? 'active' : ''} key={item.id} onClick={() => setView(item.id)}>
{item.icon}<span>{item.label}</span>
</button>
))}
</nav>
{error ? <Alert className="mini-alert" type="error" content={error} closable onClose={() => setError('')} /> : null}
<main className="mini-content"><Spin loading={loading} block>{content}</Spin></main>
<footer className="mini-footer">
<span><i /> </span>
<button onClick={() => { localStorage.removeItem('senlin-mini-session'); setSession(null); setWorkspace(null) }}>退</button>
</footer>
<ActionModal
action={action}
workspace={workspace}
onClose={() => 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()
}}
/>
</div>
)
}
function renderView(view: View, props: ViewProps) {
if (view === 'tasks') return <TasksView {...props} />
if (view === 'notes') return <NotesView {...props} />
if (view === 'ai') return <AIView {...props} />
if (view === 'more') return <MoreView {...props} />
return <HomeView {...props} />
}
type ViewProps = {
session: ApiSession
workspace: Workspace
experts: Expert[]
aiSessions: AISession[]
refresh: () => void | Promise<void>
openAction: (action: Action) => void
}
function HomeView({ workspace, openAction }: ViewProps) {
const pending = workspace.tasks.filter((task) => !task.completed)
return (
<section className="mini-page home-view">
<div className="page-intro">
<Text type="secondary"></Text>
<Title heading={4}>{workspace.project.name}</Title>
<p>{workspace.project.description || '把计划、资料与 AI 会话放进同一个项目上下文。'}</p>
</div>
<div className="metric-grid">
<Metric value={pending.length} label="待办计划" color="blue" />
<Metric value={workspace.documents.length} label="笔记资料" color="green" />
<Metric value={workspace.aiSessions.length} label="AI 会话" color="purple" />
<Metric value={workspace.cronPlans.length} label="计划任务" color="orange" />
</div>
<div className="section-title"><strong></strong></div>
<div className="quick-actions">
<button onClick={() => openAction('task')}><IconPlus /><span><b></b><small></small></span></button>
<button onClick={() => openAction('cron')}><IconClockCircle /><span><b></b><small></small></span></button>
</div>
<div className="section-title"><strong></strong><Tag color="arcoblue">{pending.length}</Tag></div>
<div className="compact-list">
{pending.slice(0, 4).map((task) => <div key={task.id}><IconCheckCircle /><span><b>{task.title}</b><small>{task.tag || '未分类'}</small></span></div>)}
{!pending.length && <Empty description="当前没有未完成计划" />}
</div>
</section>
)
}
function Metric({ value, label, color }: { value: number; label: string; color: string }) {
return <div className={`metric ${color}`}><strong>{value}</strong><span>{label}</span></div>
}
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 (
<section className="mini-page">
<div className="page-title"><div><Title heading={5}></Title><Text type="secondary"></Text></div><Button type="primary" size="small" icon={<IconPlus />} onClick={() => openAction('task')}></Button></div>
<div className="filter-strip">
{['全部', ...tags.map((item) => item.name)].map((item) => <button className={tag === item ? 'active' : ''} key={item} onClick={() => setTag(item)}>{item}</button>)}
</div>
<div className="sticky-list">
{tasks.map((task) => (
<article className={task.completed ? 'task-note completed' : 'task-note'} key={task.id}>
<button className="task-check" aria-label={task.completed ? '恢复计划' : '完成计划'} onClick={async () => { await updateTask(session, workspace.project.id, task); await refresh() }}><IconCheckCircle /></button>
<div><strong>{task.title}</strong><p>{task.summary || '暂无说明'}</p><span>{task.tag || '未分类'} · {task.createdAt}</span></div>
</article>
))}
{!tasks.length && <Empty description="当前筛选下没有计划" />}
</div>
</section>
)
}
function NotesView({ session, workspace, refresh }: ViewProps) {
const fileInput = useRef<HTMLInputElement>(null)
return (
<section className="mini-page">
<div className="page-title"><div><Title heading={5}></Title><Text type="secondary"></Text></div><Button type="primary" size="small" icon={<IconPlus />} onClick={() => fileInput.current?.click()}></Button></div>
<input className="hidden-file" ref={fileInput} type="file" onChange={async (event) => { const file = event.target.files?.[0]; if (!file) return; await uploadSource(session, workspace.project.id, file); await refresh(); event.target.value = '' }} />
<div className="note-grid">
{workspace.documents.map((document, index) => <article className={`source-note tone-${index % 4}`} key={document.id}><IconFile /><strong>{document.name}</strong><p>{document.extension || document.mimeType || document.kind}</p><span>{document.kind || '资料'} · {document.updatedAt}</span></article>)}
{!workspace.documents.length && <Empty description="还没有笔记或资料" />}
</div>
</section>
)
}
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 (
<section className="mini-page ai-view">
<div className="page-title"><div><Title heading={5}>AI </Title><Text type="secondary"></Text></div><Tag color="purple">{experts.length} </Tag></div>
<Input prefix={<IconSearch />} value={query} onChange={setQuery} allowClear placeholder="搜索专家" />
<div className="filter-strip">
{['全部', ...categories].map((item) => <button className={category === item ? 'active' : ''} key={item} onClick={() => setCategory(item)}>{item}</button>)}
</div>
<div className="expert-strip">
{visible.slice(0, 30).map((expert) => <button className={expertId === expert.id ? 'active' : ''} key={expert.id} onClick={() => setExpertId(expert.id)}><i style={{ background: expert.color }}>{expert.emoji}</i><span><b>{expert.name}</b><small>{expert.categoryName}</small></span></button>)}
</div>
<div className="mini-composer">
<div className="selected-expert">{selected ? <><i style={{ background: selected.color }}>{selected.emoji}</i><span><b>{selected.name}</b><small>{selected.description}</small></span></> : <Text type="secondary"></Text>}</div>
<Input.TextArea value={prompt} onChange={setPrompt} autoSize={{ minRows: 3, maxRows: 6 }} placeholder={selected ? `${selected.name}发送消息` : '请先选择专家'} />
<Button type="primary" long loading={sending} disabled={!selected || !prompt.trim()} onClick={async () => { if (!selected) return; setSending(true); try { await createAISession(session, workspace.project.id, selected.id, prompt.trim()); setPrompt(''); await refresh() } finally { setSending(false) } }}></Button>
</div>
<div className="section-title"><strong></strong></div>
<div className="session-list">{aiSessions.slice(0, 5).map((item) => <div key={item.id}><i style={{ background: item.expert?.color }}>{item.expert?.emoji || '🤖'}</i><span><b>{item.title}</b><small>{item.expert?.name || '森林AI'}</small></span></div>)}</div>
</section>
)
}
function MoreView({ workspace, openAction }: ViewProps) {
return (
<section className="mini-page">
<div className="page-title"><div><Title heading={5}></Title><Text type="secondary"></Text></div><Button size="small" icon={<IconPlus />} onClick={() => openAction('cron')}></Button></div>
<div className="settings-card"><strong></strong>{workspace.cronPlans.map((plan) => <div key={plan.id}><IconClockCircle /><span><b>{plan.title}</b><small>{plan.schedule} · {plan.enabled ? '已启用' : '已停用'}</small></span></div>)}{!workspace.cronPlans.length && <Text type="secondary"></Text>}</div>
<div className="settings-card"><strong></strong><p>Web </p></div>
<div className="settings-card version-card"><img src="/senlinai-icon.svg" alt="" /><span><b>AI App</b><small>v1.0 · </small></span></div>
</section>
)
}
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<ConnectionStatus>('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 <Login onLogin={(next) => { setSession(next); setUser(next.user); persistSession(next) }} />
return (
<div className="mini-login">
<form className="login-note" onSubmit={(event) => { event.preventDefault(); void submitLogin() }}>
<img src="/senlinai-icon.svg" alt="" />
<Title heading={3}>AI App</Title>
<Text type="secondary"></Text>
<label><Input value={server} onChange={setServer} /></label>
<div className={`mini-connection ${connection}`} role="status">
<i />
<strong>{connection === 'checking' ? '正在检查' : connection === 'online' ? '连接正常' : '连接异常'}</strong>
<button type="button" onClick={() => void checkConnection()}>{connection === 'checking' ? '检查中' : '重新检查'}</button>
<div className={`app-shell ${dark ? 'theme-dark' : ''}`}>
<header className="app-header">
<div className="app-brand" data-tauri-drag-region><img src="/senlinai-icon.svg" alt="" /><span>AI</span></div>
<div className="header-actions">
<Button type="text" shape="circle" aria-label={dark ? '切换浅色模式' : '切换深色模式'} icon={dark ? <IconSun /> : <IconMoon />} onClick={() => setDark((value) => !value)} />
<ProfileMenu user={user} onProfile={() => setProfileModalOpen(true)} onLogout={() => logout()} />
</div>
<label><Input value={email} onChange={setEmail} /></label>
<label><Input.Password value={password} onChange={setPassword} /></label>
{error ? <Alert type="error" content={error} /> : null}
<Button type="primary" htmlType="submit" long loading={loading}></Button>
<small>v1.0 </small>
</form>
</header>
<section className="project-strip" aria-label="项目列表">
<button className={!projectId ? 'project-pill active' : 'project-pill'} onClick={() => setProjectId('')}><IconApps /><span></span></button>
<div className="project-scroller">
{projects.map((project) => <button key={project.id} className={projectId === project.id ? 'project-pill active' : 'project-pill'} onClick={() => setProjectId(project.id)}><i style={{ background: project.background || '#165DFF' }}>{projectLabel(project)}</i><span>{project.name}</span></button>)}
</div>
<Button type="text" size="mini" shape="circle" aria-label="上一个项目" icon={<IconLeft />} onClick={() => cycleProject(-1)} />
<Button type="text" size="mini" shape="circle" aria-label="下一个项目" icon={<IconRight />} onClick={() => cycleProject(1)} />
<Button type="primary" size="mini" shape="circle" aria-label="新建项目" icon={<IconPlus />} onClick={() => setProjectModalOpen(true)} />
</section>
{error ? <Alert className="app-alert" type="error" content={error} closable onClose={() => setError('')} /> : null}
<main className="app-main"><Spin loading={loading} block>{visibleWorkspaces.length ? <Content view={view} title={title} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onLoadAI={loadAI} experts={experts} /> : <Empty description="还没有项目,点击右上方加号创建一个项目" />}</Spin></main>
<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>)}
</aside>
<TaskEditor task={taskEditor} tags={visibleWorkspaces.flatMap((workspace) => workspace.tags.map((tag) => tag.name))} onClose={() => setTaskEditor(null)} onSave={async (draft) => {
if (!taskEditor) return
await updateTask(session, taskEditor.projectId, taskEditor.id, { title: draft.title, description: draft.summary, tag: draft.tag, completed: draft.completed })
await loadWorkspaces()
setTaskEditor(null)
}} />
<ProjectModal visible={projectModalOpen} onClose={() => 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)
}} />
<ProfileModal user={user} visible={profileModalOpen} onClose={() => setProfileModalOpen(false)} onSave={async (input) => {
const nextUser = await updateCurrentUser(session, input)
setUser(nextUser)
persistSession({ ...session, user: nextUser })
setProfileModalOpen(false)
}} />
</div>
)
}
function ActionModal({ action, workspace, onClose, onProject, onTask, onCron }: {
action: Action
workspace: Workspace | null
onClose: () => void
onProject: (name: string) => Promise<void>
onTask: (title: string, tag: string) => Promise<void>
onCron: (title: string, schedule: string) => Promise<void>
}) {
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 (
<Modal className="mini-modal" visible={Boolean(action)} title={labels[0]} onCancel={onClose} footer={null} unmountOnExit>
<div className="mini-form">
<label>{labels[1]}<Input autoFocus value={title} onChange={setTitle} /></label>
{action === 'task' ? <label><Select value={tag || undefined} placeholder="可选" onChange={setTag}>{workspace?.tags.filter((item) => item.name !== 'all' && item.name !== '全部').map((item) => <Select.Option key={item.id} value={item.name}>{item.name}</Select.Option>)}</Select></label> : null}
{action === 'cron' ? <label>Cron <Input value={schedule} onChange={setSchedule} /></label> : null}
<Button type="primary" long loading={loading} disabled={!title.trim()} onClick={async () => { setLoading(true); try { if (action === 'project') await onProject(title.trim()); if (action === 'task') await onTask(title.trim(), tag); if (action === 'cron') await onCron(title.trim(), schedule.trim()); onClose() } catch (requestError) { Message.error(errorMessage(requestError)) } finally { setLoading(false) } }}></Button>
</div>
</Modal>
)
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 <TaskBoard title={view === 'overview' ? '待办任务' : '工作计划'} subtitle={title} tasks={tasks} onEditTask={onEditTask} />
if (view === 'inbox') return <InboxPanel title={title} workspaces={workspaces} />
if (view === 'documents') return <DocumentPanel title={title} workspaces={workspaces} />
if (view === 'ai') return <AIPanel title={title} workspaces={workspaces} experts={experts} onLoad={onLoadAI} />
return <CronPanel title={title} workspaces={workspaces} />
}
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 <section className="content-page task-page">
<div className="content-heading"><div><Text type="secondary">{subtitle}</Text><Title heading={4}>{title}</Title></div><Tag color="arcoblue">{pending.length} </Tag></div>
<div className="tag-filter"><button className={tag === '全部' ? 'active' : ''} onClick={() => setTag('全部')}></button>{tags.map((item) => <button key={item} className={tag === item ? 'active' : ''} onClick={() => setTag(item)}>{item}</button>)}</div>
<section className="task-section"><div className="section-label"><strong></strong><span>{pending.length}</span></div><div className="task-list">{pending.map((task) => <TaskRow key={task.id} task={task} onClick={() => onEditTask(task)} />)}{!pending.length && <Empty description="当前没有待办任务" />}</div></section>
<section className="task-section completed-section"><div className="section-label"><strong></strong><span>{completed.length}</span></div><div className="task-list">{completed.map((task) => <TaskRow key={task.id} task={task} onClick={() => onEditTask(task)} />)}{!completed.length && <Empty description="尚未完成任何任务" />}</div></section>
</section>
}
function projectMark(project?: Project) {
if (!project) return <IconFolder />
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 <button className={`task-row ${task.completed ? 'done' : ''}`} onClick={onClick}><span className="task-check">{task.completed ? <IconCheckCircleFill /> : <IconCheckCircle />}</span><span className="task-copy"><strong>{task.title}</strong><small>{task.summary || '暂无任务说明'}</small></span><span className="task-meta"><Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag || task.projectName}</Tag><small>{formatDate(task.completedAt || task.createdAt)}</small></span></button>
}
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 <Panel title="收集箱" subtitle={title}>{items.map((item) => <article className="detail-row" key={item.id}><IconEmail /><div><strong>{item.title}</strong><p>{item.summary || item.source}</p></div><Tag>{item.projectName}</Tag></article>)}{!items.length && <Empty description="收集箱为空" />}</Panel>
}
function DocumentPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) {
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>
}
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 <Panel title="AI 会话" subtitle={title}><div className="expert-chips">{experts.slice(0, 6).map((expert) => <span key={expert.id}><i style={{ background: expert.color }}>{expert.emoji}</i>{expert.name}</span>)}</div>{sessions.map((item) => <article className="detail-row" key={item.id}><IconRobot /><div><strong>{item.title}</strong><p>{item.summary || 'AI 会话'}</p></div><Tag>{item.projectName}</Tag></article>)}{!sessions.length && <Empty description="暂无 AI 会话" />}</Panel>
}
function CronPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) {
const plans = workspaces.flatMap((workspace) => workspace.cronPlans.map((item) => ({ ...item, projectName: workspace.project.name })))
return <Panel title="周期计划" subtitle={title}>{plans.map((item) => <article className="detail-row" key={item.id}><IconClockCircle /><div><strong>{item.title}</strong><p>{item.schedule} · {item.enabled ? '已启用' : '已停用'}</p></div><Tag>{item.projectName}</Tag></article>)}{!plans.length && <Empty description="暂无周期计划" />}</Panel>
}
function Panel({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) { return <section className="content-page"><div className="content-heading"><div><Text type="secondary">{subtitle}</Text><Title heading={4}>{title}</Title></div></div><div className="detail-list">{children}</div></section> }
function ProfileMenu({ user, onProfile, onLogout }: { user: CurrentUser | null; onProfile: () => void; onLogout: () => void }) {
const menu = <Menu><Menu.Item key="profile" onClick={onProfile}></Menu.Item><Menu.Item key="logout" onClick={onLogout}>退</Menu.Item></Menu>
return <Dropdown droplist={menu} position="br" trigger="click"><button className="profile-trigger"><Avatar size={28}>{user?.displayName.slice(0, 1) || <IconUser />}</Avatar><span>{user?.displayName || '用户'}</span><IconDown /></button></Dropdown>
}
function TaskEditor({ task, tags, onClose, onSave }: { task: TaskWithProject | null; tags: string[]; onClose: () => void; onSave: (draft: { title: string; summary: string; tag: string; completed: boolean }) => Promise<void> }) {
const [title, setTitle] = useState(''); const [summary, setSummary] = useState(''); const [tag, setTag] = useState(''); const [completed, setCompleted] = useState(false); const [saving, setSaving] = useState(false)
useEffect(() => { if (task) { setTitle(task.title); setSummary(task.summary); setTag(task.tag); setCompleted(task.completed) } }, [task])
return <Modal title="编辑任务" visible={Boolean(task)} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!title.trim()) return; setSaving(true); try { await onSave({ title: title.trim(), summary: summary.trim(), tag, completed }) } finally { setSaving(false) } }}><Form layout="vertical"><Form.Item label="标题" required><Input value={title} onChange={setTitle} /></Form.Item><Form.Item label="说明"><Input.TextArea value={summary} onChange={setSummary} autoSize={{ minRows: 3, maxRows: 6 }} /></Form.Item><Form.Item label="标签"><Select value={tag || undefined} allowClear onChange={(value) => setTag(value ?? '')}>{[...new Set(tags.filter(Boolean))].map((item) => <Select.Option key={item} value={item}>{item}</Select.Option>)}</Select></Form.Item><Form.Item label="完成状态"><Switch checked={completed} checkedText="已完成" uncheckedText="待办" onChange={setCompleted} /></Form.Item></Form></Modal>
}
function ProjectModal({ visible, onClose, onSave }: { visible: boolean; onClose: () => void; onSave: (name: string) => Promise<void> }) {
const [name, setName] = useState(''); const [saving, setSaving] = useState(false)
return <Modal title="新建项目" visible={visible} onCancel={onClose} confirmLoading={saving} onOk={async () => { if (!name.trim()) return; setSaving(true); try { await onSave(name.trim()); setName('') } finally { setSaving(false) } }}><Input autoFocus placeholder="项目名称" value={name} onChange={setName} /></Modal>
}
function ProfileModal({ user, visible, onClose, onSave }: { user: CurrentUser | null; visible: boolean; onClose: () => void; onSave: (input: { displayName: string; currentPassword?: string; newPassword?: string }) => Promise<void> }) {
const [displayName, setDisplayName] = useState(user?.displayName ?? ''); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [saving, setSaving] = useState(false)
useEffect(() => setDisplayName(user?.displayName ?? ''), [user])
return <Modal title="修改资料" visible={visible} onCancel={onClose} confirmLoading={saving} onOk={async () => { setSaving(true); try { await onSave({ displayName, currentPassword: currentPassword || undefined, newPassword: newPassword || undefined }); setCurrentPassword(''); setNewPassword('') } finally { setSaving(false) } }}><Form layout="vertical"><Form.Item label="名称" required><Input value={displayName} onChange={setDisplayName} /></Form.Item><Form.Item label="邮箱"><Input disabled value={user?.email} /></Form.Item><Form.Item label="当前密码"><Input.Password value={currentPassword} onChange={setCurrentPassword} placeholder="修改密码时必填" /></Form.Item><Form.Item label="新密码"><Input.Password value={newPassword} onChange={setNewPassword} placeholder="不修改可留空" /></Form.Item></Form></Modal>
}
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 <div className="login-page"><form className="login-card" onSubmit={(event) => { event.preventDefault(); void submit() }}><img src="/senlinai-icon.svg" alt="" /><Title heading={3}>AI App</Title><Text type="secondary"></Text><label><Input value={server} onChange={setServer} /></label><label><Input value={email} onChange={setEmail} /></label><label><Input.Password value={password} onChange={setPassword} /></label>{error && <Alert type="error" content={error} />}<Button type="primary" htmlType="submit" long loading={loading}></Button></form></div>
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() || <IconFolder /> }
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 : '操作失败,请稍后重试' }

View File

@@ -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<ApiSession & { user: CurrentUser }> {
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<ApiSession> {
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<CurrentUser>('/api/v1/auth/me', { token: session.token })
}
export function updateCurrentUser(session: ApiSession, input: { displayName: string; currentPassword?: string; newPassword?: string }) {
useSessionBase(session)
return apiRequest<CurrentUser>('/api/v1/auth/me', { method: 'PATCH', token: session.token, body: input })
}
export function fetchProjects(session: ApiSession) {
useSessionBase(session)
return apiRequest<Project[]>('/api/v1/projects', { token: session.token })
}
export function fetchProjectWorkspace(session: ApiSession, projectId: string) {
useSessionBase(session)
return apiRequest<Workspace>(`/api/v1/projects/${projectId}/workspace`, { token: session.token })
}
export function createProject(session: ApiSession, input: Pick<Project, 'name' | 'identifier' | 'icon' | 'background' | 'description'>) {
useSessionBase(session)
return apiRequest<Project>('/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<Expert[]>('/api/v1/ai-experts', { token: session.token })
}
export function listAISessions(session: ApiSession, projectId: string) {
useSessionBase(session)
return apiRequest<AISession[]>(`/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<AISession>(`/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<Project[]>('/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<Workspace>(`/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<Project>('/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<Expert[]>('/api/v1/ai-experts', { token: session.token })
}
export async function fetchAISessions(session: ApiSession, projectId: string) {
useSessionBase(session)
return request<AISession[]>(`/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<AISession>(`/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<T>(path: string, options: RequestOptions = {}): Promise<T> {
const isForm = options.body instanceof FormData
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
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<T>
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 ? '服务器暂时无法处理请求,请稍后重试' : '请求失败,请稍后重试' }

View File

@@ -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; } }