chore: rename web client to web_v1

This commit is contained in:
2026-07-21 11:24:27 +08:00
parent 465e7bea9b
commit ad6dcd6de4
101 changed files with 24 additions and 5537 deletions

26
apps/web_v1/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# 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
audit-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 }]
}
}

17
apps/web_v1/README.md Normal file
View File

@@ -0,0 +1,17 @@
# SenlinAI Acro React
SenlinAI primary web client built with React, TypeScript, Vite, and Arco Design.
This app is the active web client under `apps/web_v1`.
## 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/web_v1/index.html Normal file
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>web_v1</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1803
apps/web_v1/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
apps/web_v1/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "web_v1",
"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,52 @@
import { existsSync, readFileSync } from 'node:fs'
const requiredFiles = [
'src/app/App.tsx',
'src/pages/login.tsx',
'src/pages/workspace-body.tsx',
'src/pages/workspace-home.tsx',
'src/pages/workspace-explore.tsx',
'src/pages/projects/project-overview.tsx',
'src/pages/projects/project-channel-page.tsx',
'src/pages/projects/project-tasks.tsx',
'src/pages/projects/project-ai.tsx',
'src/pages/projects/project-notes.tsx',
'src/pages/projects/project-cron.tsx',
'src/pages/projects/project-new-channel.tsx',
'src/pages/projects/project-rail.tsx',
'src/pages/projects/project-sidebar.tsx',
'src/pages/projects/project-topbar.tsx',
'src/pages/projects/project-statusbar.tsx',
'src/pages/projects/project-types.ts',
'src/api/client.ts',
'src/api/projects.ts',
'src/api/mappers.tsx',
]
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')
}
for (const removed of ['src/pages/projects/project-data.tsx', 'src/pages/projects/project-inspector.tsx', 'src/pages/projects/project-inbox.tsx']) {
if (existsSync(removed)) {
failures.push(`${removed} should be removed; frontend data must come from backend APIs`)
}
}
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

@@ -0,0 +1,323 @@
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())
})
const collectMetrics = async () => page.evaluate(() => {
const statusbar = document.querySelector('.statusbar')
const statusName = document.querySelector('.status-name')
const statusOnlineDot = document.querySelector('.status-online-dot')
const search = document.querySelector('.global-search')
const main = document.querySelector('.workbench-main')
const workspacePage = document.querySelector('.workspace-page')
const projectOverview = document.querySelector('.overview-page:not(.workspace-page)')
const rail = document.querySelector('.project-rail')
const railChildren = rail?.querySelector('.arco-layout-sider-children')
const sidebar = document.querySelector('.channel-sidebar')
const channelList = document.querySelector('.channel-list')
const stage = document.querySelector('.stage')
const inspector = document.querySelector('.inspector')
const workspaceActions = [...document.querySelectorAll('.workspace-page .overview-head .arco-btn')]
.map((button) => button.textContent?.trim())
const dashboard = document.querySelector('.dashboard-button')
const workspaceExplore = document.querySelector('.dashboard-button[title="探索"]')
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,
scrollbarWidth: getComputedStyle(node).scrollbarWidth,
}
: null
return {
statusbarHeight: statusbar?.getBoundingClientRect().height,
statusName: rect(statusName),
statusOnlineDot: rect(statusOnlineDot),
searchHeight: search?.getBoundingClientRect().height,
workbenchMain: rect(main),
hasWorkspacePage: Boolean(workspacePage),
hasProjectOverview: Boolean(projectOverview),
projectRailWidth: rail?.getBoundingClientRect().width,
projectRail: rect(rail),
channelSidebar: rect(sidebar),
stage: rect(stage),
inspector: rect(inspector),
workspaceActions,
dashboardButton: rect(dashboard),
dashboardButtonActive: dashboard?.classList.contains('active') ?? false,
workspaceExploreButton: rect(workspaceExplore),
workspaceExploreButtonActive: workspaceExplore?.classList.contains('active') ?? false,
projectButton: rect(project),
projectButtonActive: project?.classList.contains('active') ?? false,
createProjectButton: rect(create),
firstProjectBadge: rect(firstBadge),
horizontalInsets,
verticalGaps,
projectRailOverflow: overflowState(rail),
projectRailChildrenOverflow: overflowState(railChildren),
channelSidebarOverflow: overflowState(sidebar),
channelListOverflow: overflowState(channelList),
stageOverflow: overflowState(stage),
inspectorOverflow: overflowState(inspector),
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
viewportWidth: window.innerWidth,
}
})
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 })
const workspaceMetrics = await collectMetrics()
await page.locator('.dashboard-button[title="探索"]').click()
await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/workspace-explore-react-acro-light.png', fullPage: true })
const workspaceExploreMetrics = await collectMetrics()
await page.locator('.project-button').first().click()
await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/project-react-acro-light.png', fullPage: true })
const projectMetrics = await collectMetrics()
await page.locator('.channel-sidebar').hover()
const channelSidebarHoverMetrics = await collectMetrics()
await page.locator('.stage').hover()
const stageHoverMetrics = await collectMetrics()
await page.locator('.channel-list').hover()
const channelListHoverMetrics = await collectMetrics()
const channelPageChecks = []
for (const channel of [
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
{ label: 'AI 会话', pageClass: 'project-ai-page', expectedHeading: 'AI 会话' },
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
{ label: '计划任务', pageClass: 'project-cron-page', expectedHeading: '计划任务' },
{ label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' },
]) {
await page.locator('.channel-button', { hasText: channel.label }).click()
await page.waitForTimeout(250)
channelPageChecks.push(await page.evaluate((expected) => {
const pageNode = document.querySelector(`.${expected.pageClass}`)
const heading = pageNode?.querySelector('h4, h5')?.textContent ?? ''
const activeChannel = document.querySelector('.channel-button.active')?.textContent ?? ''
return {
...expected,
foundPage: Boolean(pageNode),
heading,
activeChannel,
}
}, channel))
}
await page.locator('.topbar-actions .arco-btn').first().click()
await page.screenshot({ path: 'test-results/project-react-acro-dark.png', fullPage: true })
await browser.close()
await server.close()
console.log(JSON.stringify({
workspaceMetrics,
workspaceExploreMetrics,
projectMetrics,
channelSidebarHoverMetrics,
stageHoverMetrics,
channelListHoverMetrics,
channelPageChecks,
errors,
}, null, 2))
const failures = []
const metrics = workspaceMetrics
if (errors.length) failures.push(`console errors: ${errors.join('; ')}`)
if (metrics.statusbarHeight !== 32) failures.push(`expected statusbar height 32, got ${metrics.statusbarHeight}`)
if (!metrics.statusOnlineDot) failures.push('expected online status to render as an icon dot')
if (metrics.statusName && metrics.statusOnlineDot && Math.abs(metrics.statusName.top - metrics.statusOnlineDot.top) > 6) {
failures.push(`expected online dot to sit beside username, got name=${JSON.stringify(metrics.statusName)}, dot=${JSON.stringify(metrics.statusOnlineDot)}`)
}
if (metrics.searchHeight !== 42) failures.push(`expected search height 42, got ${metrics.searchHeight}`)
if (metrics.overflowX) failures.push('expected no horizontal overflow')
if (!metrics.workbenchMain) failures.push('missing workbench main')
if (!metrics.hasWorkspacePage) failures.push('expected login to land on workspace page')
if (!metrics.dashboardButtonActive) failures.push('expected dashboard button to be active after login')
if (!metrics.workspaceExploreButton) failures.push('expected fixed workspace explore button to render in project rail')
if (metrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button not to be active after login')
if (metrics.projectButtonActive) failures.push('expected first project button not to be active on workspace landing page')
if (metrics.channelSidebar) failures.push(`expected workspace to hide channel sidebar, got ${JSON.stringify(metrics.channelSidebar)}`)
if (metrics.inspector) failures.push(`expected workspace to hide inspector, got ${JSON.stringify(metrics.inspector)}`)
if (metrics.workspaceActions.some((text) => text?.includes('团队视图') || text?.includes('进入重点项目'))) {
failures.push(`expected workspace header actions to remove team view and focused project buttons, got ${JSON.stringify(metrics.workspaceActions)}`)
}
if (!metrics.projectRail || !metrics.stage) {
failures.push(`missing workspace layout regions: rail=${JSON.stringify(metrics.projectRail)}, stage=${JSON.stringify(metrics.stage)}`)
} else {
if (Math.abs(metrics.stage.left - metrics.projectRail.right) > 1) {
failures.push(`expected workspace stage to start after project rail, got stage.left=${metrics.stage.left}, rail.right=${metrics.projectRail.right}`)
}
if (Math.abs(metrics.stage.right - metrics.viewportWidth) > 1) {
failures.push(`expected workspace stage to end at viewport right edge, got stage.right=${metrics.stage.right}, viewport=${metrics.viewportWidth}`)
}
}
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.workspaceExploreButton ||
metrics.workspaceExploreButton.width !== metrics.projectButton?.width ||
metrics.workspaceExploreButton.height !== metrics.projectButton?.height
) {
failures.push(`workspace explore button size ${JSON.stringify(metrics.workspaceExploreButton)} 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 (!workspaceExploreMetrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button to become active when selected')
if (workspaceExploreMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace explore page')
if (workspaceExploreMetrics.channelSidebar) failures.push(`expected workspace explore to hide channel sidebar, got ${JSON.stringify(workspaceExploreMetrics.channelSidebar)}`)
if (workspaceExploreMetrics.inspector) failures.push(`expected workspace explore to hide inspector, got ${JSON.stringify(workspaceExploreMetrics.inspector)}`)
if (!workspaceExploreMetrics.stage || !workspaceExploreMetrics.projectRail) {
failures.push(`missing workspace explore layout regions: rail=${JSON.stringify(workspaceExploreMetrics.projectRail)}, stage=${JSON.stringify(workspaceExploreMetrics.stage)}`)
} else if (Math.abs(workspaceExploreMetrics.stage.left - workspaceExploreMetrics.projectRail.right) > 1) {
failures.push(`expected workspace explore stage to start after project rail, got stage.left=${workspaceExploreMetrics.stage.left}, rail.right=${workspaceExploreMetrics.projectRail.right}`)
}
if (!projectMetrics.hasProjectOverview) failures.push('expected first project click to show project overview page')
if (projectMetrics.hasWorkspacePage) failures.push('expected project overview mode to leave workspace page')
if (!projectMetrics.projectButtonActive) failures.push('expected first project button to be active in project mode')
if (projectMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active in project mode')
if (projectMetrics.inspector) {
failures.push(`expected project page to remove right inspector until it becomes an on-demand panel, got ${JSON.stringify(projectMetrics.inspector)}`)
}
if (!projectMetrics.projectRail || !projectMetrics.channelSidebar || !projectMetrics.stage) {
failures.push(
`missing project layout regions: rail=${JSON.stringify(projectMetrics.projectRail)}, sidebar=${JSON.stringify(projectMetrics.channelSidebar)}, stage=${JSON.stringify(projectMetrics.stage)}`,
)
} else {
const sameTop = [projectMetrics.projectRail, projectMetrics.channelSidebar, projectMetrics.stage]
.every((region) => Math.abs(region.top - projectMetrics.projectRail.top) <= 1)
if (!sameTop) {
failures.push(
`expected project rail, channel sidebar, and stage to share the same top edge, got rail=${projectMetrics.projectRail.top}, sidebar=${projectMetrics.channelSidebar.top}, stage=${projectMetrics.stage.top}`,
)
}
if (Math.abs(projectMetrics.channelSidebar.left - projectMetrics.projectRail.right) > 1) {
failures.push(`expected channel sidebar to sit after project rail, got sidebar.left=${projectMetrics.channelSidebar.left}, rail.right=${projectMetrics.projectRail.right}`)
}
if (Math.abs(projectMetrics.stage.left - projectMetrics.channelSidebar.right) > 1) {
failures.push(`expected stage to sit after channel sidebar, got stage.left=${projectMetrics.stage.left}, sidebar.right=${projectMetrics.channelSidebar.right}`)
}
if (Math.abs(projectMetrics.stage.right - projectMetrics.viewportWidth) > 1) {
failures.push(`expected project stage to end at viewport right edge, got stage.right=${projectMetrics.stage.right}, viewport=${projectMetrics.viewportWidth}`)
}
}
for (const [name, overflow] of [
['channel sidebar', projectMetrics.channelSidebarOverflow],
['channel list', projectMetrics.channelListOverflow],
['stage', projectMetrics.stageOverflow],
]) {
if (!overflow || overflow.overflowX !== 'hidden' || overflow.overflowY !== 'auto') {
failures.push(`expected ${name} to hide horizontal scrollbars and show vertical scrollbars only when needed, got ${JSON.stringify(overflow)}`)
}
if (overflow?.scrollbarWidth !== 'none') {
failures.push(`expected ${name} scrollbar to be hidden until hover, got ${JSON.stringify(overflow)}`)
}
}
for (const [name, overflow] of [
['channel sidebar hover', channelSidebarHoverMetrics.channelSidebarOverflow],
['channel list hover', channelListHoverMetrics.channelListOverflow],
['stage hover', stageHoverMetrics.stageOverflow],
]) {
if (overflow?.scrollbarWidth !== 'thin') {
failures.push(`expected ${name} scrollbar to appear on hover, got ${JSON.stringify(overflow)}`)
}
}
for (const check of channelPageChecks) {
if (!check.foundPage) {
failures.push(`expected ${check.label} to render .${check.pageClass}`)
}
if (!check.heading.includes(check.expectedHeading)) {
failures.push(`expected ${check.label} heading to include ${check.expectedHeading}, got ${JSON.stringify(check.heading)}`)
}
if (!check.activeChannel.includes(check.label)) {
failures.push(`expected ${check.label} sidebar button to stay active, got ${JSON.stringify(check.activeChannel)}`)
}
}
if (failures.length) {
throw new Error(failures.join('\n'))
}

1813
apps/web_v1/src/App.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
export type ApiSession = {
baseUrl: string
token: string
}
type RequestOptions = {
method?: string
body?: unknown
token?: string
}
let configuredBaseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:18080')
export function setApiBaseUrl(baseUrl: string) {
configuredBaseUrl = normalizeBaseUrl(baseUrl)
}
export function getApiBaseUrl() {
return configuredBaseUrl
}
export async function login(email: string, password: string): Promise<ApiSession> {
const response = await apiRequest<{ token: string }>('/api/auth/login', {
method: 'POST',
body: { email, password },
})
return { baseUrl: configuredBaseUrl, token: response.token }
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
const requestBody = options.body === undefined ? undefined : isFormData ? options.body : JSON.stringify(options.body)
const response = await fetch(`${configuredBaseUrl}${path}`, {
method: options.method ?? 'GET',
headers: {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
},
body: requestBody as BodyInit | undefined,
})
if (!response.ok) {
let message = `请求失败:${response.status}`
try {
const payload = await response.json()
if (typeof payload.error === 'string') message = payload.error
} catch {
// Keep the status-based message when the server does not return JSON.
}
throw new Error(message)
}
if (response.status === 204) {
return undefined as T
}
const text = await response.text()
if (text.trim() === '') {
return undefined as T
}
return JSON.parse(text) as T
}
function normalizeBaseUrl(value: string) {
const trimmed = value.trim()
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
}

View File

@@ -0,0 +1,52 @@
import { apiRequest, type ApiSession } from './client'
export type BackendInboxItem = {
id?: number
ID?: number
project_id?: number
projectID?: number
source_type?: string
sourceType?: string
title?: string
body?: string
status?: string
}
export type InboxSuggestion = {
type: string
title: string
body: string
}
export type CaptureInboxInput = {
sourceType: string
title: string
body: string
}
export async function captureProjectInbox(session: ApiSession, projectId: string | number, input: CaptureInboxInput) {
return apiRequest<BackendInboxItem>(`/api/projects/${projectId}/inbox`, {
method: 'POST',
token: session.token,
body: {
source_type: input.sourceType,
title: input.title,
body: input.body,
},
})
}
export async function analyzeInboxItem(session: ApiSession, inboxId: string | number) {
return apiRequest<{ suggestions: InboxSuggestion[] }>(`/api/inbox/${inboxId}/analyze`, {
method: 'POST',
token: session.token,
})
}
export async function confirmInboxItem(session: ApiSession, inboxId: string | number, suggestions: InboxSuggestion[]) {
return apiRequest<void>(`/api/inbox/${inboxId}/confirm`, {
method: 'POST',
token: session.token,
body: { suggestions },
})
}

View File

@@ -0,0 +1,219 @@
import { IconClockCircle, IconEmail, IconFile, IconHome, IconLink, IconList, IconRobot, IconUserGroup } from '@arco-design/web-react/icon'
import type {
BackendAISession,
BackendChannel,
BackendCronPlan,
BackendInboxMessage,
BackendNoteSource,
BackendProjectWorkspace,
BackendTask,
} from './projects'
import type { AISession, ChannelKey, CronJob, InboxItem, NoteSource, Project, ProjectChannel, ProjectWorkspace, TaskItem } from '../pages/projects/project-types'
const projectColors = ['#165DFF', '#00B42A', '#722ED1', '#FF7D00', '#14C9C9', '#F53F3F']
export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): ProjectWorkspace {
const project: Project = {
id: String(payload.project.id),
name: payload.project.name,
identifier: payload.project.identifier ?? '',
icon: payload.project.icon ?? '',
background: payload.project.background ?? '',
description: payload.project.description,
short: projectIconLabel(payload.project.icon ?? '', payload.project.initials),
badge: payload.project.unreadCount,
urgent: payload.project.unreadCount > 20,
color: projectColor(payload.project.background ?? '', index),
}
return {
project,
channels: payload.channels.filter((channel) => channel.type !== 'inbox').map(mapChannel),
tags: payload.tags.map((tag) => (tag === 'all' ? '全部' : tag)),
recentSessions: payload.recentSessions.map(mapAISession),
inbox: payload.inbox.map(mapInbox),
tasks: payload.tasks.map(mapTask),
aiSessions: payload.aiSessions.map(mapAISession),
notes: payload.notesSources.map(mapNoteSource),
cronJobs: payload.cronPlans.map(mapCronPlan),
}
}
function projectIconLabel(icon: string, fallback: string) {
const trimmed = icon.trim()
if (!trimmed) return fallback
const chars = Array.from(trimmed)
if (chars.length <= 2) return trimmed
return chars.slice(0, 2).join('').toUpperCase()
}
function projectColor(background: string, index: number) {
const trimmed = background.trim()
if (trimmed.startsWith('#') || trimmed.startsWith('rgb') || trimmed.startsWith('hsl')) {
return trimmed
}
return projectColors[index % projectColors.length]
}
function mapChannel(channel: BackendChannel): ProjectChannel {
return {
id: channel.id,
key: mapChannelKey(channel.type, channel.id),
label: channelLabel(channel),
count: channel.count,
icon: channelIcon(channel),
url: channel.url,
sortOrder: channel.sortOrder,
}
}
function mapChannelKey(type: string, id = ''): ChannelKey {
switch (type) {
case 'inbox':
return 'inbox'
case 'tasks':
return 'tasks'
case 'ai_sessions':
return 'ai'
case 'notes_sources':
return 'notes'
case 'cron':
return 'cron'
case 'overview':
return 'overview'
default:
return `custom:${id || type}`
}
}
function channelLabel(channel: BackendChannel) {
switch (channel.type) {
case 'overview':
return '概况'
case 'inbox':
return 'Inbox 消息流'
case 'tasks':
return '工作计划'
case 'ai_sessions':
return 'AI 会话'
case 'notes_sources':
return '笔记资料'
case 'cron':
return '计划任务'
default:
return channel.title
}
}
function channelIcon(channel: BackendChannel) {
switch (channel.type) {
case 'overview':
return <IconHome />
case 'inbox':
return <IconEmail />
case 'tasks':
return <IconList />
case 'ai_sessions':
return <IconRobot />
case 'notes_sources':
return <IconFile />
case 'cron':
return <IconClockCircle />
default:
return channel.icon === 'user' ? <IconUserGroup /> : <IconLink />
}
}
function mapInbox(item: BackendInboxMessage): InboxItem {
return {
id: item.id,
title: item.title,
meta: `来源:${item.source}`,
owner: '系统',
time: item.time,
tag: item.tag || item.status,
summary: item.summary,
status: item.status,
}
}
function mapTask(task: BackendTask): TaskItem {
return {
id: task.id,
title: task.title,
owner: task.owner,
progress: task.completed ? '100%' : '60%',
due: task.due || '未设置',
createdAt: formatCreatedAt(task.createdAt),
completedAt: task.completedAt ? formatCreatedAt(task.completedAt) : '',
duration: task.duration || '',
tag: task.tag || '',
summary: task.summary,
completed: task.completed,
}
}
function formatCreatedAt(value: string) {
const date = parseBackendDate(value)
if (!date) return '未知时间'
const now = new Date()
const today = startOfDay(now)
const yesterday = new Date(today)
yesterday.setDate(today.getDate() - 1)
const dateDay = startOfDay(date)
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`
if (dateDay.getTime() === today.getTime()) return `今天 ${time}`
if (dateDay.getTime() === yesterday.getTime()) return `昨天 ${time}`
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${time}`
}
function parseBackendDate(value: string) {
if (!value) return null
const normalized = value.includes('T') ? value : `${value.replace(' ', 'T')}Z`
const date = new Date(normalized)
return Number.isNaN(date.getTime()) ? null : date
}
function startOfDay(value: Date) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate())
}
function pad(value: number) {
return String(value).padStart(2, '0')
}
function mapAISession(session: BackendAISession): AISession {
return {
id: session.id,
title: session.title,
summary: session.summary,
owner: 'AI',
time: session.updatedAt,
}
}
function mapNoteSource(note: BackendNoteSource): NoteSource {
return {
id: note.id,
title: note.title,
type: note.kind === 'note' ? '笔记' : note.source,
updated: note.updatedAt,
owner: note.source,
source: note.source,
}
}
function mapCronPlan(plan: BackendCronPlan): CronJob {
return {
id: plan.id,
name: plan.title,
expr: plan.schedule,
status: plan.enabled ? '运行中' : '暂停',
lastRun: plan.lastResult,
nextRun: plan.nextRun || '暂停中',
owner: plan.owner,
enabled: plan.enabled,
}
}

View File

@@ -0,0 +1,210 @@
import { apiRequest, type ApiSession } from './client'
export type BackendProject = {
ID?: number
id?: number
Name?: string
name?: string
Identifier?: string
identifier?: string
Icon?: string
icon?: string
Background?: string
background?: string
Description?: string
description?: string
}
export type BackendWorkspaceProject = {
id: number
name: string
identifier?: string
icon?: string
background?: string
description: string
initials: string
unreadCount: number
}
export type BackendChannel = {
id: string
projectID: number
type: string
title: string
icon: string
count?: number
url?: string
sortOrder: number
}
export type BackendInboxMessage = {
id: string
source: string
title: string
summary: string
status: string
tag: string
time: string
}
export type BackendTask = {
id: string
title: string
summary: string
completed: boolean
owner: string
due: string
createdAt: string
completedAt: string
duration: string
tag: string
}
export type BackendAISession = {
id: string
title: string
summary: string
updatedAt: string
references: string[]
}
export type BackendNoteSource = {
id: string
kind: string
title: string
updatedAt: string
tag: string
source: string
}
export type BackendCronPlan = {
id: string
title: string
schedule: string
nextRun: string
enabled: boolean
lastResult: string
owner: string
}
export type BackendProjectWorkspace = {
project: BackendWorkspaceProject
channels: BackendChannel[]
tags: string[]
recentSessions: BackendAISession[]
inbox: BackendInboxMessage[]
tasks: BackendTask[]
aiSessions: BackendAISession[]
notesSources: BackendNoteSource[]
cronPlans: BackendCronPlan[]
}
export type BackendTag = {
ID?: number
id?: number
Name?: string
name?: string
}
export async function fetchProjects(session: ApiSession) {
return apiRequest<BackendProject[]>('/api/projects', { token: session.token })
}
export async function fetchProjectWorkspace(session: ApiSession, projectId: string | number) {
return apiRequest<BackendProjectWorkspace>(`/api/projects/${projectId}/workspace`, { token: session.token })
}
export async function fetchProjectTags(session: ApiSession, projectId: string | number) {
return apiRequest<BackendTag[]>(`/api/projects/${projectId}/tags`, { token: session.token })
}
export type CreateProjectInput = {
name: string
identifier?: string
icon?: string
background?: string
description?: string
}
export type CreateTaskInput = {
title: string
description?: string
status?: string
dueAt?: string
tag?: string
}
export type UpdateTaskInput = {
title: string
description?: string
completed: boolean
nextProjectId?: number
tag?: string
}
export type UploadSourceInput = {
title?: string
file: File
}
export type CreateCronPlanInput = {
title: string
schedule: string
enabled: boolean
nextRunAt?: string
}
export type CreateProjectTagInput = {
name: string
}
export async function createProject(session: ApiSession, input: CreateProjectInput) {
return apiRequest<BackendProject>('/api/projects', {
method: 'POST',
token: session.token,
body: input,
})
}
export async function createTask(session: ApiSession, projectId: string | number, input: CreateTaskInput) {
return apiRequest<BackendTask>(`/api/projects/${projectId}/tasks`, {
method: 'POST',
token: session.token,
body: input,
})
}
export async function updateTask(session: ApiSession, projectId: string | number, taskId: string | number, input: UpdateTaskInput) {
return apiRequest<BackendTask>(`/api/projects/${projectId}/tasks/${taskId}`, {
method: 'PATCH',
token: session.token,
body: input,
})
}
export async function uploadSource(session: ApiSession, projectId: string | number, input: UploadSourceInput) {
const body = new FormData()
body.append('file', input.file)
if (input.title) body.append('title', input.title)
return apiRequest<BackendNoteSource>(`/api/projects/${projectId}/sources`, {
method: 'POST',
token: session.token,
body,
})
}
export async function createCronPlan(session: ApiSession, projectId: string | number, input: CreateCronPlanInput) {
return apiRequest<BackendCronPlan>(`/api/projects/${projectId}/cron-plans`, {
method: 'POST',
token: session.token,
body: input,
})
}
export async function createProjectTag(session: ApiSession, projectId: string | number, input: CreateProjectTagInput) {
return apiRequest<BackendTag>(`/api/projects/${projectId}/tags`, {
method: 'POST',
token: session.token,
body: input,
})
}

303
apps/web_v1/src/app/App.tsx Normal file
View File

@@ -0,0 +1,303 @@
import { useState } from 'react'
import { ConfigProvider, Message, Spin } from '@arco-design/web-react'
import '@arco-design/web-react/dist/css/arco.css'
import '../App.css'
import { login, setApiBaseUrl, type ApiSession } from '../api/client'
import { mapWorkspace } from '../api/mappers'
import {
createCronPlan,
createProject,
createProjectTag,
createTask,
fetchProjectWorkspace,
fetchProjects,
updateTask,
uploadSource,
} from '../api/projects'
import { LoginPage } from '../pages/login'
import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals'
import type { ProjectSettingsUpdate } from '../pages/projects/project-sidebar'
import { ProjectPage } from '../pages/workspace-home'
import type { WorkspaceTaskUpdate } from '../pages/workspace-body'
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
function App() {
const [screen, setScreen] = useState<Screen>('login')
const [theme, setTheme] = useState<Theme>('light')
const [activeView, setActiveView] = useState<WorkbenchView>('workspace')
const [session, setSession] = useState<ApiSession | null>(null)
const [workspaces, setWorkspaces] = useState<ProjectWorkspace[]>([])
const [activeProjectID, setActiveProjectID] = useState<string>('')
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
const [, setSelectedItem] = useState('探索采集')
const [loading, setLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false)
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
const dark = theme === 'dark'
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0]
const activeTagOptions = activeWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID = activeProjectID) {
const backendProjects = await fetchProjects(nextSession)
const backendWorkspaces = await Promise.all(
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.ID ?? project.id ?? '').then((workspace) => mapWorkspace(workspace, index))),
)
setWorkspaces(backendWorkspaces)
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === preferredProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? ''
setActiveProjectID(nextProjectID)
return backendWorkspaces
}
async function handleLogin(input: { server: string; email: string; password: string }) {
setLoading(true)
try {
setApiBaseUrl(input.server)
const nextSession = await login(input.email, input.password)
const backendWorkspaces = await loadWorkspaces(nextSession, '')
setSession(nextSession)
setActiveProjectID(backendWorkspaces[0]?.project.id ?? '')
setActiveChannel('overview')
setActiveView('workspace')
setScreen('workbench')
} catch (error) {
Message.error(error instanceof Error ? error.message : '登录失败')
} finally {
setLoading(false)
}
}
async function refreshAfterAction(nextProjectID = activeProjectID) {
if (!session) return
await loadWorkspaces(session, nextProjectID)
}
async function runAction(action: () => Promise<void>, success: string) {
setActionLoading(true)
try {
await action()
setActiveModal(null)
Message.success(success)
} catch (error) {
Message.error(error instanceof Error ? error.message : '操作失败')
} finally {
setActionLoading(false)
}
}
function requireSession() {
if (!session) throw new Error('未登录')
return session
}
function requireActiveProject() {
if (!activeWorkspace?.project.id) throw new Error('未选择项目')
return activeWorkspace.project.id
}
function normalizeOptionalTime(value: string) {
const trimmed = value.trim()
return trimmed === '' ? undefined : trimmed
}
function handleCreateProject(draft: ProjectDraft) {
if (!draft.name.trim()) {
Message.warning('请输入项目名称')
return
}
void runAction(async () => {
const created = await createProject(requireSession(), {
name: draft.name.trim(),
identifier: draft.identifier.trim(),
icon: draft.icon.trim(),
background: draft.background.trim(),
description: draft.description.trim(),
})
const nextProjectID = String(created.ID ?? created.id ?? '')
await refreshAfterAction(nextProjectID)
if (nextProjectID) {
setActiveProjectID(nextProjectID)
setActiveView('project')
setActiveChannel('overview')
}
}, '项目已创建')
}
function handleCreateTask(draft: TaskDraft) {
if (!draft.title.trim()) {
Message.warning('请输入任务标题')
return
}
void runAction(async () => {
await createTask(requireSession(), requireActiveProject(), {
title: draft.title.trim(),
description: draft.description.trim(),
status: 'open',
tag: draft.tag.trim(),
})
await refreshAfterAction()
setActiveChannel('tasks')
}, '任务已创建')
}
function handleUploadSource(draft: SourceDraft) {
if (!draft.file) {
Message.warning('请选择文件')
return
}
const file = draft.file
void runAction(async () => {
await uploadSource(requireSession(), requireActiveProject(), {
title: draft.title.trim(),
file,
})
await refreshAfterAction()
setActiveChannel('notes')
}, '文件已上传')
}
function handleCreateCronPlan(draft: CronDraft) {
if (!draft.title.trim()) {
Message.warning('请输入计划名称')
return
}
if (!draft.schedule.trim()) {
Message.warning('请输入 Cron 表达式')
return
}
void runAction(async () => {
await createCronPlan(requireSession(), requireActiveProject(), {
title: draft.title.trim(),
schedule: draft.schedule.trim(),
enabled: draft.enabled,
nextRunAt: normalizeOptionalTime(draft.nextRunAt),
})
await refreshAfterAction()
setActiveChannel('cron')
}, '计划任务已创建')
}
function handleCreateProjectTag(name: string) {
const trimmedName = name.trim()
if (!trimmedName) {
Message.warning('请输入标签名称')
return
}
void runAction(async () => {
await createProjectTag(requireSession(), requireActiveProject(), { name: trimmedName })
await refreshAfterAction()
}, '标签已创建')
}
function openTask(project: Project, taskID: string) {
setActiveProjectID(project.id)
setActiveView('project')
setActiveChannel('tasks')
setActiveTaskID(taskID)
}
function handleUpdateWorkspaceTask(update: WorkspaceTaskUpdate) {
void runAction(async () => {
await updateTask(requireSession(), update.originalProjectId, update.taskId, {
title: update.title,
description: update.summary,
completed: update.completed,
nextProjectId: Number(update.nextProjectId),
tag: update.tag,
})
await refreshAfterAction(update.nextProjectId)
}, '任务已更新')
}
function handleUpdateProject(update: ProjectSettingsUpdate) {
setWorkspaces((current) =>
current.map((workspace) => {
if (workspace.project.id !== update.projectId) return workspace
const nextProject = {
...workspace.project,
name: update.name,
identifier: update.identifier,
icon: update.icon,
background: update.background,
description: update.description,
short: projectIconLabel(update.icon, update.identifier || update.name.slice(0, 2)),
color: projectColor(update.background, workspace.project.color),
}
return { ...workspace, project: nextProject }
}),
)
}
return (
<ConfigProvider>
<main className={dark ? 'app theme-dark' : 'app'}>
{screen === 'login' ? (
<Spin loading={loading} style={{ width: '100%' }}>
<LoginPage onLogin={handleLogin} />
</Spin>
) : activeWorkspace && session ? (
<ProjectPage
activeView={activeView}
activeWorkspace={activeWorkspace}
workspaces={workspaces}
activeChannel={activeChannel}
activeTaskID={activeTaskID}
theme={theme}
onSelectWorkspace={() => setActiveView('workspace')}
onSelectWorkspaceExplore={() => setActiveView('workspace-explore')}
onSelectProject={(project) => {
setActiveProjectID(project.id)
setActiveView('project')
setActiveChannel('overview')
setActiveTaskID(null)
}}
onSelectChannel={(channel) => {
setActiveChannel(channel)
setActiveTaskID(null)
}}
onSelectItem={setSelectedItem}
onOpenTask={openTask}
onCloseTask={() => setActiveTaskID(null)}
onToggleTheme={() => setTheme(dark ? 'light' : 'dark')}
onCreateProject={() => setActiveModal('project')}
onCreateTask={() => setActiveModal('task')}
onUploadSource={() => setActiveModal('source')}
onCreateCronPlan={() => setActiveModal('cron')}
onUpdateWorkspaceTask={handleUpdateWorkspaceTask}
onUpdateProject={handleUpdateProject}
onCreateProjectTag={handleCreateProjectTag}
/>
) : (
<Spin loading />
)}
<ProjectActionModals
activeModal={activeModal}
loading={actionLoading}
onClose={() => setActiveModal(null)}
onCreateProject={handleCreateProject}
onCreateTask={handleCreateTask}
onUploadSource={handleUploadSource}
onCreateCronPlan={handleCreateCronPlan}
tagOptions={activeTagOptions}
/>
</main>
</ConfigProvider>
)
}
function projectIconLabel(icon: string, fallback: string) {
const trimmed = icon.trim()
if (!trimmed) return fallback
const chars = Array.from(trimmed)
if (chars.length <= 2) return trimmed
return chars.slice(0, 2).join('').toUpperCase()
}
function projectColor(background: string, fallback: string) {
const trimmed = background.trim()
if (trimmed.startsWith('#') || trimmed.startsWith('rgb') || trimmed.startsWith('hsl')) return trimmed
return fallback
}
export default App

30
apps/web_v1/src/index.css Normal file
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;
}

10
apps/web_v1/src/main.tsx Normal file
View File

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

View File

@@ -0,0 +1,147 @@
import type { ReactElement } from 'react'
import { useEffect, useState } from 'react'
import { Button, Card, Form, Input, Space, Typography } from '@arco-design/web-react'
import {
IconBook,
IconCheckCircleFill,
IconCloseCircleFill,
IconLaunch,
IconLoading,
IconSafe,
IconUserGroup,
} from '@arco-design/web-react/icon'
const { Title, Text, Paragraph } = Typography
type ConnectionStatus = 'checking' | 'online' | 'offline'
export function LoginPage({ onLogin }: { onLogin: (input: { server: string; email: string; password: string }) => void }) {
const [server, setServer] = useState('http://localhost:9150')
const [email, setEmail] = useState('demo@senlin.ai')
const [password, setPassword] = useState('password123')
const [status, setStatus] = useState<ConnectionStatus>('checking')
useEffect(() => {
const controller = new AbortController()
const timer = window.setTimeout(() => {
void checkServerStatus(server, controller.signal, setStatus)
}, 300)
return () => {
controller.abort()
window.clearTimeout(timer)
}
}, [server])
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}>森林Agent</Title>
</div>
<Text className="login-slogan">私有部署 · 安全可控的智能协作平台</Text>
<Text type="secondary">知识沉淀 · 团队协作 · AI Agent</Text>
<Space className="login-footer-links" size={24}>
<Text type="secondary">v1.0.0</Text>
<Text type="secondary">隐私政策</Text>
<Text type="secondary">服务协议</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 />} />
<ConnectionCard status={status} onCheck={() => checkServerStatus(server, undefined, setStatus)} />
</Form.Item>
<Form.Item label="账号">
<Input value={email} onChange={setEmail} placeholder="请输入用户名或邮箱" />
</Form.Item>
<Form.Item label="密码">
<Input.Password value={password} onChange={setPassword} 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({ server, email, password })}>
登录
</Button>
</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 ConnectionCard({ status, onCheck }: { status: ConnectionStatus; onCheck: () => void }) {
const icon = status === 'checking' ? <IconLoading /> : status === 'online' ? <IconCheckCircleFill /> : <IconCloseCircleFill />
const title = status === 'checking' ? '正在检查' : status === 'online' ? '连接正常' : '连接异常'
return (
<div className={`connection-card ${status}`}>
{icon}
<div>
<Text className="connection-title">{title}</Text>
</div>
<Button type="text" size="mini" loading={status === 'checking'} onClick={onCheck}>
重新检查
</Button>
</div>
)
}
async function checkServerStatus(
server: string,
signal: AbortSignal | undefined,
setStatus: (status: ConnectionStatus) => void,
) {
const baseUrl = normalizeBaseUrl(server)
setStatus('checking')
try {
const response = await fetch(`${baseUrl}/api/status`, { signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const payload = await response.json() as { timestamp_ms?: number }
if (typeof payload.timestamp_ms !== 'number') throw new Error('invalid status payload')
setStatus('online')
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
setStatus('offline')
}
}
function normalizeBaseUrl(value: string) {
const trimmed = value.trim()
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
}
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,188 @@
import { useEffect, useState } from 'react'
import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
const { Text } = Typography
const { TextArea } = Input
export type ProjectActionModal = 'project' | 'task' | 'source' | 'cron' | null
export type ProjectDraft = {
name: string
identifier: string
icon: string
background: string
description: string
}
export type TaskDraft = {
title: string
description: string
tag: string
}
export type SourceDraft = {
title: string
file: File | null
}
export type CronDraft = {
title: string
schedule: string
enabled: boolean
nextRunAt: string
}
export function ProjectActionModals({
activeModal,
loading,
onClose,
onCreateProject,
onCreateTask,
onUploadSource,
onCreateCronPlan,
tagOptions,
}: {
activeModal: ProjectActionModal
loading: boolean
onClose: () => void
onCreateProject: (draft: ProjectDraft) => void
onCreateTask: (draft: TaskDraft) => void
onUploadSource: (draft: SourceDraft) => void
onCreateCronPlan: (draft: CronDraft) => void
tagOptions: string[]
}) {
const [project, setProject] = useState<ProjectDraft>({ name: '', identifier: '', icon: '', background: '', description: '' })
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', tag: '' })
const [source, setSource] = useState<SourceDraft>({ title: '', file: null })
const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
useEffect(() => {
if (activeModal === null) {
setProject({ name: '', identifier: '', icon: '', background: '', description: '' })
setTask({ title: '', description: '', tag: '' })
setSource({ title: '', file: null })
setCron({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
}
}, [activeModal])
return (
<>
<Modal
className="action-modal"
title="新建项目"
visible={activeModal === 'project'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onCreateProject(project)}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text>项目名称</Text>
<Input placeholder="例如:项目 A3" value={project.name} onChange={(name) => setProject((draft) => ({ ...draft, name }))} />
</label>
<label>
<Text>项目标识</Text>
<Input placeholder="例如:A3 / EXP / DATA" value={project.identifier} onChange={(identifier) => setProject((draft) => ({ ...draft, identifier }))} />
</label>
<label>
<Text>项目图标</Text>
<Input placeholder="例如:A3 / 🚀 / compass" value={project.icon} onChange={(icon) => setProject((draft) => ({ ...draft, icon }))} />
</label>
<label>
<Text>项目背景</Text>
<Input placeholder="例如:#165DFF 或背景图片 URL" value={project.background} onChange={(background) => setProject((draft) => ({ ...draft, background }))} />
</label>
<label>
<Text>项目简介</Text>
<TextArea rows={4} placeholder="项目目标、背景或协作说明" value={project.description} onChange={(description) => setProject((draft) => ({ ...draft, description }))} />
</label>
</Space>
</Modal>
<Modal
className="action-modal"
title="新建任务"
visible={activeModal === 'task'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onCreateTask(task)}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text>任务标题</Text>
<Input placeholder="要完成什么?" value={task.title} onChange={(title) => setTask((draft) => ({ ...draft, title }))} />
</label>
<label>
<Text>任务说明</Text>
<TextArea rows={4} placeholder="补充背景、验收口径或下一步" value={task.description} onChange={(description) => setTask((draft) => ({ ...draft, description }))} />
</label>
<label>
<Text>所属标签</Text>
<Select
allowCreate
placeholder="选择或输入标签"
value={task.tag}
onChange={(tag) => setTask((draft) => ({ ...draft, tag }))}
>
{tagOptions.map((tag) => (
<Select.Option key={tag} value={tag}>{tag}</Select.Option>
))}
</Select>
</label>
</Space>
</Modal>
<Modal
className="action-modal"
title="上传文件"
visible={activeModal === 'source'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onUploadSource(source)}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text>资料标题</Text>
<Input placeholder="默认使用文件名" value={source.title} onChange={(title) => setSource((draft) => ({ ...draft, title }))} />
</label>
<label>
<Text>选择文件</Text>
<input
className="native-file-input"
type="file"
onChange={(event) => setSource((draft) => ({ ...draft, file: event.target.files?.[0] ?? null }))}
/>
</label>
</Space>
</Modal>
<Modal
className="action-modal"
title="新建计划任务"
visible={activeModal === 'cron'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onCreateCronPlan(cron)}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text>计划名称</Text>
<Input placeholder="例如:每日 Inbox 整理" value={cron.title} onChange={(title) => setCron((draft) => ({ ...draft, title }))} />
</label>
<label>
<Text>Cron 表达式</Text>
<Input value={cron.schedule} onChange={(schedule) => setCron((draft) => ({ ...draft, schedule }))} />
</label>
<label>
<Text>下次运行时间</Text>
<Input placeholder="2026-07-22T08:00:00Z,可留空" value={cron.nextRunAt} onChange={(nextRunAt) => setCron((draft) => ({ ...draft, nextRunAt }))} />
</label>
<label className="action-switch">
<Text>启用计划</Text>
<Switch checked={cron.enabled} onChange={(enabled) => setCron((draft) => ({ ...draft, enabled }))} />
</label>
</Space>
</Modal>
</>
)
}

View File

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

View File

@@ -0,0 +1,50 @@
import { ProjectAi } from './project-ai'
import { ProjectCron } from './project-cron'
import { ProjectNewChannel } from './project-new-channel'
import { ProjectNotes } from './project-notes'
import { ProjectOverview } from './project-overview'
import type { ProjectTaskUpdate } from './project-task-edit-modal'
import { ProjectTasks } from './project-tasks'
import type { ChannelKey, ProjectWorkspace } from './project-types'
export function ProjectChannelPage({
activeChannel,
activeWorkspace,
activeTaskID,
onSelectItem,
onOpenTask,
onCloseTask,
onCreateTask,
onUploadSource,
onCreateCronPlan,
onCreateProjectTag,
onUpdateTask,
}: {
activeChannel: ChannelKey
activeWorkspace: ProjectWorkspace
activeTaskID: string | null
onSelectItem: (title: string) => void
onOpenTask: (taskID: string) => void
onCloseTask: () => void
onCreateTask: () => void
onUploadSource: () => void
onCreateCronPlan: () => void
onCreateProjectTag: (name: string) => void
onUpdateTask: (update: ProjectTaskUpdate) => void
}) {
switch (activeChannel) {
case 'tasks':
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} />
case 'ai':
return <ProjectAi activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
case 'notes':
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
case 'cron':
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} />
case 'new-channel':
return <ProjectNewChannel activeWorkspace={activeWorkspace} />
case 'overview':
default:
return <ProjectOverview activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUpdateTask={onUpdateTask} />
}
}

View File

@@ -0,0 +1,60 @@
import { Button, Card, Space, Switch, Tag, Typography } from '@arco-design/web-react'
import { IconClockCircle, IconPlus, IconThunderbolt } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void; onCreateCronPlan: () => void }) {
const { cronJobs, project } = activeWorkspace
const enabledCount = cronJobs.filter((job) => job.enabled).length
const pausedCount = cronJobs.length - enabledCount
return (
<div className="project-channel-page project-cron-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>计划任务</Title>
<Text type="secondary">{project.name} 的定时任务、自动检查和周期性 AI 工作。</Text>
</div>
<Button type="primary" icon={<IconPlus />} onClick={onCreateCronPlan}>新建计划</Button>
</div>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>任务列表</Title>
<Space>
<Tag color="green">{enabledCount} 运行中</Tag>
<Tag color="gray">{pausedCount} 暂停</Tag>
</Space>
</div>
{cronJobs.map((job) => (
<div
className="data-row cron-row"
key={job.name}
role="button"
tabIndex={0}
onClick={() => onSelectItem(job.name)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') onSelectItem(job.name)
}}
>
<span className="row-title"><IconClockCircle /> {job.name}</span>
<Tag color={job.enabled ? 'green' : 'gray'}>{job.status}</Tag>
<span>{job.expr}</span>
<span>{job.lastRun}</span>
<span>{job.nextRun}</span>
<span>{job.owner}</span>
<Switch size="small" checked={job.enabled} />
</div>
))}
</Card>
<Card className="queue-section cron-summary" bordered>
<Space>
<Tag color="arcoblue"><IconThunderbolt /></Tag>
<Text>最近一次自动任务完成于今天 12:00,资料索引刷新成功,无异常重试。</Text>
</Space>
</Card>
</div>
)
}

View File

@@ -0,0 +1,57 @@
import { IconFile, IconFileImage, IconFilePdf, IconFolder } from '@arco-design/web-react/icon'
import type { NoteSource } from './project-types'
export type ProjectFileItem = NoteSource | {
id: string
title: string
type: string
updated: string
owner: string
source?: string
}
export function ProjectFileGrid({
items,
onSelectItem,
compact = false,
}: {
items: ProjectFileItem[]
onSelectItem: (title: string) => void
compact?: boolean
}) {
return (
<div className={compact ? 'project-file-grid compact' : 'project-file-grid'}>
{items.map((item) => {
const fileKind = inferFileKind(item)
return (
<button className="project-file-tile" key={item.id} onClick={() => onSelectItem(item.title)}>
<span className={`project-file-icon ${fileKind.kind}`}>{fileIcon(fileKind.kind)}</span>
<span className="project-file-copy">
<span className="project-file-name">{item.title}</span>
<span className="project-file-type">{fileKind.label}</span>
<span className="project-file-meta">{fileKind.meta}</span>
</span>
</button>
)
})}
</div>
)
}
function inferFileKind(item: ProjectFileItem) {
const value = `${item.title} ${item.type} ${item.source ?? ''}`.toLowerCase()
if (item.type === 'folder' || item.source === 'folder') return { kind: 'folder', label: '文件夹', meta: item.updated }
if (value.includes('pdf')) return { kind: 'pdf', label: 'PDF 文档', meta: item.updated }
if (value.includes('png') || value.includes('jpg') || value.includes('jpeg') || value.includes('图片')) return { kind: 'image', label: '图片文件', meta: item.updated }
if (value.includes('excel') || value.includes('xlsx') || value.includes('表')) return { kind: 'sheet', label: '表格文件', meta: item.updated }
if (value.includes('word') || value.includes('doc') || value.includes('文档')) return { kind: 'doc', label: '文档', meta: item.updated }
if (value.includes('markdown') || value.includes('笔记')) return { kind: 'note', label: 'Markdown 笔记', meta: item.updated }
return { kind: 'file', label: item.type || '文件', meta: item.updated }
}
function fileIcon(kind: string) {
if (kind === 'folder') return <IconFolder />
if (kind === 'pdf') return <IconFilePdf />
if (kind === 'image') return <IconFileImage />
return <IconFile />
}

View File

@@ -0,0 +1,62 @@
import { Button, Card, Input, Select, Space, Tag, Typography } from '@arco-design/web-react'
import { IconApps, IconLink, IconPlus } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
const { TextArea } = Input
export function ProjectNewChannel({ activeWorkspace }: { activeWorkspace: ProjectWorkspace }) {
return (
<div className="project-channel-page project-new-channel-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>新建频道</Title>
<Text type="secondary">{activeWorkspace.project.name} 的频道入口配置,用于接入外部页面、资料集合或项目流程。</Text>
</div>
<Button type="primary" icon={<IconPlus />}>保存频道</Button>
</div>
<Card className="queue-section channel-editor-card" bordered>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text>频道名称</Text>
<Input placeholder="例如:客户研究频道" />
</label>
<label>
<Text>频道类型</Text>
<Select defaultValue="link">
<Select.Option value="link">外部链接</Select.Option>
<Select.Option value="source">资料集合</Select.Option>
<Select.Option value="workflow">项目流程</Select.Option>
</Select>
</label>
<label>
<Text>图标</Text>
<Input placeholder="例如:link / user / file" prefix={<IconApps />} />
</label>
<label>
<Text>链接地址</Text>
<Input placeholder="https://example.com/channel" prefix={<IconLink />} />
</label>
<label>
<Text>说明</Text>
<TextArea rows={4} placeholder="频道用途、维护规则或采集说明" />
</label>
</Space>
</Card>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>创建后可接入的内容</Title>
<Tag color="gray">配置预览</Tag>
</div>
<Space wrap>
<Tag color="arcoblue">外部资料库</Tag>
<Tag color="green">项目流程页</Tag>
<Tag color="orange">RSS/探索结果</Tag>
<Tag color="purple">AI 会话上下文</Tag>
</Space>
</Card>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { Button, Card, Space, Typography } from '@arco-design/web-react'
import { IconPlus, IconSearch } from '@arco-design/web-react/icon'
import { ProjectFileGrid } from './project-file-grid'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void; onUploadSource: () => void }) {
const { notes, project } = activeWorkspace
return (
<div className="project-channel-page project-notes-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>笔记资料</Title>
<Text type="secondary">{project.name} 的在线文件管理页,集中管理笔记、资料和附件。</Text>
</div>
<Space>
<Button icon={<IconSearch />}>搜索资料</Button>
<Button type="primary" icon={<IconPlus />} onClick={onUploadSource}>上传/新建</Button>
</Space>
</div>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>最近更新</Title>
<Button type="text" size="mini">按更新时间排序</Button>
</div>
<ProjectFileGrid items={notes} onSelectItem={onSelectItem} />
</Card>
</div>
)
}

View File

@@ -0,0 +1,151 @@
import { useMemo, useState } from 'react'
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { IconCheckCircleFill, IconClockCircle, IconFile, IconLink } from '@arco-design/web-react/icon'
import { ProjectFileGrid } from './project-file-grid'
import { ProjectTaskEditModal, type ProjectTaskUpdate } from './project-task-edit-modal'
import type { ProjectWorkspace, TaskItem } from './project-types'
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
export function ProjectOverview({
activeWorkspace,
onSelectItem,
onUpdateTask,
}: {
activeWorkspace: ProjectWorkspace
onSelectItem: (title: string) => void
onUpdateTask: (update: ProjectTaskUpdate) => void
}) {
const { project, tasks, notes, cronJobs } = activeWorkspace
const runningTasks = useMemo(() => tasks.filter((task) => !task.completed), [tasks])
const completedTasks = useMemo(() => tasks.filter((task) => task.completed), [tasks])
const [editingTask, setEditingTask] = useState<TaskItem | null>(null)
const shareURL = useMemo(() => projectShareURL(project.identifier || project.id), [project.id, project.identifier])
const metrics = useMemo(() => [
{ title: '进行中的任务', value: runningTasks.length, icon: <IconCheckCircleFill />, color: 'green' },
{ title: '完成的任务', value: completedTasks.length, icon: <IconCheckCircleFill />, color: 'arcoblue' },
{ title: '笔记资料总数', value: notes.length, icon: <IconFile />, color: 'cyan' },
{ title: '计划任务', value: cronJobs.length, icon: <IconClockCircle />, color: 'orange' },
], [completedTasks.length, cronJobs.length, notes.length, runningTasks.length])
return (
<div className="overview-page project-overview-page">
<section className="project-overview-hero" style={{ background: projectHeroBackground(project.background, project.color) }}>
<div className="project-overview-identity">
<span className="project-overview-icon">{project.short}</span>
<div>
<Title heading={3}>{project.name}</Title>
<Paragraph ellipsis={{ rows: 2 }}>{project.description || '暂无简介'}</Paragraph>
</div>
</div>
<div className="project-overview-share">
<Text>项目 URL</Text>
<Button icon={<IconLink />} onClick={() => onSelectItem(shareURL)}>{shareURL}</Button>
</div>
</section>
<Row gutter={12} className="metric-row">
{metrics.map((metric) => (
<Col span={6} 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">实时接口</Text>
</div>
</Space>
</Card>
</Col>
))}
</Row>
<TaskCardSection title={`进行中的任务(${runningTasks.length})`} tasks={runningTasks} emptyText="当前没有进行中的任务" onEditTask={setEditingTask} />
<TaskCardSection title={`完成的任务(${completedTasks.length})`} tasks={completedTasks} emptyText="当前没有完成的任务" onSelectItem={onSelectItem} />
<NoteSection notes={notes} onSelectItem={onSelectItem} />
<ProjectTaskEditModal
task={editingTask}
workspace={activeWorkspace}
onClose={() => setEditingTask(null)}
onSubmit={(update) => {
onUpdateTask(update)
setEditingTask(null)
}}
/>
</div>
)
}
function TaskCardSection({
title,
tasks,
emptyText,
onSelectItem,
onEditTask,
}: {
title: string
tasks: TaskItem[]
emptyText: string
onSelectItem?: (title: string) => void
onEditTask?: (task: TaskItem) => void
}) {
return (
<Card className="queue-section overview-task-section" bordered>
<div className="section-header">
<Title heading={6}>{title}</Title>
<Button type="text" size="mini">查看全部</Button>
</div>
{tasks.length === 0 ? (
<Text type="secondary">{emptyText}</Text>
) : (
<div className="overview-task-grid">
{tasks.map((task) => (
<button key={task.id} className={task.completed ? 'overview-task-card completed' : 'overview-task-card'} onClick={() => onEditTask ? onEditTask(task) : onSelectItem?.(task.title)}>
<span className="overview-task-title">
<IconCheckCircleFill />
{task.title}
</span>
<Text type="secondary" ellipsis={{ showTooltip: true }}>{task.summary || '暂无任务内容'}</Text>
<div className="overview-task-meta">
<Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag || (task.completed ? '已完成' : '进行中')}</Tag>
<span>进度 {task.progress}</span>
<span>创建 {task.createdAt}</span>
</div>
</button>
))}
</div>
)}
</Card>
)
}
function NoteSection({ notes, onSelectItem }: { notes: ProjectWorkspace['notes']; onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>最近笔记资料({notes.length})</Title>
<Button type="text" size="mini">查看全部</Button>
</div>
<ProjectFileGrid items={notes.slice(0, 8)} onSelectItem={onSelectItem} compact />
</Card>
)
}
function projectShareURL(identifier: string) {
const slug = encodeURIComponent(identifier.trim() || 'project')
const origin = typeof window === 'undefined' ? 'https://senlinai.local' : window.location.origin
return `${origin}/share/projects/${slug}`
}
function projectHeroBackground(background: string, fallback: string) {
const value = background.trim() || fallback
if (value.startsWith('http')) {
return `linear-gradient(135deg, color-mix(in srgb, ${fallback} 78%, transparent), color-mix(in srgb, ${fallback} 34%, transparent)), url("${value.replaceAll('"', '%22')}") center / cover`
}
if (value.startsWith('linear-gradient') || value.startsWith('radial-gradient')) {
return value
}
return `linear-gradient(135deg, ${value}, color-mix(in srgb, ${value} 58%, white))`
}

View File

@@ -0,0 +1,58 @@
import type { CSSProperties } from 'react'
import { Badge, Button, Layout, Space } from '@arco-design/web-react'
import { IconCompass, IconDashboard, IconPlus } from '@arco-design/web-react/icon'
import type { Project, WorkbenchView } from './project-types'
const { Sider } = Layout
export function ProjectRail({
activeProject,
projects,
activeView,
onSelectWorkspace,
onSelectWorkspaceExplore,
onSelectProject,
onCreateProject,
}: {
activeProject: Project
projects: Project[]
activeView: WorkbenchView
onSelectWorkspace: () => void
onSelectWorkspaceExplore: () => void
onSelectProject: (project: Project) => void
onCreateProject: () => void
}) {
return (
<Sider className="project-rail" width={96}>
<Button
className={activeView === 'workspace' ? 'dashboard-button active' : 'dashboard-button'}
icon={<IconDashboard />}
onClick={onSelectWorkspace}
aria-label="工作台"
title="工作台"
/>
<Button
className={activeView === 'workspace-explore' ? 'dashboard-button active' : 'dashboard-button'}
icon={<IconCompass />}
onClick={onSelectWorkspaceExplore}
aria-label="探索"
title="探索"
/>
<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={activeView === 'project' && 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="新建项目" onClick={onCreateProject} />
</Sider>
)
}

View File

@@ -0,0 +1,167 @@
import { useEffect, useState } from 'react'
import { Badge, Button, Divider, Input, Layout, Modal, Space, Typography } from '@arco-design/web-react'
import { IconApps, IconDashboard, IconMore, IconPlus, IconSettings } from '@arco-design/web-react/icon'
import type { ChannelKey, ProjectChannel, ProjectWorkspace, WorkbenchView } from './project-types'
const { Sider } = Layout
const { Title, Text } = Typography
const { TextArea } = Input
export type ProjectSettingsUpdate = {
projectId: string
name: string
identifier: string
icon: string
background: string
description: string
}
export function ProjectSidebar({
activeView,
activeWorkspace,
activeChannel,
onSelectChannel,
onSelectItem,
onUpdateProject,
}: {
activeView: WorkbenchView
activeWorkspace: ProjectWorkspace
activeChannel: ChannelKey
onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void
onUpdateProject: (update: ProjectSettingsUpdate) => void
}) {
const workspaceMode = activeView === 'workspace'
const channels = activeWorkspace.channels
const [settingsOpen, setSettingsOpen] = useState(false)
const [draft, setDraft] = useState({
name: activeWorkspace.project.name,
identifier: activeWorkspace.project.identifier,
icon: activeWorkspace.project.icon,
background: activeWorkspace.project.background,
description: activeWorkspace.project.description,
})
useEffect(() => {
if (!settingsOpen) return
setDraft({
name: activeWorkspace.project.name,
identifier: activeWorkspace.project.identifier,
icon: activeWorkspace.project.icon,
background: activeWorkspace.project.background,
description: activeWorkspace.project.description,
})
}, [activeWorkspace.project, settingsOpen])
const projectIcon = activeWorkspace.project.short || activeWorkspace.project.identifier || activeWorkspace.project.name.slice(0, 2)
return (
<Sider className="channel-sidebar" width={248}>
<div className="project-title">
<Space>
{workspaceMode ? <IconApps /> : <span className="project-title-icon">{projectIcon}</span>}
<Title heading={5}>{workspaceMode ? '工作台' : activeWorkspace.project.name}</Title>
</Space>
<Button icon={<IconSettings />} size="mini" onClick={() => setSettingsOpen(true)} />
</div>
<div className="channel-list">
{workspaceMode ? (
<>
<button className="channel-button active">
<span className="channel-left"><IconDashboard /><Text>工作台总览</Text></span>
<Badge count={channels.length} />
</button>
<button className="channel-button">
<span className="channel-left"><IconApps /><Text>全部项目</Text></span>
<IconMore />
</button>
</>
) : (
<>
{channels.flatMap((channel) => {
const items = [renderChannelButton(channel, activeChannel, onSelectChannel)]
if (channel.key === 'cron') {
items.push(
<button
key="new-channel"
className={activeChannel === 'new-channel' ? 'channel-button active' : 'channel-button'}
onClick={() => onSelectChannel('new-channel')}
>
<span className="channel-left"><IconPlus /><Text>新建频道</Text></span>
<IconMore />
</button>,
)
}
return items
})}
</>
)}
</div>
<Divider />
<section className="recent-sessions">
<Text className="section-title">最近会话</Text>
{activeWorkspace.recentSessions.slice(0, 4).map((session) => (
<button key={session.id} onClick={() => onSelectItem(session.title)}>
<Text>{session.title}</Text>
<Text type="secondary">{session.time}</Text>
</button>
))}
</section>
<Modal
className="action-modal"
title="编辑项目"
visible={settingsOpen}
onCancel={() => setSettingsOpen(false)}
onOk={() => {
onUpdateProject({
projectId: activeWorkspace.project.id,
name: draft.name.trim() || activeWorkspace.project.name,
identifier: draft.identifier.trim(),
icon: draft.icon.trim(),
background: draft.background.trim(),
description: draft.description.trim(),
})
setSettingsOpen(false)
}}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text>名称</Text>
<Input value={draft.name} onChange={(name) => setDraft((value) => ({ ...value, name }))} />
</label>
<label>
<Text>标识</Text>
<Input value={draft.identifier} onChange={(identifier) => setDraft((value) => ({ ...value, identifier }))} />
</label>
<label>
<Text>图标</Text>
<Input value={draft.icon} onChange={(icon) => setDraft((value) => ({ ...value, icon }))} />
</label>
<label>
<Text>背景</Text>
<Input value={draft.background} onChange={(background) => setDraft((value) => ({ ...value, background }))} />
</label>
<label>
<Text>简介</Text>
<TextArea rows={4} value={draft.description} onChange={(description) => setDraft((value) => ({ ...value, description }))} />
</label>
</Space>
</Modal>
</Sider>
)
}
function renderChannelButton(channel: ProjectChannel, activeChannel: ChannelKey, onSelectChannel: (key: ChannelKey) => void) {
return (
<button
key={channel.id}
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.key === 'overview' ? null : channel.count !== undefined ? <Badge className="channel-badge-muted" count={channel.count} maxCount={999} /> : <IconMore />}
</button>
)
}

View File

@@ -0,0 +1,47 @@
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 className="status-identity">
<Text className="status-name">张明</Text>
<span className="status-online-dot" aria-label="在线" title="在线" />
</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,93 @@
import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import type { ProjectWorkspace, TaskItem } from './project-types'
const { Text } = Typography
const { TextArea } = Input
export type ProjectTaskUpdate = {
taskId: string
title: string
summary: string
tag: string
completed: boolean
}
type TaskDraft = {
title: string
summary: string
tag: string
completed: boolean
}
export function ProjectTaskEditModal({
task,
workspace,
onClose,
onSubmit,
}: {
task: TaskItem | null
workspace: ProjectWorkspace
onClose: () => void
onSubmit: (update: ProjectTaskUpdate) => void
}) {
const tagOptions = useMemo(() => workspace.tags.filter((tag) => tag !== 'all' && tag !== '全部'), [workspace.tags])
const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', tag: '', completed: false })
useEffect(() => {
if (!task) return
setDraft({
title: task.title,
summary: task.summary,
tag: task.tag,
completed: task.completed,
})
}, [task])
return (
<Modal
className="action-modal"
title="编辑计划"
visible={Boolean(task)}
onCancel={onClose}
onOk={() => {
if (!task) return
onSubmit({
taskId: task.id,
title: draft.title.trim() || task.title,
summary: draft.summary.trim(),
tag: draft.tag.trim(),
completed: draft.completed,
})
}}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text>标题</Text>
<Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} />
</label>
<label>
<Text>正文</Text>
<TextArea rows={4} value={draft.summary} onChange={(summary) => setDraft((value) => ({ ...value, summary }))} />
</label>
<label>
<Text>标签</Text>
<Select
allowClear
value={draft.tag || undefined}
placeholder="不设置标签"
onChange={(tag) => setDraft((value) => ({ ...value, tag: tag ?? '' }))}
>
{tagOptions.map((tag) => (
<Select.Option key={tag} value={tag}>{tag}</Select.Option>
))}
</Select>
</label>
<label className="action-switch">
<Text>完成状态</Text>
<Switch checked={draft.completed} checkedText="完成" uncheckedText="未完成" onChange={(completed) => setDraft((value) => ({ ...value, completed }))} />
</label>
</Space>
</Modal>
)
}

View File

@@ -0,0 +1,178 @@
import { useState } from 'react'
import { Button, Card, Descriptions, Input, Modal, Progress, Space, Tag, Typography } from '@arco-design/web-react'
import { IconArrowLeft, IconCalendar, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconPlus } from '@arco-design/web-react/icon'
import { ProjectTaskEditModal, type ProjectTaskUpdate } from './project-task-edit-modal'
import type { ProjectWorkspace, TaskItem } from './project-types'
const { Title, Text, Paragraph } = Typography
export function ProjectTasks({
activeWorkspace,
activeTaskID,
onOpenTask,
onCloseTask,
onCreateTask,
onCreateProjectTag,
onUpdateTask,
}: {
activeWorkspace: ProjectWorkspace
activeTaskID: string | null
onOpenTask: (taskID: string) => void
onCloseTask: () => void
onSelectItem: (title: string) => void
onCreateTask: () => void
onCreateProjectTag: (name: string) => void
onUpdateTask: (update: ProjectTaskUpdate) => void
}) {
const { tasks, project, tags } = activeWorkspace
const activeTask = tasks.find((task) => task.id === activeTaskID)
const pendingTasks = tasks.filter((task) => !task.completed)
const completedTasks = tasks.filter((task) => task.completed)
const projectTags = tags.filter((tag) => tag !== 'all' && tag !== '全部')
const [tagModalOpen, setTagModalOpen] = useState(false)
const [tagName, setTagName] = useState('')
const [editingTask, setEditingTask] = useState<TaskItem | null>(null)
if (activeTask) {
return <TaskDetail activeWorkspace={activeWorkspace} task={activeTask} onBack={onCloseTask} />
}
function submitTag() {
const nextTag = tagName.trim()
if (!nextTag) return
onCreateProjectTag(nextTag)
setTagName('')
setTagModalOpen(false)
}
return (
<div className="project-channel-page project-tasks-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>工作计划</Title>
<Text type="secondary">{project.name} 的标签、未完成计划和完成记录。</Text>
</div>
<Button type="primary" icon={<IconPlus />} onClick={onCreateTask}>新建任务</Button>
</div>
<Card className="queue-section task-tag-section" bordered>
<div className="section-header">
<Title heading={6}>标签</Title>
<Button className="tag-create-button" size="mini" type="text" icon={<IconPlus />} onClick={() => setTagModalOpen(true)}>新建</Button>
</div>
<Space wrap>
<Tag color="arcoblue">全部</Tag>
{projectTags.map((tag) => (
<Tag key={tag} color="gray">{tag}</Tag>
))}
</Space>
</Card>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>未完成的计划</Title>
<Tag color="arcoblue">{pendingTasks.length}</Tag>
</div>
<div className="task-plan-card-grid">
{pendingTasks.map((task) => (
<button className="task-plan-card" key={task.id} onClick={() => setEditingTask(task)}>
<span className="task-plan-title"><IconCheckCircleFill />{task.title}</span>
<Paragraph ellipsis={{ rows: 2 }} type="secondary">{task.summary || '暂无正文'}</Paragraph>
<div className="task-plan-meta">
<Tag color="arcoblue">{task.tag || '未设置标签'}</Tag>
<span><IconCalendar /> 创建 {task.createdAt}</span>
</div>
</button>
))}
{pendingTasks.length === 0 && <Text type="secondary">当前没有未完成计划</Text>}
</div>
</Card>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>完成的计划</Title>
<Tag color="green">{completedTasks.length}</Tag>
</div>
{completedTasks.map((task) => (
<button className="completed-plan-row" key={task.id} onClick={() => onOpenTask(task.id)}>
<span className="completed-plan-main">
<Text className="completed-plan-title"><IconCheckCircle />{task.title}</Text>
<Text type="secondary" ellipsis={{ showTooltip: true }}>{task.summary || '暂无正文'}</Text>
</span>
<span>{task.createdAt}</span>
<span>{task.completedAt || '未知'}</span>
<span><IconClockCircle /> {task.duration || '未知'}</span>
</button>
))}
{completedTasks.length === 0 && <Text type="secondary">当前没有完成计划</Text>}
</Card>
<Modal
className="action-modal"
title="新建标签"
visible={tagModalOpen}
onCancel={() => {
setTagModalOpen(false)
setTagName('')
}}
onOk={submitTag}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text>标签名称</Text>
<Input autoFocus placeholder="例如:设计、客户、重要" value={tagName} onChange={setTagName} onPressEnter={submitTag} />
</label>
</Space>
</Modal>
<ProjectTaskEditModal
task={editingTask}
workspace={activeWorkspace}
onClose={() => setEditingTask(null)}
onSubmit={(update) => {
onUpdateTask(update)
setEditingTask(null)
}}
/>
</div>
)
}
function TaskDetail({ activeWorkspace, task, onBack }: { activeWorkspace: ProjectWorkspace; task: TaskItem; onBack: () => void }) {
const percent = Number.parseInt(task.progress, 10)
return (
<div className="project-channel-page project-task-detail-page overview-page">
<div className="overview-head">
<div>
<Button type="text" icon={<IconArrowLeft />} onClick={onBack}>返回任务列表</Button>
<Title heading={4}>{task.title}</Title>
<Text type="secondary">{activeWorkspace.project.name} 的任务详情</Text>
</div>
<Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag || '未设置标签'}</Tag>
</div>
<Card className="queue-section task-detail-card" bordered>
<Descriptions
colon=":"
column={2}
data={[
{ label: '所属项目', value: activeWorkspace.project.name },
{ label: '负责人', value: task.owner },
{ label: '创建时间', value: task.createdAt },
{ label: '完成状态', value: task.completed ? '已完成' : '未完成' },
{ label: '完成时间', value: task.completedAt || '未完成' },
{ label: '耗时', value: task.duration || '未完成' },
]}
/>
<div className="task-detail-progress">
<Text type="secondary">任务进度</Text>
<Progress percent={Number.isNaN(percent) ? 0 : percent} />
</div>
<div className="task-detail-summary">
<Title heading={6}>任务说明</Title>
<Text>{task.summary || '暂无任务说明'}</Text>
</div>
</Card>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { Button, Input, Layout, Space, Typography } from '@arco-design/web-react'
import { IconApps, IconMoon, 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 }) {
const desktop = isDesktopRuntime()
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} />
{desktop && (
<>
<Button aria-label="停靠左边" icon={<DockIcon side="left" />} />
<Button aria-label="停靠右边" icon={<DockIcon side="right" />} />
</>
)}
<Button icon={<IconApps />} />
</Space>
</Header>
)
}
function isDesktopRuntime() {
return typeof window !== 'undefined' && '__TAURI__' in window
}
function DockIcon({ side }: { side: 'left' | 'right' }) {
return (
<span className={`dock-icon dock-icon-${side}`} aria-hidden="true">
<span />
</span>
)
}

View File

@@ -0,0 +1,94 @@
import type { ReactElement } from 'react'
export type Screen = 'login' | 'workbench'
export type Theme = 'light' | 'dark'
export type WorkbenchView = 'workspace' | 'workspace-explore' | 'project'
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | 'new-channel' | `custom:${string}`
export type Project = {
id: string
name: string
identifier: string
icon: string
background: string
description: string
short: string
badge: number
urgent?: boolean
color: string
}
export type ProjectChannel = {
key: ChannelKey
id: string
label: string
count?: number
icon: ReactElement
url?: string
sortOrder: number
}
export type InboxItem = {
id: string
title: string
meta: string
owner: string
time: string
tag: string
summary: string
status: string
}
export type TaskItem = {
id: string
title: string
owner: string
progress: string
due: string
createdAt: string
completedAt: string
duration: string
tag: string
summary: string
completed: boolean
}
export type AISession = {
id: string
title: string
summary: string
owner: string
time: string
}
export type NoteSource = {
id: string
title: string
type: string
updated: string
owner: string
source: string
}
export type CronJob = {
id: string
name: string
expr: string
status: string
lastRun: string
nextRun: string
owner: string
enabled: boolean
}
export type ProjectWorkspace = {
project: Project
channels: ProjectChannel[]
tags: string[]
recentSessions: AISession[]
inbox: InboxItem[]
tasks: TaskItem[]
aiSessions: AISession[]
notes: NoteSource[]
cronJobs: CronJob[]
}

View File

@@ -0,0 +1,206 @@
import { useEffect, useMemo, useState } from 'react'
import { Card, Empty, Grid, Input, Modal, Select, Space, Switch, Tag, Typography } from '@arco-design/web-react'
import {
IconCalendar,
IconCheckCircleFill,
IconDashboard,
IconFile,
IconRobot,
} from '@arco-design/web-react/icon'
import type { Project, ProjectWorkspace, TaskItem } from './projects/project-types'
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
const { TextArea } = Input
export type WorkspaceTaskUpdate = {
originalProjectId: string
nextProjectId: string
taskId: string
title: string
summary: string
tag: string
completed: boolean
}
type WorkspaceTask = TaskItem & {
project: Project
}
type TaskDraft = {
title: string
summary: string
projectId: string
tag: string
completed: boolean
}
export function WorkspacePage({
workspaces,
onUpdateTask,
}: {
workspaces: ProjectWorkspace[]
onOpenTask: (project: Project, taskID: string) => void
onUpdateTask: (update: WorkspaceTaskUpdate) => void
}) {
const projects = workspaces.map((workspace) => workspace.project)
const projectOptions = useMemo(() => workspaces.map((workspace) => workspace.project), [workspaces])
const unfinishedTasks: WorkspaceTask[] = workspaces.flatMap((workspace) =>
workspace.tasks
.filter((task) => !task.completed)
.map((task) => ({
...task,
project: workspace.project,
})),
)
const [editingTask, setEditingTask] = useState<WorkspaceTask | null>(null)
const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', projectId: '', tag: '', completed: false })
const tagOptions = useMemo(() => {
const selectedWorkspace = workspaces.find((workspace) => workspace.project.id === draft.projectId)
return selectedWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
}, [draft.projectId, workspaces])
useEffect(() => {
if (!editingTask) return
setDraft({
title: editingTask.title,
summary: editingTask.summary,
projectId: editingTask.project.id,
tag: editingTask.tag,
completed: editingTask.completed,
})
}, [editingTask])
useEffect(() => {
if (!draft.tag || tagOptions.includes(draft.tag)) return
setDraft((value) => ({ ...value, tag: '' }))
}, [draft.tag, tagOptions])
const aiSessions = workspaces.flatMap((workspace) => workspace.aiSessions)
const notes = workspaces.flatMap((workspace) => workspace.notes)
const urgentProjects = projects.filter((project) => project.urgent).length
const workspaceMetrics = [
{ title: '项目总数', value: projects.length, delta: `${urgentProjects} 个高优先级`, icon: <IconDashboard />, color: 'arcoblue' },
{ title: '未完成计划', value: unfinishedTasks.length, delta: '跨项目聚合', icon: <IconCheckCircleFill />, color: 'green' },
{ title: 'AI 会话', value: aiSessions.length, delta: '项目内上下文', icon: <IconRobot />, color: 'purple' },
{ title: '知识资料', value: notes.length, delta: '笔记与资料合计', icon: <IconFile />, color: 'cyan' },
]
return (
<div className="workspace-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>工作台</Title>
<Text type="secondary">所有项目的统一工作台,汇总项目任务、AI 会话和知识资产。</Text>
</div>
</div>
<Row gutter={12} className="metric-row">
{workspaceMetrics.map((metric) => (
<Col span={24 / workspaceMetrics.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>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>未完成的计划</Title>
<Tag color="arcoblue">{unfinishedTasks.length}</Tag>
</div>
{unfinishedTasks.length ? (
<div className="workspace-task-list">
{unfinishedTasks.map((task) => (
<button key={`${task.project.id}-${task.id}`} className="workspace-task-card" onClick={() => setEditingTask(task)}>
<div className="workspace-task-main">
<span className="workspace-task-title">
<IconCheckCircleFill />
{task.title}
</span>
<Paragraph ellipsis={{ rows: 2 }} type="secondary">
{task.summary || '暂无任务说明'}
</Paragraph>
</div>
<div className="workspace-task-meta">
<Tag color={task.project.urgent ? 'red' : 'arcoblue'}>{task.project.name}</Tag>
<Tag color="green">{task.tag || '-'}</Tag>
<span><IconCalendar /> {task.createdAt}</span>
</div>
</button>
))}
</div>
) : (
<Empty description="暂无未完成计划" />
)}
</Card>
<Modal
className="action-modal"
title="编辑任务"
visible={Boolean(editingTask)}
onCancel={() => setEditingTask(null)}
onOk={() => {
if (!editingTask) return
onUpdateTask({
originalProjectId: editingTask.project.id,
nextProjectId: draft.projectId,
taskId: editingTask.id,
title: draft.title.trim() || editingTask.title,
summary: draft.summary.trim(),
tag: draft.tag.trim(),
completed: draft.completed,
})
setEditingTask(null)
}}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text>任务标题</Text>
<Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} />
</label>
<label>
<Text>任务内容</Text>
<TextArea rows={4} value={draft.summary} onChange={(summary) => setDraft((value) => ({ ...value, summary }))} />
</label>
<label>
<Text>所属项目</Text>
<Select
showSearch
value={draft.projectId}
placeholder="选择项目"
onChange={(projectId) => setDraft((value) => ({ ...value, projectId }))}
>
{projectOptions.map((project) => (
<Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>
))}
</Select>
</label>
<label>
<Text>所属标签</Text>
<Select
allowClear
value={draft.tag || undefined}
placeholder="不设置标签"
onChange={(tag) => setDraft((value) => ({ ...value, tag: tag ?? '' }))}
>
{tagOptions.map((tag) => (
<Select.Option key={tag} value={tag}>{tag}</Select.Option>
))}
</Select>
</label>
<label className="action-switch">
<Text>完成状态</Text>
<Switch checked={draft.completed} checkedText="完成" uncheckedText="未完成" onChange={(completed) => setDraft((value) => ({ ...value, completed }))} />
</label>
</Space>
</Modal>
</div>
)
}

View File

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

View File

@@ -0,0 +1,126 @@
import { Layout } from '@arco-design/web-react'
import { WorkspacePage, type WorkspaceTaskUpdate } from './workspace-body'
import { WorkspaceExplorePage } from './workspace-explore'
import { ProjectChannelPage } from './projects/project-channel-page'
import type { ProjectTaskUpdate } from './projects/project-task-edit-modal'
import { ProjectRail } from './projects/project-rail'
import { ProjectSidebar, type ProjectSettingsUpdate } from './projects/project-sidebar'
import { ProjectStatusbar } from './projects/project-statusbar'
import { ProjectTopbar } from './projects/project-topbar'
import type { ChannelKey, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
const { Content } = Layout
export function ProjectPage({
activeView,
activeWorkspace,
workspaces,
activeChannel,
activeTaskID,
theme,
onSelectProject,
onSelectWorkspace,
onSelectWorkspaceExplore,
onSelectChannel,
onSelectItem,
onOpenTask,
onCloseTask,
onToggleTheme,
onCreateProject,
onCreateTask,
onUploadSource,
onCreateCronPlan,
onUpdateWorkspaceTask,
onUpdateProject,
onCreateProjectTag,
}: {
activeView: WorkbenchView
activeWorkspace: ProjectWorkspace
workspaces: ProjectWorkspace[]
activeChannel: ChannelKey
activeTaskID: string | null
theme: Theme
onSelectProject: (project: Project) => void
onSelectWorkspace: () => void
onSelectWorkspaceExplore: () => void
onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void
onOpenTask: (project: Project, taskID: string) => void
onCloseTask: () => void
onToggleTheme: () => void
onCreateProject: () => void
onCreateTask: () => void
onUploadSource: () => void
onCreateCronPlan: () => void
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => void
onUpdateProject: (update: ProjectSettingsUpdate) => void
onCreateProjectTag: (name: string) => void
}) {
const isProject = activeView === 'project'
const projects = workspaces.map((workspace) => workspace.project)
const updateActiveProjectTask = (update: ProjectTaskUpdate) => {
onUpdateWorkspaceTask({
originalProjectId: activeWorkspace.project.id,
nextProjectId: activeWorkspace.project.id,
taskId: update.taskId,
title: update.title,
summary: update.summary,
tag: update.tag,
completed: update.completed,
})
}
return (
<Layout className="workbench-shell">
<ProjectTopbar theme={theme} onToggleTheme={onToggleTheme} />
<Layout className="workbench-main" hasSider>
<ProjectRail
activeProject={activeWorkspace.project}
projects={projects}
activeView={activeView}
onSelectWorkspace={onSelectWorkspace}
onSelectWorkspaceExplore={onSelectWorkspaceExplore}
onSelectProject={onSelectProject}
onCreateProject={onCreateProject}
/>
{isProject && (
<ProjectSidebar
activeView={activeView}
activeWorkspace={activeWorkspace}
activeChannel={activeChannel}
onSelectChannel={onSelectChannel}
onSelectItem={onSelectItem}
onUpdateProject={onUpdateProject}
/>
)}
<Content className="stage">
{activeView === 'workspace' ? (
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} onUpdateTask={onUpdateWorkspaceTask} />
) : activeView === 'workspace-explore' ? (
<WorkspaceExplorePage workspaces={workspaces} onSelectItem={onSelectItem} />
) : (
<ProjectChannelPage
activeChannel={activeChannel}
activeWorkspace={activeWorkspace}
activeTaskID={activeTaskID}
onSelectItem={onSelectItem}
onOpenTask={(taskID) => onOpenTask(activeWorkspace.project, taskID)}
onCloseTask={onCloseTask}
onCreateTask={onCreateTask}
onUploadSource={onUploadSource}
onCreateCronPlan={onCreateCronPlan}
onCreateProjectTag={onCreateProjectTag}
onUpdateTask={updateActiveProjectTask}
/>
)}
</Content>
</Layout>
<ProjectStatusbar />
</Layout>
)
}

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()],
})