Connect React workspace to backend data
This commit is contained in:
@@ -12,13 +12,14 @@ const requiredFiles = [
|
||||
'src/pages/projects/project-ai.tsx',
|
||||
'src/pages/projects/project-notes.tsx',
|
||||
'src/pages/projects/project-cron.tsx',
|
||||
'src/pages/projects/project-data.tsx',
|
||||
'src/pages/projects/project-rail.tsx',
|
||||
'src/pages/projects/project-sidebar.tsx',
|
||||
'src/pages/projects/project-topbar.tsx',
|
||||
'src/pages/projects/project-inspector.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}`)
|
||||
@@ -27,6 +28,12 @@ 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']) {
|
||||
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']) {
|
||||
|
||||
57
apps/senlinai-acro-react/src/api/client.ts
Normal file
57
apps/senlinai-acro-react/src/api/client.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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 response = await fetch(`${configuredBaseUrl}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
},
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string) {
|
||||
const trimmed = value.trim()
|
||||
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
|
||||
}
|
||||
165
apps/senlinai-acro-react/src/api/mappers.tsx
Normal file
165
apps/senlinai-acro-react/src/api/mappers.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
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,
|
||||
short: payload.project.initials,
|
||||
badge: payload.project.unreadCount,
|
||||
urgent: payload.project.unreadCount > 20,
|
||||
color: projectColors[index % projectColors.length],
|
||||
}
|
||||
|
||||
return {
|
||||
project,
|
||||
channels: payload.channels.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 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 'Cron 计划任务'
|
||||
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 || '未设置',
|
||||
tag: task.tag || (task.completed ? '已完成' : '进行中'),
|
||||
summary: task.summary,
|
||||
completed: task.completed,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
96
apps/senlinai-acro-react/src/api/projects.ts
Normal file
96
apps/senlinai-acro-react/src/api/projects.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { apiRequest, type ApiSession } from './client'
|
||||
|
||||
export type BackendProject = {
|
||||
ID?: number
|
||||
id?: number
|
||||
Name?: string
|
||||
name?: string
|
||||
Description?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type BackendWorkspaceProject = {
|
||||
id: number
|
||||
name: 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
|
||||
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 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 })
|
||||
}
|
||||
@@ -1,42 +1,76 @@
|
||||
import { useState } from 'react'
|
||||
import { ConfigProvider } from '@arco-design/web-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 { fetchProjectWorkspace, fetchProjects } from '../api/projects'
|
||||
import { LoginPage } from '../pages/login'
|
||||
import { ProjectPage } from '../pages/project'
|
||||
import { projects } from '../pages/projects/project-data'
|
||||
import type { ChannelKey, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
|
||||
import type { ChannelKey, 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 [activeProject, setActiveProject] = useState(projects[0])
|
||||
const [session, setSession] = useState<ApiSession | null>(null)
|
||||
const [workspaces, setWorkspaces] = useState<ProjectWorkspace[]>([])
|
||||
const [activeProjectID, setActiveProjectID] = useState<string>('')
|
||||
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
|
||||
const [, setSelectedItem] = useState('Inbox 待处理(36)')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const dark = theme === 'dark'
|
||||
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0]
|
||||
|
||||
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 backendProjects = await fetchProjects(nextSession)
|
||||
const backendWorkspaces = await Promise.all(
|
||||
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.ID ?? project.id ?? '').then((workspace) => mapWorkspace(workspace, index))),
|
||||
)
|
||||
setSession(nextSession)
|
||||
setWorkspaces(backendWorkspaces)
|
||||
setActiveProjectID(backendWorkspaces[0]?.project.id ?? '')
|
||||
setActiveChannel('overview')
|
||||
setActiveView('workspace')
|
||||
setScreen('workbench')
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<main className={dark ? 'app theme-dark' : 'app'}>
|
||||
{screen === 'login' ? (
|
||||
<LoginPage onLogin={() => setScreen('workbench')} />
|
||||
) : (
|
||||
<Spin loading={loading} style={{ width: '100%' }}>
|
||||
<LoginPage onLogin={handleLogin} />
|
||||
</Spin>
|
||||
) : activeWorkspace && session ? (
|
||||
<ProjectPage
|
||||
activeView={activeView}
|
||||
activeProject={activeProject}
|
||||
activeWorkspace={activeWorkspace}
|
||||
workspaces={workspaces}
|
||||
activeChannel={activeChannel}
|
||||
theme={theme}
|
||||
onSelectWorkspace={() => setActiveView('workspace')}
|
||||
onSelectProject={(project) => {
|
||||
setActiveProject(project)
|
||||
setActiveProjectID(project.id)
|
||||
setActiveView('project')
|
||||
setActiveChannel('overview')
|
||||
}}
|
||||
onSelectChannel={setActiveChannel}
|
||||
onSelectItem={setSelectedItem}
|
||||
onToggleTheme={() => setTheme(dark ? 'light' : 'dark')}
|
||||
/>
|
||||
) : (
|
||||
<Spin loading />
|
||||
)}
|
||||
</main>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -5,8 +5,10 @@ import { IconBook, IconCheckCircleFill, IconLaunch, IconSafe, IconUserGroup } fr
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
export function LoginPage({ onLogin }: { onLogin: () => void }) {
|
||||
const [server, setServer] = useState('https://10.0.0.15:8080')
|
||||
export function LoginPage({ onLogin }: { onLogin: (input: { server: string; email: string; password: string }) => void }) {
|
||||
const [server, setServer] = useState('http://127.0.0.1:8080')
|
||||
const [email, setEmail] = useState('demo@senlin.ai')
|
||||
const [password, setPassword] = useState('password123')
|
||||
|
||||
return (
|
||||
<section className="login-screen">
|
||||
@@ -34,10 +36,10 @@ export function LoginPage({ onLogin }: { onLogin: () => void }) {
|
||||
<Input value={server} onChange={setServer} placeholder="例如:https://10.0.0.15:8080" suffix={<IconLaunch />} />
|
||||
</Form.Item>
|
||||
<Form.Item label="账号">
|
||||
<Input placeholder="请输入用户名或邮箱" />
|
||||
<Input value={email} onChange={setEmail} placeholder="请输入用户名或邮箱" />
|
||||
</Form.Item>
|
||||
<Form.Item label="密码">
|
||||
<Input.Password placeholder="请输入密码" />
|
||||
<Input.Password value={password} onChange={setPassword} placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
<div className="login-row">
|
||||
<Space size={8}>
|
||||
@@ -46,7 +48,7 @@ export function LoginPage({ onLogin }: { onLogin: () => void }) {
|
||||
</Space>
|
||||
<Button type="text" size="mini">无法连接?</Button>
|
||||
</div>
|
||||
<Button type="primary" long size="large" onClick={onLogin}>
|
||||
<Button type="primary" long size="large" onClick={() => onLogin({ server, email, password })}>
|
||||
登录
|
||||
</Button>
|
||||
<div className="connection-card">
|
||||
|
||||
@@ -5,13 +5,14 @@ import { ProjectRail } from './projects/project-rail'
|
||||
import { ProjectSidebar } from './projects/project-sidebar'
|
||||
import { ProjectStatusbar } from './projects/project-statusbar'
|
||||
import { ProjectTopbar } from './projects/project-topbar'
|
||||
import type { ChannelKey, Project, Theme, WorkbenchView } from './projects/project-types'
|
||||
import type { ChannelKey, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
|
||||
|
||||
const { Content } = Layout
|
||||
|
||||
export function ProjectPage({
|
||||
activeView,
|
||||
activeProject,
|
||||
activeWorkspace,
|
||||
workspaces,
|
||||
activeChannel,
|
||||
theme,
|
||||
onSelectProject,
|
||||
@@ -21,7 +22,8 @@ export function ProjectPage({
|
||||
onToggleTheme,
|
||||
}: {
|
||||
activeView: WorkbenchView
|
||||
activeProject: Project
|
||||
activeWorkspace: ProjectWorkspace
|
||||
workspaces: ProjectWorkspace[]
|
||||
activeChannel: ChannelKey
|
||||
theme: Theme
|
||||
onSelectProject: (project: Project) => void
|
||||
@@ -31,6 +33,7 @@ export function ProjectPage({
|
||||
onToggleTheme: () => void
|
||||
}) {
|
||||
const isWorkspace = activeView === 'workspace'
|
||||
const projects = workspaces.map((workspace) => workspace.project)
|
||||
|
||||
return (
|
||||
<Layout className="workbench-shell">
|
||||
@@ -38,7 +41,8 @@ export function ProjectPage({
|
||||
|
||||
<Layout className="workbench-main" hasSider>
|
||||
<ProjectRail
|
||||
activeProject={activeProject}
|
||||
activeProject={activeWorkspace.project}
|
||||
projects={projects}
|
||||
activeView={activeView}
|
||||
onSelectWorkspace={onSelectWorkspace}
|
||||
onSelectProject={onSelectProject}
|
||||
@@ -47,7 +51,7 @@ export function ProjectPage({
|
||||
{!isWorkspace && (
|
||||
<ProjectSidebar
|
||||
activeView={activeView}
|
||||
activeProject={activeProject}
|
||||
activeWorkspace={activeWorkspace}
|
||||
activeChannel={activeChannel}
|
||||
onSelectChannel={onSelectChannel}
|
||||
onSelectItem={onSelectItem}
|
||||
@@ -56,11 +60,11 @@ export function ProjectPage({
|
||||
|
||||
<Content className="stage">
|
||||
{isWorkspace ? (
|
||||
<WorkspacePage onSelectProject={onSelectProject} />
|
||||
<WorkspacePage workspaces={workspaces} onSelectProject={onSelectProject} />
|
||||
) : (
|
||||
<ProjectChannelPage
|
||||
activeChannel={activeChannel}
|
||||
activeProject={activeProject}
|
||||
activeWorkspace={activeWorkspace}
|
||||
onSelectItem={onSelectItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Avatar, Button, Card, Input, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconPlus, IconRobot, IconSend } from '@arco-design/web-react/icon'
|
||||
import { aiSessions } from './project-data'
|
||||
import type { Project } from './project-types'
|
||||
import type { ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
@@ -11,13 +10,16 @@ const messages = [
|
||||
{ role: 'user', text: '把建议拆成可执行任务。' },
|
||||
]
|
||||
|
||||
export function ProjectAi({ activeProject, onSelectItem }: { activeProject: Project; onSelectItem: (title: string) => void }) {
|
||||
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">{activeProject.name} 的项目内 AI session,左侧会话列表,右侧对话详情。</Text>
|
||||
<Text type="secondary">{project.name} 的项目内 AI session,左侧会话列表,右侧对话详情。</Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<IconPlus />}>新建会话</Button>
|
||||
</div>
|
||||
@@ -43,7 +45,7 @@ export function ProjectAi({ activeProject, onSelectItem }: { activeProject: Proj
|
||||
|
||||
<Card className="ai-chat-panel queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>{aiSessions[0].title}</Title>
|
||||
<Title heading={6}>{selected?.title ?? '暂无会话'}</Title>
|
||||
<Tag color="green">队列空闲</Tag>
|
||||
</div>
|
||||
<div className="chat-thread">
|
||||
|
||||
@@ -4,32 +4,30 @@ import { ProjectInbox } from './project-inbox'
|
||||
import { ProjectNotes } from './project-notes'
|
||||
import { ProjectOverview } from './project-overview'
|
||||
import { ProjectTasks } from './project-tasks'
|
||||
import type { ChannelKey, Project } from './project-types'
|
||||
import type { ChannelKey, ProjectWorkspace } from './project-types'
|
||||
|
||||
export function ProjectChannelPage({
|
||||
activeChannel,
|
||||
activeProject,
|
||||
activeWorkspace,
|
||||
onSelectItem,
|
||||
}: {
|
||||
activeChannel: ChannelKey
|
||||
activeProject: Project
|
||||
activeWorkspace: ProjectWorkspace
|
||||
onSelectItem: (title: string) => void
|
||||
}) {
|
||||
switch (activeChannel) {
|
||||
case 'inbox':
|
||||
return <ProjectInbox activeProject={activeProject} onSelectItem={onSelectItem} />
|
||||
return <ProjectInbox activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
|
||||
case 'tasks':
|
||||
return <ProjectTasks activeProject={activeProject} onSelectItem={onSelectItem} />
|
||||
return <ProjectTasks activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
|
||||
case 'ai':
|
||||
return <ProjectAi activeProject={activeProject} onSelectItem={onSelectItem} />
|
||||
return <ProjectAi activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
|
||||
case 'notes':
|
||||
return <ProjectNotes activeProject={activeProject} onSelectItem={onSelectItem} />
|
||||
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
|
||||
case 'cron':
|
||||
return <ProjectCron activeProject={activeProject} onSelectItem={onSelectItem} />
|
||||
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
|
||||
case 'overview':
|
||||
case 'research':
|
||||
case 'design':
|
||||
default:
|
||||
return <ProjectOverview onSelectItem={onSelectItem} />
|
||||
return <ProjectOverview activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { Button, Card, Space, Switch, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconClockCircle, IconPlus, IconThunderbolt } from '@arco-design/web-react/icon'
|
||||
import type { Project } from './project-types'
|
||||
import type { ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const cronJobs = [
|
||||
{ name: '每日竞品价格抓取', expr: '0 8 * * *', status: '运行中', lastRun: '今天 08:00', nextRun: '明天 08:00', owner: '李明' },
|
||||
{ name: '周报摘要生成', expr: '0 18 * * 5', status: '运行中', lastRun: '上周五 18:00', nextRun: '本周五 18:00', owner: '张伟' },
|
||||
{ name: '过期任务提醒', expr: '*/30 9-18 * * 1-5', status: '暂停', lastRun: '昨天 17:30', nextRun: '暂停中', owner: '系统' },
|
||||
{ name: '资料索引刷新', expr: '0 */4 * * *', status: '运行中', lastRun: '今天 12:00', nextRun: '今天 16:00', owner: '系统' },
|
||||
]
|
||||
export function ProjectCron({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
|
||||
const { cronJobs, project } = activeWorkspace
|
||||
const enabledCount = cronJobs.filter((job) => job.enabled).length
|
||||
const pausedCount = cronJobs.length - enabledCount
|
||||
|
||||
export function ProjectCron({ activeProject, onSelectItem }: { activeProject: Project; onSelectItem: (title: string) => void }) {
|
||||
return (
|
||||
<div className="project-channel-page project-cron-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>Cron 计划任务</Title>
|
||||
<Text type="secondary">{activeProject.name} 的定时任务、自动检查和周期性 AI 工作。</Text>
|
||||
<Text type="secondary">{project.name} 的定时任务、自动检查和周期性 AI 工作。</Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<IconPlus />}>新建计划</Button>
|
||||
</div>
|
||||
@@ -26,8 +23,8 @@ export function ProjectCron({ activeProject, onSelectItem }: { activeProject: Pr
|
||||
<div className="section-header">
|
||||
<Title heading={6}>任务列表</Title>
|
||||
<Space>
|
||||
<Tag color="green">3 运行中</Tag>
|
||||
<Tag color="gray">1 暂停</Tag>
|
||||
<Tag color="green">{enabledCount} 运行中</Tag>
|
||||
<Tag color="gray">{pausedCount} 暂停</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
{cronJobs.map((job) => (
|
||||
@@ -42,12 +39,12 @@ export function ProjectCron({ activeProject, onSelectItem }: { activeProject: Pr
|
||||
}}
|
||||
>
|
||||
<span className="row-title"><IconClockCircle /> {job.name}</span>
|
||||
<Tag color={job.status === '运行中' ? 'green' : 'gray'}>{job.status}</Tag>
|
||||
<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.status === '运行中'} />
|
||||
<Switch size="small" checked={job.enabled} />
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { IconClockCircle, IconEmail, IconFile, IconHome, IconLink, IconList, IconRobot, IconUserGroup } from '@arco-design/web-react/icon'
|
||||
import type { Project, ProjectChannel } from './project-types'
|
||||
|
||||
export const projects: Project[] = [
|
||||
{ id: 'a1', name: '项目 A1', short: 'A1', badge: 635, urgent: true, color: '#165DFF' },
|
||||
{ id: 'a2', name: '项目 A2', short: 'PM', badge: 36, color: '#00B42A' },
|
||||
{ id: 'b1', name: '项目 B1', short: 'DS', badge: 4, urgent: true, color: '#722ED1' },
|
||||
{ id: 'c3', name: '项目 C3', short: 'OP', badge: 45, color: '#FF7D00' },
|
||||
]
|
||||
|
||||
export const channels: ProjectChannel[] = [
|
||||
{ key: 'overview', label: '概况', count: 635, icon: <IconHome /> },
|
||||
{ key: 'inbox', label: 'Inbox 消息流', count: 36, icon: <IconEmail /> },
|
||||
{ key: 'tasks', label: '工作计划', count: 12, icon: <IconList /> },
|
||||
{ key: 'ai', label: 'AI 会话', count: 4, icon: <IconRobot /> },
|
||||
{ key: 'notes', label: '笔记资料', count: 343, icon: <IconFile /> },
|
||||
{ key: 'cron', label: 'Cron 计划任务', count: 45, icon: <IconClockCircle /> },
|
||||
{ key: 'research', label: '客户研究频道', icon: <IconUserGroup /> },
|
||||
{ key: 'design', label: '产品设计频道', icon: <IconLink /> },
|
||||
]
|
||||
|
||||
export const inbox = [
|
||||
{ title: '竞争对手定价更新监控', meta: '来源:产品研究 · 竞品追踪', owner: '李明', time: '2 小时前', tag: '市场情报' },
|
||||
{ title: '客户反馈:导出功能报错', meta: '来源:客户支持 · 工单 #CS-1287', owner: '王芳', time: '4 小时前', tag: '客户反馈' },
|
||||
{ title: 'API 调用策略评估建议', meta: '来源:后端架构讨论', owner: '张伟', time: '6 小时前', tag: '架构' },
|
||||
{ title: '更新需求文档 v1.3.2', meta: '来源:产品需求 · 文档评审', owner: '陈晨', time: '昨天', tag: '需求文档' },
|
||||
]
|
||||
|
||||
export const tasks = [
|
||||
{ title: '智能报表导出功能', owner: '张伟', progress: '60%', due: '2026-07-24', tag: '进行中' },
|
||||
{ title: '企业版权限体系梳理', owner: '李明', progress: '30%', due: '2026-07-28', tag: '进行中' },
|
||||
{ title: 'Q3 营销策略制定', owner: '王芳', progress: '45%', due: '2026-07-31', tag: '进行中' },
|
||||
]
|
||||
|
||||
export const aiSessions = [
|
||||
{ title: '生成 Q3 营销策略初稿', summary: '基于市场分析和竞品数据,输出 Q3 营销策略重点...', owner: '张伟', time: '今天 08:47' },
|
||||
{ title: '产品定价模型讨论', summary: '针对企业版与标准版定价模型进行对比分析...', owner: '李明', time: '昨天 16:05' },
|
||||
{ title: '客户流失原因分析', summary: '分析近三个月客户流失数据与反馈原因...', owner: '王芳', time: '昨天 10:22' },
|
||||
{ title: '行业趋势与机会洞察', summary: '围绕 AI 在企业协同领域的趋势与机会...', owner: '张伟', time: '7 月 18 日' },
|
||||
]
|
||||
|
||||
export const notes = [
|
||||
{ title: '用户权限体系设计文档 v2.1', type: '文档', updated: '2026-07-19', owner: '李明' },
|
||||
{ title: '导出功能技术方案评审', type: '文档', updated: '2026-07-18', owner: '张伟' },
|
||||
{ title: '2026 Q3 竞品功能对比表', type: '表格', updated: '2026-07-18', owner: '王芳' },
|
||||
]
|
||||
|
||||
export const discussions = [
|
||||
{ name: '李明', time: '2026-07-20 09:12', text: '关于导出报错的问题,已在测试环境复现,初步定位为权限校验逻辑异常。' },
|
||||
{ name: '张伟', time: '2026-07-20 09:18', text: '@李明 麻烦同步修复预计完成时间,我这边需要同步给客户。' },
|
||||
{ name: '王芳', time: '2026-07-20 10:05', text: '补充:客户反馈影响范围主要集中在企业版用户。' },
|
||||
{ name: '系统通知', time: '2026-07-20 10:30', text: '已将此讨论关联到任务:智能报表导出功能。' },
|
||||
]
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconArchive, IconEmail, IconRefresh, IconSend, IconStar } from '@arco-design/web-react/icon'
|
||||
import { inbox } from './project-data'
|
||||
import type { Project } from './project-types'
|
||||
import type { ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Row, Col } = Grid
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
@@ -12,7 +11,8 @@ const folders = [
|
||||
{ label: '已归档', count: 42, color: 'gray' },
|
||||
]
|
||||
|
||||
export function ProjectInbox({ activeProject, onSelectItem }: { activeProject: Project; onSelectItem: (title: string) => void }) {
|
||||
export function ProjectInbox({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
|
||||
const { inbox, project } = activeWorkspace
|
||||
const selected = inbox[0]
|
||||
|
||||
return (
|
||||
@@ -20,7 +20,7 @@ export function ProjectInbox({ activeProject, onSelectItem }: { activeProject: P
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>Inbox 消息流</Title>
|
||||
<Text type="secondary">{activeProject.name} 的邮件式收件箱,聚合外部反馈、系统通知和待处理线索。</Text>
|
||||
<Text type="secondary">{project.name} 的邮件式收件箱,聚合外部反馈、系统通知和待处理线索。</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<IconRefresh />}>刷新</Button>
|
||||
@@ -65,13 +65,13 @@ export function ProjectInbox({ activeProject, onSelectItem }: { activeProject: P
|
||||
|
||||
<Card className="mail-detail queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>{selected.title}</Title>
|
||||
<Title heading={6}>{selected?.title ?? '暂无消息'}</Title>
|
||||
<Space>
|
||||
<Button type="text" icon={<IconStar />} />
|
||||
<Button type="text" icon={<IconSend />} />
|
||||
</Space>
|
||||
</div>
|
||||
<Text type="secondary">{selected.meta} · {selected.owner}</Text>
|
||||
<Text type="secondary">{selected ? `${selected.meta} · ${selected.owner}` : '真实接口暂无消息'}</Text>
|
||||
<Paragraph>
|
||||
已收到新的项目消息,需要判断是否转为任务、笔记或资料。当前建议先补充影响范围,再同步给相关负责人。
|
||||
</Paragraph>
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Avatar, Button, Divider, Input, Layout, Message, Space, Tabs, Typography } from '@arco-design/web-react'
|
||||
import { IconLink, IconMessage, IconMore } from '@arco-design/web-react/icon'
|
||||
import { channels, discussions } from './project-data'
|
||||
import type { ChannelKey, Project, WorkbenchView } from './project-types'
|
||||
|
||||
const { Sider } = Layout
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
export function ProjectInspector({
|
||||
activeView,
|
||||
activeProject,
|
||||
activeChannel,
|
||||
selectedItem,
|
||||
}: {
|
||||
activeView: WorkbenchView
|
||||
activeProject: Project
|
||||
activeChannel: ChannelKey
|
||||
selectedItem: string
|
||||
}) {
|
||||
const workspaceMode = activeView === 'workspace'
|
||||
|
||||
return (
|
||||
<Sider className="inspector" width={360}>
|
||||
<Tabs defaultActiveTab="discussion">
|
||||
<Tabs.TabPane key="discussion" title="讨论">
|
||||
<InspectorDiscussion selectedItem={selectedItem} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="props" title="属性">
|
||||
<div className="property-list">
|
||||
<Property label="所属项目" value={workspaceMode ? '全部项目' : activeProject.name} />
|
||||
<Property label="频道" value={workspaceMode ? '工作台总览' : channels.find((channel) => channel.key === activeChannel)?.label ?? '概况'} />
|
||||
<Property label="创建人" value="张伟" />
|
||||
<Property label="更新时间" value="2026-07-20 10:30" />
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="more" title="更多">
|
||||
<Space direction="vertical" className="more-actions">
|
||||
<Button long>复制链接</Button>
|
||||
<Button long>标记已处理</Button>
|
||||
<Button long status="danger">归档对象</Button>
|
||||
</Space>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Sider>
|
||||
)
|
||||
}
|
||||
|
||||
function InspectorDiscussion({ selectedItem }: { selectedItem: string }) {
|
||||
return (
|
||||
<div className="discussion-panel">
|
||||
<div className="inspector-title">
|
||||
<Title heading={5}>关于「{selectedItem}」</Title>
|
||||
<Button size="mini" icon={<IconMore />} />
|
||||
</div>
|
||||
<Text type="secondary">注:同一对象的讨论与协作记录</Text>
|
||||
<Divider />
|
||||
<Space direction="vertical" size={18} className="discussion-list">
|
||||
{discussions.map((item) => (
|
||||
<div className="discussion-item" key={`${item.name}-${item.time}`}>
|
||||
<Avatar size={30}>{item.name.slice(0, 1)}</Avatar>
|
||||
<div>
|
||||
<Space>
|
||||
<Text className="discussion-name">{item.name}</Text>
|
||||
<Text type="secondary">{item.time}</Text>
|
||||
</Space>
|
||||
<Paragraph>{item.text}</Paragraph>
|
||||
<Button type="text" size="mini">回复</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
<div className="comment-box">
|
||||
<Input.TextArea placeholder="写下你的评论,Enter 发送,⌘ + Enter 换行" autoSize={{ minRows: 3, maxRows: 4 }} />
|
||||
<div className="comment-actions">
|
||||
<Space>
|
||||
<Button type="text" icon={<IconAttachmentFallback />} />
|
||||
<Button type="text" icon={<IconMessage />} />
|
||||
</Space>
|
||||
<Button type="primary" onClick={() => Message.success('评论已发送(原型)')}>发送</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IconAttachmentFallback() {
|
||||
return <IconLink />
|
||||
}
|
||||
|
||||
function Property({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<Text type="secondary">{label}</Text>
|
||||
<Text>{value}</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
import { Button, Card, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconFile, IconFolder, IconPlus, IconSearch } from '@arco-design/web-react/icon'
|
||||
import { notes } from './project-data'
|
||||
import type { Project } from './project-types'
|
||||
import type { ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const folders = ['需求文档', '技术方案', '会议纪要', '竞品资料']
|
||||
|
||||
export function ProjectNotes({ activeProject, onSelectItem }: { activeProject: Project; onSelectItem: (title: string) => void }) {
|
||||
export function ProjectNotes({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => 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">{activeProject.name} 的在线文件管理页,集中管理笔记、资料和附件。</Text>
|
||||
<Text type="secondary">{project.name} 的在线文件管理页,集中管理笔记、资料和附件。</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<IconSearch />}>搜索资料</Button>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconCalendar, IconCheckCircleFill, IconClockCircle, IconEmail, IconFile, IconMore, IconRobot } from '@arco-design/web-react/icon'
|
||||
import { aiSessions, inbox, notes, tasks } from './project-data'
|
||||
import type { InboxItem, ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Row, Col } = Grid
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export function ProjectOverview({ onSelectItem }: { onSelectItem: (title: string) => void }) {
|
||||
export function ProjectOverview({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
|
||||
const { inbox, tasks, aiSessions, notes, cronJobs } = activeWorkspace
|
||||
const metrics = useMemo(() => [
|
||||
{ title: 'Inbox 待处理', value: 36, delta: '较昨日 -8', icon: <IconEmail />, color: 'arcoblue' },
|
||||
{ title: '进行中的任务', value: 12, delta: '较昨日 -2', icon: <IconCheckCircleFill />, color: 'green' },
|
||||
{ title: 'AI 会话活跃', value: 4, delta: '较昨日 +1', icon: <IconRobot />, color: 'purple' },
|
||||
{ title: '笔记资料总数', value: 343, delta: '较昨日 +5', icon: <IconFile />, color: 'cyan' },
|
||||
{ title: '计划任务', value: 45, delta: '较昨日 +0', icon: <IconClockCircle />, color: 'orange' },
|
||||
], [])
|
||||
{ title: 'Inbox 待处理', value: inbox.length, delta: '实时接口', icon: <IconEmail />, color: 'arcoblue' },
|
||||
{ title: '进行中的任务', value: tasks.filter((task) => !task.completed).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' },
|
||||
{ title: '计划任务', value: cronJobs.length, delta: '实时接口', icon: <IconClockCircle />, color: 'orange' },
|
||||
], [aiSessions.length, cronJobs.length, inbox.length, notes.length, tasks])
|
||||
|
||||
return (
|
||||
<div className="overview-page">
|
||||
@@ -44,15 +45,15 @@ export function ProjectOverview({ onSelectItem }: { onSelectItem: (title: string
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<QueueSection title="Inbox 待处理(36)" items={inbox} onSelectItem={onSelectItem} />
|
||||
<TaskSection onSelectItem={onSelectItem} />
|
||||
<AISessionSection onSelectItem={onSelectItem} />
|
||||
<NoteSection onSelectItem={onSelectItem} />
|
||||
<QueueSection title={`Inbox 待处理(${inbox.length})`} items={inbox} onSelectItem={onSelectItem} />
|
||||
<TaskSection tasks={tasks} onSelectItem={onSelectItem} />
|
||||
<AISessionSection aiSessions={aiSessions} onSelectItem={onSelectItem} />
|
||||
<NoteSection notes={notes} onSelectItem={onSelectItem} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QueueSection({ title, items, onSelectItem }: { title: string; items: typeof inbox; onSelectItem: (title: string) => void }) {
|
||||
function QueueSection({ title, items, onSelectItem }: { title: string; items: InboxItem[]; onSelectItem: (title: string) => void }) {
|
||||
return (
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
@@ -72,11 +73,11 @@ function QueueSection({ title, items, onSelectItem }: { title: string; items: ty
|
||||
)
|
||||
}
|
||||
|
||||
function TaskSection({ onSelectItem }: { onSelectItem: (title: string) => void }) {
|
||||
function TaskSection({ tasks, onSelectItem }: { tasks: ProjectWorkspace['tasks']; onSelectItem: (title: string) => void }) {
|
||||
return (
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>进行中的任务(12)</Title>
|
||||
<Title heading={6}>进行中的任务({tasks.length})</Title>
|
||||
<Button type="text" size="mini">查看全部</Button>
|
||||
</div>
|
||||
{tasks.map((task) => (
|
||||
@@ -92,11 +93,11 @@ function TaskSection({ onSelectItem }: { onSelectItem: (title: string) => void }
|
||||
)
|
||||
}
|
||||
|
||||
function AISessionSection({ onSelectItem }: { onSelectItem: (title: string) => void }) {
|
||||
function AISessionSection({ aiSessions, onSelectItem }: { aiSessions: ProjectWorkspace['aiSessions']; onSelectItem: (title: string) => void }) {
|
||||
return (
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>AI 会话(4)</Title>
|
||||
<Title heading={6}>AI 会话({aiSessions.length})</Title>
|
||||
<Button type="text" size="mini">查看全部</Button>
|
||||
</div>
|
||||
{aiSessions.map((session) => (
|
||||
@@ -111,7 +112,7 @@ function AISessionSection({ onSelectItem }: { onSelectItem: (title: string) => v
|
||||
)
|
||||
}
|
||||
|
||||
function NoteSection({ onSelectItem }: { onSelectItem: (title: string) => void }) {
|
||||
function NoteSection({ notes, onSelectItem }: { notes: ProjectWorkspace['notes']; onSelectItem: (title: string) => void }) {
|
||||
return (
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { Badge, Button, Layout, Space } from '@arco-design/web-react'
|
||||
import { IconDashboard, IconPlus } from '@arco-design/web-react/icon'
|
||||
import { projects } from './project-data'
|
||||
import type { Project, WorkbenchView } from './project-types'
|
||||
|
||||
const { Sider } = Layout
|
||||
|
||||
export function ProjectRail({
|
||||
activeProject,
|
||||
projects,
|
||||
activeView,
|
||||
onSelectWorkspace,
|
||||
onSelectProject,
|
||||
}: {
|
||||
activeProject: Project
|
||||
projects: Project[]
|
||||
activeView: WorkbenchView
|
||||
onSelectWorkspace: () => void
|
||||
onSelectProject: (project: Project) => void
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { Badge, Button, Divider, Layout, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconApps, IconDashboard, IconMore, IconSettings } from '@arco-design/web-react/icon'
|
||||
import { channels } from './project-data'
|
||||
import type { ChannelKey, Project, WorkbenchView } from './project-types'
|
||||
import type { ChannelKey, ProjectWorkspace, WorkbenchView } from './project-types'
|
||||
|
||||
const { Sider } = Layout
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export function ProjectSidebar({
|
||||
activeView,
|
||||
activeProject,
|
||||
activeWorkspace,
|
||||
activeChannel,
|
||||
onSelectChannel,
|
||||
onSelectItem,
|
||||
}: {
|
||||
activeView: WorkbenchView
|
||||
activeProject: Project
|
||||
activeWorkspace: ProjectWorkspace
|
||||
activeChannel: ChannelKey
|
||||
onSelectChannel: (key: ChannelKey) => void
|
||||
onSelectItem: (title: string) => void
|
||||
}) {
|
||||
const workspaceMode = activeView === 'workspace'
|
||||
const channels = activeWorkspace.channels
|
||||
|
||||
return (
|
||||
<Sider className="channel-sidebar" width={248}>
|
||||
<div className="project-title">
|
||||
<Space>
|
||||
{workspaceMode ? <IconApps /> : <IconDashboard />}
|
||||
<Title heading={5}>{workspaceMode ? '工作台' : activeProject.name}</Title>
|
||||
<Title heading={5}>{workspaceMode ? '工作台' : activeWorkspace.project.name}</Title>
|
||||
</Space>
|
||||
<Button icon={<IconSettings />} size="mini" />
|
||||
</div>
|
||||
@@ -36,7 +36,7 @@ export function ProjectSidebar({
|
||||
<>
|
||||
<button className="channel-button active">
|
||||
<span className="channel-left"><IconDashboard /><Text>工作台总览</Text></span>
|
||||
<Badge count={4} />
|
||||
<Badge count={channels.length} />
|
||||
</button>
|
||||
<button className="channel-button">
|
||||
<span className="channel-left"><IconApps /><Text>全部项目</Text></span>
|
||||
@@ -46,7 +46,7 @@ export function ProjectSidebar({
|
||||
) : (
|
||||
channels.map((channel) => (
|
||||
<button
|
||||
key={channel.key}
|
||||
key={channel.id}
|
||||
className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'}
|
||||
onClick={() => onSelectChannel(channel.key)}
|
||||
>
|
||||
@@ -61,7 +61,7 @@ export function ProjectSidebar({
|
||||
<section className="tag-cloud">
|
||||
<Text className="section-title">标签</Text>
|
||||
<Space wrap>
|
||||
{['全部', '需求', '设计', '研发', '运营', 'AI'].map((tag) => (
|
||||
{activeWorkspace.tags.map((tag) => (
|
||||
<Tag key={tag} color={tag === '全部' ? 'arcoblue' : 'gray'}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
@@ -70,10 +70,10 @@ export function ProjectSidebar({
|
||||
<Divider />
|
||||
<section className="recent-sessions">
|
||||
<Text className="section-title">最近会话</Text>
|
||||
{['需求评审要点整理', '生成 Q3 营销策略初稿', 'npx 安装 skill 至 codex', '产品定价模型讨论'].map((title, index) => (
|
||||
<button key={title} onClick={() => onSelectItem(title)}>
|
||||
<Text>{title}</Text>
|
||||
<Text type="secondary">{index === 0 ? '09:15' : index === 1 ? '08:47' : '昨天'}</Text>
|
||||
{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>
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import { Button, Card, Progress, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconCalendar, IconCheckCircle, IconPlus, IconUser } from '@arco-design/web-react/icon'
|
||||
import { tasks } from './project-data'
|
||||
import type { Project } from './project-types'
|
||||
import type { ProjectWorkspace, TaskItem } from './project-types'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const lanes = [
|
||||
{ title: '待开始', color: 'gray', items: ['权限边界确认', '客户案例素材整理'] },
|
||||
{ title: '进行中', color: 'arcoblue', items: tasks.map((task) => task.title) },
|
||||
{ title: '已完成', color: 'green', items: ['项目模板初始化', '会议纪要归档'] },
|
||||
]
|
||||
|
||||
export function ProjectTasks({ activeProject, onSelectItem }: { activeProject: Project; onSelectItem: (title: string) => void }) {
|
||||
export function ProjectTasks({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
|
||||
const { tasks, project } = activeWorkspace
|
||||
const lanes = makeLanes(tasks)
|
||||
return (
|
||||
<div className="project-channel-page project-tasks-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>工作计划</Title>
|
||||
<Text type="secondary">{activeProject.name} 的 TODO 计划、负责人和截止时间。</Text>
|
||||
<Text type="secondary">{project.name} 的 TODO 计划、负责人和截止时间。</Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<IconPlus />}>新建任务</Button>
|
||||
</div>
|
||||
@@ -59,3 +54,13 @@ export function ProjectTasks({ activeProject, onSelectItem }: { activeProject: P
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function makeLanes(tasks: TaskItem[]) {
|
||||
const done = tasks.filter((task) => task.completed)
|
||||
const active = tasks.filter((task) => !task.completed)
|
||||
return [
|
||||
{ title: '待开始', color: 'gray', items: active.slice(0, 1).map((task) => task.title) },
|
||||
{ title: '进行中', color: 'arcoblue', items: active.map((task) => task.title) },
|
||||
{ title: '已完成', color: 'green', items: done.length ? done.map((task) => task.title) : ['暂无已完成任务'] },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReactElement } from 'react'
|
||||
export type Screen = 'login' | 'workbench'
|
||||
export type Theme = 'light' | 'dark'
|
||||
export type WorkbenchView = 'workspace' | 'project'
|
||||
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | 'research' | 'design'
|
||||
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | `custom:${string}`
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
@@ -16,7 +16,72 @@ export type Project = {
|
||||
|
||||
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
|
||||
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[]
|
||||
}
|
||||
|
||||
@@ -6,13 +6,17 @@ import {
|
||||
IconFile,
|
||||
IconRobot,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import { aiSessions, inbox, notes, projects, tasks } from './projects/project-data'
|
||||
import type { Project } from './projects/project-types'
|
||||
import type { Project, ProjectWorkspace } from './projects/project-types'
|
||||
|
||||
const { Row, Col } = Grid
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export function WorkspacePage({ onSelectProject }: { onSelectProject: (project: Project) => void }) {
|
||||
export function WorkspacePage({ workspaces, onSelectProject }: { workspaces: ProjectWorkspace[]; onSelectProject: (project: Project) => void }) {
|
||||
const projects = workspaces.map((workspace) => workspace.project)
|
||||
const inbox = workspaces.flatMap((workspace) => workspace.inbox)
|
||||
const tasks = workspaces.flatMap((workspace) => workspace.tasks).filter((task) => !task.completed)
|
||||
const aiSessions = workspaces.flatMap((workspace) => workspace.aiSessions)
|
||||
const notes = workspaces.flatMap((workspace) => workspace.notes)
|
||||
const totalProjectBadges = projects.reduce((sum, project) => sum + project.badge, 0)
|
||||
const urgentProjects = projects.filter((project) => project.urgent).length
|
||||
const workspaceMetrics = [
|
||||
|
||||
@@ -22,7 +22,8 @@ func main() {
|
||||
authService := auth.NewService(cfg.AuthSecret)
|
||||
projectHandler := projects.NewHandler(projects.NewService())
|
||||
inboxHandler := inbox.NewHandler(inbox.NewService(inbox.StaticAnalyzer{}))
|
||||
appRouter := httpx.NewProtectedRouter(cfg, authService.VerifySession, projectHandler, inboxHandler)
|
||||
authHandler := auth.NewHandler(authService)
|
||||
appRouter := httpx.NewProtectedRouter(cfg, authService.VerifySession, authHandler, projectHandler, inboxHandler)
|
||||
if err := appRouter.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
37
backend/cmd/seed/main.go
Normal file
37
backend/cmd/seed/main.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"senlinai-agent/backend/internal/config"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
"senlinai-agent/backend/internal/seed"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
if dsn := envOrDefault("DATABASE_DSN", envOrDefault("DATABASE_URL", "")); dsn != "" {
|
||||
cfg.DSN = dsn
|
||||
}
|
||||
if err := models.New(cfg.DSN); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
result, err := seed.Demo(models.DBService, seed.DemoOptions{
|
||||
Email: envOrDefault("SENLIN_SEED_EMAIL", "demo@senlin.ai"),
|
||||
DisplayName: envOrDefault("SENLIN_SEED_NAME", "演示用户"),
|
||||
Password: envOrDefault("SENLIN_SEED_PASSWORD", "password123"),
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("seeded %s with %d projects\n", result.User.Email, len(result.Projects))
|
||||
}
|
||||
|
||||
func envOrDefault(key string, fallback string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -47,6 +47,12 @@ func cors() gin.HandlerFunc {
|
||||
allowedOrigins := map[string]bool{
|
||||
"http://localhost:5173": true,
|
||||
"http://127.0.0.1:5173": true,
|
||||
"http://localhost:4173": true,
|
||||
"http://127.0.0.1:4173": true,
|
||||
"http://localhost:4174": true,
|
||||
"http://127.0.0.1:4174": true,
|
||||
"http://localhost:4175": true,
|
||||
"http://127.0.0.1:4175": true,
|
||||
"http://tauri.localhost": true,
|
||||
}
|
||||
|
||||
|
||||
36
backend/internal/logic/auth/handlers.go
Normal file
36
backend/internal/logic/auth/handlers.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) Register(router gin.IRouter) {
|
||||
router.POST("/auth/login", h.login)
|
||||
}
|
||||
|
||||
func (h *Handler) login(c *gin.Context) {
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
token, err := h.service.Login(input.Email, input.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||
}
|
||||
72
backend/internal/logic/auth/handlers_test.go
Normal file
72
backend/internal/logic/auth/handlers_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/config"
|
||||
"senlinai-agent/backend/internal/httpx"
|
||||
"senlinai-agent/backend/internal/logic/auth"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestLoginHandlerReturnsSessionToken(t *testing.T) {
|
||||
newTestDB(t)
|
||||
service := auth.NewService("test-secret")
|
||||
invite, err := service.CreateInvite(1, "demo@senlin.ai")
|
||||
require.NoError(t, err)
|
||||
_, err = service.RegisterWithInvite(invite, "demo@senlin.ai", "Demo User", "password123")
|
||||
require.NoError(t, err)
|
||||
router := httpx.NewProtectedRouter(config.Config{Env: "test"}, service.VerifySession, auth.NewHandler(service))
|
||||
|
||||
body, err := json.Marshal(gin.H{"email": "demo@senlin.ai", "password": "password123"})
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var response struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response))
|
||||
require.NotEmpty(t, response.Token)
|
||||
userID, err := service.VerifySession(response.Token)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint(1), userID)
|
||||
}
|
||||
|
||||
func TestLoginHandlerRejectsInvalidCredentials(t *testing.T) {
|
||||
newTestDB(t)
|
||||
service := auth.NewService("test-secret")
|
||||
require.NoError(t, models.DBService.Create(&models.SenlinAgentUser{Email: "demo@senlin.ai", DisplayName: "Demo User", PasswordHash: "not-a-bcrypt-hash"}).Error)
|
||||
router := httpx.NewProtectedRouter(config.Config{Env: "test"}, service.VerifySession, auth.NewHandler(service))
|
||||
|
||||
body, err := json.Marshal(gin.H{"email": "demo@senlin.ai", "password": "wrong"})
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
|
||||
func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
database, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
return database
|
||||
}
|
||||
@@ -11,6 +11,11 @@ const CurrentUserIDKey = "currentUserID"
|
||||
|
||||
func RequireUser(tokenVerifier func(string) (uint, error)) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method == http.MethodPost && c.Request.URL.Path == "/api/auth/login" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
header := c.GetHeader("Authorization")
|
||||
token := strings.TrimPrefix(header, "Bearer ")
|
||||
if token == "" || token == header {
|
||||
|
||||
249
backend/internal/seed/demo.go
Normal file
249
backend/internal/seed/demo.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package seed
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type DemoOptions struct {
|
||||
Email string
|
||||
DisplayName string
|
||||
Password string
|
||||
}
|
||||
|
||||
type DemoResult struct {
|
||||
User models.SenlinAgentUser
|
||||
Projects []models.SenlinAgentProject
|
||||
}
|
||||
|
||||
func Demo(database *gorm.DB, options DemoOptions) (DemoResult, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(options.Email))
|
||||
if email == "" {
|
||||
email = "demo@senlin.ai"
|
||||
}
|
||||
displayName := strings.TrimSpace(options.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = "演示用户"
|
||||
}
|
||||
password := options.Password
|
||||
if password == "" {
|
||||
password = "password123"
|
||||
}
|
||||
|
||||
var result DemoResult
|
||||
err := database.Transaction(func(tx *gorm.DB) error {
|
||||
user, err := upsertUser(tx, email, displayName, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.User = user
|
||||
|
||||
projects, err := seedProjects(tx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.Projects = projects
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func upsertUser(tx *gorm.DB, email string, displayName string, password string) (models.SenlinAgentUser, error) {
|
||||
var user models.SenlinAgentUser
|
||||
err := tx.Where("email = ?", email).First(&user).Error
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return user, err
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
user = models.SenlinAgentUser{
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
PasswordHash: string(hash),
|
||||
Role: "user",
|
||||
}
|
||||
return user, tx.Create(&user).Error
|
||||
}
|
||||
|
||||
func seedProjects(tx *gorm.DB, ownerID uint) ([]models.SenlinAgentProject, error) {
|
||||
definitions := []struct {
|
||||
Name string
|
||||
Description string
|
||||
}{
|
||||
{Name: "项目 A1", Description: "企业智能协作平台的核心工作空间"},
|
||||
{Name: "项目 A2", Description: "客户成功与交付跟踪"},
|
||||
{Name: "数据中台", Description: "内部数据治理与指标分析"},
|
||||
{Name: "运营自动化", Description: "定时任务和业务流程自动化"},
|
||||
}
|
||||
|
||||
projects := make([]models.SenlinAgentProject, 0, len(definitions))
|
||||
for _, definition := range definitions {
|
||||
project, err := firstOrCreateProject(tx, ownerID, definition.Name, definition.Description)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projects = append(projects, project)
|
||||
}
|
||||
if len(projects) == 0 {
|
||||
return projects, nil
|
||||
}
|
||||
if err := seedProjectA1(tx, ownerID, projects[0].ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, project := range projects[1:] {
|
||||
if err := seedCompactProject(tx, ownerID, project.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
func firstOrCreateProject(tx *gorm.DB, ownerID uint, name string, description string) (models.SenlinAgentProject, error) {
|
||||
var project models.SenlinAgentProject
|
||||
err := tx.Where("owner_id = ? AND name = ?", ownerID, name).First(&project).Error
|
||||
if err == nil {
|
||||
return project, nil
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return project, err
|
||||
}
|
||||
project = models.SenlinAgentProject{OwnerID: ownerID, Name: name, Description: description}
|
||||
return project, tx.Create(&project).Error
|
||||
}
|
||||
|
||||
func seedProjectA1(tx *gorm.DB, userID uint, projectID uint) error {
|
||||
now := time.Now().UTC()
|
||||
nextRun := now.Add(6 * time.Hour)
|
||||
dueSoon := now.Add(72 * time.Hour)
|
||||
dueLater := now.Add(7 * 24 * time.Hour)
|
||||
|
||||
for _, tag := range []string{"UI", "知识碎片", "客户反馈", "自动化"} {
|
||||
if err := firstOrCreateTag(tx, projectID, tag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, item := range []models.SenlinAgentInboxItem{
|
||||
{ProjectID: projectID, CreatedBy: userID, SourceType: "产品研究", Title: "竞争对手定价更新监控", Body: "跟进企业版价格调整和功能组合变化。", Status: "open"},
|
||||
{ProjectID: projectID, CreatedBy: userID, SourceType: "客户支持", Title: "客户反馈:导出功能报错", Body: "客户在导出智能报表时遇到权限校验异常。", Status: "open"},
|
||||
{ProjectID: projectID, CreatedBy: userID, SourceType: "架构讨论", Title: "API 调用策略评估建议", Body: "评估批量任务调度和 AI 限流策略。", Status: "open"},
|
||||
{ProjectID: projectID, CreatedBy: userID, SourceType: "需求文档", Title: "更新需求文档 v1.3.2", Body: "补充项目频道和属性弹层的交互说明。", Status: "open"},
|
||||
} {
|
||||
if err := firstOrCreateInbox(tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, task := range []models.SenlinAgentTask{
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "智能报表导出功能", Description: "修复权限校验并补充导出失败提示。", Status: "open", DueAt: &dueSoon, SortOrder: 1},
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "企业版权限体系梳理", Description: "整理角色、项目和任务分享边界。", Status: "open", DueAt: &dueLater, SortOrder: 2},
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "Q3 营销策略制定", Description: "汇总竞品情报和客户反馈,生成策略草案。", Status: "open", DueAt: &dueLater, SortOrder: 3},
|
||||
} {
|
||||
if err := firstOrCreateTask(tx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, session := range []models.SenlinAgentAISession{
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "生成 Q3 营销策略初稿", Context: "基于市场分析和竞品数据,输出 Q3 营销策略重点。"},
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "产品定价模型讨论", Context: "对企业版与标准版定价模型进行对比分析。"},
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "客户流失原因分析", Context: "分析近三个月客户流失数据与反馈原因。"},
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "行业趋势与机会洞察", Context: "围绕 AI 在企业协同领域的趋势与机会。"},
|
||||
} {
|
||||
if err := firstOrCreateAISession(tx, session); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, note := range []models.SenlinAgentNote{
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "用户权限体系设计文档 v2.1", Markdown: "# 权限体系\n\n围绕项目、任务和资料分享做保守授权。"},
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "导出功能技术方案评审", Markdown: "# 导出功能\n\n记录权限校验和任务队列方案。"},
|
||||
} {
|
||||
if err := firstOrCreateNote(tx, note); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, source := range []models.SenlinAgentSource{
|
||||
{ProjectID: projectID, CreatedBy: userID, Kind: "file", Title: "2026 Q3 竞品功能对比表", FilePath: "projects/a1/competitors.xlsx", ContentText: "竞品功能矩阵和价格摘要"},
|
||||
{ProjectID: projectID, CreatedBy: userID, Kind: "link", Title: "客户访谈记录索引", URL: "https://example.com/interviews", ContentText: "客户访谈和问题归类"},
|
||||
} {
|
||||
if err := firstOrCreateSource(tx, source); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, plan := range []models.SenlinAgentCronPlan{
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "每日 Inbox 摘要", Schedule: "0 9 * * *", NextRunAt: &nextRun, Enabled: true, LastResult: "上次执行成功"},
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "每周任务风险扫描", Schedule: "0 10 * * 1", NextRunAt: &dueSoon, Enabled: true, LastResult: "等待下次执行"},
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "月底知识归档提醒", Schedule: "0 18 28 * *", NextRunAt: &dueLater, Enabled: false, LastResult: "已暂停"},
|
||||
} {
|
||||
if err := firstOrCreateCronPlan(tx, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, channel := range []models.SenlinAgentProjectChannel{
|
||||
{ProjectID: projectID, Title: "客户研究频道", Icon: "user", URL: "https://example.com/research", SortOrder: 7},
|
||||
{ProjectID: projectID, Title: "产品设计频道", Icon: "link", URL: "https://example.com/design", SortOrder: 8},
|
||||
} {
|
||||
if err := firstOrCreateChannel(tx, channel); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedCompactProject(tx *gorm.DB, userID uint, projectID uint) error {
|
||||
item := models.SenlinAgentInboxItem{ProjectID: projectID, CreatedBy: userID, SourceType: "手动收集", Title: "待处理事项", Body: "项目启动资料待整理。", Status: "open"}
|
||||
if err := firstOrCreateInbox(tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
task := models.SenlinAgentTask{ProjectID: projectID, CreatedBy: userID, Title: "项目例会纪要整理", Description: "归档本周同步内容。", Status: "open"}
|
||||
return firstOrCreateTask(tx, task)
|
||||
}
|
||||
|
||||
func firstOrCreateTag(tx *gorm.DB, projectID uint, name string) error {
|
||||
return firstOrCreate(tx, &models.SenlinAgentTag{}, "project_id = ? AND name = ?", []any{projectID, name}, models.SenlinAgentTag{ProjectID: projectID, Name: name})
|
||||
}
|
||||
|
||||
func firstOrCreateInbox(tx *gorm.DB, item models.SenlinAgentInboxItem) error {
|
||||
return firstOrCreate(tx, &models.SenlinAgentInboxItem{}, "project_id = ? AND title = ?", []any{item.ProjectID, item.Title}, item)
|
||||
}
|
||||
|
||||
func firstOrCreateTask(tx *gorm.DB, task models.SenlinAgentTask) error {
|
||||
return firstOrCreate(tx, &models.SenlinAgentTask{}, "project_id = ? AND title = ?", []any{task.ProjectID, task.Title}, task)
|
||||
}
|
||||
|
||||
func firstOrCreateAISession(tx *gorm.DB, session models.SenlinAgentAISession) error {
|
||||
return firstOrCreate(tx, &models.SenlinAgentAISession{}, "project_id = ? AND title = ?", []any{session.ProjectID, session.Title}, session)
|
||||
}
|
||||
|
||||
func firstOrCreateNote(tx *gorm.DB, note models.SenlinAgentNote) error {
|
||||
return firstOrCreate(tx, &models.SenlinAgentNote{}, "project_id = ? AND title = ?", []any{note.ProjectID, note.Title}, note)
|
||||
}
|
||||
|
||||
func firstOrCreateSource(tx *gorm.DB, source models.SenlinAgentSource) error {
|
||||
return firstOrCreate(tx, &models.SenlinAgentSource{}, "project_id = ? AND title = ?", []any{source.ProjectID, source.Title}, source)
|
||||
}
|
||||
|
||||
func firstOrCreateCronPlan(tx *gorm.DB, plan models.SenlinAgentCronPlan) error {
|
||||
return firstOrCreate(tx, &models.SenlinAgentCronPlan{}, "project_id = ? AND title = ?", []any{plan.ProjectID, plan.Title}, plan)
|
||||
}
|
||||
|
||||
func firstOrCreateChannel(tx *gorm.DB, channel models.SenlinAgentProjectChannel) error {
|
||||
return firstOrCreate(tx, &models.SenlinAgentProjectChannel{}, "project_id = ? AND title = ?", []any{channel.ProjectID, channel.Title}, channel)
|
||||
}
|
||||
|
||||
func firstOrCreate[T any](tx *gorm.DB, model *T, where string, args []any, create T) error {
|
||||
err := tx.Where(where, args...).First(model).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&create).Error
|
||||
}
|
||||
65
backend/internal/seed/demo_test.go
Normal file
65
backend/internal/seed/demo_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package seed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/logic/auth"
|
||||
"senlinai-agent/backend/internal/logic/projects"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestDemoSeedCreatesFrontendWorkspaceData(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
|
||||
result, err := Demo(database, DemoOptions{
|
||||
Email: "demo@senlin.ai",
|
||||
DisplayName: "演示用户",
|
||||
Password: "password123",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "demo@senlin.ai", result.User.Email)
|
||||
require.Len(t, result.Projects, 4)
|
||||
|
||||
token, err := auth.NewService("test-secret").Login("demo@senlin.ai", "password123")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, token)
|
||||
|
||||
workspace, err := projects.NewService().Workspace(result.User.ID, result.Projects[0].ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "项目 A1", workspace.Project.Name)
|
||||
require.GreaterOrEqual(t, len(workspace.Inbox), 4)
|
||||
require.GreaterOrEqual(t, len(workspace.Tasks), 3)
|
||||
require.GreaterOrEqual(t, len(workspace.AISessions), 4)
|
||||
require.GreaterOrEqual(t, len(workspace.NotesSources), 3)
|
||||
require.GreaterOrEqual(t, len(workspace.CronPlans), 3)
|
||||
require.Len(t, workspace.Channels, 8)
|
||||
}
|
||||
|
||||
func TestDemoSeedIsIdempotent(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
|
||||
first, err := Demo(database, DemoOptions{Email: "demo@senlin.ai", DisplayName: "演示用户", Password: "password123"})
|
||||
require.NoError(t, err)
|
||||
second, err := Demo(database, DemoOptions{Email: "demo@senlin.ai", DisplayName: "演示用户", Password: "password123"})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, first.User.ID, second.User.ID)
|
||||
require.Len(t, second.Projects, 4)
|
||||
var userCount int64
|
||||
require.NoError(t, database.Model(&models.SenlinAgentUser{}).Where("email = ?", "demo@senlin.ai").Count(&userCount).Error)
|
||||
require.Equal(t, int64(1), userCount)
|
||||
}
|
||||
|
||||
func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
return database
|
||||
}
|
||||
Reference in New Issue
Block a user