feat: align controlled AI sessions and MVP controls

This commit is contained in:
2026-07-21 19:19:36 +08:00
parent 9fa6744516
commit 82829645c2
19 changed files with 850 additions and 465 deletions

View File

@@ -24,6 +24,7 @@ const requiredFiles = [
'src/api/mappers.tsx',
'src/api/search.ts',
'src/api/inbox.ts',
'src/api/ai.ts',
'scripts/api-client.test.mjs',
]
@@ -59,7 +60,7 @@ if (existsSync('src/app/App.tsx')) {
}
const apiFiles = existsSync('src/api')
? ['client.ts', 'projects.ts', 'mappers.tsx', 'search.ts', 'inbox.ts']
? ['client.ts', 'projects.ts', 'mappers.tsx', 'search.ts', 'inbox.ts', 'ai.ts']
.map((name) => `src/api/${name}`)
.filter((file) => existsSync(file))
: []
@@ -112,6 +113,48 @@ if (!inboxSource.includes('body: { suggestionIds }')) failures.push('inbox confi
const inboxPageSource = existsSync('src/pages/projects/project-inbox.tsx') ? readFileSync('src/pages/projects/project-inbox.tsx', 'utf8') : ''
if (inboxPageSource && !inboxPageSource.includes('确认创建')) failures.push('project inbox must expose the single confirmation action')
const aiApiSource = existsSync('src/api/ai.ts') ? readFileSync('src/api/ai.ts', 'utf8') : ''
for (const required of ['/api/v1/projects/', '/ai-sessions', 'listAISessions', 'createAISession']) {
if (!aiApiSource.includes(required)) failures.push(`AI API must include ${required}`)
}
if (/\b(?:task|note|source)Id\b/.test(aiApiSource)) failures.push('AI session responses must not expose automatic formal object IDs')
const aiPageSource = existsSync('src/pages/projects/project-ai.tsx') ? readFileSync('src/pages/projects/project-ai.tsx', 'utf8') : ''
for (const required of ['AI 助手', '创建会话', 'loading', 'error']) {
if (!aiPageSource.includes(required)) failures.push(`project AI page must include ${required}`)
}
for (const forbidden of ['DeepSeek V4.0 Flash', '给 DeepSeek 发送消息', 'IconAttachment', 'agent-send-button']) {
if (aiPageSource.includes(forbidden)) failures.push(`project AI page contains unsupported chat control ${forbidden}`)
}
const unsupportedControls = [
{
file: 'src/pages/workspace-explore.tsx',
required: '暂未开放',
forbidden: ['同步数据源', '添加数据源', 'IconRefresh', 'IconEdit', 'IconDelete'],
},
{
file: 'src/pages/projects/project-new-channel.tsx',
required: '暂未开放',
forbidden: ['保存频道', '<Input', '<Select', '<TextArea'],
},
{
file: 'src/pages/projects/project-statusbar.tsx',
forbidden: ['升级', '支付', 'billing-plan', '152GB', 'AI 空闲'],
},
{
file: 'src/pages/projects/project-topbar.tsx',
forbidden: ['停靠左边', '停靠右边', 'DockIcon', 'isDesktopRuntime'],
},
]
for (const check of unsupportedControls) {
const source = existsSync(check.file) ? readFileSync(check.file, 'utf8') : ''
if (check.required && !source.includes(check.required)) failures.push(`${check.file} must show ${check.required}`)
for (const forbidden of check.forbidden) {
if (source.includes(forbidden)) failures.push(`${check.file} contains unsupported control ${forbidden}`)
}
}
const html = readFileSync('index.html', 'utf8')
if (!html.includes('<html lang="zh-CN">')) failures.push('index language must be zh-CN')
if (!html.includes('<title>森林AI</title>')) failures.push('document title must be 森林AI')

View File

@@ -31,6 +31,8 @@ const secondInboxNoteSuggestionId = '019b0000-0000-7000-8000-000000000010'
const secondInboxSourceSuggestionId = '019b0000-0000-7000-8000-000000000011'
const thirdInboxItemId = '019b0000-0000-7000-8000-000000000013'
const thirdInboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000014'
const aiSessionId = '019b0000-0000-7000-8000-000000000015'
const createdAISessionId = '019b0000-0000-7000-8000-000000000016'
const unknownProjectId = '019b0000-0000-7000-8000-000000000099'
const externalProjectId = '019b0000-0000-7000-8000-000000000088'
const externalTaskId = '019b0000-0000-7000-8000-000000000089'
@@ -40,6 +42,7 @@ const workspaceRequests = []
const projectPatchRequests = []
const inboxAnalyzeRequests = []
const inboxConfirmRequests = []
const aiSessionRequests = []
let expectingProjectPatchError = false
let expectedProjectPatchConsoleErrorCount = 0
let expectingInboxConfirmError = false
@@ -55,6 +58,17 @@ let failNextInboxWorkspaceRefresh = false
let conflictNextInboxConfirm = false
let validationNextInboxConfirm = false
let invalidResponseNextInboxConfirm = false
const controlledAISessions = [
{
id: aiSessionId,
projectId,
title: '项目风险梳理',
context: '只维护会话上下文,不创建正式对象。',
status: 'ready',
createdAt: '2026-07-21T03:00:00Z',
updatedAt: '2026-07-21T03:00:00Z',
},
]
page.on('console', (message) => {
if (message.type() !== 'error') return
if (
@@ -217,6 +231,27 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
await route.fulfill({ json: secondVisualCheckWorkspace })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions` && method === 'GET') {
await new Promise((resolve) => setTimeout(resolve, 120))
await route.fulfill({ json: controlledAISessions })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions` && method === 'POST') {
const input = route.request().postDataJSON()
aiSessionRequests.push(input)
const created = {
id: createdAISessionId,
projectId,
title: input.title,
context: input.context,
status: 'ready',
createdAt: '2026-07-21T03:10:00Z',
updatedAt: '2026-07-21T03:10:00Z',
}
controlledAISessions.unshift(created)
await route.fulfill({ status: 201, json: created })
return
}
if (url.pathname === `/api/v1/inbox/${inboxItemId}/analyze` && method === 'POST') {
inboxAnalyzeRequests.push(inboxItemId)
if (inboxAnalyzeRequests.length === 1) await new Promise((resolve) => setTimeout(resolve, 250))
@@ -487,6 +522,10 @@ await page.locator('.dashboard-button[title="探索"]').click()
await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/workspace-explore-react-acro-light.png', fullPage: true })
const workspaceExploreMetrics = await collectMetrics()
const workspaceExploreControls = await page.evaluate(() => ({
emptyState: document.querySelector('.workspace-explore-page')?.textContent ?? '',
buttonCount: document.querySelectorAll('.workspace-explore-page button').length,
}))
await page.locator('.project-button').first().click()
await page.waitForTimeout(500)
@@ -924,13 +963,22 @@ if (await inboxChannelButton.count() === 0) {
const channelPageChecks = []
for (const channel of [
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
{ label: 'AI 助手', pageClass: 'project-ai-page', expectedHeading: '选择专家,开始对话' },
{ label: 'AI 助手', pageClass: 'project-ai-page', expectedHeading: 'AI 助手' },
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
{ label: '计划任务', pageClass: 'project-cron-page', expectedHeading: '计划任务' },
{ label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' },
]) {
await page.locator('.channel-button', { hasText: channel.label }).click()
await page.waitForTimeout(250)
if (channel.label === 'AI 助手') {
const aiPage = page.locator('.project-ai-page')
await aiPage.getByText('项目风险梳理', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
await aiPage.locator('input').fill('新会话视觉检查')
await aiPage.locator('textarea').fill('只创建受控会话入口')
await aiPage.getByRole('button', { name: '创建会话', exact: true }).click()
await aiPage.getByText('新会话视觉检查', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
await page.screenshot({ path: 'test-results/project-ai-controlled.png', fullPage: true })
}
channelPageChecks.push(await page.evaluate((expected) => {
const pageNode = document.querySelector(`.${expected.pageClass}`)
const heading = pageNode?.querySelector('h2, h3, h4, h5')?.textContent ?? ''
@@ -944,6 +992,13 @@ for (const channel of [
}, channel))
}
const unsupportedControlMetrics = await page.evaluate(() => ({
topbarLabels: [...document.querySelectorAll('.topbar-actions button')].map((button) => button.getAttribute('aria-label') ?? button.textContent?.trim()),
statusbarText: document.querySelector('.statusbar')?.textContent ?? '',
newChannelText: document.querySelector('.project-new-channel-page')?.textContent ?? '',
newChannelButtonCount: document.querySelectorAll('.project-new-channel-page button').length,
}))
await page.getByRole('button', { name: '切换深色模式', exact: true }).click()
await page.screenshot({ path: 'test-results/project-react-acro-dark.png', fullPage: true })
@@ -953,12 +1008,15 @@ await server.close()
console.log(JSON.stringify({
workspaceMetrics,
workspaceExploreMetrics,
workspaceExploreControls,
projectMetrics,
mobileProjectMetrics,
channelSidebarHoverMetrics,
stageHoverMetrics,
channelListHoverMetrics,
channelPageChecks,
unsupportedControlMetrics,
aiSessionRequests,
errors,
}, null, 2))
@@ -1038,6 +1096,8 @@ if (
}
if (!workspaceExploreMetrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button to become active when selected')
if (!workspaceExploreControls.emptyState.includes('暂未开放')) failures.push('workspace explore must present the unsupported feature as 暂未开放')
if (workspaceExploreControls.buttonCount !== 0) failures.push(`workspace explore must not render active controls, got ${workspaceExploreControls.buttonCount}`)
if (workspaceExploreMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace explore page')
if (workspaceExploreMetrics.channelSidebar) failures.push(`expected workspace explore to hide channel sidebar, got ${JSON.stringify(workspaceExploreMetrics.channelSidebar)}`)
if (workspaceExploreMetrics.inspector) failures.push(`expected workspace explore to hide inspector, got ${JSON.stringify(workspaceExploreMetrics.inspector)}`)
@@ -1110,6 +1170,23 @@ for (const check of channelPageChecks) {
}
}
if (aiSessionRequests.length !== 1) failures.push(`expected one controlled AI session request, got ${aiSessionRequests.length}`)
if (aiSessionRequests.length === 1) {
const requestKeys = Object.keys(aiSessionRequests[0]).sort()
if (JSON.stringify(requestKeys) !== JSON.stringify(['context', 'title'])) {
failures.push(`AI session create must send only title/context, got ${JSON.stringify(requestKeys)}`)
}
}
if (unsupportedControlMetrics.topbarLabels.some((label) => label?.includes('停靠'))) {
failures.push(`topbar must not expose dock controls, got ${JSON.stringify(unsupportedControlMetrics.topbarLabels)}`)
}
if (/升级|支付|AI 空闲|GB 可用/.test(unsupportedControlMetrics.statusbarText)) {
failures.push(`statusbar contains unsupported product state: ${unsupportedControlMetrics.statusbarText}`)
}
if (!unsupportedControlMetrics.newChannelText.includes('暂未开放') || unsupportedControlMetrics.newChannelButtonCount !== 0) {
failures.push(`new channel must be a non-interactive 暂未开放 state, got ${JSON.stringify(unsupportedControlMetrics)}`)
}
if (failures.length) {
throw new Error(failures.join('\n'))
}

30
apps/web_v1/src/api/ai.ts Normal file
View File

@@ -0,0 +1,30 @@
import { apiRequest, type ApiSession } from './client'
export type AISessionDTO = {
id: string
projectId: string
title: string
context: string
status: string
createdAt: string
updatedAt: string
}
export type CreateAISessionInput = {
title: string
context: string
}
export async function listAISessions(session: ApiSession, projectId: string) {
return apiRequest<AISessionDTO[]>(`/api/v1/projects/${projectId}/ai-sessions`, {
token: session.token,
})
}
export async function createAISession(session: ApiSession, projectId: string, input: CreateAISessionInput) {
return apiRequest<AISessionDTO>(`/api/v1/projects/${projectId}/ai-sessions`, {
method: 'POST',
token: session.token,
body: input,
})
}

View File

@@ -1,8 +1,9 @@
import { useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { ConfigProvider, Message, Spin } from '@arco-design/web-react'
import '@arco-design/web-react/dist/css/arco.css'
import '../App.css'
import { ApiError, login, setApiBaseUrl, type ApiSession } from '../api/client'
import { createAISession, listAISessions, type CreateAISessionInput } from '../api/ai'
import { analyzeInboxItem, confirmInboxItem } from '../api/inbox'
import { mapWorkspace } from '../api/mappers'
import {
@@ -43,6 +44,16 @@ function App() {
const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null)
const workspaceSearch = useWorkbenchSearch(session)
const handleListAISessions = useCallback((projectId: string) => {
if (!session) return Promise.reject(new Error('未登录'))
return listAISessions(session, projectId)
}, [session])
const handleCreateAISession = useCallback((projectId: string, input: CreateAISessionInput) => {
if (!session) return Promise.reject(new Error('未登录'))
return createAISession(session, projectId, input)
}, [session])
const dark = theme === 'dark'
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0]
const activeTagOptions = activeWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
@@ -346,6 +357,8 @@ function App() {
onSelectSearchResult={handleSelectSearchResult}
onAnalyzeInbox={handleAnalyzeInbox}
onConfirmInbox={handleConfirmInbox}
onListAISessions={handleListAISessions}
onCreateAISession={handleCreateAISession}
/>
) : (
<Spin loading />

View File

@@ -1,80 +1,134 @@
import { Button, Card, Input, Space, Typography } from '@arco-design/web-react'
import {
IconArrowUp,
IconAttachment,
IconPlusCircle,
IconRobot,
} from '@arco-design/web-react/icon'
import type { AISession, ProjectWorkspace } from './project-types'
import { useEffect, useState } from 'react'
import { Alert, Button, Card, Empty, Input, Space, Spin, Tag, Typography } from '@arco-design/web-react'
import { IconPlusCircle, IconRobot } from '@arco-design/web-react/icon'
import type { AISessionDTO, CreateAISessionInput } from '../../api/ai'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
export function ProjectAi({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
const { aiSessions } = activeWorkspace
const groupedSessions = groupSessions(aiSessions)
export function ProjectAi({
activeWorkspace,
onSelectItem,
onListSessions,
onCreateSession,
}: {
activeWorkspace: ProjectWorkspace
onSelectItem: (title: string) => void
onListSessions: (projectId: string) => Promise<AISessionDTO[]>
onCreateSession: (projectId: string, input: CreateAISessionInput) => Promise<AISessionDTO>
}) {
const [sessions, setSessions] = useState<AISessionDTO[]>([])
const [title, setTitle] = useState('')
const [context, setContext] = useState('')
const [loading, setLoading] = useState(true)
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
const projectId = activeWorkspace.project.id
useEffect(() => {
let current = true
setLoading(true)
setError('')
void onListSessions(projectId)
.then((items) => {
if (current) setSessions(items)
})
.catch((requestError: unknown) => {
if (current) setError(requestError instanceof Error ? requestError.message : 'AI 会话加载失败,请稍后重试')
})
.finally(() => {
if (current) setLoading(false)
})
return () => {
current = false
}
}, [onListSessions, projectId])
const createSession = async () => {
const trimmedTitle = title.trim()
if (!trimmedTitle) {
setError('请输入 AI 会话标题')
return
}
setCreating(true)
setError('')
try {
const created = await onCreateSession(projectId, {
title: trimmedTitle,
context: context.trim(),
})
setSessions((current) => [created, ...current.filter((session) => session.id !== created.id)])
setTitle('')
setContext('')
onSelectItem(created.title)
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : 'AI 会话创建失败,请稍后重试')
} finally {
setCreating(false)
}
}
return (
<div className="project-channel-page project-ai-page overview-page">
<div className="agent-chat-shell">
<Card className="agent-session-list queue-section" bordered>
<Button className="agent-new-chat" icon={<IconPlusCircle />}>
</Button>
<div className="overview-head">
<div>
<Title heading={4}>AI </Title>
<Text type="secondary"></Text>
</div>
</div>
<div className="agent-session-groups">
{groupedSessions.length ? (
groupedSessions.map((group) => (
<section className="agent-session-group" key={group.label}>
<Text type="secondary">{group.label}</Text>
{group.items.map((session, index) => (
<button
className={index === 0 && group.label === groupedSessions[0]?.label ? 'agent-session active' : 'agent-session'}
key={session.id}
onClick={() => onSelectItem(session.title)}
>
{session.title}
</button>
))}
</section>
))
) : (
<Text type="secondary"></Text>
)}
</div>
{error ? <Alert className="agent-request-error" type="error" content={error} closable onClose={() => setError('')} /> : null}
<div className="agent-chat-shell">
<Card className="agent-session-list queue-section" bordered title="会话列表">
<Spin loading={loading} style={{ width: '100%' }}>
{sessions.length ? (
<div className="agent-session-groups">
{sessions.map((session) => (
<button className="agent-session" key={session.id} onClick={() => onSelectItem(session.title)}>
<span>{session.title}</span>
<Tag size="small" color={session.status === 'ready' ? 'arcoblue' : 'gray'}>
{sessionStatusLabel(session.status)}
</Tag>
</button>
))}
</div>
) : loading ? null : <Empty description="暂无 AI 会话" />}
</Spin>
</Card>
<Card className="agent-chat-panel queue-section" bordered>
<div className="agent-chat-main">
<Title heading={2}></Title>
</div>
<div className="agent-composer">
<Input.TextArea placeholder="给 DeepSeek 发送消息" autoSize={{ minRows: 3, maxRows: 6 }} />
<div className="agent-composer-footer">
<Button className="agent-model-chip" icon={<IconRobot />}>
DeepSeek V4.0 Flash
</Button>
<Space>
<Button type="text" icon={<IconAttachment />} />
<Button className="agent-send-button" type="primary" shape="circle" icon={<IconArrowUp />} />
</Space>
<Space direction="vertical" size={16} className="action-form">
<div>
<Title heading={5}></Title>
<Text type="secondary"> AI </Text>
</div>
</div>
<label>
<Text></Text>
<Input value={title} onChange={setTitle} placeholder="例如:报价分析" maxLength={120} />
</label>
<label>
<Text></Text>
<Input.TextArea
value={context}
onChange={setContext}
placeholder="描述本次会话要参考的项目背景"
autoSize={{ minRows: 5, maxRows: 10 }}
/>
</label>
<Button type="primary" icon={<IconPlusCircle />} loading={creating} onClick={() => void createSession()}>
</Button>
<Text type="secondary"><IconRobot /> AI </Text>
</Space>
</Card>
</div>
</div>
)
}
function groupSessions(sessions: AISession[]) {
const labels = ['今天', '昨天', '7 天内']
return labels.map((label) => ({
label,
items: sessions.filter((session, index) => session.time.includes(label) || (!labels.some((item) => session.time.includes(item)) && labels.indexOf(label) === fallbackGroupIndex(index))),
})).filter((group) => group.items.length > 0)
}
function fallbackGroupIndex(index: number) {
if (index === 0) return 0
if (index === 1) return 1
return 2
function sessionStatusLabel(status: string) {
if (status === 'ready') return '待开始'
if (status === 'failed') return '创建失败'
return status || '未知状态'
}

View File

@@ -8,6 +8,7 @@ import type { ProjectTaskUpdate } from './project-task-edit-modal'
import { ProjectTasks } from './project-tasks'
import type { ChannelKey, InboxConfirmationOutcome, ProjectWorkspace } from './project-types'
import type { InboxSuggestionDTO } from '../../api/inbox'
import type { AISessionDTO, CreateAISessionInput } from '../../api/ai'
export function ProjectChannelPage({
activeChannel,
@@ -23,6 +24,8 @@ export function ProjectChannelPage({
onUpdateTask,
onAnalyzeInbox,
onConfirmInbox,
onListAISessions,
onCreateAISession,
}: {
activeChannel: ChannelKey
activeWorkspace: ProjectWorkspace
@@ -37,6 +40,8 @@ export function ProjectChannelPage({
onUpdateTask: (update: ProjectTaskUpdate) => void
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
onListAISessions: (projectId: string) => Promise<AISessionDTO[]>
onCreateAISession: (projectId: string, input: CreateAISessionInput) => Promise<AISessionDTO>
}) {
switch (activeChannel) {
case 'inbox':
@@ -44,7 +49,7 @@ export function ProjectChannelPage({
case 'tasks':
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} />
case 'ai':
return <ProjectAi activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
return <ProjectAi activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onListSessions={onListAISessions} onCreateSession={onCreateAISession} />
case 'notes':
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
case 'cron':

View File

@@ -14,7 +14,7 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary">{project.name} AI </Text>
<Text type="secondary">{project.name} AI </Text>
</div>
<Button type="primary" icon={<IconPlus />} onClick={onCreateCronPlan}></Button>
</div>

View File

@@ -1,9 +1,7 @@
import { Button, Card, Input, Select, Space, Tag, Typography } from '@arco-design/web-react'
import { IconApps, IconLink, IconPlus } from '@arco-design/web-react/icon'
import { Card, Empty, Typography } from '@arco-design/web-react'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
const { TextArea } = Input
export function ProjectNewChannel({ activeWorkspace }: { activeWorkspace: ProjectWorkspace }) {
return (
@@ -11,51 +9,11 @@ export function ProjectNewChannel({ activeWorkspace }: { activeWorkspace: Projec
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary">{activeWorkspace.project.name} </Text>
<Text type="secondary">{activeWorkspace.project.name} </Text>
</div>
<Button type="primary" icon={<IconPlus />}></Button>
</div>
<Card className="queue-section channel-editor-card" bordered>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input placeholder="例如:客户研究频道" />
</label>
<label>
<Text></Text>
<Select defaultValue="link">
<Select.Option value="link"></Select.Option>
<Select.Option value="source"></Select.Option>
<Select.Option value="workflow"></Select.Option>
</Select>
</label>
<label>
<Text></Text>
<Input placeholder="例如link / user / file" prefix={<IconApps />} />
</label>
<label>
<Text></Text>
<Input placeholder="https://example.com/channel" prefix={<IconLink />} />
</label>
<label>
<Text></Text>
<TextArea rows={4} placeholder="频道用途、维护规则或采集说明" />
</label>
</Space>
</Card>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Tag color="gray"></Tag>
</div>
<Space wrap>
<Tag color="arcoblue"></Tag>
<Tag color="green"></Tag>
<Tag color="orange">RSS/</Tag>
<Tag color="purple">AI </Tag>
</Space>
<Empty description="自定义频道暂未开放" />
</Card>
</div>
)

View File

@@ -1,111 +1,19 @@
import { useState } from 'react'
import { Avatar, Button, Card, Form, Input, Layout, Modal, Radio, Space, Typography } from '@arco-design/web-react'
import {
IconApps,
IconCalendar,
IconRobot,
IconStorage,
IconUser,
} from '@arco-design/web-react/icon'
import { Layout, Space } from '@arco-design/web-react'
import { IconCheckCircle } from '@arco-design/web-react/icon'
const { Footer } = Layout
const { Text, Title } = Typography
const plans = [
{ id: 'starter', name: '基础版', price: '¥29/月', desc: '个人项目、基础智能体与 20GB 存储。' },
{ id: 'pro', name: '专业版', price: '¥99/月', desc: '团队协作、高级智能体与 200GB 存储。' },
{ id: 'team', name: '团队版', price: '¥299/月', desc: '成员管理、审计日志与私有化部署支持。' },
]
export function ProjectStatusbar() {
const [profileOpen, setProfileOpen] = useState(false)
const [upgradeOpen, setUpgradeOpen] = useState(false)
const [selectedPlan, setSelectedPlan] = useState('pro')
return (
<>
<Footer className="statusbar">
<div className="status-user">
<button className="status-profile" type="button" onClick={() => setProfileOpen(true)}>
<span className="status-avatar" aria-hidden="true"></span>
<span className="status-name"></span>
</button>
<Button className="status-upgrade" size="mini" type="primary" onClick={() => setUpgradeOpen(true)}></Button>
</div>
<Space className="status-system" size={18}>
<span><IconInteractionFallback /> 3 </span>
<span><IconRobot /> AI </span>
<span><IconStorage /> 152GB </span>
<span> <b></b></span>
<IconApps />
</Space>
</Footer>
<Modal
title="个人资料"
visible={profileOpen}
onCancel={() => setProfileOpen(false)}
onOk={() => setProfileOpen(false)}
okText="保存"
cancelText="取消"
>
<Form layout="vertical" className="status-modal-form">
<Form.Item label="头像">
<Space>
<Avatar size={42} style={{ backgroundColor: 'var(--color-primary)' }}></Avatar>
<Button icon={<IconUser />}></Button>
</Space>
</Form.Item>
<Form.Item label="名称">
<Input defaultValue="张明" placeholder="请输入名称" />
</Form.Item>
<Form.Item label="邮箱">
<Input defaultValue="zhangming@senlin.ai" placeholder="请输入邮箱" />
</Form.Item>
<Form.Item label="当前密码">
<Input.Password placeholder="用于确认身份" />
</Form.Item>
<Form.Item label="新密码">
<Input.Password placeholder="不修改密码可留空" />
</Form.Item>
</Form>
</Modal>
<Modal
title="升级套餐"
visible={upgradeOpen}
onCancel={() => setUpgradeOpen(false)}
onOk={() => setUpgradeOpen(false)}
okText="去支付"
cancelText="取消"
>
<Radio.Group value={selectedPlan} onChange={setSelectedPlan} className="billing-plan-group">
{plans.map((plan) => (
<Card className={selectedPlan === plan.id ? 'billing-plan active' : 'billing-plan'} key={plan.id} bordered>
<Radio value={plan.id}>
<div>
<Title heading={6}>{plan.name}</Title>
<Text className="billing-price">{plan.price}</Text>
<Text type="secondary">{plan.desc}</Text>
</div>
</Radio>
</Card>
))}
</Radio.Group>
<div className="billing-payments">
<Text type="secondary"></Text>
<Space>
<Button></Button>
<Button></Button>
<Button></Button>
</Space>
</div>
</Modal>
</>
<Footer className="statusbar">
<div className="status-user">
<span className="status-avatar" aria-hidden="true"></span>
<span className="status-name"></span>
</div>
<Space className="status-system" size={8}>
<IconCheckCircle />
<span></span>
</Space>
</Footer>
)
}
function IconInteractionFallback() {
return <IconCalendar />
}

View File

@@ -34,7 +34,6 @@ export function ProjectTopbar({
onSearch: () => void
onSelectSearchResult: (result: SearchResultDTO) => void
}) {
const desktop = isDesktopRuntime()
const [popupVisible, setPopupVisible] = useState(false)
useEffect(() => {
@@ -109,13 +108,6 @@ export function ProjectTopbar({
<Button className="mobile-nav-button" aria-label="打开频道导航" icon={<IconMenuUnfold />} onClick={onOpenChannels} />
)}
<Button aria-label={theme === 'dark' ? '切换浅色模式' : '切换深色模式'} icon={theme === 'dark' ? <IconSun /> : <IconMoon />} onClick={onToggleTheme} />
{desktop && (
<>
<Button aria-label="停靠左边" icon={<DockIcon side="left" />} />
<Button aria-label="停靠右边" icon={<DockIcon side="right" />} />
</>
)}
<Button icon={<IconApps />} />
</Space>
</Header>
)
@@ -127,15 +119,3 @@ function searchTypeLabel(type: string) {
if (type === 'note') return '笔记'
return '未知'
}
function isDesktopRuntime() {
return typeof window !== 'undefined' && '__TAURI__' in window
}
function DockIcon({ side }: { side: 'left' | 'right' }) {
return (
<span className={`dock-icon dock-icon-${side}`} aria-hidden="true">
<span />
</span>
)
}

View File

@@ -1,223 +1,19 @@
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { Button, Card, Empty, Grid, Space, Typography } from '@arco-design/web-react'
import {
IconBook,
IconCheckCircle,
IconCompass,
IconDelete,
IconEdit,
IconFile,
IconLink,
IconRefresh,
IconStar,
IconStorage,
} from '@arco-design/web-react/icon'
import type { InboxItem, Project, ProjectWorkspace } from './projects/project-types'
import { Card, Empty, Typography } from '@arco-design/web-react'
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
type DataSourceID = 'all' | 'manual' | 'requirements' | 'architecture'
type ExploreArticle = InboxItem & {
project: Project
sourceID: DataSourceID
}
type DataSourceCard = {
id: DataSourceID
name: string
count: number
icon: ReactNode
color: string
}
const SOURCE_META: Record<DataSourceID, { name: string; icon: ReactNode; color: string }> = {
all: { name: '全部', icon: <IconStorage />, color: 'blue' },
manual: { name: '手动收集', icon: <IconCompass />, color: 'green' },
requirements: { name: '需求文档', icon: <IconFile />, color: 'orange' },
architecture: { name: '架构讨论', icon: <IconBook />, color: 'purple' },
}
export function WorkspaceExplorePage({
workspaces,
onSelectItem,
}: {
workspaces: ProjectWorkspace[]
onSelectItem: (title: string) => void
}) {
const articles = useMemo(
() =>
workspaces.flatMap((workspace) =>
workspace.inbox.map((item) => ({
...item,
project: workspace.project,
sourceID: detectSource(item),
})),
),
[workspaces],
)
const [activeSourceID, setActiveSourceID] = useState<DataSourceID>('all')
const filteredArticles = useMemo(
() => (activeSourceID === 'all' ? articles : articles.filter((article) => article.sourceID === activeSourceID)),
[activeSourceID, articles],
)
const sources = useMemo(() => dataSources(articles), [articles])
const [activeArticleID, setActiveArticleID] = useState<string | null>(filteredArticles[0]?.id ?? null)
useEffect(() => {
if (filteredArticles.length === 0) {
setActiveArticleID(null)
return
}
if (!activeArticleID || !filteredArticles.some((article) => article.id === activeArticleID)) {
setActiveArticleID(filteredArticles[0].id)
}
}, [activeArticleID, filteredArticles])
const selected = filteredArticles.find((article) => article.id === activeArticleID) ?? filteredArticles[0]
const activeSource = SOURCE_META[activeSourceID]
const { Title, Text } = Typography
export function WorkspaceExplorePage() {
return (
<div className="workspace-explore-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary"></Text>
<Text type="secondary"> MVP </Text>
</div>
<Space>
<Button icon={<IconRefresh />}></Button>
<Button type="primary" icon={<IconLink />}>
</Button>
</Space>
</div>
<Row gutter={10} className="explore-source-row">
{sources.map((source) => (
<Col span={6} key={source.id}>
<Card
className={activeSourceID === source.id ? 'compact-card explore-source-card active' : 'compact-card explore-source-card'}
bordered
onClick={() => setActiveSourceID(source.id)}
>
<span className={`explore-source-icon ${source.color}`}>{source.icon}</span>
<span className="explore-source-copy">
<Text className="explore-source-name">{source.name}</Text>
<Text type="secondary">{source.count} </Text>
</span>
{source.id !== 'all' ? (
<span className="explore-source-actions" onClick={(event) => event.stopPropagation()}>
<Button type="text" size="mini" icon={<IconEdit />} />
<Button type="text" size="mini" status="danger" icon={<IconDelete />} />
</span>
) : null}
</Card>
</Col>
))}
</Row>
{selected ? (
<section className="explore-reader-layout">
<Card className="explore-article-list queue-section" bordered>
<div className="explore-list-header">
<Title heading={5}>
<Space size={6}>
<span className={`explore-source-icon mini ${activeSource.color}`}>{activeSource.icon}</span>
<span>{activeSource.name}</span>
</Space>
</Title>
<Button type="text" icon={<IconRefresh />} />
</div>
<div className="explore-list-body">
{filteredArticles.map((article) => (
<button
key={`${article.project.id}-${article.id}`}
className={article.id === selected.id ? 'explore-article-item active' : 'explore-article-item'}
onClick={() => {
setActiveArticleID(article.id)
onSelectItem(article.title)
}}
>
<span className={`explore-source-logo ${SOURCE_META[article.sourceID].color}`}>
{sourceInitial(SOURCE_META[article.sourceID].name)}
</span>
<span className="explore-article-copy">
<Text type="secondary">
{SOURCE_META[article.sourceID].name} · {article.time}
</Text>
<Text className="explore-article-title">{article.title}</Text>
<Text className="explore-article-summary" type="secondary" ellipsis={{ showTooltip: true }}>
{article.summary || '暂无正文'}
</Text>
</span>
</button>
))}
</div>
</Card>
<Card className="explore-article-detail queue-section" bordered>
<div className="explore-detail-toolbar">
<Title heading={5}>{selected.title}</Title>
<Space>
<Button type="text" icon={<IconCheckCircle />} />
<Button type="text" icon={<IconStar />} />
<Button type="text" icon={<IconBook />} />
</Space>
</div>
<article className="explore-article-body">
<Space className="explore-article-meta" wrap>
<span className={`explore-source-logo small ${SOURCE_META[selected.sourceID].color}`}>
{sourceInitial(SOURCE_META[selected.sourceID].name)}
</span>
<Text type="secondary">{SOURCE_META[selected.sourceID].name}</Text>
<Text type="secondary">{selected.project.name}</Text>
<Text type="secondary">{selected.time}</Text>
</Space>
<Paragraph className="explore-article-content">{selected.summary || '暂无正文'}</Paragraph>
<blockquote>
线
</blockquote>
</article>
</Card>
</section>
) : (
<Card className="queue-section" bordered>
<Empty description="暂无数据源文章" />
</Card>
)}
<Card className="queue-section" bordered>
<Empty description="探索数据源暂未开放" />
</Card>
</div>
)
}
function dataSources(articles: ExploreArticle[]): DataSourceCard[] {
const counts = new Map<DataSourceID, number>([
['all', articles.length],
['manual', 0],
['requirements', 0],
['architecture', 0],
])
articles.forEach((article) => counts.set(article.sourceID, (counts.get(article.sourceID) ?? 0) + 1))
return (Object.keys(SOURCE_META) as DataSourceID[]).map((id) => ({
id,
...SOURCE_META[id],
count: counts.get(id) ?? 0,
}))
}
function detectSource(article: InboxItem): DataSourceID {
const text = `${article.title} ${article.meta} ${article.tag} ${article.summary}`
if (/架构|技术方案|系统设计|architecture/i.test(text)) {
return 'architecture'
}
if (/需求|PRD|产品文档|requirement/i.test(text)) {
return 'requirements'
}
return 'manual'
}
function sourceInitial(name: string) {
return name.trim().slice(0, 1) || '源'
}

View File

@@ -10,6 +10,7 @@ import { ProjectStatusbar } from './projects/project-statusbar'
import { ProjectTopbar } from './projects/project-topbar'
import type { SearchResultDTO } from '../api/search'
import type { InboxSuggestionDTO } from '../api/inbox'
import type { AISessionDTO, CreateAISessionInput } from '../api/ai'
import type { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
const { Content } = Layout
@@ -45,6 +46,8 @@ export function ProjectPage({
onSelectSearchResult,
onAnalyzeInbox,
onConfirmInbox,
onListAISessions,
onCreateAISession,
}: {
activeView: WorkbenchView
activeWorkspace: ProjectWorkspace
@@ -76,6 +79,8 @@ export function ProjectPage({
onSelectSearchResult: (result: SearchResultDTO) => void
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
onListAISessions: (projectId: string) => Promise<AISessionDTO[]>
onCreateAISession: (projectId: string, input: CreateAISessionInput) => Promise<AISessionDTO>
}) {
const isProject = activeView === 'project'
const [navOpen, setNavOpen] = useState<'projects' | 'channels' | null>(null)
@@ -143,7 +148,7 @@ export function ProjectPage({
{activeView === 'workspace' ? (
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} onUpdateTask={onUpdateWorkspaceTask} />
) : activeView === 'workspace-explore' ? (
<WorkspaceExplorePage workspaces={workspaces} onSelectItem={onSelectItem} />
<WorkspaceExplorePage />
) : (
<ProjectChannelPage
activeChannel={activeChannel}
@@ -159,6 +164,8 @@ export function ProjectPage({
onUpdateTask={updateActiveProjectTask}
onAnalyzeInbox={onAnalyzeInbox}
onConfirmInbox={onConfirmInbox}
onListAISessions={onListAISessions}
onCreateAISession={onCreateAISession}
/>
)}
</Content>