Split React client pages into modules

This commit is contained in:
2026-07-20 10:50:42 +08:00
parent bbd6c62029
commit 2660bc65bd
14 changed files with 605 additions and 570 deletions

View File

@@ -0,0 +1,37 @@
import { existsSync, readFileSync } from 'node:fs'
const requiredFiles = [
'src/app/App.tsx',
'src/pages/login.tsx',
'src/pages/project.tsx',
'src/pages/overview.tsx',
'src/features/projects/project-data.tsx',
'src/features/projects/project-rail.tsx',
'src/features/projects/project-sidebar.tsx',
'src/features/projects/project-topbar.tsx',
'src/features/projects/project-inspector.tsx',
'src/features/projects/project-statusbar.tsx',
'src/features/projects/project-types.ts',
]
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
if (existsSync('src/App.tsx')) {
failures.push('legacy src/App.tsx should be removed after page split')
}
if (existsSync('src/app/App.tsx')) {
const appSource = readFileSync('src/app/App.tsx', 'utf8')
for (const forbidden of ['function LoginPage', 'function Workbench', 'function OverviewContent']) {
if (appSource.includes(forbidden)) {
failures.push(`src/app/App.tsx still contains ${forbidden}`)
}
}
}
if (failures.length) {
console.error(failures.join('\n'))
process.exit(1)
}
console.log('structure ok')

View File

@@ -1,569 +0,0 @@
import type { CSSProperties, ReactElement } from 'react'
import { useMemo, useState } from 'react'
import {
Avatar,
Badge,
Button,
Card,
ConfigProvider,
Divider,
Form,
Grid,
Input,
Layout,
Message,
Space,
Tabs,
Tag,
Typography,
} from '@arco-design/web-react'
import {
IconApps,
IconArrowLeft,
IconArrowRight,
IconBook,
IconCalendar,
IconCheckCircleFill,
IconClockCircle,
IconDashboard,
IconEmail,
IconFile,
IconHome,
IconLaunch,
IconLink,
IconList,
IconMessage,
IconMoon,
IconMore,
IconNotification,
IconPlus,
IconQuestionCircle,
IconRobot,
IconSafe,
IconSearch,
IconSettings,
IconStorage,
IconSun,
IconThunderbolt,
IconUserGroup,
} from '@arco-design/web-react/icon'
import '@arco-design/web-react/dist/css/arco.css'
import './App.css'
const { Header, Sider, Content, Footer } = Layout
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
type Screen = 'login' | 'workbench'
type Theme = 'light' | 'dark'
type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | 'research' | 'design'
const projects = [
{ id: 'a1', name: '项目 A1', short: 'A1', badge: 635, urgent: true, color: '#165DFF' },
{ id: 'a2', name: '项目 A2', short: 'PM', badge: 36, color: '#00B42A' },
{ id: 'b1', name: '项目 B1', short: 'DS', badge: 4, urgent: true, color: '#722ED1' },
{ id: 'c3', name: '项目 C3', short: 'OP', badge: 45, color: '#FF7D00' },
]
const channels: Array<{ key: ChannelKey; label: string; count?: number; icon: ReactElement }> = [
{ key: 'overview', label: '概况', count: 635, icon: <IconHome /> },
{ key: 'inbox', label: 'Inbox 消息流', count: 36, icon: <IconEmail /> },
{ key: 'tasks', label: '工作计划', count: 12, icon: <IconList /> },
{ key: 'ai', label: 'AI 会话', count: 4, icon: <IconRobot /> },
{ key: 'notes', label: '笔记资料', count: 343, icon: <IconFile /> },
{ key: 'cron', label: 'Cron 计划任务', count: 45, icon: <IconClockCircle /> },
{ key: 'research', label: '客户研究频道', icon: <IconUserGroup /> },
{ key: 'design', label: '产品设计频道', icon: <IconLink /> },
]
const inbox = [
{ title: '竞争对手定价更新监控', meta: '来源:产品研究 · 竞品追踪', owner: '李明', time: '2 小时前', tag: '市场情报' },
{ title: '客户反馈:导出功能报错', meta: '来源:客户支持 · 工单 #CS-1287', owner: '王芳', time: '4 小时前', tag: '客户反馈' },
{ title: 'API 调用策略评估建议', meta: '来源:后端架构讨论', owner: '张伟', time: '6 小时前', tag: '架构' },
{ title: '更新需求文档 v1.3.2', meta: '来源:产品需求 · 文档评审', owner: '陈晨', time: '昨天', tag: '需求文档' },
]
const tasks = [
{ title: '智能报表导出功能', owner: '张伟', progress: '60%', due: '2026-07-24', tag: '进行中' },
{ title: '企业版权限体系梳理', owner: '李明', progress: '30%', due: '2026-07-28', tag: '进行中' },
{ title: 'Q3 营销策略制定', owner: '王芳', progress: '45%', due: '2026-07-31', tag: '进行中' },
]
const aiSessions = [
{ title: '生成 Q3 营销策略初稿', summary: '基于市场分析和竞品数据,输出 Q3 营销策略重点...', owner: '张伟', time: '今天 08:47' },
{ title: '产品定价模型讨论', summary: '针对企业版与标准版定价模型进行对比分析...', owner: '李明', time: '昨天 16:05' },
{ title: '客户流失原因分析', summary: '分析近三个月客户流失数据与反馈原因...', owner: '王芳', time: '昨天 10:22' },
{ title: '行业趋势与机会洞察', summary: '围绕 AI 在企业协同领域的趋势与机会...', owner: '张伟', time: '7 月 18 日' },
]
const notes = [
{ title: '用户权限体系设计文档 v2.1', type: '文档', updated: '2026-07-19', owner: '李明' },
{ title: '导出功能技术方案评审', type: '文档', updated: '2026-07-18', owner: '张伟' },
{ title: '2026 Q3 竞品功能对比表', type: '表格', updated: '2026-07-18', owner: '王芳' },
]
const discussions = [
{ name: '李明', time: '2026-07-20 09:12', text: '关于导出报错的问题,已在测试环境复现,初步定位为权限校验逻辑异常。' },
{ name: '张伟', time: '2026-07-20 09:18', text: '@李明 麻烦同步修复预计完成时间,我这边需要同步给客户。' },
{ name: '王芳', time: '2026-07-20 10:05', text: '补充:客户反馈影响范围主要集中在企业版用户。' },
{ name: '系统通知', time: '2026-07-20 10:30', text: '已将此讨论关联到任务:智能报表导出功能。' },
]
function App() {
const [screen, setScreen] = useState<Screen>('login')
const [theme, setTheme] = useState<Theme>('light')
const [activeProject, setActiveProject] = useState(projects[0])
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
const [selectedItem, setSelectedItem] = useState('Inbox 待处理36')
const dark = theme === 'dark'
return (
<ConfigProvider>
<main className={dark ? 'app theme-dark' : 'app'}>
{screen === 'login' ? (
<LoginPage onLogin={() => setScreen('workbench')} />
) : (
<Workbench
activeProject={activeProject}
activeChannel={activeChannel}
selectedItem={selectedItem}
theme={theme}
onSelectProject={setActiveProject}
onSelectChannel={setActiveChannel}
onSelectItem={setSelectedItem}
onToggleTheme={() => setTheme(dark ? 'light' : 'dark')}
/>
)}
</main>
</ConfigProvider>
)
}
function LoginPage({ onLogin }: { onLogin: () => void }) {
const [server, setServer] = useState('https://10.0.0.15:8080')
return (
<section className="login-screen">
<Card className="login-card" bordered>
<div className="login-brand-panel">
<div className="brand-lockup large">
<span className="brand-symbol">S</span>
<Title heading={2}>SenlinAI</Title>
</div>
<Text className="login-slogan"> · </Text>
<Text type="secondary"> · · AI </Text>
<Space className="login-footer-links" size={24}>
<Text type="secondary">v1.0.0</Text>
<Text type="secondary"></Text>
<Text type="secondary"></Text>
<Text type="secondary"> SenlinAI</Text>
</Space>
</div>
<div className="login-form-panel">
<Title heading={4}> SenlinAI</Title>
<Text type="secondary"></Text>
<Form layout="vertical" className="login-form">
<Form.Item label="服务器地址IP 或域名优先)">
<Input value={server} onChange={setServer} placeholder="例如https://10.0.0.15:8080" suffix={<IconLaunch />} />
</Form.Item>
<Form.Item label="账号">
<Input placeholder="请输入用户名或邮箱" />
</Form.Item>
<Form.Item label="密码">
<Input.Password placeholder="请输入密码" />
</Form.Item>
<div className="login-row">
<Space size={8}>
<span className="check-dot" />
<Text></Text>
</Space>
<Button type="text" size="mini">?</Button>
</div>
<Button type="primary" long size="large" onClick={onLogin}>
</Button>
<div className="connection-card">
<IconCheckCircleFill />
<div>
<Text className="connection-title"></Text>
<Text type="secondary"> {server}</Text>
</div>
<Button type="text" size="mini"></Button>
</div>
</Form>
</div>
<div className="login-value-panel">
<Title heading={3}> · · </Title>
<Paragraph>
SenlinAI
</Paragraph>
<ValuePoint icon={<IconSafe />} title="私有部署,数据自有" desc="所有数据与模型运行在您的环境中,安全可控。" />
<ValuePoint icon={<IconBook />} title="知识沉淀,结构清晰" desc="从收集到整理,形成可复用的团队知识库。" />
<ValuePoint icon={<IconUserGroup />} title="协同高效,聚焦成果" desc="围绕项目与任务协同,减少沟通成本,专注交付。" />
</div>
</Card>
</section>
)
}
function ValuePoint({ icon, title, desc }: { icon: ReactElement; title: string; desc: string }) {
return (
<div className="value-point">
<span>{icon}</span>
<div>
<Text className="value-title">{title}</Text>
<Text type="secondary">{desc}</Text>
</div>
</div>
)
}
function Workbench({
activeProject,
activeChannel,
selectedItem,
theme,
onSelectProject,
onSelectChannel,
onSelectItem,
onToggleTheme,
}: {
activeProject: (typeof projects)[number]
activeChannel: ChannelKey
selectedItem: string
theme: Theme
onSelectProject: (project: (typeof projects)[number]) => void
onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void
onToggleTheme: () => void
}) {
return (
<Layout className="workbench-shell">
<Header className="topbar">
<div className="brand-lockup">
<span className="brand-symbol">S</span>
<Text className="brand-name">SenlinAI</Text>
</div>
<div className="global-search" role="search">
<Input size="large" prefix={<IconSearch />} placeholder="搜索项目、频道、文档、任务、联系人..." />
<Button size="large" type="primary"></Button>
</div>
<Space className="topbar-actions">
<Button
aria-label={theme === 'dark' ? '切换浅色模式' : '切换深色模式'}
icon={theme === 'dark' ? <IconSun /> : <IconMoon />}
onClick={onToggleTheme}
/>
<Button icon={<IconArrowLeft />} disabled />
<Button icon={<IconArrowRight />} disabled />
<Badge count={8} dot>
<Button icon={<IconNotification />} />
</Badge>
<Button icon={<IconQuestionCircle />} />
<Button icon={<IconApps />} />
</Space>
</Header>
<Layout className="workbench-main">
<Sider className="project-rail" width={96}>
<Button className="dashboard-button" icon={<IconDashboard />} />
<Space className="project-stack" direction="vertical" size={12}>
{projects.map((project) => (
<Badge key={project.id} count={project.badge} dot={false} className={project.urgent ? 'project-badge urgent' : 'project-badge'}>
<button
className={activeProject.id === project.id ? 'project-button active' : 'project-button'}
onClick={() => onSelectProject(project)}
style={{ '--project-color': project.color } as CSSProperties}
title={project.name}
>
{project.short}
</button>
</Badge>
))}
</Space>
<Button className="create-project" aria-label="新建项目" icon={<IconPlus />} title="新建项目" />
</Sider>
<Sider className="channel-sidebar" width={248}>
<div className="project-title">
<Space>
<IconDashboard />
<Title heading={5}>{activeProject.name}</Title>
</Space>
<Button icon={<IconSettings />} size="mini" />
</div>
<div className="channel-list">
{channels.map((channel) => (
<button
key={channel.key}
className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'}
onClick={() => onSelectChannel(channel.key)}
>
<span className="channel-left">{channel.icon}<Text>{channel.label}</Text></span>
{channel.count !== undefined ? <Badge count={channel.count} maxCount={999} /> : <IconMore />}
</button>
))}
</div>
<Divider />
<section className="recent-sessions">
<Text className="section-title"></Text>
{['需求评审要点整理', '生成 Q3 营销策略初稿', 'npx 安装 skill 至 codex', '产品定价模型讨论'].map((title, index) => (
<button key={title} onClick={() => onSelectItem(title)}>
<Text>{title}</Text>
<Text type="secondary">{index === 0 ? '09:15' : index === 1 ? '08:47' : '昨天'}</Text>
</button>
))}
</section>
<Divider />
<section className="tag-cloud">
<Text className="section-title"></Text>
<Space wrap>
{['全部', '需求', '设计', '研发', '运营', 'AI'].map((tag) => (
<Tag key={tag} color={tag === '全部' ? 'arcoblue' : 'gray'}>{tag}</Tag>
))}
</Space>
</section>
</Sider>
<Content className="stage">
<OverviewContent onSelectItem={onSelectItem} />
</Content>
<Sider className="inspector" width={360}>
<Tabs defaultActiveTab="discussion">
<Tabs.TabPane key="discussion" title="讨论">
<InspectorDiscussion selectedItem={selectedItem} />
</Tabs.TabPane>
<Tabs.TabPane key="props" title="属性">
<div className="property-list">
<Property label="所属项目" value={activeProject.name} />
<Property label="频道" value={channels.find((c) => c.key === activeChannel)?.label ?? '概况'} />
<Property label="创建人" value="张伟" />
<Property label="更新时间" value="2026-07-20 10:30" />
</div>
</Tabs.TabPane>
<Tabs.TabPane key="more" title="更多">
<Space direction="vertical" className="more-actions">
<Button long></Button>
<Button long></Button>
<Button long status="danger"></Button>
</Space>
</Tabs.TabPane>
</Tabs>
</Sider>
</Layout>
<Footer className="statusbar">
<div className="status-user">
<Avatar size={26} style={{ backgroundColor: '#165DFF' }}></Avatar>
<div>
<Text className="status-name"></Text>
<Text className="status-online">线</Text>
</div>
<Button type="text" icon={<IconUserGroup />}></Button>
<Button type="text" icon={<IconMessage />}></Button>
<Button type="text" icon={<IconSettings />}></Button>
</div>
<Space className="status-tasks" size={28}>
<span><IconCheckCircleFill /> </span>
<span><IconInteractionFallback /> 3 </span>
<span><IconRobot /> AI </span>
<span><IconThunderbolt /> 稿</span>
</Space>
<Space className="status-system" size={24}>
<span><IconStorage /> 152GB </span>
<span> <b></b></span>
<IconApps />
</Space>
</Footer>
</Layout>
)
}
function IconInteractionFallback() {
return <IconCalendar />
}
function OverviewContent({ onSelectItem }: { onSelectItem: (title: string) => void }) {
const metrics = useMemo(() => [
{ title: 'Inbox 待处理', value: 36, delta: '较昨日 -8', icon: <IconEmail />, color: 'arcoblue' },
{ title: '进行中的任务', value: 12, delta: '较昨日 -2', icon: <IconCheckCircleFill />, color: 'green' },
{ title: 'AI 会话活跃', value: 4, delta: '较昨日 +1', icon: <IconRobot />, color: 'purple' },
{ title: '笔记资料总数', value: 343, delta: '较昨日 +5', icon: <IconFile />, color: 'cyan' },
{ title: '计划任务', value: 45, delta: '较昨日 +0', icon: <IconClockCircle />, color: 'orange' },
], [])
return (
<div className="overview-page">
<div className="overview-head">
<div>
<Text type="secondary"></Text>
<Title heading={4}></Title>
</div>
<Space>
<Button icon={<IconCalendar />}>2026-07-20</Button>
<Button icon={<IconMore />} />
</Space>
</div>
<Row gutter={12} className="metric-row">
{metrics.map((metric) => (
<Col span={24 / metrics.length} key={metric.title}>
<Card className="metric-card" bordered>
<Space align="start">
<Tag color={metric.color}>{metric.icon}</Tag>
<div>
<Text type="secondary">{metric.title}</Text>
<Title heading={3}>{metric.value}</Title>
<Text className="metric-delta">{metric.delta}</Text>
</div>
</Space>
</Card>
</Col>
))}
</Row>
<QueueSection title="Inbox 待处理36" items={inbox} onSelectItem={onSelectItem} />
<TaskSection onSelectItem={onSelectItem} />
<AISessionSection onSelectItem={onSelectItem} />
<NoteSection onSelectItem={onSelectItem} />
</div>
)
}
function QueueSection({ title, items, onSelectItem }: { title: string; items: typeof inbox; onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>{title}</Title>
<Button type="text" size="mini"></Button>
</div>
{items.map((item) => (
<button key={item.title} className="data-row" onClick={() => onSelectItem(item.title)}>
<span className="row-title"><IconFile /> {item.title}</span>
<span>{item.meta}</span>
<Tag>{item.tag}</Tag>
<span>{item.owner}</span>
<time>{item.time}</time>
</button>
))}
</Card>
)
}
function TaskSection({ onSelectItem }: { onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>12</Title>
<Button type="text" size="mini"></Button>
</div>
{tasks.map((task) => (
<button key={task.title} className="data-row task-row" onClick={() => onSelectItem(task.title)}>
<span className="row-title"><IconCheckCircleFill /> {task.title}</span>
<Tag color="arcoblue">{task.tag}</Tag>
<span> {task.progress}</span>
<span> {task.owner}</span>
<time> {task.due}</time>
</button>
))}
</Card>
)
}
function AISessionSection({ onSelectItem }: { onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>AI 4</Title>
<Button type="text" size="mini"></Button>
</div>
{aiSessions.map((session) => (
<button key={session.title} className="data-row ai-row" onClick={() => onSelectItem(session.title)}>
<span className="row-title"><IconRobot /> {session.title}</span>
<span>{session.summary}</span>
<span>{session.owner}</span>
<time>{session.time}</time>
</button>
))}
</Card>
)
}
function NoteSection({ onSelectItem }: { onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>5</Title>
<Button type="text" size="mini"></Button>
</div>
{notes.map((note) => (
<button key={note.title} className="data-row note-row" 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>
)
}
function InspectorDiscussion({ selectedItem }: { selectedItem: string }) {
return (
<div className="discussion-panel">
<div className="inspector-title">
<Title heading={5}>{selectedItem}</Title>
<Button size="mini" icon={<IconMore />} />
</div>
<Text type="secondary"></Text>
<Divider />
<Space direction="vertical" size={18} className="discussion-list">
{discussions.map((item) => (
<div className="discussion-item" key={`${item.name}-${item.time}`}>
<Avatar size={30}>{item.name.slice(0, 1)}</Avatar>
<div>
<Space>
<Text className="discussion-name">{item.name}</Text>
<Text type="secondary">{item.time}</Text>
</Space>
<Paragraph>{item.text}</Paragraph>
<Button type="text" size="mini"></Button>
</div>
</div>
))}
</Space>
<div className="comment-box">
<Input.TextArea placeholder="写下你的评论Enter 发送,⌘ + Enter 换行" autoSize={{ minRows: 3, maxRows: 4 }} />
<div className="comment-actions">
<Space>
<Button type="text" icon={<IconAttachmentFallback />} />
<Button type="text" icon={<IconMessage />} />
</Space>
<Button type="primary" onClick={() => Message.success('评论已发送(原型)')}></Button>
</div>
</div>
</div>
)
}
function IconAttachmentFallback() {
return <IconLink />
}
function Property({ label, value }: { label: string; value: string }) {
return (
<div className="property-row">
<Text type="secondary">{label}</Text>
<Text>{value}</Text>
</div>
)
}
export default App

View File

@@ -0,0 +1,41 @@
import { useState } from 'react'
import { ConfigProvider } from '@arco-design/web-react'
import '@arco-design/web-react/dist/css/arco.css'
import '../App.css'
import { LoginPage } from '../pages/login'
import { ProjectPage } from '../pages/project'
import { projects } from '../features/projects/project-data'
import type { ChannelKey, Screen, Theme } from '../features/projects/project-types'
function App() {
const [screen, setScreen] = useState<Screen>('login')
const [theme, setTheme] = useState<Theme>('light')
const [activeProject, setActiveProject] = useState(projects[0])
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
const [selectedItem, setSelectedItem] = useState('Inbox 待处理36')
const dark = theme === 'dark'
return (
<ConfigProvider>
<main className={dark ? 'app theme-dark' : 'app'}>
{screen === 'login' ? (
<LoginPage onLogin={() => setScreen('workbench')} />
) : (
<ProjectPage
activeProject={activeProject}
activeChannel={activeChannel}
selectedItem={selectedItem}
theme={theme}
onSelectProject={setActiveProject}
onSelectChannel={setActiveChannel}
onSelectItem={setSelectedItem}
onToggleTheme={() => setTheme(dark ? 'light' : 'dark')}
/>
)}
</main>
</ConfigProvider>
)
}
export default App

View File

@@ -0,0 +1,53 @@
import { IconClockCircle, IconEmail, IconFile, IconHome, IconLink, IconList, IconRobot, IconUserGroup } from '@arco-design/web-react/icon'
import type { Project, ProjectChannel } from './project-types'
export const projects: Project[] = [
{ id: 'a1', name: '项目 A1', short: 'A1', badge: 635, urgent: true, color: '#165DFF' },
{ id: 'a2', name: '项目 A2', short: 'PM', badge: 36, color: '#00B42A' },
{ id: 'b1', name: '项目 B1', short: 'DS', badge: 4, urgent: true, color: '#722ED1' },
{ id: 'c3', name: '项目 C3', short: 'OP', badge: 45, color: '#FF7D00' },
]
export const channels: ProjectChannel[] = [
{ key: 'overview', label: '概况', count: 635, icon: <IconHome /> },
{ key: 'inbox', label: 'Inbox 消息流', count: 36, icon: <IconEmail /> },
{ key: 'tasks', label: '工作计划', count: 12, icon: <IconList /> },
{ key: 'ai', label: 'AI 会话', count: 4, icon: <IconRobot /> },
{ key: 'notes', label: '笔记资料', count: 343, icon: <IconFile /> },
{ key: 'cron', label: 'Cron 计划任务', count: 45, icon: <IconClockCircle /> },
{ key: 'research', label: '客户研究频道', icon: <IconUserGroup /> },
{ key: 'design', label: '产品设计频道', icon: <IconLink /> },
]
export const inbox = [
{ title: '竞争对手定价更新监控', meta: '来源:产品研究 · 竞品追踪', owner: '李明', time: '2 小时前', tag: '市场情报' },
{ title: '客户反馈:导出功能报错', meta: '来源:客户支持 · 工单 #CS-1287', owner: '王芳', time: '4 小时前', tag: '客户反馈' },
{ title: 'API 调用策略评估建议', meta: '来源:后端架构讨论', owner: '张伟', time: '6 小时前', tag: '架构' },
{ title: '更新需求文档 v1.3.2', meta: '来源:产品需求 · 文档评审', owner: '陈晨', time: '昨天', tag: '需求文档' },
]
export const tasks = [
{ title: '智能报表导出功能', owner: '张伟', progress: '60%', due: '2026-07-24', tag: '进行中' },
{ title: '企业版权限体系梳理', owner: '李明', progress: '30%', due: '2026-07-28', tag: '进行中' },
{ title: 'Q3 营销策略制定', owner: '王芳', progress: '45%', due: '2026-07-31', tag: '进行中' },
]
export const aiSessions = [
{ title: '生成 Q3 营销策略初稿', summary: '基于市场分析和竞品数据,输出 Q3 营销策略重点...', owner: '张伟', time: '今天 08:47' },
{ title: '产品定价模型讨论', summary: '针对企业版与标准版定价模型进行对比分析...', owner: '李明', time: '昨天 16:05' },
{ title: '客户流失原因分析', summary: '分析近三个月客户流失数据与反馈原因...', owner: '王芳', time: '昨天 10:22' },
{ title: '行业趋势与机会洞察', summary: '围绕 AI 在企业协同领域的趋势与机会...', owner: '张伟', time: '7 月 18 日' },
]
export const notes = [
{ title: '用户权限体系设计文档 v2.1', type: '文档', updated: '2026-07-19', owner: '李明' },
{ title: '导出功能技术方案评审', type: '文档', updated: '2026-07-18', owner: '张伟' },
{ title: '2026 Q3 竞品功能对比表', type: '表格', updated: '2026-07-18', owner: '王芳' },
]
export const discussions = [
{ name: '李明', time: '2026-07-20 09:12', text: '关于导出报错的问题,已在测试环境复现,初步定位为权限校验逻辑异常。' },
{ name: '张伟', time: '2026-07-20 09:18', text: '@李明 麻烦同步修复预计完成时间,我这边需要同步给客户。' },
{ name: '王芳', time: '2026-07-20 10:05', text: '补充:客户反馈影响范围主要集中在企业版用户。' },
{ name: '系统通知', time: '2026-07-20 10:30', text: '已将此讨论关联到任务:智能报表导出功能。' },
]

View File

@@ -0,0 +1,70 @@
import { Avatar, Button, Divider, Input, Layout, Message, Space, Tabs, Typography } from '@arco-design/web-react'
import { IconLink, IconMessage, IconMore } from '@arco-design/web-react/icon'
import { channels, discussions } from './project-data'
import type { ChannelKey, Project } from './project-types'
const { Sider } = Layout
const { Title, Text, Paragraph } = Typography
export function ProjectInspector({ activeProject, activeChannel, selectedItem }: { activeProject: Project; activeChannel: ChannelKey; selectedItem: string }) {
return (
<Sider className="inspector" width={360}>
<Tabs defaultActiveTab="discussion">
<Tabs.TabPane key="discussion" title="讨论"><InspectorDiscussion selectedItem={selectedItem} /></Tabs.TabPane>
<Tabs.TabPane key="props" title="属性"><div className="property-list"><Property label="所属项目" value={activeProject.name} /><Property label="频道" value={channels.find((c) => c.key === activeChannel)?.label ?? '概况'} /><Property label="创建人" value="张伟" /><Property label="更新时间" value="2026-07-20 10:30" /></div></Tabs.TabPane>
<Tabs.TabPane key="more" title="更多"><Space direction="vertical" className="more-actions"><Button long></Button><Button long></Button><Button long status="danger"></Button></Space></Tabs.TabPane>
</Tabs>
</Sider>
)
}
function InspectorDiscussion({ selectedItem }: { selectedItem: string }) {
return (
<div className="discussion-panel">
<div className="inspector-title">
<Title heading={5}>{selectedItem}</Title>
<Button size="mini" icon={<IconMore />} />
</div>
<Text type="secondary"></Text>
<Divider />
<Space direction="vertical" size={18} className="discussion-list">
{discussions.map((item) => (
<div className="discussion-item" key={`${item.name}-${item.time}`}>
<Avatar size={30}>{item.name.slice(0, 1)}</Avatar>
<div>
<Space>
<Text className="discussion-name">{item.name}</Text>
<Text type="secondary">{item.time}</Text>
</Space>
<Paragraph>{item.text}</Paragraph>
<Button type="text" size="mini"></Button>
</div>
</div>
))}
</Space>
<div className="comment-box">
<Input.TextArea placeholder="写下你的评论Enter 发送,⌘ + Enter 换行" autoSize={{ minRows: 3, maxRows: 4 }} />
<div className="comment-actions">
<Space>
<Button type="text" icon={<IconAttachmentFallback />} />
<Button type="text" icon={<IconMessage />} />
</Space>
<Button type="primary" onClick={() => Message.success('评论已发送(原型)')}></Button>
</div>
</div>
</div>
)
}
function IconAttachmentFallback() {
return <IconLink />
}
function Property({ label, value }: { label: string; value: string }) {
return (
<div className="property-row">
<Text type="secondary">{label}</Text>
<Text>{value}</Text>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import type { CSSProperties } from 'react'
import { Badge, Button, Layout, Space } from '@arco-design/web-react'
import { IconDashboard, IconPlus } from '@arco-design/web-react/icon'
import { projects } from './project-data'
import type { Project } from './project-types'
const { Sider } = Layout
export function ProjectRail({ activeProject, onSelectProject }: { activeProject: Project; onSelectProject: (project: Project) => void }) {
return (
<Sider className="project-rail" width={96}>
<Button className="dashboard-button" icon={<IconDashboard />} />
<Space className="project-stack" direction="vertical" size={12}>
{projects.map((project) => (
<Badge key={project.id} count={project.badge} dot={false} className={project.urgent ? 'project-badge urgent' : 'project-badge'}>
<button className={activeProject.id === project.id ? 'project-button active' : 'project-button'} onClick={() => onSelectProject(project)} style={{ '--project-color': project.color } as CSSProperties} title={project.name}>
{project.short}
</button>
</Badge>
))}
</Space>
<Button className="create-project" aria-label="新建项目" icon={<IconPlus />} title="新建项目" />
</Sider>
)
}

View File

@@ -0,0 +1,35 @@
import { Badge, Button, Divider, Layout, Space, Tag, Typography } from '@arco-design/web-react'
import { IconDashboard, IconMore, IconSettings } from '@arco-design/web-react/icon'
import { channels } from './project-data'
import type { ChannelKey, Project } from './project-types'
const { Sider } = Layout
const { Title, Text } = Typography
export function ProjectSidebar({ activeProject, activeChannel, onSelectChannel, onSelectItem }: { activeProject: Project; activeChannel: ChannelKey; onSelectChannel: (key: ChannelKey) => void; onSelectItem: (title: string) => void }) {
return (
<Sider className="channel-sidebar" width={248}>
<div className="project-title"><Space><IconDashboard /><Title heading={5}>{activeProject.name}</Title></Space><Button icon={<IconSettings />} size="mini" /></div>
<div className="channel-list">
{channels.map((channel) => (
<button key={channel.key} className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'} onClick={() => onSelectChannel(channel.key)}>
<span className="channel-left">{channel.icon}<Text>{channel.label}</Text></span>
{channel.count !== undefined ? <Badge count={channel.count} maxCount={999} /> : <IconMore />}
</button>
))}
</div>
<Divider />
<section className="recent-sessions">
<Text className="section-title"></Text>
{['需求评审要点整理', '生成 Q3 营销策略初稿', 'npx 安装 skill 至 codex', '产品定价模型讨论'].map((title, index) => (
<button key={title} onClick={() => onSelectItem(title)}><Text>{title}</Text><Text type="secondary">{index === 0 ? '09:15' : index === 1 ? '08:47' : '昨天'}</Text></button>
))}
</section>
<Divider />
<section className="tag-cloud">
<Text className="section-title"></Text>
<Space wrap>{['全部', '需求', '设计', '研发', '运营', 'AI'].map((tag) => <Tag key={tag} color={tag === '全部' ? 'arcoblue' : 'gray'}>{tag}</Tag>)}</Space>
</section>
</Sider>
)
}

View File

@@ -0,0 +1,19 @@
import { Avatar, Button, Layout, Space, Typography } from '@arco-design/web-react'
import { IconApps, IconCalendar, IconCheckCircleFill, IconMessage, IconRobot, IconSettings, IconStorage, IconThunderbolt, IconUserGroup } from '@arco-design/web-react/icon'
const { Footer } = Layout
const { Text } = Typography
export function ProjectStatusbar() {
return (
<Footer className="statusbar">
<div className="status-user"><Avatar size={26} style={{ backgroundColor: '#165DFF' }}></Avatar><div><Text className="status-name"></Text><Text className="status-online">线</Text></div><Button type="text" icon={<IconUserGroup />}></Button><Button type="text" icon={<IconMessage />}></Button><Button type="text" icon={<IconSettings />}></Button></div>
<Space className="status-tasks" size={28}><span><IconCheckCircleFill /> </span><span><IconInteractionFallback /> 3 </span><span><IconRobot /> AI </span><span><IconThunderbolt /> 稿</span></Space>
<Space className="status-system" size={24}><span><IconStorage /> 152GB </span><span> <b></b></span><IconApps /></Space>
</Footer>
)
}
function IconInteractionFallback() {
return <IconCalendar />
}

View File

@@ -0,0 +1,29 @@
import { Badge, Button, Input, Layout, Space, Typography } from '@arco-design/web-react'
import { IconApps, IconArrowLeft, IconArrowRight, IconMoon, IconNotification, IconQuestionCircle, IconSearch, IconSun } from '@arco-design/web-react/icon'
import type { Theme } from './project-types'
const { Header } = Layout
const { Text } = Typography
export function ProjectTopbar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => void }) {
return (
<Header className="topbar">
<div className="brand-lockup">
<span className="brand-symbol">S</span>
<Text className="brand-name">SenlinAI</Text>
</div>
<div className="global-search" role="search">
<Input size="large" prefix={<IconSearch />} placeholder="搜索项目、频道、文档、任务、联系人..." />
<Button size="large" type="primary"></Button>
</div>
<Space className="topbar-actions">
<Button aria-label={theme === 'dark' ? '切换浅色模式' : '切换深色模式'} icon={theme === 'dark' ? <IconSun /> : <IconMoon />} onClick={onToggleTheme} />
<Button icon={<IconArrowLeft />} disabled />
<Button icon={<IconArrowRight />} disabled />
<Badge count={8} dot><Button icon={<IconNotification />} /></Badge>
<Button icon={<IconQuestionCircle />} />
<Button icon={<IconApps />} />
</Space>
</Header>
)
}

View File

@@ -0,0 +1,21 @@
import type { ReactElement } from 'react'
export type Screen = 'login' | 'workbench'
export type Theme = 'light' | 'dark'
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | 'research' | 'design'
export type Project = {
id: string
name: string
short: string
badge: number
urgent?: boolean
color: string
}
export type ProjectChannel = {
key: ChannelKey
label: string
count?: number
icon: ReactElement
}

View File

@@ -1,7 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import App from './app/App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>

View File

@@ -0,0 +1,87 @@
import type { ReactElement } from 'react'
import { useState } from 'react'
import { Button, Card, Form, Input, Space, Typography } from '@arco-design/web-react'
import { IconBook, IconCheckCircleFill, IconLaunch, IconSafe, IconUserGroup } from '@arco-design/web-react/icon'
const { Title, Text, Paragraph } = Typography
export function LoginPage({ onLogin }: { onLogin: () => void }) {
const [server, setServer] = useState('https://10.0.0.15:8080')
return (
<section className="login-screen">
<Card className="login-card" bordered>
<div className="login-brand-panel">
<div className="brand-lockup large">
<span className="brand-symbol">S</span>
<Title heading={2}>SenlinAI</Title>
</div>
<Text className="login-slogan"> · </Text>
<Text type="secondary"> · · AI </Text>
<Space className="login-footer-links" size={24}>
<Text type="secondary">v1.0.0</Text>
<Text type="secondary"></Text>
<Text type="secondary"></Text>
<Text type="secondary"> SenlinAI</Text>
</Space>
</div>
<div className="login-form-panel">
<Title heading={4}> SenlinAI</Title>
<Text type="secondary"></Text>
<Form layout="vertical" className="login-form">
<Form.Item label="服务器地址IP 或域名优先)">
<Input value={server} onChange={setServer} placeholder="例如https://10.0.0.15:8080" suffix={<IconLaunch />} />
</Form.Item>
<Form.Item label="账号">
<Input placeholder="请输入用户名或邮箱" />
</Form.Item>
<Form.Item label="密码">
<Input.Password placeholder="请输入密码" />
</Form.Item>
<div className="login-row">
<Space size={8}>
<span className="check-dot" />
<Text></Text>
</Space>
<Button type="text" size="mini">?</Button>
</div>
<Button type="primary" long size="large" onClick={onLogin}>
</Button>
<div className="connection-card">
<IconCheckCircleFill />
<div>
<Text className="connection-title"></Text>
<Text type="secondary"> {server}</Text>
</div>
<Button type="text" size="mini"></Button>
</div>
</Form>
</div>
<div className="login-value-panel">
<Title heading={3}> · · </Title>
<Paragraph>
SenlinAI
</Paragraph>
<ValuePoint icon={<IconSafe />} title="私有部署,数据自有" desc="所有数据与模型运行在您的环境中,安全可控。" />
<ValuePoint icon={<IconBook />} title="知识沉淀,结构清晰" desc="从收集到整理,形成可复用的团队知识库。" />
<ValuePoint icon={<IconUserGroup />} title="协同高效,聚焦成果" desc="围绕项目与任务协同,减少沟通成本,专注交付。" />
</div>
</Card>
</section>
)
}
function ValuePoint({ icon, title, desc }: { icon: ReactElement; title: string; desc: string }) {
return (
<div className="value-point">
<span>{icon}</span>
<div>
<Text className="value-title">{title}</Text>
<Text type="secondary">{desc}</Text>
</div>
</div>
)
}

View File

@@ -0,0 +1,132 @@
import { useMemo } from 'react'
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { IconCalendar, IconCheckCircleFill, IconClockCircle, IconEmail, IconFile, IconMore, IconRobot } from '@arco-design/web-react/icon'
import { aiSessions, inbox, notes, tasks } from '../features/projects/project-data'
const { Row, Col } = Grid
const { Title, Text } = Typography
export function OverviewPage({ onSelectItem }: { onSelectItem: (title: string) => void }) {
const metrics = useMemo(() => [
{ title: 'Inbox 待处理', value: 36, delta: '较昨日 -8', icon: <IconEmail />, color: 'arcoblue' },
{ title: '进行中的任务', value: 12, delta: '较昨日 -2', icon: <IconCheckCircleFill />, color: 'green' },
{ title: 'AI 会话活跃', value: 4, delta: '较昨日 +1', icon: <IconRobot />, color: 'purple' },
{ title: '笔记资料总数', value: 343, delta: '较昨日 +5', icon: <IconFile />, color: 'cyan' },
{ title: '计划任务', value: 45, delta: '较昨日 +0', icon: <IconClockCircle />, color: 'orange' },
], [])
return (
<div className="overview-page">
<div className="overview-head">
<div>
<Text type="secondary"></Text>
<Title heading={4}></Title>
</div>
<Space>
<Button icon={<IconCalendar />}>2026-07-20</Button>
<Button icon={<IconMore />} />
</Space>
</div>
<Row gutter={12} className="metric-row">
{metrics.map((metric) => (
<Col span={24 / metrics.length} key={metric.title}>
<Card className="metric-card" bordered>
<Space align="start">
<Tag color={metric.color}>{metric.icon}</Tag>
<div>
<Text type="secondary">{metric.title}</Text>
<Title heading={3}>{metric.value}</Title>
<Text className="metric-delta">{metric.delta}</Text>
</div>
</Space>
</Card>
</Col>
))}
</Row>
<QueueSection title="Inbox 待处理36" items={inbox} onSelectItem={onSelectItem} />
<TaskSection onSelectItem={onSelectItem} />
<AISessionSection onSelectItem={onSelectItem} />
<NoteSection onSelectItem={onSelectItem} />
</div>
)
}
function QueueSection({ title, items, onSelectItem }: { title: string; items: typeof inbox; onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>{title}</Title>
<Button type="text" size="mini"></Button>
</div>
{items.map((item) => (
<button key={item.title} className="data-row" onClick={() => onSelectItem(item.title)}>
<span className="row-title"><IconFile /> {item.title}</span>
<span>{item.meta}</span>
<Tag>{item.tag}</Tag>
<span>{item.owner}</span>
<time>{item.time}</time>
</button>
))}
</Card>
)
}
function TaskSection({ onSelectItem }: { onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>12</Title>
<Button type="text" size="mini"></Button>
</div>
{tasks.map((task) => (
<button key={task.title} className="data-row task-row" onClick={() => onSelectItem(task.title)}>
<span className="row-title"><IconCheckCircleFill /> {task.title}</span>
<Tag color="arcoblue">{task.tag}</Tag>
<span> {task.progress}</span>
<span> {task.owner}</span>
<time> {task.due}</time>
</button>
))}
</Card>
)
}
function AISessionSection({ onSelectItem }: { onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>AI 4</Title>
<Button type="text" size="mini"></Button>
</div>
{aiSessions.map((session) => (
<button key={session.title} className="data-row ai-row" onClick={() => onSelectItem(session.title)}>
<span className="row-title"><IconRobot /> {session.title}</span>
<span>{session.summary}</span>
<span>{session.owner}</span>
<time>{session.time}</time>
</button>
))}
</Card>
)
}
function NoteSection({ onSelectItem }: { onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>5</Title>
<Button type="text" size="mini"></Button>
</div>
{notes.map((note) => (
<button key={note.title} className="data-row note-row" 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>
)
}

View File

@@ -0,0 +1,55 @@
import { Layout } from '@arco-design/web-react'
import { OverviewPage } from './overview'
import { ProjectInspector } from '../features/projects/project-inspector'
import { ProjectRail } from '../features/projects/project-rail'
import { ProjectSidebar } from '../features/projects/project-sidebar'
import { ProjectStatusbar } from '../features/projects/project-statusbar'
import { ProjectTopbar } from '../features/projects/project-topbar'
import type { ChannelKey, Project, Theme } from '../features/projects/project-types'
const { Content } = Layout
export function ProjectPage({
activeProject,
activeChannel,
selectedItem,
theme,
onSelectProject,
onSelectChannel,
onSelectItem,
onToggleTheme,
}: {
activeProject: Project
activeChannel: ChannelKey
selectedItem: string
theme: Theme
onSelectProject: (project: Project) => void
onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void
onToggleTheme: () => void
}) {
return (
<Layout className="workbench-shell">
<ProjectTopbar theme={theme} onToggleTheme={onToggleTheme} />
<Layout className="workbench-main">
<ProjectRail activeProject={activeProject} onSelectProject={onSelectProject} />
<ProjectSidebar
activeProject={activeProject}
activeChannel={activeChannel}
onSelectChannel={onSelectChannel}
onSelectItem={onSelectItem}
/>
<Content className="stage">
<OverviewPage onSelectItem={onSelectItem} />
</Content>
<ProjectInspector activeProject={activeProject} activeChannel={activeChannel} selectedItem={selectedItem} />
</Layout>
<ProjectStatusbar />
</Layout>
)
}