Promote React Arco client into apps

This commit is contained in:
2026-07-20 10:37:40 +08:00
parent aa2bf9b88f
commit bbd6c62029
21 changed files with 40 additions and 42 deletions

View File

@@ -4,10 +4,10 @@
"version": "0.1.0",
"identifier": "ai.senlin.workbench",
"build": {
"frontendDist": "../../web/dist",
"frontendDist": "../../senlinai-acro-react/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "cd ../web && npm run dev",
"beforeBuildCommand": "cd ../web && npm run build"
"beforeDevCommand": "cd ../senlinai-acro-react && npm run dev",
"beforeBuildCommand": "cd ../senlinai-acro-react && npm run build"
},
"app": {
"windows": [

25
apps/senlinai-acro-react/.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
test-results
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

View File

@@ -0,0 +1,17 @@
# SenlinAI Acro React
SenlinAI primary web client built with React, TypeScript, Vite, and Arco Design.
This app is now the active web client under `apps`. The legacy `apps/web` Svelte app is no longer the primary frontend.
## Scripts
```bash
npm install
npm run dev
npm run build
npm run lint
node scripts/visual-check.mjs
```
Default local preview port: `4174`.

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>senlinai-acro-react</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1803
apps/senlinai-acro-react/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"name": "senlinai-acro-react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@arco-design/web-react": "^2.66.16",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"playwright": "^1.61.1",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,143 @@
import { createServer } from 'vite'
import { chromium } from 'playwright'
const port = Number(process.env.VISUAL_CHECK_PORT ?? 4175)
const server = await createServer({
root: process.cwd(),
server: {
host: '127.0.0.1',
port,
strictPort: true,
},
})
await server.listen()
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({ viewport: { width: 1440, height: 1024 }, deviceScaleFactor: 1 })
const errors = []
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text())
})
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle' })
await page.screenshot({ path: 'test-results/login-react-acro.png', fullPage: true })
await page.locator('.login-form-panel .arco-btn-primary').click()
await page.waitForSelector('.workbench-shell')
await page.waitForTimeout(700)
await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true })
await page.locator('.topbar-actions .arco-btn').first().click()
await page.screenshot({ path: 'test-results/workbench-react-acro-dark.png', fullPage: true })
const metrics = await page.evaluate(() => {
const statusbar = document.querySelector('.statusbar')
const search = document.querySelector('.global-search')
const rail = document.querySelector('.project-rail')
const railChildren = rail?.querySelector('.arco-layout-sider-children')
const dashboard = document.querySelector('.dashboard-button')
const project = document.querySelector('.project-button')
const create = document.querySelector('.create-project')
const firstBadge = document.querySelector('.project-badge .arco-badge-number')
const railItems = [...document.querySelectorAll('.dashboard-button, .project-button, .create-project')]
const rect = (node) => {
const box = node?.getBoundingClientRect()
return box ? { left: box.left, right: box.right, top: box.top, width: box.width, height: box.height } : null
}
const railBox = rail?.getBoundingClientRect()
const itemRects = railItems.map(rect).filter(Boolean)
const horizontalInsets = railBox && project
? {
left: project.getBoundingClientRect().left - railBox.left,
right: railBox.right - project.getBoundingClientRect().right,
}
: null
const verticalGaps = itemRects.slice(1).map((item, index) => item.top - (itemRects[index].top + itemRects[index].height))
const overflowState = (node) => node
? {
clientWidth: node.clientWidth,
scrollWidth: node.scrollWidth,
clientHeight: node.clientHeight,
scrollHeight: node.scrollHeight,
overflowX: getComputedStyle(node).overflowX,
overflowY: getComputedStyle(node).overflowY,
}
: null
return {
statusbarHeight: statusbar?.getBoundingClientRect().height,
searchHeight: search?.getBoundingClientRect().height,
projectRailWidth: rail?.getBoundingClientRect().width,
dashboardButton: rect(dashboard),
projectButton: rect(project),
createProjectButton: rect(create),
firstProjectBadge: rect(firstBadge),
horizontalInsets,
verticalGaps,
projectRailOverflow: overflowState(rail),
projectRailChildrenOverflow: overflowState(railChildren),
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
}
})
await browser.close()
await server.close()
console.log(JSON.stringify({ metrics, errors }, null, 2))
const failures = []
if (errors.length) failures.push(`console errors: ${errors.join('; ')}`)
if (metrics.statusbarHeight !== 32) failures.push(`expected statusbar height 32, got ${metrics.statusbarHeight}`)
if (metrics.searchHeight !== 42) failures.push(`expected search height 42, got ${metrics.searchHeight}`)
if (metrics.overflowX) failures.push('expected no horizontal overflow')
if (!metrics.projectRailWidth || metrics.projectRailWidth < 88) {
failures.push(`expected project rail at least 88px wide, got ${metrics.projectRailWidth}`)
}
if (!metrics.projectButton) failures.push('missing project button')
if (!metrics.horizontalInsets || Math.abs(metrics.horizontalInsets.left - metrics.horizontalInsets.right) > 1) {
failures.push(`expected equal rail horizontal insets, got ${JSON.stringify(metrics.horizontalInsets)}`)
}
if (metrics.verticalGaps.some((gap) => gap !== 12)) {
failures.push(`expected all project rail vertical gaps to be 12px, got ${JSON.stringify(metrics.verticalGaps)}`)
}
for (const [name, overflow] of [
['project rail', metrics.projectRailOverflow],
['project rail children', metrics.projectRailChildrenOverflow],
]) {
if (
!overflow ||
overflow.overflowX !== 'hidden' ||
overflow.overflowY !== 'hidden'
) {
failures.push(`expected ${name} scrollbars to be hidden with no overflow, got ${JSON.stringify(overflow)}`)
}
}
if (
!metrics.firstProjectBadge ||
metrics.firstProjectBadge.right > metrics.projectRailWidth ||
metrics.firstProjectBadge.left < 0
) {
failures.push(`expected first project badge to stay inside the project rail, got ${JSON.stringify(metrics.firstProjectBadge)}`)
}
if (
!metrics.dashboardButton ||
metrics.dashboardButton.width !== metrics.projectButton?.width ||
metrics.dashboardButton.height !== metrics.projectButton?.height
) {
failures.push(
`dashboard button size ${JSON.stringify(metrics.dashboardButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`,
)
}
if (
!metrics.createProjectButton ||
metrics.createProjectButton.width !== metrics.projectButton?.width ||
metrics.createProjectButton.height !== metrics.projectButton?.height
) {
failures.push(
`create project button size ${JSON.stringify(metrics.createProjectButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`,
)
}
if (failures.length) {
throw new Error(failures.join('\n'))
}

View File

@@ -0,0 +1,694 @@
:root {
--senlin-primary: #165dff;
--senlin-bg: #f7f8fa;
--senlin-panel: #ffffff;
--senlin-border: #e5e6eb;
--senlin-text: #1d2129;
--senlin-muted: #86909c;
--senlin-soft-blue: #e8f3ff;
--senlin-status: #142033;
}
.app {
min-height: 100vh;
overflow-x: hidden;
background: var(--senlin-bg);
color: var(--senlin-text);
}
.theme-dark {
--senlin-bg: #1e1e1e;
--senlin-panel: #252526;
--senlin-border: #3c3c3c;
--senlin-text: #cccccc;
--senlin-muted: #858585;
--senlin-soft-blue: rgba(0, 122, 204, 0.22);
--senlin-primary: #007acc;
--senlin-status: #007acc;
}
.theme-dark .arco-layout,
.theme-dark .arco-card,
.theme-dark .arco-tabs,
.theme-dark .arco-input,
.theme-dark .arco-textarea,
.theme-dark .arco-btn-secondary {
background: var(--senlin-panel);
color: var(--senlin-text);
}
.theme-dark .arco-card,
.theme-dark .arco-input,
.theme-dark .arco-textarea,
.theme-dark .arco-btn,
.theme-dark .arco-tabs-header-title {
border-color: var(--senlin-border);
}
.theme-dark .arco-typography,
.theme-dark .arco-typography p,
.theme-dark .arco-typography h1,
.theme-dark .arco-typography h2,
.theme-dark .arco-typography h3,
.theme-dark .arco-typography h4,
.theme-dark .arco-typography h5,
.theme-dark .arco-typography h6 {
color: var(--senlin-text);
}
.theme-dark .arco-typography-secondary {
color: var(--senlin-muted);
}
.login-screen {
min-height: 100vh;
display: grid;
place-items: center;
padding: 40px;
background:
radial-gradient(circle at 0 0, rgba(22, 93, 255, 0.08), transparent 28%),
linear-gradient(180deg, #f3f7ff 0%, #f7f8fa 42%, #ffffff 100%);
}
.login-card.arco-card {
width: min(1180px, 96vw);
min-height: 430px;
overflow: hidden;
border-color: var(--senlin-border);
border-radius: 8px;
box-shadow: 0 12px 36px rgba(29, 33, 41, 0.08);
}
.login-card .arco-card-body {
display: grid;
grid-template-columns: 1fr 1.1fr 1fr;
padding: 0;
}
.login-brand-panel,
.login-form-panel,
.login-value-panel {
min-height: 430px;
padding: 42px;
}
.login-brand-panel,
.login-form-panel {
border-right: 1px solid var(--senlin-border);
}
.login-brand-panel {
display: grid;
align-content: center;
gap: 12px;
}
.brand-lockup {
display: flex;
align-items: center;
gap: 10px;
}
.brand-lockup.large {
gap: 16px;
}
.brand-symbol {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 8px;
background: var(--senlin-primary);
color: white;
font-weight: 800;
font-size: 18px;
}
.brand-lockup.large .brand-symbol {
width: 46px;
height: 46px;
border-radius: 12px;
font-size: 24px;
}
.brand-name {
font-size: 18px;
font-weight: 700;
}
.login-slogan {
margin-top: 28px;
font-size: 16px;
font-weight: 600;
}
.login-footer-links {
align-self: end;
flex-wrap: wrap;
margin-top: 72px;
}
.login-footer-links .arco-typography {
white-space: nowrap;
}
.login-form-panel {
display: grid;
align-content: center;
}
.login-form {
margin-top: 20px;
}
.login-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.check-dot {
width: 14px;
height: 14px;
display: inline-block;
border-radius: 3px;
background: var(--senlin-primary);
box-shadow: inset 0 0 0 3px white;
}
.connection-card {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
margin-top: 14px;
padding: 10px 12px;
border: 1px solid #7be188;
border-radius: 6px;
background: #f0fff4;
color: #168a2f;
}
.connection-title {
display: block;
color: #168a2f;
font-weight: 600;
}
.login-value-panel {
display: grid;
align-content: center;
gap: 18px;
}
.value-point {
display: grid;
grid-template-columns: 42px 1fr;
gap: 12px;
align-items: center;
}
.value-point > span {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 999px;
background: var(--senlin-soft-blue);
color: var(--senlin-primary);
font-size: 22px;
}
.value-title {
display: block;
font-weight: 700;
}
.workbench-shell {
height: 100vh;
display: grid;
grid-template-rows: 58px minmax(0, 1fr) 32px;
overflow: hidden;
background: var(--senlin-bg);
}
.topbar {
display: grid;
grid-template-columns: 210px minmax(480px, 680px) 1fr;
align-items: center;
gap: 18px;
height: 58px;
padding: 0 16px;
border-bottom: 1px solid var(--senlin-border);
background: var(--senlin-panel);
}
.global-search {
display: grid;
grid-template-columns: minmax(0, 1fr) 76px;
height: 42px;
border: 1px solid var(--senlin-primary);
border-radius: 6px;
overflow: hidden;
box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.08);
}
.global-search .arco-input-inner-wrapper {
height: 40px;
border: 0;
border-radius: 0;
background: var(--senlin-panel);
}
.global-search .arco-btn {
height: 40px;
border-radius: 0;
}
.topbar-actions {
justify-content: flex-end;
}
.workbench-main {
min-height: 0;
min-width: 0;
}
.project-rail {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
overflow: hidden;
border-right: 1px solid var(--senlin-border);
background: var(--senlin-panel);
}
.project-rail .arco-layout-sider-children {
width: calc(100% - 1px);
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
overflow: hidden !important;
padding: 14px 24px;
}
.project-stack,
.project-stack .arco-space-item {
width: 48px;
}
.dashboard-button,
.create-project {
width: 48px !important;
height: 48px !important;
min-width: 48px;
padding: 0 !important;
border-radius: 8px;
}
.create-project {
font-size: 11px;
white-space: normal;
}
.project-button {
width: 48px;
height: 48px;
border: 1px solid var(--senlin-border);
border-radius: 8px;
background: color-mix(in srgb, var(--project-color) 9%, white);
color: var(--project-color);
font-weight: 700;
cursor: pointer;
}
.project-button.active {
border-color: var(--project-color);
background: var(--project-color);
color: white;
}
.project-badge .arco-badge-number {
box-shadow: 0 0 0 2px var(--senlin-panel);
}
.project-badge.urgent .arco-badge-number {
background: #f53f3f;
}
.channel-sidebar {
min-height: 0;
overflow: auto;
padding: 16px;
border-right: 1px solid var(--senlin-border);
background: var(--senlin-panel);
}
.project-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.project-title h5 {
margin: 0;
}
.channel-list {
display: grid;
gap: 4px;
}
.channel-button {
width: 100%;
min-height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--senlin-text);
padding: 7px 10px;
cursor: pointer;
}
.channel-button.active {
background: var(--senlin-soft-blue);
color: var(--senlin-primary);
box-shadow: inset 3px 0 0 var(--senlin-primary);
}
.channel-left {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.channel-left .arco-typography {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-sessions,
.tag-cloud {
display: grid;
gap: 8px;
}
.section-title {
font-weight: 700;
}
.recent-sessions button {
display: grid;
gap: 2px;
border: 0;
border-radius: 6px;
background: transparent;
padding: 6px 8px;
text-align: left;
cursor: pointer;
}
.recent-sessions button:hover {
background: var(--senlin-soft-blue);
}
.stage {
min-width: 0;
overflow: auto;
padding: 16px;
background: var(--senlin-bg);
}
.overview-page {
display: grid;
gap: 12px;
}
.overview-head {
display: flex;
align-items: end;
justify-content: space-between;
padding-bottom: 10px;
border-bottom: 1px solid var(--senlin-border);
}
.overview-head h4 {
margin: 3px 0 0;
}
.metric-row {
min-width: 0;
}
.metric-card.arco-card {
border-radius: 6px;
}
.metric-card .arco-card-body {
padding: 14px;
}
.metric-card h3 {
margin: 2px 0;
}
.metric-delta {
color: #00b42a;
font-size: 12px;
}
.queue-section.arco-card {
border-radius: 6px;
}
.queue-section .arco-card-body {
padding: 12px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.section-header h6 {
margin: 0;
}
.data-row {
width: 100%;
display: grid;
grid-template-columns: minmax(220px, 1.25fr) minmax(220px, 1fr) 82px 70px 82px;
align-items: center;
gap: 12px;
border: 0;
border-top: 1px solid var(--senlin-border);
background: transparent;
color: var(--senlin-text);
padding: 9px 0;
text-align: left;
cursor: pointer;
}
.data-row:first-of-type {
border-top: 0;
}
.data-row:hover {
background: color-mix(in srgb, var(--senlin-primary) 5%, transparent);
}
.data-row span,
.data-row time {
overflow: hidden;
color: var(--senlin-muted);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.data-row .row-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--senlin-text);
font-weight: 600;
}
.task-row {
grid-template-columns: minmax(260px, 1fr) 82px 100px 100px 130px;
}
.ai-row {
grid-template-columns: minmax(260px, 1fr) minmax(260px, 1.2fr) 80px 90px;
}
.note-row {
grid-template-columns: minmax(260px, 1fr) 80px 160px 80px;
}
.inspector {
min-height: 0;
overflow: auto;
padding: 0 16px 16px;
border-left: 1px solid var(--senlin-border);
background: var(--senlin-panel);
}
.inspector .arco-tabs-header {
margin-bottom: 12px;
}
.discussion-panel {
display: grid;
}
.inspector-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.discussion-list {
width: 100%;
}
.discussion-item {
display: grid;
grid-template-columns: 30px 1fr;
gap: 10px;
}
.discussion-item p {
margin: 4px 0 0;
color: var(--senlin-text);
}
.discussion-name {
font-weight: 700;
}
.comment-box {
position: sticky;
bottom: 0;
display: grid;
gap: 8px;
margin-top: 16px;
padding-top: 12px;
background: var(--senlin-panel);
}
.comment-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.property-list,
.more-actions {
width: 100%;
}
.property-row {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid var(--senlin-border);
}
.statusbar {
height: 32px;
display: grid;
grid-template-columns: minmax(310px, 0.9fr) minmax(480px, 1.2fr) minmax(320px, 0.9fr);
align-items: center;
gap: 16px;
padding: 0 16px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
background: var(--senlin-status);
color: rgba(255, 255, 255, 0.86);
}
.statusbar .arco-typography,
.statusbar .arco-btn-text {
color: rgba(255, 255, 255, 0.86);
}
.status-user,
.status-tasks,
.status-system {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
white-space: nowrap;
}
.status-tasks {
justify-content: center;
}
.status-tasks span,
.status-system span {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
}
.status-system {
justify-content: flex-end;
}
.status-name {
display: block;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.status-online {
display: block;
color: #7be188 !important;
font-size: 11px;
line-height: 1;
}
.status-system b {
color: #7be188;
}
@media (max-width: 1180px) {
.workbench-shell {
min-width: 1180px;
}
.login-card .arco-card-body {
grid-template-columns: 1fr;
}
.login-brand-panel,
.login-form-panel {
min-height: auto;
border-right: 0;
border-bottom: 1px solid var(--senlin-border);
}
.login-value-panel {
min-height: auto;
}
}

View File

@@ -0,0 +1,569 @@
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,30 @@
* {
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
min-width: 320px;
min-height: 100%;
margin: 0;
}
body {
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: #f7f8fa;
}
button,
input,
textarea {
font: inherit;
}

View File

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

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})