From 875b326ddcda4f6bc149c5e67539f8d268f6ce95 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 20 Jul 2026 13:02:26 +0800 Subject: [PATCH] Connect React workspace to backend data --- .../scripts/structure-check.mjs | 11 +- apps/senlinai-acro-react/src/api/client.ts | 57 ++++ apps/senlinai-acro-react/src/api/mappers.tsx | 165 ++++++++++++ apps/senlinai-acro-react/src/api/projects.ts | 96 +++++++ apps/senlinai-acro-react/src/app/App.tsx | 50 +++- apps/senlinai-acro-react/src/pages/login.tsx | 12 +- .../senlinai-acro-react/src/pages/project.tsx | 18 +- .../src/pages/projects/project-ai.tsx | 12 +- .../pages/projects/project-channel-page.tsx | 20 +- .../src/pages/projects/project-cron.tsx | 23 +- .../src/pages/projects/project-data.tsx | 53 ---- .../src/pages/projects/project-inbox.tsx | 12 +- .../src/pages/projects/project-inspector.tsx | 97 ------- .../src/pages/projects/project-notes.tsx | 9 +- .../src/pages/projects/project-overview.tsx | 37 +-- .../src/pages/projects/project-rail.tsx | 3 +- .../src/pages/projects/project-sidebar.tsx | 24 +- .../src/pages/projects/project-tasks.tsx | 25 +- .../src/pages/projects/project-types.ts | 67 ++++- .../src/pages/workspace.tsx | 10 +- backend/cmd/api/main.go | 3 +- backend/cmd/seed/main.go | 37 +++ backend/internal/httpx/router.go | 6 + backend/internal/logic/auth/handlers.go | 36 +++ backend/internal/logic/auth/handlers_test.go | 72 +++++ backend/internal/logic/auth/middleware.go | 5 + backend/internal/seed/demo.go | 249 ++++++++++++++++++ backend/internal/seed/demo_test.go | 65 +++++ 28 files changed, 1017 insertions(+), 257 deletions(-) create mode 100644 apps/senlinai-acro-react/src/api/client.ts create mode 100644 apps/senlinai-acro-react/src/api/mappers.tsx create mode 100644 apps/senlinai-acro-react/src/api/projects.ts delete mode 100644 apps/senlinai-acro-react/src/pages/projects/project-data.tsx delete mode 100644 apps/senlinai-acro-react/src/pages/projects/project-inspector.tsx create mode 100644 backend/cmd/seed/main.go create mode 100644 backend/internal/logic/auth/handlers.go create mode 100644 backend/internal/logic/auth/handlers_test.go create mode 100644 backend/internal/seed/demo.go create mode 100644 backend/internal/seed/demo_test.go diff --git a/apps/senlinai-acro-react/scripts/structure-check.mjs b/apps/senlinai-acro-react/scripts/structure-check.mjs index d445992..abff6bf 100644 --- a/apps/senlinai-acro-react/scripts/structure-check.mjs +++ b/apps/senlinai-acro-react/scripts/structure-check.mjs @@ -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']) { diff --git a/apps/senlinai-acro-react/src/api/client.ts b/apps/senlinai-acro-react/src/api/client.ts new file mode 100644 index 0000000..a22ea84 --- /dev/null +++ b/apps/senlinai-acro-react/src/api/client.ts @@ -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 { + const response = await apiRequest<{ token: string }>('/api/auth/login', { + method: 'POST', + body: { email, password }, + }) + return { baseUrl: configuredBaseUrl, token: response.token } +} + +export async function apiRequest(path: string, options: RequestOptions = {}): Promise { + 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 +} + +function normalizeBaseUrl(value: string) { + const trimmed = value.trim() + return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed +} diff --git a/apps/senlinai-acro-react/src/api/mappers.tsx b/apps/senlinai-acro-react/src/api/mappers.tsx new file mode 100644 index 0000000..93bd421 --- /dev/null +++ b/apps/senlinai-acro-react/src/api/mappers.tsx @@ -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 + case 'inbox': + return + case 'tasks': + return + case 'ai_sessions': + return + case 'notes_sources': + return + case 'cron': + return + default: + return channel.icon === 'user' ? : + } +} + +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, + } +} diff --git a/apps/senlinai-acro-react/src/api/projects.ts b/apps/senlinai-acro-react/src/api/projects.ts new file mode 100644 index 0000000..a8114f7 --- /dev/null +++ b/apps/senlinai-acro-react/src/api/projects.ts @@ -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('/api/projects', { token: session.token }) +} + +export async function fetchProjectWorkspace(session: ApiSession, projectID: string | number) { + return apiRequest(`/api/projects/${projectID}/workspace`, { token: session.token }) +} diff --git a/apps/senlinai-acro-react/src/app/App.tsx b/apps/senlinai-acro-react/src/app/App.tsx index c743487..d2aeb01 100644 --- a/apps/senlinai-acro-react/src/app/App.tsx +++ b/apps/senlinai-acro-react/src/app/App.tsx @@ -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('login') const [theme, setTheme] = useState('light') const [activeView, setActiveView] = useState('workspace') - const [activeProject, setActiveProject] = useState(projects[0]) + const [session, setSession] = useState(null) + const [workspaces, setWorkspaces] = useState([]) + const [activeProjectID, setActiveProjectID] = useState('') const [activeChannel, setActiveChannel] = useState('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 (
{screen === 'login' ? ( - setScreen('workbench')} /> - ) : ( + + + + ) : activeWorkspace && session ? ( setActiveView('workspace')} onSelectProject={(project) => { - setActiveProject(project) + setActiveProjectID(project.id) setActiveView('project') + setActiveChannel('overview') }} onSelectChannel={setActiveChannel} onSelectItem={setSelectedItem} onToggleTheme={() => setTheme(dark ? 'light' : 'dark')} /> + ) : ( + )}
diff --git a/apps/senlinai-acro-react/src/pages/login.tsx b/apps/senlinai-acro-react/src/pages/login.tsx index 10ec27c..af6d8af 100644 --- a/apps/senlinai-acro-react/src/pages/login.tsx +++ b/apps/senlinai-acro-react/src/pages/login.tsx @@ -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 (
@@ -34,10 +36,10 @@ export function LoginPage({ onLogin }: { onLogin: () => void }) { } /> - + - +
@@ -46,7 +48,7 @@ export function LoginPage({ onLogin }: { onLogin: () => void }) {
-
diff --git a/apps/senlinai-acro-react/src/pages/project.tsx b/apps/senlinai-acro-react/src/pages/project.tsx index 07b62a5..16d262b 100644 --- a/apps/senlinai-acro-react/src/pages/project.tsx +++ b/apps/senlinai-acro-react/src/pages/project.tsx @@ -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 ( @@ -38,7 +41,8 @@ export function ProjectPage({ {isWorkspace ? ( - + ) : ( )} diff --git a/apps/senlinai-acro-react/src/pages/projects/project-ai.tsx b/apps/senlinai-acro-react/src/pages/projects/project-ai.tsx index 8fccac6..e14678b 100644 --- a/apps/senlinai-acro-react/src/pages/projects/project-ai.tsx +++ b/apps/senlinai-acro-react/src/pages/projects/project-ai.tsx @@ -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 (
AI 会话 - {activeProject.name} 的项目内 AI session,左侧会话列表,右侧对话详情。 + {project.name} 的项目内 AI session,左侧会话列表,右侧对话详情。
@@ -43,7 +45,7 @@ export function ProjectAi({ activeProject, onSelectItem }: { activeProject: Proj
- {aiSessions[0].title} + {selected?.title ?? '暂无会话'} 队列空闲
diff --git a/apps/senlinai-acro-react/src/pages/projects/project-channel-page.tsx b/apps/senlinai-acro-react/src/pages/projects/project-channel-page.tsx index fda8166..18ea4c4 100644 --- a/apps/senlinai-acro-react/src/pages/projects/project-channel-page.tsx +++ b/apps/senlinai-acro-react/src/pages/projects/project-channel-page.tsx @@ -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 + return case 'tasks': - return + return case 'ai': - return + return case 'notes': - return + return case 'cron': - return + return case 'overview': - case 'research': - case 'design': default: - return + return } } diff --git a/apps/senlinai-acro-react/src/pages/projects/project-cron.tsx b/apps/senlinai-acro-react/src/pages/projects/project-cron.tsx index ad6fe53..6e09f0c 100644 --- a/apps/senlinai-acro-react/src/pages/projects/project-cron.tsx +++ b/apps/senlinai-acro-react/src/pages/projects/project-cron.tsx @@ -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 (
Cron 计划任务 - {activeProject.name} 的定时任务、自动检查和周期性 AI 工作。 + {project.name} 的定时任务、自动检查和周期性 AI 工作。
@@ -26,8 +23,8 @@ export function ProjectCron({ activeProject, onSelectItem }: { activeProject: Pr
任务列表 - 3 运行中 - 1 暂停 + {enabledCount} 运行中 + {pausedCount} 暂停
{cronJobs.map((job) => ( @@ -42,12 +39,12 @@ export function ProjectCron({ activeProject, onSelectItem }: { activeProject: Pr }} > {job.name} - {job.status} + {job.status} {job.expr} {job.lastRun} {job.nextRun} {job.owner} - +
))} diff --git a/apps/senlinai-acro-react/src/pages/projects/project-data.tsx b/apps/senlinai-acro-react/src/pages/projects/project-data.tsx deleted file mode 100644 index d010aac..0000000 --- a/apps/senlinai-acro-react/src/pages/projects/project-data.tsx +++ /dev/null @@ -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: }, - { key: 'inbox', label: 'Inbox 消息流', count: 36, icon: }, - { key: 'tasks', label: '工作计划', count: 12, icon: }, - { key: 'ai', label: 'AI 会话', count: 4, icon: }, - { key: 'notes', label: '笔记资料', count: 343, icon: }, - { key: 'cron', label: 'Cron 计划任务', count: 45, icon: }, - { key: 'research', label: '客户研究频道', icon: }, - { key: 'design', label: '产品设计频道', icon: }, -] - -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: '已将此讨论关联到任务:智能报表导出功能。' }, -] diff --git a/apps/senlinai-acro-react/src/pages/projects/project-inbox.tsx b/apps/senlinai-acro-react/src/pages/projects/project-inbox.tsx index 711c943..0de3312 100644 --- a/apps/senlinai-acro-react/src/pages/projects/project-inbox.tsx +++ b/apps/senlinai-acro-react/src/pages/projects/project-inbox.tsx @@ -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
Inbox 消息流 - {activeProject.name} 的邮件式收件箱,聚合外部反馈、系统通知和待处理线索。 + {project.name} 的邮件式收件箱,聚合外部反馈、系统通知和待处理线索。
@@ -65,13 +65,13 @@ export function ProjectInbox({ activeProject, onSelectItem }: { activeProject: P
- {selected.title} + {selected?.title ?? '暂无消息'}
- {selected.meta} · {selected.owner} + {selected ? `${selected.meta} · ${selected.owner}` : '真实接口暂无消息'} 已收到新的项目消息,需要判断是否转为任务、笔记或资料。当前建议先补充影响范围,再同步给相关负责人。 diff --git a/apps/senlinai-acro-react/src/pages/projects/project-inspector.tsx b/apps/senlinai-acro-react/src/pages/projects/project-inspector.tsx deleted file mode 100644 index 7c9c6ef..0000000 --- a/apps/senlinai-acro-react/src/pages/projects/project-inspector.tsx +++ /dev/null @@ -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 ( - - - - - - -
- - channel.key === activeChannel)?.label ?? '概况'} /> - - -
-
- - - - - - - -
-
- ) -} - -function InspectorDiscussion({ selectedItem }: { selectedItem: string }) { - return ( -
-
- 关于「{selectedItem}」 -
- 注:同一对象的讨论与协作记录 - - - {discussions.map((item) => ( -
- {item.name.slice(0, 1)} -
- - {item.name} - {item.time} - - {item.text} - -
-
- ))} -
-
- -
- - -
-
-
- ) -} - -function IconAttachmentFallback() { - return -} - -function Property({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ) -} diff --git a/apps/senlinai-acro-react/src/pages/projects/project-notes.tsx b/apps/senlinai-acro-react/src/pages/projects/project-notes.tsx index 26ee787..1d38181 100644 --- a/apps/senlinai-acro-react/src/pages/projects/project-notes.tsx +++ b/apps/senlinai-acro-react/src/pages/projects/project-notes.tsx @@ -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 (
笔记资料 - {activeProject.name} 的在线文件管理页,集中管理笔记、资料和附件。 + {project.name} 的在线文件管理页,集中管理笔记、资料和附件。
diff --git a/apps/senlinai-acro-react/src/pages/projects/project-overview.tsx b/apps/senlinai-acro-react/src/pages/projects/project-overview.tsx index f0b1b36..4244401 100644 --- a/apps/senlinai-acro-react/src/pages/projects/project-overview.tsx +++ b/apps/senlinai-acro-react/src/pages/projects/project-overview.tsx @@ -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: , color: 'arcoblue' }, - { title: '进行中的任务', value: 12, delta: '较昨日 -2', icon: , color: 'green' }, - { title: 'AI 会话活跃', value: 4, delta: '较昨日 +1', icon: , color: 'purple' }, - { title: '笔记资料总数', value: 343, delta: '较昨日 +5', icon: , color: 'cyan' }, - { title: '计划任务', value: 45, delta: '较昨日 +0', icon: , color: 'orange' }, - ], []) + { title: 'Inbox 待处理', value: inbox.length, delta: '实时接口', icon: , color: 'arcoblue' }, + { title: '进行中的任务', value: tasks.filter((task) => !task.completed).length, delta: '实时接口', icon: , color: 'green' }, + { title: 'AI 会话活跃', value: aiSessions.length, delta: '实时接口', icon: , color: 'purple' }, + { title: '笔记资料总数', value: notes.length, delta: '实时接口', icon: , color: 'cyan' }, + { title: '计划任务', value: cronJobs.length, delta: '实时接口', icon: , color: 'orange' }, + ], [aiSessions.length, cronJobs.length, inbox.length, notes.length, tasks]) return (
@@ -44,15 +45,15 @@ export function ProjectOverview({ onSelectItem }: { onSelectItem: (title: string ))} - - - - + + + +
) } -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 (
@@ -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 (
- 进行中的任务(12) + 进行中的任务({tasks.length})
{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 (
- AI 会话(4) + AI 会话({aiSessions.length})
{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 (
diff --git a/apps/senlinai-acro-react/src/pages/projects/project-rail.tsx b/apps/senlinai-acro-react/src/pages/projects/project-rail.tsx index 6d83b19..af0a6dc 100644 --- a/apps/senlinai-acro-react/src/pages/projects/project-rail.tsx +++ b/apps/senlinai-acro-react/src/pages/projects/project-rail.tsx @@ -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 diff --git a/apps/senlinai-acro-react/src/pages/projects/project-sidebar.tsx b/apps/senlinai-acro-react/src/pages/projects/project-sidebar.tsx index 566ec68..3b3203c 100644 --- a/apps/senlinai-acro-react/src/pages/projects/project-sidebar.tsx +++ b/apps/senlinai-acro-react/src/pages/projects/project-sidebar.tsx @@ -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 (
{workspaceMode ? : } - {workspaceMode ? '工作台' : activeProject.name} + {workspaceMode ? '工作台' : activeWorkspace.project.name}
@@ -36,7 +36,7 @@ export function ProjectSidebar({ <> ))}
diff --git a/apps/senlinai-acro-react/src/pages/projects/project-tasks.tsx b/apps/senlinai-acro-react/src/pages/projects/project-tasks.tsx index 99d988b..2e74e2c 100644 --- a/apps/senlinai-acro-react/src/pages/projects/project-tasks.tsx +++ b/apps/senlinai-acro-react/src/pages/projects/project-tasks.tsx @@ -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 (
工作计划 - {activeProject.name} 的 TODO 计划、负责人和截止时间。 + {project.name} 的 TODO 计划、负责人和截止时间。
@@ -59,3 +54,13 @@ export function ProjectTasks({ activeProject, onSelectItem }: { activeProject: P
) } + +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) : ['暂无已完成任务'] }, + ] +} diff --git a/apps/senlinai-acro-react/src/pages/projects/project-types.ts b/apps/senlinai-acro-react/src/pages/projects/project-types.ts index e6b69bd..88d8723 100644 --- a/apps/senlinai-acro-react/src/pages/projects/project-types.ts +++ b/apps/senlinai-acro-react/src/pages/projects/project-types.ts @@ -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[] } diff --git a/apps/senlinai-acro-react/src/pages/workspace.tsx b/apps/senlinai-acro-react/src/pages/workspace.tsx index 6747f66..aed5304 100644 --- a/apps/senlinai-acro-react/src/pages/workspace.tsx +++ b/apps/senlinai-acro-react/src/pages/workspace.tsx @@ -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 = [ diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 91af6ee..6d02921 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -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) } diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go new file mode 100644 index 0000000..51bb1bd --- /dev/null +++ b/backend/cmd/seed/main.go @@ -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 +} diff --git a/backend/internal/httpx/router.go b/backend/internal/httpx/router.go index 4ce1664..1a9ad0c 100644 --- a/backend/internal/httpx/router.go +++ b/backend/internal/httpx/router.go @@ -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, } diff --git a/backend/internal/logic/auth/handlers.go b/backend/internal/logic/auth/handlers.go new file mode 100644 index 0000000..4cd2b90 --- /dev/null +++ b/backend/internal/logic/auth/handlers.go @@ -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}) +} diff --git a/backend/internal/logic/auth/handlers_test.go b/backend/internal/logic/auth/handlers_test.go new file mode 100644 index 0000000..8f7b9ef --- /dev/null +++ b/backend/internal/logic/auth/handlers_test.go @@ -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 +} diff --git a/backend/internal/logic/auth/middleware.go b/backend/internal/logic/auth/middleware.go index 92a7fb7..8d44011 100644 --- a/backend/internal/logic/auth/middleware.go +++ b/backend/internal/logic/auth/middleware.go @@ -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 { diff --git a/backend/internal/seed/demo.go b/backend/internal/seed/demo.go new file mode 100644 index 0000000..99f88eb --- /dev/null +++ b/backend/internal/seed/demo.go @@ -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 +} diff --git a/backend/internal/seed/demo_test.go b/backend/internal/seed/demo_test.go new file mode 100644 index 0000000..f90ee36 --- /dev/null +++ b/backend/internal/seed/demo_test.go @@ -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 +}