feat(mini): add dockable compact client
This commit is contained in:
13
apps/mini/README.md
Normal file
13
apps/mini/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 森林AI Mini
|
||||
|
||||
面向屏幕左右侧停靠场景的窄屏客户端。它使用与 `web_v1` 相同的后端 API,采用顶部导航和单列内容布局。
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run desktop:dev
|
||||
```
|
||||
|
||||
- Web 开发端口:`5180`
|
||||
- 桌面窗口默认尺寸:`420 × 820`
|
||||
- 左右停靠状态保存在本地;在 Tauri 桌面环境中会移动实际窗口。
|
||||
13
apps/mini/index.html
Normal file
13
apps/mini/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/senlinai-icon.svg" />
|
||||
<title>森林AI Mini</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2047
apps/mini/package-lock.json
generated
Normal file
2047
apps/mini/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
apps/mini/package.json
Normal file
32
apps/mini/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "senlinai-mini",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"visual-check": "node scripts/visual-check.mjs",
|
||||
"tauri": "tauri",
|
||||
"desktop:dev": "tauri dev",
|
||||
"desktop:build": "tauri build --no-bundle"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-react": "^2.66.16",
|
||||
"@tauri-apps/api": "^2.9.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.3",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"playwright": "^1.61.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
10
apps/mini/public/senlinai-icon.svg
Normal file
10
apps/mini/public/senlinai-icon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<title>森林AI 图标</title>
|
||||
<rect x="4" y="4" width="120" height="120" rx="28" fill="#165DFF"/>
|
||||
<path d="M64 23C43 23 27 40 27 61c0 13 7 24 17 30h40c10-6 17-17 17-30 0-21-16-38-37-38Z" fill="#F4F8FF"/>
|
||||
<path d="M64 99V65M64 76 47 61M64 82l18-16M64 68l12-13" fill="none" stroke="#F4F8FF" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
|
||||
<path d="M42 58c6-15 29-22 45-7M39 69c12-11 38-13 50 2M48 78c9-5 24-6 33-1" fill="none" stroke="#165DFF" stroke-linecap="round" stroke-width="5"/>
|
||||
<circle cx="47" cy="61" r="6" fill="#14B8A6"/>
|
||||
<circle cx="76" cy="55" r="6" fill="#60A5FA"/>
|
||||
<circle cx="82" cy="66" r="6" fill="#A7F3D0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 731 B |
111
apps/mini/scripts/visual-check.mjs
Normal file
111
apps/mini/scripts/visual-check.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
import { createServer } from 'vite'
|
||||
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 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: [],
|
||||
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: [] }],
|
||||
notesSources: [
|
||||
{ id: 'note-1', projectId, kind: 'note', title: '版本规划', updatedAt: '今天', tag: '产品', source: '项目笔记' },
|
||||
{ id: 'source-1', projectId, kind: 'file', title: '需求说明.pdf', updatedAt: '昨天', tag: '资料', source: '上传文件' },
|
||||
],
|
||||
cronPlans: [{ id: 'cron-1', projectId, title: '每周复盘', schedule: '0 17 * * 5', nextRun: null, enabled: true, lastResult: '', owner: '张明' }],
|
||||
}
|
||||
|
||||
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/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] })
|
||||
}
|
||||
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.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: '未配置的视觉检查接口' } } })
|
||||
})
|
||||
|
||||
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle' })
|
||||
await page.screenshot({ path: 'test-results/mini-login.png', fullPage: true })
|
||||
await page.locator('.login-note input').nth(1).fill('demo@senlin.ai')
|
||||
await page.locator('.login-note input').nth(2).fill('password123')
|
||||
await page.getByRole('button', { name: '连接工作台' }).click()
|
||||
await page.waitForSelector('.mini-shell')
|
||||
await page.waitForTimeout(400)
|
||||
await page.screenshot({ path: 'test-results/mini-home-right.png', fullPage: true })
|
||||
|
||||
const home = await page.evaluate(() => ({
|
||||
width: document.querySelector('.mini-shell')?.getBoundingClientRect().width,
|
||||
navCount: document.querySelectorAll('.mini-nav button').length,
|
||||
dock: document.documentElement.dataset.dock,
|
||||
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
title: document.querySelector('.page-intro h4')?.textContent ?? '',
|
||||
}))
|
||||
|
||||
await page.getByRole('button', { name: '停靠左侧' }).click()
|
||||
const leftDock = await page.evaluate(() => document.documentElement.dataset.dock)
|
||||
|
||||
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/mini-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.screenshot({ path: 'test-results/mini-ai.png', fullPage: true })
|
||||
|
||||
await browser.close()
|
||||
await server.close()
|
||||
|
||||
const failures = []
|
||||
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.dock !== 'right' || leftDock !== 'left') failures.push(`dock state failed: right=${home.dock}, left=${leftDock}`)
|
||||
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, leftDock, tasks, notes, sendEnabled, errors }, null, 2))
|
||||
if (failures.length) throw new Error(failures.join('\n'))
|
||||
4394
apps/mini/src-tauri/Cargo.lock
generated
Normal file
4394
apps/mini/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
apps/mini/src-tauri/Cargo.toml
Normal file
18
apps/mini/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "senlinai-mini"
|
||||
version = "1.0.0"
|
||||
description = "森林AI 迷你停靠客户端"
|
||||
authors = ["森林AI"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "senlinai_mini_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
3
apps/mini/src-tauri/build.rs
Normal file
3
apps/mini/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
BIN
apps/mini/src-tauri/icons/128x128.png
Normal file
BIN
apps/mini/src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
apps/mini/src-tauri/icons/128x128@2x.png
Normal file
BIN
apps/mini/src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
BIN
apps/mini/src-tauri/icons/32x32.png
Normal file
BIN
apps/mini/src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 926 B |
BIN
apps/mini/src-tauri/icons/icon.icns
Normal file
BIN
apps/mini/src-tauri/icons/icon.icns
Normal file
Binary file not shown.
BIN
apps/mini/src-tauri/icons/icon.ico
Normal file
BIN
apps/mini/src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
33
apps/mini/src-tauri/src/lib.rs
Normal file
33
apps/mini/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use tauri::{PhysicalPosition, Window};
|
||||
|
||||
#[tauri::command]
|
||||
fn dock_window(window: Window, side: String) -> Result<(), String> {
|
||||
let monitor = window
|
||||
.current_monitor()
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| "无法识别当前屏幕".to_string())?;
|
||||
let window_size = window.outer_size().map_err(|error| error.to_string())?;
|
||||
let monitor_position = monitor.position();
|
||||
let monitor_size = monitor.size();
|
||||
let x = if side == "left" {
|
||||
monitor_position.x
|
||||
} else {
|
||||
monitor_position.x + monitor_size.width as i32 - window_size.width as i32
|
||||
};
|
||||
|
||||
window
|
||||
.set_position(PhysicalPosition::new(x, monitor_position.y))
|
||||
.map_err(|error| error.to_string())?;
|
||||
window
|
||||
.set_always_on_top(true)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![dock_window])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("failed to run 森林AI Mini");
|
||||
}
|
||||
3
apps/mini/src-tauri/src/main.rs
Normal file
3
apps/mini/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
senlinai_mini_lib::run();
|
||||
}
|
||||
41
apps/mini/src-tauri/tauri.conf.json
Normal file
41
apps/mini/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "森林AI Mini",
|
||||
"version": "1.0.0",
|
||||
"identifier": "ai.senlin.mini",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5180",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "森林AI Mini",
|
||||
"width": 420,
|
||||
"height": 820,
|
||||
"minWidth": 360,
|
||||
"minHeight": 640,
|
||||
"resizable": true,
|
||||
"alwaysOnTop": true,
|
||||
"center": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' asset: https: data:; style-src 'self' 'unsafe-inline'; connect-src http://* https://*"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
384
apps/mini/src/App.tsx
Normal file
384
apps/mini/src/App.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Alert, Avatar, Button, Empty, Input, Message, Modal, Select, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||
import {
|
||||
IconApps, IconArrowLeft, IconArrowRight, IconCheckCircle, IconClockCircle,
|
||||
IconFile, IconFolder, IconPlus, IconRefresh, IconRobot, IconSearch, IconSettings,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import {
|
||||
ApiError, createAISession, createCron, createProject, createTask, fetchAISessions, fetchExperts,
|
||||
fetchProjects, fetchWorkspace, login, updateTask, uploadSource,
|
||||
type AISession, type ApiSession, type Expert, type Project, type Workspace,
|
||||
} from './api'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
type View = 'home' | 'tasks' | 'notes' | 'ai' | 'more'
|
||||
type DockSide = 'left' | 'right'
|
||||
type Action = 'project' | 'task' | 'cron' | null
|
||||
|
||||
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: 'ai', label: 'AI', icon: <IconRobot /> },
|
||||
{ id: 'more', label: '更多', icon: <IconSettings /> },
|
||||
]
|
||||
|
||||
export function App() {
|
||||
const [session, setSession] = useState<ApiSession | null>(() => readStoredSession())
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
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 [dockSide, setDockSide] = useState<DockSide>(() => localStorage.getItem('senlin-mini-dock') === 'left' ? 'left' : 'right')
|
||||
const [loading, setLoading] = useState(Boolean(session))
|
||||
const [error, setError] = useState('')
|
||||
const [action, setAction] = useState<Action>(null)
|
||||
const refreshGeneration = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.dock = dockSide
|
||||
localStorage.setItem('senlin-mini-dock', dockSide)
|
||||
void moveDesktopWindow(dockSide)
|
||||
}, [dockSide])
|
||||
|
||||
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 }
|
||||
}, [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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
async function dock(side: DockSide) {
|
||||
setDockSide(side)
|
||||
}
|
||||
|
||||
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">Mini 1.0</Tag>
|
||||
</div>
|
||||
<div className="window-actions">
|
||||
<Button aria-label="停靠左侧" className={dockSide === 'left' ? 'active' : ''} type="text" size="mini" icon={<IconArrowLeft />} onClick={() => void dock('left')} />
|
||||
<Button aria-label="停靠右侧" className={dockSide === 'right' ? 'active' : ''} type="text" size="mini" icon={<IconArrowRight />} onClick={() => void dock('right')} />
|
||||
</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.notesSources.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.notesSources.map((note, index) => <article className={`source-note tone-${index % 4}`} key={note.id}><IconFile /><strong>{note.title}</strong><p>{note.source || note.kind}</p><span>{note.tag || '资料'} · {note.updatedAt}</span></article>)}
|
||||
{!workspace.notesSources.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 Mini</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('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
return (
|
||||
<div className="mini-login">
|
||||
<div className="login-note">
|
||||
<img src="/senlinai-icon.svg" alt="" />
|
||||
<Title heading={3}>森林AI Mini</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} /> : null}
|
||||
<Button type="primary" long loading={loading} onClick={async () => { setLoading(true); setError(''); try { onLogin(await login(server, email, password)) } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) } }}>连接工作台</Button>
|
||||
<small>v1.0 完整版</small>
|
||||
</div>
|
||||
</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 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 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 errorMessage(error: unknown) {
|
||||
if (error instanceof ApiError || error instanceof Error) return error.message
|
||||
return '操作失败,请稍后重试'
|
||||
}
|
||||
|
||||
async function moveDesktopWindow(side: DockSide) {
|
||||
if (!('__TAURI_INTERNALS__' in window)) return
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
await invoke('dock_window', { side })
|
||||
} catch (requestError) {
|
||||
Message.warning(errorMessage(requestError))
|
||||
}
|
||||
}
|
||||
200
apps/mini/src/api.ts
Normal file
200
apps/mini/src/api.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
export type ApiSession = { baseUrl: string; token: string }
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
name: string
|
||||
identifier: string
|
||||
icon: string
|
||||
background: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type WorkspaceTask = {
|
||||
id: string
|
||||
projectId: string
|
||||
title: string
|
||||
summary: string
|
||||
completed: boolean
|
||||
owner: string
|
||||
due: string | null
|
||||
createdAt: string
|
||||
completedAt: string | null
|
||||
tagId: string | null
|
||||
tag: string
|
||||
}
|
||||
|
||||
export type NoteSource = {
|
||||
id: string
|
||||
projectId: string
|
||||
kind: string
|
||||
title: string
|
||||
updatedAt: string
|
||||
tag: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export type CronPlan = {
|
||||
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 }
|
||||
tags: Array<{ id: string; name: string }>
|
||||
tasks: WorkspaceTask[]
|
||||
notesSources: NoteSource[]
|
||||
aiSessions: Array<{ id: string; projectId: string; title: string; summary: string; updatedAt: string; references: string[] }>
|
||||
cronPlans: CronPlan[]
|
||||
}
|
||||
|
||||
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 class ApiError extends Error {
|
||||
constructor(public status: number, public code: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
let baseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9150')
|
||||
|
||||
export function configureApi(value: string) {
|
||||
baseUrl = normalizeBaseUrl(value)
|
||||
}
|
||||
|
||||
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 async function fetchProjects(session: ApiSession) {
|
||||
useSessionBase(session)
|
||||
return request<Project[]>('/api/v1/projects', { token: session.token })
|
||||
}
|
||||
|
||||
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
|
||||
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),
|
||||
signal: options.signal,
|
||||
})
|
||||
} catch {
|
||||
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 ?? '请求失败,请稍后重试')
|
||||
}
|
||||
if (response.status === 204) return undefined as T
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
function useSessionBase(session: ApiSession) {
|
||||
baseUrl = normalizeBaseUrl(session.baseUrl)
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string) {
|
||||
return value.trim().replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value.toLowerCase().trim().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
14
apps/mini/src/main.tsx
Normal file
14
apps/mini/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { ConfigProvider } from '@arco-design/web-react'
|
||||
import '@arco-design/web-react/dist/css/arco.css'
|
||||
import { App } from './App'
|
||||
import './styles.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
243
apps/mini/src/styles.css
Normal file
243
apps/mini/src/styles.css
Normal file
@@ -0,0 +1,243 @@
|
||||
: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;
|
||||
}
|
||||
|
||||
* { 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; }
|
||||
|
||||
.mini-shell {
|
||||
width: min(100%, 440px);
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 44px 50px 58px minmax(0, 1fr) 28px;
|
||||
overflow: hidden;
|
||||
background: var(--canvas);
|
||||
border: 1px solid rgba(78, 89, 105, 0.18);
|
||||
box-shadow: 0 20px 60px rgba(29, 33, 41, 0.18);
|
||||
}
|
||||
|
||||
html[data-dock="right"] .mini-shell { margin-left: auto; }
|
||||
html[data-dock="left"] .mini-shell { margin-right: auto; }
|
||||
|
||||
.mini-header,
|
||||
.project-switcher,
|
||||
.mini-nav,
|
||||
.mini-footer {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.mini-brand,
|
||||
.window-actions,
|
||||
.project-trigger,
|
||||
.mini-footer,
|
||||
.section-title,
|
||||
.page-title,
|
||||
.selected-expert,
|
||||
.session-list > div,
|
||||
.settings-card > div,
|
||||
.version-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mini-brand { gap: 7px; font-weight: 800; }
|
||||
.mini-brand img { width: 25px; height: 25px; }
|
||||
.mini-brand .arco-tag { margin-left: 2px; }
|
||||
.window-actions { gap: 2px; }
|
||||
.window-actions .arco-btn { color: #6b778c; }
|
||||
.window-actions .active { color: var(--blue); background: #edf3ff; }
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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-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 (min-width: 700px) {
|
||||
body { display: flex; }
|
||||
html[data-dock="right"] body { justify-content: flex-end; }
|
||||
html[data-dock="left"] body { justify-content: flex-start; }
|
||||
}
|
||||
|
||||
@media (max-width: 359px) {
|
||||
.mini-shell { border: 0; }
|
||||
.mini-brand .arco-tag { display: none; }
|
||||
.note-grid, .expert-strip { grid-template-columns: 1fr; }
|
||||
}
|
||||
1
apps/mini/src/vite-env.d.ts
vendored
Normal file
1
apps/mini/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
apps/mini/tsconfig.app.json
Normal file
21
apps/mini/tsconfig.app.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
apps/mini/tsconfig.json
Normal file
7
apps/mini/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
15
apps/mini/tsconfig.node.json
Normal file
15
apps/mini/tsconfig.node.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
12
apps/mini/vite.config.ts
Normal file
12
apps/mini/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5180,
|
||||
strictPort: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user