Add project channel pages

This commit is contained in:
2026-07-20 12:26:21 +08:00
parent a65012c1b3
commit a299da63e7
10 changed files with 563 additions and 2 deletions

View File

@@ -6,6 +6,12 @@ const requiredFiles = [
'src/pages/workspace.tsx',
'src/pages/project.tsx',
'src/pages/projects/project-overview.tsx',
'src/pages/projects/project-channel-page.tsx',
'src/pages/projects/project-inbox.tsx',
'src/pages/projects/project-tasks.tsx',
'src/pages/projects/project-ai.tsx',
'src/pages/projects/project-notes.tsx',
'src/pages/projects/project-cron.tsx',
'src/pages/projects/project-data.tsx',
'src/pages/projects/project-rail.tsx',
'src/pages/projects/project-sidebar.tsx',

View File

@@ -133,6 +133,29 @@ const propertyMetrics = await collectMetrics()
await page.locator('.property-list').hover()
const propertyListHoverMetrics = await collectMetrics()
const channelPageChecks = []
for (const channel of [
{ label: 'Inbox 消息流', pageClass: 'project-inbox-page', expectedHeading: 'Inbox 消息流' },
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
{ label: 'AI 会话', pageClass: 'project-ai-page', expectedHeading: 'AI 会话' },
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
{ label: 'Cron 计划任务', pageClass: 'project-cron-page', expectedHeading: 'Cron 计划任务' },
]) {
await page.locator('.channel-button', { hasText: channel.label }).click()
await page.waitForTimeout(250)
channelPageChecks.push(await page.evaluate((expected) => {
const pageNode = document.querySelector(`.${expected.pageClass}`)
const heading = pageNode?.querySelector('h4, h5')?.textContent ?? ''
const activeChannel = document.querySelector('.channel-button.active')?.textContent ?? ''
return {
...expected,
foundPage: Boolean(pageNode),
heading,
activeChannel,
}
}, channel))
}
await page.locator('.topbar-actions .arco-btn').first().click()
await page.screenshot({ path: 'test-results/project-react-acro-dark.png', fullPage: true })
@@ -149,6 +172,7 @@ console.log(JSON.stringify({
inspectorTabsContentHoverMetrics,
propertyMetrics,
propertyListHoverMetrics,
channelPageChecks,
errors,
}, null, 2))
@@ -279,6 +303,17 @@ for (const [name, overflow] of [
if (propertyMetrics.propertyListOverflow?.scrollbarWidth !== 'none') {
failures.push(`expected property list scrollbar to be hidden until hover, got ${JSON.stringify(propertyMetrics.propertyListOverflow)}`)
}
for (const check of channelPageChecks) {
if (!check.foundPage) {
failures.push(`expected ${check.label} to render .${check.pageClass}`)
}
if (!check.heading.includes(check.expectedHeading)) {
failures.push(`expected ${check.label} heading to include ${check.expectedHeading}, got ${JSON.stringify(check.heading)}`)
}
if (!check.activeChannel.includes(check.label)) {
failures.push(`expected ${check.label} sidebar button to stay active, got ${JSON.stringify(check.activeChannel)}`)
}
}
if (failures.length) {
throw new Error(failures.join('\n'))

View File

@@ -558,6 +558,154 @@
grid-template-columns: minmax(260px, 1fr) 80px 160px 80px;
}
.project-channel-page {
min-width: 0;
}
.compact-card .arco-card-body {
padding: 12px 14px;
}
.mail-layout,
.ai-workspace {
min-width: 0;
display: grid;
grid-template-columns: minmax(260px, 0.42fr) minmax(360px, 0.58fr);
gap: 12px;
}
.mail-list,
.mail-detail,
.ai-session-list,
.ai-chat-panel {
min-width: 0;
}
.mail-item,
.ai-session,
.task-card-button {
width: 100%;
display: grid;
gap: 4px;
border: 0;
border-top: 1px solid var(--senlin-border);
background: transparent;
color: var(--senlin-text);
padding: 10px 0;
text-align: left;
cursor: pointer;
}
.mail-item:first-of-type,
.ai-session:first-of-type {
border-top: 0;
}
.mail-item.active,
.ai-session.active,
.mail-item:hover,
.ai-session:hover,
.task-card-button:hover {
background: color-mix(in srgb, var(--senlin-primary) 6%, transparent);
}
.mail-item span:first-child,
.ai-session > .arco-typography:first-child {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.mail-item div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mail-item time,
.ai-session time {
color: var(--senlin-muted);
font-size: 12px;
}
.mail-detail .arco-typography,
.mail-item .arco-typography,
.ai-session .arco-typography {
overflow: hidden;
text-overflow: ellipsis;
}
.reply-box,
.chat-input {
display: grid;
gap: 10px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--senlin-border);
}
.task-board {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.task-lane .arco-card-body {
display: grid;
gap: 10px;
padding: 12px;
}
.task-card-button {
border: 1px solid var(--senlin-border);
border-radius: 6px;
padding: 10px;
}
.chat-thread {
display: grid;
gap: 12px;
}
.chat-message {
display: grid;
grid-template-columns: 30px 1fr;
gap: 10px;
}
.chat-message p {
margin: 0;
padding: 10px 12px;
border: 1px solid var(--senlin-border);
border-radius: 6px;
background: var(--senlin-soft-blue);
}
.chat-message.user p {
background: var(--senlin-panel);
}
.folder-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.folder-card .arco-card-body {
padding: 14px;
}
.cron-row {
grid-template-columns: minmax(180px, 1fr) 64px 92px 92px 92px 48px 40px;
}
.cron-summary .arco-card-body {
padding: 12px;
}
.inspector {
min-height: 0;
overflow-x: hidden;

View File

@@ -1,6 +1,6 @@
import { Layout } from '@arco-design/web-react'
import { WorkspacePage } from './workspace'
import { ProjectOverview } from './projects/project-overview'
import { ProjectChannelPage } from './projects/project-channel-page'
import { ProjectInspector } from './projects/project-inspector'
import { ProjectRail } from './projects/project-rail'
import { ProjectSidebar } from './projects/project-sidebar'
@@ -61,7 +61,11 @@ export function ProjectPage({
{isWorkspace ? (
<WorkspacePage onSelectProject={onSelectProject} />
) : (
<ProjectOverview onSelectItem={onSelectItem} />
<ProjectChannelPage
activeChannel={activeChannel}
activeProject={activeProject}
onSelectItem={onSelectItem}
/>
)}
</Content>

View File

@@ -0,0 +1,68 @@
import { Avatar, Button, Card, Input, Space, Tag, Typography } from '@arco-design/web-react'
import { IconPlus, IconRobot, IconSend } from '@arco-design/web-react/icon'
import { aiSessions } from './project-data'
import type { Project } from './project-types'
const { Title, Text, Paragraph } = Typography
const messages = [
{ role: 'user', text: '请基于当前项目资料,整理本周风险和推进建议。' },
{ role: 'assistant', text: '我建议优先处理导出问题、权限边界和 Q3 策略评审,并把客户反馈同步到工作计划。' },
{ role: 'user', text: '把建议拆成可执行任务。' },
]
export function ProjectAi({ activeProject, onSelectItem }: { activeProject: Project; onSelectItem: (title: string) => void }) {
return (
<div className="project-channel-page project-ai-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>AI </Title>
<Text type="secondary">{activeProject.name} AI session</Text>
</div>
<Button type="primary" icon={<IconPlus />}></Button>
</div>
<div className="ai-workspace">
<Card className="ai-session-list queue-section" bordered>
<div className="section-header">
<Title heading={6}>Session</Title>
<Tag color="purple">{aiSessions.length}</Tag>
</div>
{aiSessions.map((session, index) => (
<button
className={index === 0 ? 'ai-session active' : 'ai-session'}
key={session.title}
onClick={() => onSelectItem(session.title)}
>
<Text>{session.title}</Text>
<Text type="secondary">{session.summary}</Text>
<time>{session.time}</time>
</button>
))}
</Card>
<Card className="ai-chat-panel queue-section" bordered>
<div className="section-header">
<Title heading={6}>{aiSessions[0].title}</Title>
<Tag color="green"></Tag>
</div>
<div className="chat-thread">
{messages.map((message, index) => (
<div className={`chat-message ${message.role}`} key={`${message.role}-${index}`}>
<Avatar size={30}>{message.role === 'assistant' ? <IconRobot /> : '张'}</Avatar>
<Paragraph>{message.text}</Paragraph>
</div>
))}
</div>
<div className="chat-input">
<Input.TextArea placeholder="输入你的问题,结合项目上下文继续追问" autoSize={{ minRows: 3, maxRows: 4 }} />
<Space>
<Button></Button>
<Button type="primary" icon={<IconSend />}></Button>
</Space>
</div>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { ProjectAi } from './project-ai'
import { ProjectCron } from './project-cron'
import { ProjectInbox } from './project-inbox'
import { ProjectNotes } from './project-notes'
import { ProjectOverview } from './project-overview'
import { ProjectTasks } from './project-tasks'
import type { ChannelKey, Project } from './project-types'
export function ProjectChannelPage({
activeChannel,
activeProject,
onSelectItem,
}: {
activeChannel: ChannelKey
activeProject: Project
onSelectItem: (title: string) => void
}) {
switch (activeChannel) {
case 'inbox':
return <ProjectInbox activeProject={activeProject} onSelectItem={onSelectItem} />
case 'tasks':
return <ProjectTasks activeProject={activeProject} onSelectItem={onSelectItem} />
case 'ai':
return <ProjectAi activeProject={activeProject} onSelectItem={onSelectItem} />
case 'notes':
return <ProjectNotes activeProject={activeProject} onSelectItem={onSelectItem} />
case 'cron':
return <ProjectCron activeProject={activeProject} onSelectItem={onSelectItem} />
case 'overview':
case 'research':
case 'design':
default:
return <ProjectOverview onSelectItem={onSelectItem} />
}
}

View File

@@ -0,0 +1,63 @@
import { Button, Card, Space, Switch, Tag, Typography } from '@arco-design/web-react'
import { IconClockCircle, IconPlus, IconThunderbolt } from '@arco-design/web-react/icon'
import type { Project } from './project-types'
const { Title, Text } = Typography
const cronJobs = [
{ name: '每日竞品价格抓取', expr: '0 8 * * *', status: '运行中', lastRun: '今天 08:00', nextRun: '明天 08:00', owner: '李明' },
{ name: '周报摘要生成', expr: '0 18 * * 5', status: '运行中', lastRun: '上周五 18:00', nextRun: '本周五 18:00', owner: '张伟' },
{ name: '过期任务提醒', expr: '*/30 9-18 * * 1-5', status: '暂停', lastRun: '昨天 17:30', nextRun: '暂停中', owner: '系统' },
{ name: '资料索引刷新', expr: '0 */4 * * *', status: '运行中', lastRun: '今天 12:00', nextRun: '今天 16:00', owner: '系统' },
]
export function ProjectCron({ activeProject, onSelectItem }: { activeProject: Project; onSelectItem: (title: string) => void }) {
return (
<div className="project-channel-page project-cron-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>Cron </Title>
<Text type="secondary">{activeProject.name} AI </Text>
</div>
<Button type="primary" icon={<IconPlus />}></Button>
</div>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Space>
<Tag color="green">3 </Tag>
<Tag color="gray">1 </Tag>
</Space>
</div>
{cronJobs.map((job) => (
<div
className="data-row cron-row"
key={job.name}
role="button"
tabIndex={0}
onClick={() => onSelectItem(job.name)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') onSelectItem(job.name)
}}
>
<span className="row-title"><IconClockCircle /> {job.name}</span>
<Tag color={job.status === '运行中' ? 'green' : 'gray'}>{job.status}</Tag>
<span>{job.expr}</span>
<span>{job.lastRun}</span>
<span>{job.nextRun}</span>
<span>{job.owner}</span>
<Switch size="small" checked={job.status === '运行中'} />
</div>
))}
</Card>
<Card className="queue-section cron-summary" bordered>
<Space>
<Tag color="arcoblue"><IconThunderbolt /></Tag>
<Text> 12:00</Text>
</Space>
</Card>
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { IconArchive, IconEmail, IconRefresh, IconSend, IconStar } from '@arco-design/web-react/icon'
import { inbox } from './project-data'
import type { Project } from './project-types'
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
const folders = [
{ label: '未处理', count: 18, color: 'red' },
{ label: '待回复', count: 9, color: 'orange' },
{ label: '已归档', count: 42, color: 'gray' },
]
export function ProjectInbox({ activeProject, onSelectItem }: { activeProject: Project; onSelectItem: (title: string) => void }) {
const selected = inbox[0]
return (
<div className="project-channel-page project-inbox-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>Inbox </Title>
<Text type="secondary">{activeProject.name} 线</Text>
</div>
<Space>
<Button icon={<IconRefresh />}></Button>
<Button type="primary" icon={<IconArchive />}></Button>
</Space>
</div>
<Row gutter={12}>
{folders.map((folder) => (
<Col span={8} key={folder.label}>
<Card className="compact-card" bordered>
<Space>
<Tag color={folder.color}>{folder.count}</Tag>
<Text>{folder.label}</Text>
</Space>
</Card>
</Col>
))}
</Row>
<div className="mail-layout">
<Card className="mail-list queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button type="text" size="mini"></Button>
</div>
{inbox.map((item, index) => (
<button
className={index === 0 ? 'mail-item active' : 'mail-item'}
key={item.title}
onClick={() => onSelectItem(item.title)}
>
<span><IconEmail /> {item.title}</span>
<Text type="secondary">{item.meta}</Text>
<div>
<Tag>{item.tag}</Tag>
<time>{item.time}</time>
</div>
</button>
))}
</Card>
<Card className="mail-detail queue-section" bordered>
<div className="section-header">
<Title heading={6}>{selected.title}</Title>
<Space>
<Button type="text" icon={<IconStar />} />
<Button type="text" icon={<IconSend />} />
</Space>
</div>
<Text type="secondary">{selected.meta} · {selected.owner}</Text>
<Paragraph>
</Paragraph>
<div className="reply-box">
<Text type="secondary"></Text>
<Space>
<Button></Button>
<Button></Button>
<Button type="primary"></Button>
</Space>
</div>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { Button, Card, Space, Tag, Typography } from '@arco-design/web-react'
import { IconFile, IconFolder, IconPlus, IconSearch } from '@arco-design/web-react/icon'
import { notes } from './project-data'
import type { Project } from './project-types'
const { Title, Text } = Typography
const folders = ['需求文档', '技术方案', '会议纪要', '竞品资料']
export function ProjectNotes({ activeProject, onSelectItem }: { activeProject: Project; onSelectItem: (title: string) => void }) {
return (
<div className="project-channel-page project-notes-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary">{activeProject.name} 线</Text>
</div>
<Space>
<Button icon={<IconSearch />}></Button>
<Button type="primary" icon={<IconPlus />}>/</Button>
</Space>
</div>
<div className="folder-grid">
{folders.map((folder) => (
<Card className="folder-card" bordered key={folder}>
<Space>
<Tag color="arcoblue"><IconFolder /></Tag>
<Text>{folder}</Text>
</Space>
</Card>
))}
</div>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button type="text" size="mini"></Button>
</div>
{notes.map((note) => (
<button className="data-row note-row" key={note.title} onClick={() => onSelectItem(note.title)}>
<span className="row-title"><IconFile /> {note.title}</span>
<Tag>{note.type}</Tag>
<span>{note.updated}</span>
<span>{note.owner}</span>
</button>
))}
</Card>
</div>
)
}

View File

@@ -0,0 +1,61 @@
import { Button, Card, Progress, Space, Tag, Typography } from '@arco-design/web-react'
import { IconCalendar, IconCheckCircle, IconPlus, IconUser } from '@arco-design/web-react/icon'
import { tasks } from './project-data'
import type { Project } from './project-types'
const { Title, Text } = Typography
const lanes = [
{ title: '待开始', color: 'gray', items: ['权限边界确认', '客户案例素材整理'] },
{ title: '进行中', color: 'arcoblue', items: tasks.map((task) => task.title) },
{ title: '已完成', color: 'green', items: ['项目模板初始化', '会议纪要归档'] },
]
export function ProjectTasks({ activeProject, onSelectItem }: { activeProject: Project; onSelectItem: (title: string) => void }) {
return (
<div className="project-channel-page project-tasks-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary">{activeProject.name} TODO </Text>
</div>
<Button type="primary" icon={<IconPlus />}></Button>
</div>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Tag color="arcoblue">{tasks.length} </Tag>
</div>
{tasks.map((task) => (
<button className="data-row task-row" key={task.title} onClick={() => onSelectItem(task.title)}>
<span className="row-title"><IconCheckCircle /> {task.title}</span>
<Tag color="arcoblue">{task.tag}</Tag>
<span><IconUser /> {task.owner}</span>
<span><IconCalendar /> {task.due}</span>
<Progress percent={Number.parseInt(task.progress, 10)} size="small" />
</button>
))}
</Card>
<div className="task-board">
{lanes.map((lane) => (
<Card className="task-lane" bordered key={lane.title}>
<div className="section-header">
<Title heading={6}>{lane.title}</Title>
<Tag color={lane.color}>{lane.items.length}</Tag>
</div>
<Space direction="vertical" size={8}>
{lane.items.map((title) => (
<button className="task-card-button" key={title} onClick={() => onSelectItem(title)}>
<Text>{title}</Text>
<Text type="secondary"> · </Text>
</button>
))}
</Space>
</Card>
))}
</div>
</div>
)
}