Promote React Arco client into apps
This commit is contained in:
@@ -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
25
apps/senlinai-acro-react/.gitignore
vendored
Normal 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?
|
||||
8
apps/senlinai-acro-react/.oxlintrc.json
Normal file
8
apps/senlinai-acro-react/.oxlintrc.json
Normal 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 }]
|
||||
}
|
||||
}
|
||||
17
apps/senlinai-acro-react/README.md
Normal file
17
apps/senlinai-acro-react/README.md
Normal 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`.
|
||||
13
apps/senlinai-acro-react/index.html
Normal file
13
apps/senlinai-acro-react/index.html
Normal 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
1803
apps/senlinai-acro-react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
apps/senlinai-acro-react/package.json
Normal file
27
apps/senlinai-acro-react/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
apps/senlinai-acro-react/public/favicon.svg
Normal file
1
apps/senlinai-acro-react/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
143
apps/senlinai-acro-react/scripts/visual-check.mjs
Normal file
143
apps/senlinai-acro-react/scripts/visual-check.mjs
Normal 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'))
|
||||
}
|
||||
694
apps/senlinai-acro-react/src/App.css
Normal file
694
apps/senlinai-acro-react/src/App.css
Normal 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;
|
||||
}
|
||||
}
|
||||
569
apps/senlinai-acro-react/src/App.tsx
Normal file
569
apps/senlinai-acro-react/src/App.tsx
Normal 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
|
||||
30
apps/senlinai-acro-react/src/index.css
Normal file
30
apps/senlinai-acro-react/src/index.css
Normal 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;
|
||||
}
|
||||
10
apps/senlinai-acro-react/src/main.tsx
Normal file
10
apps/senlinai-acro-react/src/main.tsx
Normal 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>,
|
||||
)
|
||||
26
apps/senlinai-acro-react/tsconfig.app.json
Normal file
26
apps/senlinai-acro-react/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
7
apps/senlinai-acro-react/tsconfig.json
Normal file
7
apps/senlinai-acro-react/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
apps/senlinai-acro-react/tsconfig.node.json
Normal file
23
apps/senlinai-acro-react/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
7
apps/senlinai-acro-react/vite.config.ts
Normal file
7
apps/senlinai-acro-react/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user