Compare commits

...

9 Commits

39 changed files with 3093 additions and 411 deletions

View File

@@ -3,16 +3,23 @@ import { existsSync, readFileSync } from 'node:fs'
const requiredFiles = [
'src/app/App.tsx',
'src/pages/login.tsx',
'src/pages/workspace.tsx',
'src/pages/project.tsx',
'src/pages/workspace-body.tsx',
'src/pages/workspace-home.tsx',
'src/pages/workspace-inbox.tsx',
'src/pages/projects/project-overview.tsx',
'src/pages/projects/project-data.tsx',
'src/pages/projects/project-channel-page.tsx',
'src/pages/projects/project-tasks.tsx',
'src/pages/projects/project-ai.tsx',
'src/pages/projects/project-notes.tsx',
'src/pages/projects/project-cron.tsx',
'src/pages/projects/project-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}`)
@@ -21,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', 'src/pages/projects/project-inbox.tsx']) {
if (existsSync(removed)) {
failures.push(`${removed} should be removed; frontend data must come from backend APIs`)
}
}
if (existsSync('src/app/App.tsx')) {
const appSource = readFileSync('src/app/App.tsx', 'utf8')
for (const forbidden of ['function LoginPage', 'function Workbench', 'function OverviewContent']) {

View File

@@ -32,11 +32,13 @@ const collectMetrics = async () => page.evaluate(() => {
const rail = document.querySelector('.project-rail')
const railChildren = rail?.querySelector('.arco-layout-sider-children')
const sidebar = document.querySelector('.channel-sidebar')
const channelList = document.querySelector('.channel-list')
const stage = document.querySelector('.stage')
const inspector = document.querySelector('.inspector')
const workspaceActions = [...document.querySelectorAll('.workspace-page .overview-head .arco-btn')]
.map((button) => button.textContent?.trim())
const dashboard = document.querySelector('.dashboard-button')
const workspaceInbox = document.querySelector('.dashboard-button[title="Inbox"]')
const project = document.querySelector('.project-button')
const create = document.querySelector('.create-project')
const firstBadge = document.querySelector('.project-badge .arco-badge-number')
@@ -62,6 +64,7 @@ const collectMetrics = async () => page.evaluate(() => {
scrollHeight: node.scrollHeight,
overflowX: getComputedStyle(node).overflowX,
overflowY: getComputedStyle(node).overflowY,
scrollbarWidth: getComputedStyle(node).scrollbarWidth,
}
: null
@@ -81,6 +84,8 @@ const collectMetrics = async () => page.evaluate(() => {
workspaceActions,
dashboardButton: rect(dashboard),
dashboardButtonActive: dashboard?.classList.contains('active') ?? false,
workspaceInboxButton: rect(workspaceInbox),
workspaceInboxButtonActive: workspaceInbox?.classList.contains('active') ?? false,
projectButton: rect(project),
projectButtonActive: project?.classList.contains('active') ?? false,
createProjectButton: rect(create),
@@ -89,6 +94,10 @@ const collectMetrics = async () => page.evaluate(() => {
verticalGaps,
projectRailOverflow: overflowState(rail),
projectRailChildrenOverflow: overflowState(railChildren),
channelSidebarOverflow: overflowState(sidebar),
channelListOverflow: overflowState(channelList),
stageOverflow: overflowState(stage),
inspectorOverflow: overflowState(inspector),
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
viewportWidth: window.innerWidth,
}
@@ -102,18 +111,61 @@ await page.waitForTimeout(700)
await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true })
const workspaceMetrics = await collectMetrics()
await page.locator('.dashboard-button[title="Inbox"]').click()
await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/workspace-inbox-react-acro-light.png', fullPage: true })
const workspaceInboxMetrics = await collectMetrics()
await page.locator('.project-button').first().click()
await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/project-react-acro-light.png', fullPage: true })
const projectMetrics = await collectMetrics()
await page.locator('.channel-sidebar').hover()
const channelSidebarHoverMetrics = await collectMetrics()
await page.locator('.stage').hover()
const stageHoverMetrics = await collectMetrics()
await page.locator('.channel-list').hover()
const channelListHoverMetrics = await collectMetrics()
const channelPageChecks = []
for (const channel of [
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
{ label: 'AI 会话', pageClass: 'project-ai-page', expectedHeading: 'AI 会话' },
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
{ label: 'Cron 计划任务', pageClass: 'project-cron-page', expectedHeading: 'Cron 计划任务' },
]) {
await page.locator('.channel-button', { hasText: channel.label }).click()
await page.waitForTimeout(250)
channelPageChecks.push(await page.evaluate((expected) => {
const pageNode = document.querySelector(`.${expected.pageClass}`)
const heading = pageNode?.querySelector('h4, h5')?.textContent ?? ''
const activeChannel = document.querySelector('.channel-button.active')?.textContent ?? ''
return {
...expected,
foundPage: Boolean(pageNode),
heading,
activeChannel,
}
}, channel))
}
await page.locator('.topbar-actions .arco-btn').first().click()
await page.screenshot({ path: 'test-results/project-react-acro-dark.png', fullPage: true })
await browser.close()
await server.close()
console.log(JSON.stringify({ workspaceMetrics, projectMetrics, errors }, null, 2))
console.log(JSON.stringify({
workspaceMetrics,
workspaceInboxMetrics,
projectMetrics,
channelSidebarHoverMetrics,
stageHoverMetrics,
channelListHoverMetrics,
channelPageChecks,
errors,
}, null, 2))
const failures = []
const metrics = workspaceMetrics
@@ -128,6 +180,8 @@ if (metrics.overflowX) failures.push('expected no horizontal overflow')
if (!metrics.workbenchMain) failures.push('missing workbench main')
if (!metrics.hasWorkspacePage) failures.push('expected login to land on workspace page')
if (!metrics.dashboardButtonActive) failures.push('expected dashboard button to be active after login')
if (!metrics.workspaceInboxButton) failures.push('expected fixed workspace inbox button to render in project rail')
if (metrics.workspaceInboxButtonActive) failures.push('expected fixed workspace inbox button not to be active after login')
if (metrics.projectButtonActive) failures.push('expected first project button not to be active on workspace landing page')
if (metrics.channelSidebar) failures.push(`expected workspace to hide channel sidebar, got ${JSON.stringify(metrics.channelSidebar)}`)
if (metrics.inspector) failures.push(`expected workspace to hide inspector, got ${JSON.stringify(metrics.inspector)}`)
@@ -176,6 +230,13 @@ if (
) {
failures.push(`dashboard button size ${JSON.stringify(metrics.dashboardButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
}
if (
!metrics.workspaceInboxButton ||
metrics.workspaceInboxButton.width !== metrics.projectButton?.width ||
metrics.workspaceInboxButton.height !== metrics.projectButton?.height
) {
failures.push(`workspace inbox button size ${JSON.stringify(metrics.workspaceInboxButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
}
if (
!metrics.createProjectButton ||
metrics.createProjectButton.width !== metrics.projectButton?.width ||
@@ -184,20 +245,33 @@ if (
failures.push(`create project button size ${JSON.stringify(metrics.createProjectButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
}
if (!workspaceInboxMetrics.workspaceInboxButtonActive) failures.push('expected fixed workspace inbox button to become active when selected')
if (workspaceInboxMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace inbox page')
if (workspaceInboxMetrics.channelSidebar) failures.push(`expected workspace inbox to hide channel sidebar, got ${JSON.stringify(workspaceInboxMetrics.channelSidebar)}`)
if (workspaceInboxMetrics.inspector) failures.push(`expected workspace inbox to hide inspector, got ${JSON.stringify(workspaceInboxMetrics.inspector)}`)
if (!workspaceInboxMetrics.stage || !workspaceInboxMetrics.projectRail) {
failures.push(`missing workspace inbox layout regions: rail=${JSON.stringify(workspaceInboxMetrics.projectRail)}, stage=${JSON.stringify(workspaceInboxMetrics.stage)}`)
} else if (Math.abs(workspaceInboxMetrics.stage.left - workspaceInboxMetrics.projectRail.right) > 1) {
failures.push(`expected workspace inbox stage to start after project rail, got stage.left=${workspaceInboxMetrics.stage.left}, rail.right=${workspaceInboxMetrics.projectRail.right}`)
}
if (!projectMetrics.hasProjectOverview) failures.push('expected first project click to show project overview page')
if (projectMetrics.hasWorkspacePage) failures.push('expected project overview mode to leave workspace page')
if (!projectMetrics.projectButtonActive) failures.push('expected first project button to be active in project mode')
if (projectMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active in project mode')
if (!projectMetrics.projectRail || !projectMetrics.channelSidebar || !projectMetrics.stage || !projectMetrics.inspector) {
if (projectMetrics.inspector) {
failures.push(`expected project page to remove right inspector until it becomes an on-demand panel, got ${JSON.stringify(projectMetrics.inspector)}`)
}
if (!projectMetrics.projectRail || !projectMetrics.channelSidebar || !projectMetrics.stage) {
failures.push(
`missing project layout regions: rail=${JSON.stringify(projectMetrics.projectRail)}, sidebar=${JSON.stringify(projectMetrics.channelSidebar)}, stage=${JSON.stringify(projectMetrics.stage)}, inspector=${JSON.stringify(projectMetrics.inspector)}`,
`missing project layout regions: rail=${JSON.stringify(projectMetrics.projectRail)}, sidebar=${JSON.stringify(projectMetrics.channelSidebar)}, stage=${JSON.stringify(projectMetrics.stage)}`,
)
} else {
const sameTop = [projectMetrics.projectRail, projectMetrics.channelSidebar, projectMetrics.stage, projectMetrics.inspector]
const sameTop = [projectMetrics.projectRail, projectMetrics.channelSidebar, projectMetrics.stage]
.every((region) => Math.abs(region.top - projectMetrics.projectRail.top) <= 1)
if (!sameTop) {
failures.push(
`expected project rail, channel sidebar, stage, and inspector to share the same top edge, got rail=${projectMetrics.projectRail.top}, sidebar=${projectMetrics.channelSidebar.top}, stage=${projectMetrics.stage.top}, inspector=${projectMetrics.inspector.top}`,
`expected project rail, channel sidebar, and stage to share the same top edge, got rail=${projectMetrics.projectRail.top}, sidebar=${projectMetrics.channelSidebar.top}, stage=${projectMetrics.stage.top}`,
)
}
if (Math.abs(projectMetrics.channelSidebar.left - projectMetrics.projectRail.right) > 1) {
@@ -206,11 +280,40 @@ if (!projectMetrics.projectRail || !projectMetrics.channelSidebar || !projectMet
if (Math.abs(projectMetrics.stage.left - projectMetrics.channelSidebar.right) > 1) {
failures.push(`expected stage to sit after channel sidebar, got stage.left=${projectMetrics.stage.left}, sidebar.right=${projectMetrics.channelSidebar.right}`)
}
if (Math.abs(projectMetrics.inspector.left - projectMetrics.stage.right) > 1) {
failures.push(`expected inspector to sit after stage, got inspector.left=${projectMetrics.inspector.left}, stage.right=${projectMetrics.stage.right}`)
if (Math.abs(projectMetrics.stage.right - projectMetrics.viewportWidth) > 1) {
failures.push(`expected project stage to end at viewport right edge, got stage.right=${projectMetrics.stage.right}, viewport=${projectMetrics.viewportWidth}`)
}
if (Math.abs(projectMetrics.inspector.right - projectMetrics.viewportWidth) > 1) {
failures.push(`expected inspector to end at viewport right edge, got inspector.right=${projectMetrics.inspector.right}, viewport=${projectMetrics.viewportWidth}`)
}
for (const [name, overflow] of [
['channel sidebar', projectMetrics.channelSidebarOverflow],
['channel list', projectMetrics.channelListOverflow],
['stage', projectMetrics.stageOverflow],
]) {
if (!overflow || overflow.overflowX !== 'hidden' || overflow.overflowY !== 'auto') {
failures.push(`expected ${name} to hide horizontal scrollbars and show vertical scrollbars only when needed, got ${JSON.stringify(overflow)}`)
}
if (overflow?.scrollbarWidth !== 'none') {
failures.push(`expected ${name} scrollbar to be hidden until hover, got ${JSON.stringify(overflow)}`)
}
}
for (const [name, overflow] of [
['channel sidebar hover', channelSidebarHoverMetrics.channelSidebarOverflow],
['channel list hover', channelListHoverMetrics.channelListOverflow],
['stage hover', stageHoverMetrics.stageOverflow],
]) {
if (overflow?.scrollbarWidth !== 'thin') {
failures.push(`expected ${name} scrollbar to appear on hover, got ${JSON.stringify(overflow)}`)
}
}
for (const check of channelPageChecks) {
if (!check.foundPage) {
failures.push(`expected ${check.label} to render .${check.pageClass}`)
}
if (!check.heading.includes(check.expectedHeading)) {
failures.push(`expected ${check.label} heading to include ${check.expectedHeading}, got ${JSON.stringify(check.heading)}`)
}
if (!check.activeChannel.includes(check.label)) {
failures.push(`expected ${check.label} sidebar button to stay active, got ${JSON.stringify(check.activeChannel)}`)
}
}

View File

@@ -271,6 +271,35 @@
justify-content: flex-end;
}
.dock-icon {
position: relative;
display: inline-block;
width: 16px;
height: 14px;
border: 1.5px solid currentcolor;
border-radius: 2px;
box-sizing: border-box;
}
.dock-icon span {
position: absolute;
top: 0;
bottom: 0;
width: 5px;
background: currentcolor;
opacity: 0.2;
}
.dock-icon-left span {
left: 0;
border-right: 1.5px solid currentcolor;
}
.dock-icon-right span {
right: 0;
border-left: 1.5px solid currentcolor;
}
.workbench-main {
min-height: 0;
min-width: 0;
@@ -350,7 +379,10 @@
.channel-sidebar {
min-height: 0;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
scrollbar-color: color-mix(in srgb, var(--senlin-muted) 45%, transparent) transparent;
padding: 16px;
border-right: 1px solid var(--senlin-border);
background: var(--senlin-panel);
@@ -370,6 +402,10 @@
.channel-list {
display: grid;
gap: 4px;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
scrollbar-color: color-mix(in srgb, var(--senlin-muted) 45%, transparent) transparent;
}
.channel-button {
@@ -434,6 +470,10 @@
.stage {
min-width: 0;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
scrollbar-color: color-mix(in srgb, var(--senlin-muted) 45%, transparent) transparent;
padding: 16px;
background: var(--senlin-bg);
}
@@ -539,6 +579,65 @@
grid-template-columns: minmax(260px, 1fr) 82px 100px 100px 130px;
}
.workspace-task-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
padding-top: 8px;
}
.workspace-task-card {
display: flex;
min-height: 148px;
flex-direction: column;
justify-content: space-between;
gap: 14px;
border: 1px solid var(--senlin-border);
border-radius: 6px;
background: var(--senlin-surface);
color: var(--senlin-text);
padding: 14px;
text-align: left;
cursor: pointer;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.workspace-task-card:hover {
border-color: color-mix(in srgb, var(--senlin-primary) 42%, var(--senlin-border));
box-shadow: 0 8px 22px color-mix(in srgb, var(--senlin-primary) 12%, transparent);
transform: translateY(-1px);
}
.workspace-task-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.workspace-task-title svg {
color: var(--senlin-primary);
}
.workspace-task-main .arco-typography {
margin: 8px 0 0;
}
.workspace-task-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
color: var(--senlin-muted);
font-size: 12px;
}
.workspace-task-meta span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.ai-row {
grid-template-columns: minmax(260px, 1fr) minmax(260px, 1.2fr) 80px 90px;
}
@@ -547,18 +646,280 @@
grid-template-columns: minmax(260px, 1fr) 80px 160px 80px;
}
.project-channel-page {
min-width: 0;
}
.compact-card .arco-card-body {
padding: 12px 14px;
}
.mail-layout,
.ai-workspace {
min-width: 0;
display: grid;
grid-template-columns: minmax(260px, 0.42fr) minmax(360px, 0.58fr);
gap: 12px;
}
.mail-list,
.mail-detail,
.ai-session-list,
.ai-chat-panel {
min-width: 0;
}
.mail-item,
.ai-session,
.task-card-button {
width: 100%;
display: grid;
gap: 4px;
border: 0;
border-top: 1px solid var(--senlin-border);
background: transparent;
color: var(--senlin-text);
padding: 10px 0;
text-align: left;
cursor: pointer;
}
.mail-item:first-of-type,
.ai-session:first-of-type {
border-top: 0;
}
.mail-item.active,
.ai-session.active,
.mail-item:hover,
.ai-session:hover,
.task-card-button:hover {
background: color-mix(in srgb, var(--senlin-primary) 6%, transparent);
}
.mail-item span:first-child,
.ai-session > .arco-typography:first-child {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.mail-item div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mail-item time,
.ai-session time {
color: var(--senlin-muted);
font-size: 12px;
}
.task-detail-card .arco-card-body {
display: grid;
gap: 18px;
}
.task-detail-progress {
display: grid;
gap: 8px;
}
.task-detail-summary {
display: grid;
gap: 8px;
border-top: 1px solid var(--senlin-border);
padding-top: 14px;
}
.task-detail-summary h6 {
margin: 0;
}
.mail-detail .arco-typography,
.mail-item .arco-typography,
.ai-session .arco-typography {
overflow: hidden;
text-overflow: ellipsis;
}
.reply-box,
.chat-input {
display: grid;
gap: 10px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--senlin-border);
}
.task-board {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.task-lane .arco-card-body {
display: grid;
gap: 10px;
padding: 12px;
}
.task-card-button {
border: 1px solid var(--senlin-border);
border-radius: 6px;
padding: 10px;
}
.chat-thread {
display: grid;
gap: 12px;
}
.chat-message {
display: grid;
grid-template-columns: 30px 1fr;
gap: 10px;
}
.chat-message p {
margin: 0;
padding: 10px 12px;
border: 1px solid var(--senlin-border);
border-radius: 6px;
background: var(--senlin-soft-blue);
}
.chat-message.user p {
background: var(--senlin-panel);
}
.folder-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.folder-card .arco-card-body {
padding: 14px;
}
.cron-row {
grid-template-columns: minmax(180px, 1fr) 64px 92px 92px 92px 48px 40px;
}
.cron-summary .arco-card-body {
padding: 12px;
}
.action-modal .arco-modal-content {
border-radius: 8px;
}
.action-form {
width: 100%;
}
.action-form label {
display: grid;
gap: 6px;
}
.action-switch {
grid-template-columns: 1fr auto;
align-items: center;
}
.native-file-input {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--senlin-border);
border-radius: 6px;
background: var(--senlin-panel);
color: var(--senlin-text);
padding: 8px;
}
.theme-dark .arco-modal,
.theme-dark .arco-modal-header,
.theme-dark .arco-modal-content {
background: var(--senlin-panel);
color: var(--senlin-text);
}
.inspector {
min-height: 0;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
scrollbar-color: color-mix(in srgb, var(--senlin-muted) 45%, transparent) transparent;
padding: 0 16px 16px;
border-left: 1px solid var(--senlin-border);
background: var(--senlin-panel);
}
.channel-sidebar:hover,
.stage:hover,
.inspector:hover,
.channel-list:hover,
.inspector .arco-tabs-content:hover,
.property-list:hover {
scrollbar-width: thin;
}
.channel-sidebar::-webkit-scrollbar,
.stage::-webkit-scrollbar,
.inspector::-webkit-scrollbar,
.channel-list::-webkit-scrollbar,
.inspector .arco-tabs-content::-webkit-scrollbar,
.property-list::-webkit-scrollbar {
width: 0;
height: 0;
}
.channel-sidebar:hover::-webkit-scrollbar,
.stage:hover::-webkit-scrollbar,
.inspector:hover::-webkit-scrollbar,
.channel-list:hover::-webkit-scrollbar,
.inspector .arco-tabs-content:hover::-webkit-scrollbar,
.property-list:hover::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.channel-sidebar::-webkit-scrollbar-thumb,
.stage::-webkit-scrollbar-thumb,
.inspector::-webkit-scrollbar-thumb,
.channel-list::-webkit-scrollbar-thumb,
.inspector .arco-tabs-content::-webkit-scrollbar-thumb,
.property-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: color-mix(in srgb, var(--senlin-muted) 45%, transparent);
}
.channel-sidebar::-webkit-scrollbar-track,
.stage::-webkit-scrollbar-track,
.inspector::-webkit-scrollbar-track,
.channel-list::-webkit-scrollbar-track,
.inspector .arco-tabs-content::-webkit-scrollbar-track,
.property-list::-webkit-scrollbar-track {
background: transparent;
}
.inspector .arco-tabs-header {
margin-bottom: 12px;
}
.inspector .arco-tabs-content {
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
scrollbar-color: color-mix(in srgb, var(--senlin-muted) 45%, transparent) transparent;
}
.discussion-panel {
display: grid;
}
@@ -610,6 +971,13 @@
width: 100%;
}
.property-list {
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
scrollbar-color: color-mix(in srgb, var(--senlin-muted) 45%, transparent) transparent;
}
.property-row {
display: flex;
justify-content: space-between;

View File

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

View File

@@ -0,0 +1,197 @@
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.filter((channel) => channel.type !== 'inbox').map(mapChannel),
tags: payload.tags.map((tag) => (tag === 'all' ? '全部' : tag)),
recentSessions: payload.recentSessions.map(mapAISession),
inbox: payload.inbox.map(mapInbox),
tasks: payload.tasks.map(mapTask),
aiSessions: payload.aiSessions.map(mapAISession),
notes: payload.notesSources.map(mapNoteSource),
cronJobs: payload.cronPlans.map(mapCronPlan),
}
}
function 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 || '未设置',
createdAt: formatCreatedAt(task.createdAt),
tag: task.tag || (task.completed ? '已完成' : '进行中'),
summary: task.summary,
completed: task.completed,
}
}
function formatCreatedAt(value: string) {
const date = parseBackendDate(value)
if (!date) return '未知时间'
const now = new Date()
const today = startOfDay(now)
const yesterday = new Date(today)
yesterday.setDate(today.getDate() - 1)
const dateDay = startOfDay(date)
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`
if (dateDay.getTime() === today.getTime()) return `今天 ${time}`
if (dateDay.getTime() === yesterday.getTime()) return `昨天 ${time}`
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${time}`
}
function parseBackendDate(value: string) {
if (!value) return null
const normalized = value.includes('T') ? value : `${value.replace(' ', 'T')}Z`
const date = new Date(normalized)
return Number.isNaN(date.getTime()) ? null : date
}
function startOfDay(value: Date) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate())
}
function pad(value: number) {
return String(value).padStart(2, '0')
}
function mapAISession(session: BackendAISession): AISession {
return {
id: session.id,
title: session.title,
summary: session.summary,
owner: 'AI',
time: session.updatedAt,
}
}
function mapNoteSource(note: BackendNoteSource): NoteSource {
return {
id: note.id,
title: note.title,
type: note.kind === 'note' ? '笔记' : note.source,
updated: note.updatedAt,
owner: note.source,
source: note.source,
}
}
function mapCronPlan(plan: BackendCronPlan): CronJob {
return {
id: plan.id,
name: plan.title,
expr: plan.schedule,
status: plan.enabled ? '运行中' : '暂停',
lastRun: plan.lastResult,
nextRun: plan.nextRun || '暂停中',
owner: plan.owner,
enabled: plan.enabled,
}
}

View File

@@ -0,0 +1,156 @@
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
createdAt: 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 })
}
export type CreateProjectInput = {
name: string
description?: string
}
export type CreateTaskInput = {
title: string
description?: string
status?: string
dueAt?: string
}
export type UploadSourceInput = {
title?: string
file: File
}
export type CreateCronPlanInput = {
title: string
schedule: string
enabled: boolean
nextRunAt?: string
}
export async function createProject(session: ApiSession, input: CreateProjectInput) {
return apiRequest<BackendProject>('/api/projects', {
method: 'POST',
token: session.token,
body: input,
})
}
export async function createTask(session: ApiSession, projectID: string | number, input: CreateTaskInput) {
return apiRequest<BackendTask>(`/api/projects/${projectID}/tasks`, {
method: 'POST',
token: session.token,
body: input,
})
}
export async function uploadSource(session: ApiSession, projectID: string | number, input: UploadSourceInput) {
const body = new FormData()
body.append('file', input.file)
if (input.title) body.append('title', input.title)
return apiRequest<BackendNoteSource>(`/api/projects/${projectID}/sources`, {
method: 'POST',
token: session.token,
body,
})
}
export async function createCronPlan(session: ApiSession, projectID: string | number, input: CreateCronPlanInput) {
return apiRequest<BackendCronPlan>(`/api/projects/${projectID}/cron-plans`, {
method: 'POST',
token: session.token,
body: input,
})
}

View File

@@ -1,44 +1,227 @@
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 {
createCronPlan,
createProject,
createTask,
fetchProjectWorkspace,
fetchProjects,
uploadSource,
} 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 { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals'
import { ProjectPage } from '../pages/workspace-home'
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
function App() {
const [screen, setScreen] = useState<Screen>('login')
const [theme, setTheme] = useState<Theme>('light')
const [activeView, setActiveView] = useState<WorkbenchView>('workspace')
const [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 [selectedItem, setSelectedItem] = useState('Inbox 待处理36')
const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
const [, setSelectedItem] = useState('Inbox 待处理36')
const [loading, setLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false)
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
const dark = theme === 'dark'
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0]
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID = activeProjectID) {
const backendProjects = await fetchProjects(nextSession)
const backendWorkspaces = await Promise.all(
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.ID ?? project.id ?? '').then((workspace) => mapWorkspace(workspace, index))),
)
setWorkspaces(backendWorkspaces)
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === preferredProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? ''
setActiveProjectID(nextProjectID)
return backendWorkspaces
}
async function handleLogin(input: { server: string; email: string; password: string }) {
setLoading(true)
try {
setApiBaseUrl(input.server)
const nextSession = await login(input.email, input.password)
const backendWorkspaces = await loadWorkspaces(nextSession, '')
setSession(nextSession)
setActiveProjectID(backendWorkspaces[0]?.project.id ?? '')
setActiveChannel('overview')
setActiveView('workspace')
setScreen('workbench')
} catch (error) {
Message.error(error instanceof Error ? error.message : '登录失败')
} finally {
setLoading(false)
}
}
async function refreshAfterAction(nextProjectID = activeProjectID) {
if (!session) return
await loadWorkspaces(session, nextProjectID)
}
async function runAction(action: () => Promise<void>, success: string) {
setActionLoading(true)
try {
await action()
setActiveModal(null)
Message.success(success)
} catch (error) {
Message.error(error instanceof Error ? error.message : '操作失败')
} finally {
setActionLoading(false)
}
}
function requireSession() {
if (!session) throw new Error('未登录')
return session
}
function requireActiveProject() {
if (!activeWorkspace?.project.id) throw new Error('未选择项目')
return activeWorkspace.project.id
}
function normalizeOptionalTime(value: string) {
const trimmed = value.trim()
return trimmed === '' ? undefined : trimmed
}
function handleCreateProject(draft: ProjectDraft) {
if (!draft.name.trim()) {
Message.warning('请输入项目名称')
return
}
void runAction(async () => {
const created = await createProject(requireSession(), { name: draft.name.trim(), description: draft.description.trim() })
const nextProjectID = String(created.ID ?? created.id ?? '')
await refreshAfterAction(nextProjectID)
if (nextProjectID) {
setActiveProjectID(nextProjectID)
setActiveView('project')
setActiveChannel('overview')
}
}, '项目已创建')
}
function handleCreateTask(draft: TaskDraft) {
if (!draft.title.trim()) {
Message.warning('请输入任务标题')
return
}
void runAction(async () => {
await createTask(requireSession(), requireActiveProject(), {
title: draft.title.trim(),
description: draft.description.trim(),
status: 'open',
dueAt: normalizeOptionalTime(draft.dueAt),
})
await refreshAfterAction()
setActiveChannel('tasks')
}, '任务已创建')
}
function handleUploadSource(draft: SourceDraft) {
if (!draft.file) {
Message.warning('请选择文件')
return
}
const file = draft.file
void runAction(async () => {
await uploadSource(requireSession(), requireActiveProject(), {
title: draft.title.trim(),
file,
})
await refreshAfterAction()
setActiveChannel('notes')
}, '文件已上传')
}
function handleCreateCronPlan(draft: CronDraft) {
if (!draft.title.trim()) {
Message.warning('请输入计划名称')
return
}
if (!draft.schedule.trim()) {
Message.warning('请输入 Cron 表达式')
return
}
void runAction(async () => {
await createCronPlan(requireSession(), requireActiveProject(), {
title: draft.title.trim(),
schedule: draft.schedule.trim(),
enabled: draft.enabled,
nextRunAt: normalizeOptionalTime(draft.nextRunAt),
})
await refreshAfterAction()
setActiveChannel('cron')
}, '计划任务已创建')
}
function openTask(project: Project, taskID: string) {
setActiveProjectID(project.id)
setActiveView('project')
setActiveChannel('tasks')
setActiveTaskID(taskID)
}
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}
selectedItem={selectedItem}
activeTaskID={activeTaskID}
theme={theme}
onSelectWorkspace={() => setActiveView('workspace')}
onSelectWorkspaceInbox={() => setActiveView('workspace-inbox')}
onSelectProject={(project) => {
setActiveProject(project)
setActiveProjectID(project.id)
setActiveView('project')
setActiveChannel('overview')
setActiveTaskID(null)
}}
onSelectChannel={(channel) => {
setActiveChannel(channel)
setActiveTaskID(null)
}}
onSelectChannel={setActiveChannel}
onSelectItem={setSelectedItem}
onOpenTask={openTask}
onCloseTask={() => setActiveTaskID(null)}
onToggleTheme={() => setTheme(dark ? 'light' : 'dark')}
onCreateProject={() => setActiveModal('project')}
onCreateTask={() => setActiveModal('task')}
onUploadSource={() => setActiveModal('source')}
onCreateCronPlan={() => setActiveModal('cron')}
/>
) : (
<Spin loading />
)}
<ProjectActionModals
activeModal={activeModal}
loading={actionLoading}
onClose={() => setActiveModal(null)}
onCreateProject={handleCreateProject}
onCreateTask={handleCreateTask}
onUploadSource={handleUploadSource}
onCreateCronPlan={handleCreateCronPlan}
/>
</main>
</ConfigProvider>
)

View File

@@ -1,12 +1,37 @@
import type { ReactElement } from 'react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Button, Card, Form, Input, Space, Typography } from '@arco-design/web-react'
import { IconBook, IconCheckCircleFill, IconLaunch, IconSafe, IconUserGroup } from '@arco-design/web-react/icon'
import {
IconBook,
IconCheckCircleFill,
IconCloseCircleFill,
IconLaunch,
IconLoading,
IconSafe,
IconUserGroup,
} from '@arco-design/web-react/icon'
const { Title, Text, Paragraph } = Typography
export function LoginPage({ onLogin }: { onLogin: () => void }) {
const [server, setServer] = useState('https://10.0.0.15:8080')
type ConnectionStatus = 'checking' | 'online' | 'offline'
export function LoginPage({ onLogin }: { onLogin: (input: { server: string; email: string; password: string }) => void }) {
const [server, setServer] = useState('http://localhost:9150')
const [email, setEmail] = useState('demo@senlin.ai')
const [password, setPassword] = useState('password123')
const [status, setStatus] = useState<ConnectionStatus>('checking')
useEffect(() => {
const controller = new AbortController()
const timer = window.setTimeout(() => {
void checkServerStatus(server, controller.signal, setStatus)
}, 300)
return () => {
controller.abort()
window.clearTimeout(timer)
}
}, [server])
return (
<section className="login-screen">
@@ -14,15 +39,14 @@ export function LoginPage({ onLogin }: { onLogin: () => void }) {
<div className="login-brand-panel">
<div className="brand-lockup large">
<span className="brand-symbol">S</span>
<Title heading={2}>SenlinAI</Title>
<Title heading={2}>Agent</Title>
</div>
<Text className="login-slogan"> · </Text>
<Text type="secondary"> · · AI </Text>
<Text type="secondary"> · · AI Agent</Text>
<Space className="login-footer-links" size={24}>
<Text type="secondary">v1.0.0</Text>
<Text type="secondary"></Text>
<Text type="secondary"></Text>
<Text type="secondary"> SenlinAI</Text>
</Space>
</div>
@@ -32,12 +56,14 @@ export function LoginPage({ onLogin }: { onLogin: () => void }) {
<Form layout="vertical" className="login-form">
<Form.Item label="服务器地址IP 或域名优先)">
<Input value={server} onChange={setServer} placeholder="例如https://10.0.0.15:8080" suffix={<IconLaunch />} />
<ConnectionCard status={status} onCheck={() => checkServerStatus(server, undefined, setStatus)} />
</Form.Item>
<Form.Item label="账号">
<Input 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,17 +72,9 @@ 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">
<IconCheckCircleFill />
<div>
<Text className="connection-title"></Text>
<Text type="secondary"> {server}</Text>
</div>
<Button type="text" size="mini"></Button>
</div>
</Form>
</div>
@@ -74,6 +92,48 @@ export function LoginPage({ onLogin }: { onLogin: () => void }) {
)
}
function ConnectionCard({ status, onCheck }: { status: ConnectionStatus; onCheck: () => void }) {
const icon = status === 'checking' ? <IconLoading /> : status === 'online' ? <IconCheckCircleFill /> : <IconCloseCircleFill />
const title = status === 'checking' ? '正在检查' : status === 'online' ? '连接正常' : '连接异常'
return (
<div className={`connection-card ${status}`}>
{icon}
<div>
<Text className="connection-title">{title}</Text>
</div>
<Button type="text" size="mini" loading={status === 'checking'} onClick={onCheck}>
</Button>
</div>
)
}
async function checkServerStatus(
server: string,
signal: AbortSignal | undefined,
setStatus: (status: ConnectionStatus) => void,
) {
const baseUrl = normalizeBaseUrl(server)
setStatus('checking')
try {
const response = await fetch(`${baseUrl}/api/status`, { signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const payload = await response.json() as { timestamp_ms?: number }
if (typeof payload.timestamp_ms !== 'number') throw new Error('invalid status payload')
setStatus('online')
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
setStatus('offline')
}
}
function normalizeBaseUrl(value: string) {
const trimmed = value.trim()
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
}
function ValuePoint({ icon, title, desc }: { icon: ReactElement; title: string; desc: string }) {
return (
<div className="value-point">

View File

@@ -1,81 +0,0 @@
import { Layout } from '@arco-design/web-react'
import { WorkspacePage } from './workspace'
import { ProjectOverview } from './projects/project-overview'
import { ProjectInspector } from './projects/project-inspector'
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'
const { Content } = Layout
export function ProjectPage({
activeView,
activeProject,
activeChannel,
selectedItem,
theme,
onSelectProject,
onSelectWorkspace,
onSelectChannel,
onSelectItem,
onToggleTheme,
}: {
activeView: WorkbenchView
activeProject: Project
activeChannel: ChannelKey
selectedItem: string
theme: Theme
onSelectProject: (project: Project) => void
onSelectWorkspace: () => void
onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void
onToggleTheme: () => void
}) {
const isWorkspace = activeView === 'workspace'
return (
<Layout className="workbench-shell">
<ProjectTopbar theme={theme} onToggleTheme={onToggleTheme} />
<Layout className="workbench-main" hasSider>
<ProjectRail
activeProject={activeProject}
activeView={activeView}
onSelectWorkspace={onSelectWorkspace}
onSelectProject={onSelectProject}
/>
{!isWorkspace && (
<ProjectSidebar
activeView={activeView}
activeProject={activeProject}
activeChannel={activeChannel}
onSelectChannel={onSelectChannel}
onSelectItem={onSelectItem}
/>
)}
<Content className="stage">
{isWorkspace ? (
<WorkspacePage onSelectProject={onSelectProject} />
) : (
<ProjectOverview onSelectItem={onSelectItem} />
)}
</Content>
{!isWorkspace && (
<ProjectInspector
activeView={activeView}
activeProject={activeProject}
activeChannel={activeChannel}
selectedItem={selectedItem}
/>
)}
</Layout>
<ProjectStatusbar />
</Layout>
)
}

View File

@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react'
import { Input, Modal, Space, Switch, Typography } from '@arco-design/web-react'
const { Text } = Typography
const { TextArea } = Input
export type ProjectActionModal = 'project' | 'task' | 'source' | 'cron' | null
export type ProjectDraft = {
name: string
description: string
}
export type TaskDraft = {
title: string
description: string
dueAt: string
}
export type SourceDraft = {
title: string
file: File | null
}
export type CronDraft = {
title: string
schedule: string
enabled: boolean
nextRunAt: string
}
export function ProjectActionModals({
activeModal,
loading,
onClose,
onCreateProject,
onCreateTask,
onUploadSource,
onCreateCronPlan,
}: {
activeModal: ProjectActionModal
loading: boolean
onClose: () => void
onCreateProject: (draft: ProjectDraft) => void
onCreateTask: (draft: TaskDraft) => void
onUploadSource: (draft: SourceDraft) => void
onCreateCronPlan: (draft: CronDraft) => void
}) {
const [project, setProject] = useState<ProjectDraft>({ name: '', description: '' })
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', dueAt: '' })
const [source, setSource] = useState<SourceDraft>({ title: '', file: null })
const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
useEffect(() => {
if (activeModal === null) {
setProject({ name: '', description: '' })
setTask({ title: '', description: '', dueAt: '' })
setSource({ title: '', file: null })
setCron({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
}
}, [activeModal])
return (
<>
<Modal
className="action-modal"
title="新建项目"
visible={activeModal === 'project'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onCreateProject(project)}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input placeholder="例如:项目 A3" value={project.name} onChange={(name) => setProject((draft) => ({ ...draft, name }))} />
</label>
<label>
<Text></Text>
<TextArea rows={4} placeholder="项目目标、背景或协作说明" value={project.description} onChange={(description) => setProject((draft) => ({ ...draft, description }))} />
</label>
</Space>
</Modal>
<Modal
className="action-modal"
title="新建任务"
visible={activeModal === 'task'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onCreateTask(task)}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input placeholder="要完成什么?" value={task.title} onChange={(title) => setTask((draft) => ({ ...draft, title }))} />
</label>
<label>
<Text></Text>
<TextArea rows={4} placeholder="补充背景、验收口径或下一步" value={task.description} onChange={(description) => setTask((draft) => ({ ...draft, description }))} />
</label>
<label>
<Text></Text>
<Input placeholder="2026-07-21T09:30:00Z可留空" value={task.dueAt} onChange={(dueAt) => setTask((draft) => ({ ...draft, dueAt }))} />
</label>
</Space>
</Modal>
<Modal
className="action-modal"
title="上传文件"
visible={activeModal === 'source'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onUploadSource(source)}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input placeholder="默认使用文件名" value={source.title} onChange={(title) => setSource((draft) => ({ ...draft, title }))} />
</label>
<label>
<Text></Text>
<input
className="native-file-input"
type="file"
onChange={(event) => setSource((draft) => ({ ...draft, file: event.target.files?.[0] ?? null }))}
/>
</label>
</Space>
</Modal>
<Modal
className="action-modal"
title="新建计划任务"
visible={activeModal === 'cron'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onCreateCronPlan(cron)}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input placeholder="例如:每日 Inbox 整理" value={cron.title} onChange={(title) => setCron((draft) => ({ ...draft, title }))} />
</label>
<label>
<Text>Cron </Text>
<Input value={cron.schedule} onChange={(schedule) => setCron((draft) => ({ ...draft, schedule }))} />
</label>
<label>
<Text></Text>
<Input placeholder="2026-07-22T08:00:00Z可留空" value={cron.nextRunAt} onChange={(nextRunAt) => setCron((draft) => ({ ...draft, nextRunAt }))} />
</label>
<label className="action-switch">
<Text></Text>
<Switch checked={cron.enabled} onChange={(enabled) => setCron((draft) => ({ ...draft, enabled }))} />
</label>
</Space>
</Modal>
</>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
import { Button, Card, Space, Switch, Tag, Typography } from '@arco-design/web-react'
import { IconClockCircle, IconPlus, IconThunderbolt } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void; onCreateCronPlan: () => void }) {
const { cronJobs, project } = activeWorkspace
const enabledCount = cronJobs.filter((job) => job.enabled).length
const pausedCount = cronJobs.length - enabledCount
return (
<div className="project-channel-page project-cron-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>Cron </Title>
<Text type="secondary">{project.name} AI </Text>
</div>
<Button type="primary" icon={<IconPlus />} onClick={onCreateCronPlan}></Button>
</div>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Space>
<Tag color="green">{enabledCount} </Tag>
<Tag color="gray">{pausedCount} </Tag>
</Space>
</div>
{cronJobs.map((job) => (
<div
className="data-row cron-row"
key={job.name}
role="button"
tabIndex={0}
onClick={() => onSelectItem(job.name)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') onSelectItem(job.name)
}}
>
<span className="row-title"><IconClockCircle /> {job.name}</span>
<Tag color={job.enabled ? 'green' : 'gray'}>{job.status}</Tag>
<span>{job.expr}</span>
<span>{job.lastRun}</span>
<span>{job.nextRun}</span>
<span>{job.owner}</span>
<Switch size="small" checked={job.enabled} />
</div>
))}
</Card>
<Card className="queue-section cron-summary" bordered>
<Space>
<Tag color="arcoblue"><IconThunderbolt /></Tag>
<Text> 12:00</Text>
</Space>
</Card>
</div>
)
}

View File

@@ -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: '已将此讨论关联到任务:智能报表导出功能。' },
]

View File

@@ -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>
)
}

View File

@@ -0,0 +1,52 @@
import { Button, Card, Space, Tag, Typography } from '@arco-design/web-react'
import { IconFile, IconFolder, IconPlus, IconSearch } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
const folders = ['需求文档', '技术方案', '会议纪要', '竞品资料']
export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void; onUploadSource: () => void }) {
const { notes, project } = activeWorkspace
return (
<div className="project-channel-page project-notes-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary">{project.name} 线</Text>
</div>
<Space>
<Button icon={<IconSearch />}></Button>
<Button type="primary" icon={<IconPlus />} onClick={onUploadSource}>/</Button>
</Space>
</div>
<div className="folder-grid">
{folders.map((folder) => (
<Card className="folder-card" bordered key={folder}>
<Space>
<Tag color="arcoblue"><IconFolder /></Tag>
<Text>{folder}</Text>
</Space>
</Card>
))}
</div>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button type="text" size="mini"></Button>
</div>
{notes.map((note) => (
<button className="data-row note-row" key={note.title} onClick={() => onSelectItem(note.title)}>
<span className="row-title"><IconFile /> {note.title}</span>
<Tag>{note.type}</Tag>
<span>{note.updated}</span>
<span>{note.owner}</span>
</button>
))}
</Card>
</div>
)
}

View File

@@ -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) => (
@@ -85,18 +86,18 @@ function TaskSection({ onSelectItem }: { onSelectItem: (title: string) => void }
<Tag color="arcoblue">{task.tag}</Tag>
<span> {task.progress}</span>
<span> {task.owner}</span>
<time> {task.due}</time>
<time> {task.createdAt}</time>
</button>
))}
</Card>
)
}
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">

View File

@@ -1,21 +1,26 @@
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 { IconDashboard, IconEmail, IconPlus } from '@arco-design/web-react/icon'
import type { Project, WorkbenchView } from './project-types'
const { Sider } = Layout
export function ProjectRail({
activeProject,
projects,
activeView,
onSelectWorkspace,
onSelectWorkspaceInbox,
onSelectProject,
onCreateProject,
}: {
activeProject: Project
projects: Project[]
activeView: WorkbenchView
onSelectWorkspace: () => void
onSelectWorkspaceInbox: () => void
onSelectProject: (project: Project) => void
onCreateProject: () => void
}) {
return (
<Sider className="project-rail" width={96}>
@@ -26,16 +31,28 @@ export function ProjectRail({
aria-label="工作台"
title="工作台"
/>
<Button
className={activeView === 'workspace-inbox' ? 'dashboard-button active' : 'dashboard-button'}
icon={<IconEmail />}
onClick={onSelectWorkspaceInbox}
aria-label="Inbox"
title="Inbox"
/>
<Space className="project-stack" direction="vertical" size={12}>
{projects.map((project) => (
<Badge key={project.id} count={project.badge} dot={false} className={project.urgent ? 'project-badge urgent' : 'project-badge'}>
<button className={activeView === 'project' && activeProject.id === project.id ? 'project-button active' : 'project-button'} onClick={() => onSelectProject(project)} style={{ '--project-color': project.color } as CSSProperties} title={project.name}>
<button
className={activeView === 'project' && activeProject.id === project.id ? 'project-button active' : 'project-button'}
onClick={() => onSelectProject(project)}
style={{ '--project-color': project.color } as CSSProperties}
title={project.name}
>
{project.short}
</button>
</Badge>
))}
</Space>
<Button className="create-project" aria-label="新建项目" icon={<IconPlus />} title="新建项目" />
<Button className="create-project" aria-label="新建项目" icon={<IconPlus />} title="新建项目" onClick={onCreateProject} />
</Sider>
)
}

View File

@@ -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>

View File

@@ -0,0 +1,133 @@
import { Button, Card, Descriptions, Progress, Space, Tag, Typography } from '@arco-design/web-react'
import { IconArrowLeft, IconCalendar, IconCheckCircle, IconPlus, IconUser } from '@arco-design/web-react/icon'
import type { ProjectWorkspace, TaskItem } from './project-types'
const { Title, Text } = Typography
export function ProjectTasks({
activeWorkspace,
activeTaskID,
onOpenTask,
onCloseTask,
onSelectItem,
onCreateTask,
}: {
activeWorkspace: ProjectWorkspace
activeTaskID: string | null
onOpenTask: (taskID: string) => void
onCloseTask: () => void
onSelectItem: (title: string) => void
onCreateTask: () => void
}) {
const { tasks, project } = activeWorkspace
const activeTask = tasks.find((task) => task.id === activeTaskID)
const lanes = makeLanes(tasks)
if (activeTask) {
return <TaskDetail activeWorkspace={activeWorkspace} task={activeTask} onBack={onCloseTask} />
}
return (
<div className="project-channel-page project-tasks-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary">{project.name} TODO </Text>
</div>
<Button type="primary" icon={<IconPlus />} onClick={onCreateTask}></Button>
</div>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Tag color="arcoblue">{tasks.length} </Tag>
</div>
{tasks.map((task) => (
<button className="data-row task-row" key={task.id} onClick={() => onOpenTask(task.id)}>
<span className="row-title"><IconCheckCircle /> {task.title}</span>
<Tag color="arcoblue">{task.tag}</Tag>
<span><IconUser /> {task.owner}</span>
<span><IconCalendar /> {task.createdAt}</span>
<Progress percent={Number.parseInt(task.progress, 10)} size="small" />
</button>
))}
</Card>
<div className="task-board">
{lanes.map((lane) => (
<Card className="task-lane" bordered key={lane.title}>
<div className="section-header">
<Title heading={6}>{lane.title}</Title>
<Tag color={lane.color}>{lane.items.length}</Tag>
</div>
<Space direction="vertical" size={8}>
{lane.items.map((title) => (
<button className="task-card-button" key={title} onClick={() => openTaskByTitle(tasks, title, onOpenTask, onSelectItem)}>
<Text>{title}</Text>
<Text type="secondary"> · </Text>
</button>
))}
</Space>
</Card>
))}
</div>
</div>
)
}
function TaskDetail({ activeWorkspace, task, onBack }: { activeWorkspace: ProjectWorkspace; task: TaskItem; onBack: () => void }) {
const percent = Number.parseInt(task.progress, 10)
return (
<div className="project-channel-page project-task-detail-page overview-page">
<div className="overview-head">
<div>
<Button type="text" icon={<IconArrowLeft />} onClick={onBack}></Button>
<Title heading={4}>{task.title}</Title>
<Text type="secondary">{activeWorkspace.project.name} </Text>
</div>
<Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag}</Tag>
</div>
<Card className="queue-section task-detail-card" bordered>
<Descriptions
colon=""
column={2}
data={[
{ label: '所属项目', value: activeWorkspace.project.name },
{ label: '负责人', value: task.owner },
{ label: '创建时间', value: task.createdAt },
{ label: '完成状态', value: task.completed ? '已完成' : '未完成' },
]}
/>
<div className="task-detail-progress">
<Text type="secondary"></Text>
<Progress percent={Number.isNaN(percent) ? 0 : percent} />
</div>
<div className="task-detail-summary">
<Title heading={6}></Title>
<Text>{task.summary || '暂无任务说明'}</Text>
</div>
</Card>
</div>
)
}
function openTaskByTitle(tasks: TaskItem[], title: string, onOpenTask: (taskID: string) => void, onSelectItem: (title: string) => void) {
const task = tasks.find((item) => item.title === title)
if (task) {
onOpenTask(task.id)
return
}
onSelectItem(title)
}
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) : ['暂无已完成任务'] },
]
}

View File

@@ -1,11 +1,13 @@
import { Badge, Button, Input, Layout, Space, Typography } from '@arco-design/web-react'
import { IconApps, IconArrowLeft, IconArrowRight, IconMoon, IconNotification, IconQuestionCircle, IconSearch, IconSun } from '@arco-design/web-react/icon'
import { Button, Input, Layout, Space, Typography } from '@arco-design/web-react'
import { IconApps, IconMoon, IconSearch, IconSun } from '@arco-design/web-react/icon'
import type { Theme } from './project-types'
const { Header } = Layout
const { Text } = Typography
export function ProjectTopbar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => void }) {
const desktop = isDesktopRuntime()
return (
<Header className="topbar">
<div className="brand-lockup">
@@ -18,12 +20,26 @@ export function ProjectTopbar({ theme, onToggleTheme }: { theme: Theme; onToggle
</div>
<Space className="topbar-actions">
<Button aria-label={theme === 'dark' ? '切换浅色模式' : '切换深色模式'} icon={theme === 'dark' ? <IconSun /> : <IconMoon />} onClick={onToggleTheme} />
<Button icon={<IconArrowLeft />} disabled />
<Button icon={<IconArrowRight />} disabled />
<Badge count={8} dot><Button icon={<IconNotification />} /></Badge>
<Button icon={<IconQuestionCircle />} />
{desktop && (
<>
<Button aria-label="停靠左边" icon={<DockIcon side="left" />} />
<Button aria-label="停靠右边" icon={<DockIcon side="right" />} />
</>
)}
<Button icon={<IconApps />} />
</Space>
</Header>
)
}
function isDesktopRuntime() {
return typeof window !== 'undefined' && '__TAURI__' in window
}
function DockIcon({ side }: { side: 'left' | 'right' }) {
return (
<span className={`dock-icon dock-icon-${side}`} aria-hidden="true">
<span />
</span>
)
}

View File

@@ -2,8 +2,8 @@ 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 WorkbenchView = 'workspace' | 'workspace-inbox' | 'project'
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | `custom:${string}`
export type Project = {
id: string
@@ -16,7 +16,73 @@ 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
createdAt: string
tag: string
summary: string
completed: boolean
}
export type AISession = {
id: string
title: string
summary: string
owner: string
time: string
}
export type NoteSource = {
id: string
title: string
type: string
updated: string
owner: string
source: string
}
export type CronJob = {
id: string
name: string
expr: string
status: string
lastRun: string
nextRun: string
owner: string
enabled: boolean
}
export type ProjectWorkspace = {
project: Project
channels: ProjectChannel[]
tags: string[]
recentSessions: AISession[]
inbox: InboxItem[]
tasks: TaskItem[]
aiSessions: AISession[]
notes: NoteSource[]
cronJobs: CronJob[]
}

View File

@@ -0,0 +1,92 @@
import { Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import {
IconCalendar,
IconCheckCircleFill,
IconDashboard,
IconFile,
IconRobot,
} from '@arco-design/web-react/icon'
import type { Project, ProjectWorkspace } from './projects/project-types'
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectWorkspace[]; onOpenTask: (project: Project, taskID: string) => void }) {
const projects = workspaces.map((workspace) => workspace.project)
const unfinishedTasks = workspaces.flatMap((workspace) =>
workspace.tasks
.filter((task) => !task.completed)
.map((task) => ({
...task,
project: workspace.project,
})),
)
const aiSessions = workspaces.flatMap((workspace) => workspace.aiSessions)
const notes = workspaces.flatMap((workspace) => workspace.notes)
const urgentProjects = projects.filter((project) => project.urgent).length
const workspaceMetrics = [
{ title: '项目总数', value: projects.length, delta: `${urgentProjects} 个高优先级`, icon: <IconDashboard />, color: 'arcoblue' },
{ title: '未完成任务', value: unfinishedTasks.length, delta: '跨项目聚合', icon: <IconCheckCircleFill />, color: 'green' },
{ title: 'AI 会话', value: aiSessions.length, delta: '项目内上下文', icon: <IconRobot />, color: 'purple' },
{ title: '知识资料', value: notes.length, delta: '笔记与资料合计', icon: <IconFile />, color: 'cyan' },
]
return (
<div className="workspace-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary">AI </Text>
</div>
</div>
<Row gutter={12} className="metric-row">
{workspaceMetrics.map((metric) => (
<Col span={24 / workspaceMetrics.length} key={metric.title}>
<Card className="metric-card" bordered>
<Space align="start">
<Tag color={metric.color}>{metric.icon}</Tag>
<div>
<Text type="secondary">{metric.title}</Text>
<Title heading={3}>{metric.value}</Title>
<Text className="metric-delta">{metric.delta}</Text>
</div>
</Space>
</Card>
</Col>
))}
</Row>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Tag color="arcoblue">{unfinishedTasks.length}</Tag>
</div>
{unfinishedTasks.length ? (
<div className="workspace-task-list">
{unfinishedTasks.map((task) => (
<button key={`${task.project.id}-${task.id}`} className="workspace-task-card" onClick={() => onOpenTask(task.project, task.id)}>
<div className="workspace-task-main">
<span className="workspace-task-title">
<IconCheckCircleFill />
{task.title}
</span>
<Paragraph ellipsis={{ rows: 2 }} type="secondary">
{task.summary || '暂无任务说明'}
</Paragraph>
</div>
<div className="workspace-task-meta">
<Tag color={task.project.urgent ? 'red' : 'arcoblue'}>{task.project.name}</Tag>
<span><IconCalendar /> {task.createdAt}</span>
<Tag color="green">{task.tag}</Tag>
</div>
</button>
))}
</div>
) : (
<Empty description="暂无未完成任务" />
)}
</Card>
</div>
)
}

View File

@@ -0,0 +1,105 @@
import { Layout } from '@arco-design/web-react'
import { WorkspacePage } from './workspace-body'
import { WorkspaceInboxPage } from './workspace-inbox'
import { ProjectChannelPage } from './projects/project-channel-page'
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, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
const { Content } = Layout
export function ProjectPage({
activeView,
activeWorkspace,
workspaces,
activeChannel,
activeTaskID,
theme,
onSelectProject,
onSelectWorkspace,
onSelectWorkspaceInbox,
onSelectChannel,
onSelectItem,
onOpenTask,
onCloseTask,
onToggleTheme,
onCreateProject,
onCreateTask,
onUploadSource,
onCreateCronPlan,
}: {
activeView: WorkbenchView
activeWorkspace: ProjectWorkspace
workspaces: ProjectWorkspace[]
activeChannel: ChannelKey
activeTaskID: string | null
theme: Theme
onSelectProject: (project: Project) => void
onSelectWorkspace: () => void
onSelectWorkspaceInbox: () => void
onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void
onOpenTask: (project: Project, taskID: string) => void
onCloseTask: () => void
onToggleTheme: () => void
onCreateProject: () => void
onCreateTask: () => void
onUploadSource: () => void
onCreateCronPlan: () => void
}) {
const isProject = activeView === 'project'
const projects = workspaces.map((workspace) => workspace.project)
return (
<Layout className="workbench-shell">
<ProjectTopbar theme={theme} onToggleTheme={onToggleTheme} />
<Layout className="workbench-main" hasSider>
<ProjectRail
activeProject={activeWorkspace.project}
projects={projects}
activeView={activeView}
onSelectWorkspace={onSelectWorkspace}
onSelectWorkspaceInbox={onSelectWorkspaceInbox}
onSelectProject={onSelectProject}
onCreateProject={onCreateProject}
/>
{isProject && (
<ProjectSidebar
activeView={activeView}
activeWorkspace={activeWorkspace}
activeChannel={activeChannel}
onSelectChannel={onSelectChannel}
onSelectItem={onSelectItem}
/>
)}
<Content className="stage">
{activeView === 'workspace' ? (
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} />
) : activeView === 'workspace-inbox' ? (
<WorkspaceInboxPage workspaces={workspaces} onSelectItem={onSelectItem} />
) : (
<ProjectChannelPage
activeChannel={activeChannel}
activeWorkspace={activeWorkspace}
activeTaskID={activeTaskID}
onSelectItem={onSelectItem}
onOpenTask={(taskID) => onOpenTask(activeWorkspace.project, taskID)}
onCloseTask={onCloseTask}
onCreateTask={onCreateTask}
onUploadSource={onUploadSource}
onCreateCronPlan={onCreateCronPlan}
/>
)}
</Content>
</Layout>
<ProjectStatusbar />
</Layout>
)
}

View File

@@ -0,0 +1,95 @@
import { Button, Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { IconArchive, IconEmail, IconRefresh, IconSend, IconStar } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './projects/project-types'
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
export function WorkspaceInboxPage({ workspaces, onSelectItem }: { workspaces: ProjectWorkspace[]; onSelectItem: (title: string) => void }) {
const messages = workspaces.flatMap((workspace) => workspace.inbox.map((item) => ({ ...item, project: workspace.project })))
const selected = messages[0]
const openCount = messages.filter((item) => item.status === 'open').length
const handledCount = messages.length - openCount
return (
<div className="workspace-inbox-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>Inbox</Title>
<Text type="secondary">线</Text>
</div>
<Space>
<Button icon={<IconRefresh />}></Button>
<Button type="primary" icon={<IconArchive />}></Button>
</Space>
</div>
<Row gutter={12}>
<Col span={8}>
<Card className="compact-card" bordered>
<Space><Tag color="red">{openCount}</Tag><Text></Text></Space>
</Card>
</Col>
<Col span={8}>
<Card className="compact-card" bordered>
<Space><Tag color="arcoblue">{messages.length}</Tag><Text></Text></Space>
</Card>
</Col>
<Col span={8}>
<Card className="compact-card" bordered>
<Space><Tag color="green">{handledCount}</Tag><Text></Text></Space>
</Card>
</Col>
</Row>
{messages.length ? (
<div className="mail-layout">
<Card className="mail-list queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button type="text" size="mini"></Button>
</div>
{messages.map((item, index) => (
<button
className={index === 0 ? 'mail-item active' : 'mail-item'}
key={`${item.project.id}-${item.id}`}
onClick={() => onSelectItem(item.title)}
>
<span><IconEmail /> {item.title}</span>
<Text type="secondary">{item.meta}</Text>
<div>
<Tag color={item.project.urgent ? 'red' : 'arcoblue'}>{item.project.name}</Tag>
<time>{item.time}</time>
</div>
</button>
))}
</Card>
<Card className="mail-detail queue-section" bordered>
<div className="section-header">
<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.project.name}</Text>
<Paragraph>{selected.summary || '暂无消息正文'}</Paragraph>
<div className="reply-box">
<Text type="secondary"></Text>
<Space>
<Button></Button>
<Button></Button>
<Button type="primary"></Button>
</Space>
</div>
</Card>
</div>
) : (
<Card className="queue-section" bordered>
<Empty description="暂无外部消息" />
</Card>
)}
</div>
)
}

View File

@@ -1,85 +0,0 @@
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import {
IconCheckCircleFill,
IconDashboard,
IconEmail,
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'
const { Row, Col } = Grid
const { Title, Text } = Typography
export function WorkspacePage({ onSelectProject }: { onSelectProject: (project: Project) => void }) {
const totalProjectBadges = projects.reduce((sum, project) => sum + project.badge, 0)
const urgentProjects = projects.filter((project) => project.urgent).length
const workspaceMetrics = [
{ title: '项目总数', value: projects.length, delta: `${urgentProjects} 个高优先级`, icon: <IconDashboard />, color: 'arcoblue' },
{ title: '全局待处理', value: totalProjectBadges, delta: '跨项目聚合', icon: <IconEmail />, color: 'red' },
{ title: '进行中任务', value: tasks.length, delta: '本周持续推进', icon: <IconCheckCircleFill />, color: 'green' },
{ title: 'AI 会话', value: aiSessions.length, delta: '队列空闲', icon: <IconRobot />, color: 'purple' },
{ title: '知识资料', value: notes.length, delta: '最近 7 天更新', icon: <IconFile />, color: 'cyan' },
]
return (
<div className="workspace-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary"></Text>
</div>
</div>
<Row gutter={12} className="metric-row">
{workspaceMetrics.map((metric) => (
<Col span={24 / workspaceMetrics.length} key={metric.title}>
<Card className="metric-card" bordered>
<Space align="start">
<Tag color={metric.color}>{metric.icon}</Tag>
<div>
<Text type="secondary">{metric.title}</Text>
<Title heading={3}>{metric.value}</Title>
<Text className="metric-delta">{metric.delta}</Text>
</div>
</Space>
</Card>
</Col>
))}
</Row>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button type="text" size="mini"></Button>
</div>
{projects.map((project) => (
<button key={project.id} className="data-row" onClick={() => onSelectProject(project)}>
<span className="row-title"><IconDashboard /> {project.name}</span>
<Tag color={project.urgent ? 'red' : 'green'}>{project.urgent ? '高优先级' : '正常'}</Tag>
<span> {project.badge}</span>
<span> {project.short}</span>
<time></time>
</button>
))}
</Card>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button type="text" size="mini"> Inbox</Button>
</div>
{inbox.slice(0, 4).map((item) => (
<div key={item.title} className="data-row">
<span className="row-title"><IconEmail /> {item.title}</span>
<span>{item.meta}</span>
<Tag>{item.tag}</Tag>
<span>{item.owner}</span>
<time>{item.time}</time>
</div>
))}
</Card>
</div>
)
}

View File

@@ -6,6 +6,7 @@ import (
"senlinai-agent/backend/internal/config"
"senlinai-agent/backend/internal/httpx"
"senlinai-agent/backend/internal/logic/auth"
"senlinai-agent/backend/internal/logic/files"
"senlinai-agent/backend/internal/logic/inbox"
"senlinai-agent/backend/internal/logic/projects"
"senlinai-agent/backend/internal/models"
@@ -20,9 +21,10 @@ func main() {
}
authService := auth.NewService(cfg.AuthSecret)
projectHandler := projects.NewHandler(projects.NewService())
projectHandler := projects.NewHandler(projects.NewService(), files.NewService(cfg.StorageDir))
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
View 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
}

View File

@@ -2,6 +2,7 @@ package httpx
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"senlinai-agent/backend/internal/config"
@@ -33,6 +34,9 @@ func newRouter(cfg config.Config, tokenVerifier func(string) (uint, error), regi
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
api := router.Group("/api")
api.GET("/status", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"timestamp_ms": time.Now().UnixMilli()})
})
if tokenVerifier != nil {
api.Use(auth.RequireUser(tokenVerifier))
}
@@ -47,6 +51,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,
}

View File

@@ -1,9 +1,11 @@
package httpx
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
@@ -32,6 +34,19 @@ func TestNewRouterRegistersFeatureRoutesUnderAPI(t *testing.T) {
require.JSONEq(t, `{"pong":true}`, rec.Body.String())
}
func TestAPIStatusReturnsChangingTimestampMillisecondsWithoutAuth(t *testing.T) {
router := NewProtectedRouter(config.Config{Env: "test"}, func(token string) (uint, error) {
return 0, http.ErrNoCookie
})
first := getStatusTimestamp(t, router)
time.Sleep(2 * time.Millisecond)
second := getStatusTimestamp(t, router)
require.Greater(t, first, int64(0))
require.Greater(t, second, first)
}
func TestRouterAddsCORSHeadersForLocalWebClient(t *testing.T) {
router := NewRouter(config.Config{Env: "test"}, testRegistrar{})
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
@@ -69,3 +84,18 @@ func (testRegistrar) Register(router gin.IRouter) {
c.JSON(http.StatusOK, gin.H{"pong": true})
})
}
func getStatusTimestamp(t *testing.T, router http.Handler) int64 {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var payload struct {
TimestampMS int64 `json:"timestamp_ms"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
return payload.TimestampMS
}

View 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})
}

View 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
}

View File

@@ -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 {

View File

@@ -3,17 +3,25 @@ package projects
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"senlinai-agent/backend/internal/logic/auth"
"senlinai-agent/backend/internal/logic/files"
)
type Handler struct {
service *Service
service *Service
fileService *files.Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
func NewHandler(service *Service, fileServices ...*files.Service) *Handler {
var fileService *files.Service
if len(fileServices) > 0 {
fileService = fileServices[0]
}
return &Handler{service: service, fileService: fileService}
}
func (h *Handler) Register(router gin.IRouter) {
@@ -21,6 +29,9 @@ func (h *Handler) Register(router gin.IRouter) {
router.GET("/projects", h.listProjects)
router.GET("/projects/:id/dashboard", h.dashboard)
router.GET("/projects/:id/workspace", h.workspace)
router.POST("/projects/:id/tasks", h.createTask)
router.POST("/projects/:id/sources", h.uploadSource)
router.POST("/projects/:id/cron-plans", h.createCronPlan)
}
func (h *Handler) createProject(c *gin.Context) {
@@ -96,3 +107,149 @@ func (h *Handler) workspace(c *gin.Context) {
}
c.JSON(http.StatusOK, workspace)
}
func (h *Handler) createTask(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
projectID, ok := parseProjectID(c)
if !ok {
return
}
var input struct {
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
DueAt string `json:"dueAt"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
dueAt, err := parseOptionalTime(input.DueAt)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid dueAt"})
return
}
task, err := h.service.CreateTask(userID, projectID, CreateTaskInput{
Title: input.Title,
Description: input.Description,
Status: input.Status,
DueAt: dueAt,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, task)
}
func (h *Handler) uploadSource(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
if h.fileService == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "file service is not configured"})
return
}
projectID, ok := parseProjectID(c)
if !ok {
return
}
if err := ensureProjectOwner(userID, projectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
file, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read file"})
return
}
defer file.Close()
stored, err := h.fileService.Save(projectID, fileHeader.Filename, file)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
return
}
title := c.PostForm("title")
if strings.TrimSpace(title) == "" {
title = stored.OriginalName
}
source, err := h.service.CreateFileSource(userID, projectID, CreateFileSourceInput{
Title: title,
FilePath: stored.RelativePath,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, source)
}
func (h *Handler) createCronPlan(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
projectID, ok := parseProjectID(c)
if !ok {
return
}
var input struct {
Title string `json:"title"`
Schedule string `json:"schedule"`
Enabled bool `json:"enabled"`
NextRunAt string `json:"nextRunAt"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
nextRunAt, err := parseOptionalTime(input.NextRunAt)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid nextRunAt"})
return
}
plan, err := h.service.CreateCronPlan(userID, projectID, CreateCronPlanInput{
Title: input.Title,
Schedule: input.Schedule,
Enabled: input.Enabled,
NextRunAt: nextRunAt,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, plan)
}
func parseProjectID(c *gin.Context) (uint, bool) {
projectID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return 0, false
}
return uint(projectID), true
}
func parseOptionalTime(value string) (*time.Time, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, trimmed)
if err != nil {
return nil, err
}
return &parsed, nil
}

View File

@@ -0,0 +1,96 @@
package projects
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"senlinai-agent/backend/internal/config"
"senlinai-agent/backend/internal/httpx"
"senlinai-agent/backend/internal/logic/files"
"senlinai-agent/backend/internal/models"
)
func TestCreateTaskHandlerPersistsTask(t *testing.T) {
router, project, _ := newProjectsHandlerTestRouter(t)
body, err := json.Marshal(gin.H{"title": "整理需求", "description": "形成任务清单", "dueAt": "2026-07-21T09:30:00Z"})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tasks", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
var task models.SenlinAgentTask
require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "整理需求").First(&task).Error)
require.Equal(t, "open", task.Status)
require.NotNil(t, task.DueAt)
}
func TestUploadSourceHandlerStoresFileAndSource(t *testing.T) {
router, project, storageDir := newProjectsHandlerTestRouter(t)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
require.NoError(t, writer.WriteField("title", "客户资料.txt"))
fileWriter, err := writer.CreateFormFile("file", "客户资料.txt")
require.NoError(t, err)
_, err = fileWriter.Write([]byte("hello"))
require.NoError(t, err)
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/sources", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
var source models.SenlinAgentSource
require.NoError(t, models.DBService.Where("project_id = ? AND kind = ?", project.ID, "file").First(&source).Error)
require.Equal(t, "客户资料.txt", source.Title)
require.NotEmpty(t, source.FilePath)
require.FileExists(t, filepath.Join(storageDir, source.FilePath))
}
func TestCreateCronPlanHandlerPersistsPlan(t *testing.T) {
router, project, _ := newProjectsHandlerTestRouter(t)
body, err := json.Marshal(gin.H{"title": "每日整理", "schedule": "0 9 * * *", "enabled": true, "nextRunAt": "2026-07-22T08:00:00Z"})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/cron-plans", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
var plan models.SenlinAgentCronPlan
require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "每日整理").First(&plan).Error)
require.Equal(t, "0 9 * * *", plan.Schedule)
require.True(t, plan.Enabled)
require.NotNil(t, plan.NextRunAt)
}
func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgentProject, string) {
t.Helper()
newTestDB(t)
require.NoError(t, models.DBService.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
service := NewService()
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
storageDir := t.TempDir()
router := httpx.NewProtectedRouter(
config.Config{Env: "test"},
func(token string) (uint, error) { return 1, nil },
NewHandler(service, files.NewService(storageDir)),
)
return router, project, storageDir
}

View File

@@ -56,6 +56,7 @@ type WorkTask struct {
Completed bool `json:"completed"`
Owner string `json:"owner"`
Due string `json:"due"`
CreatedAt string `json:"createdAt"`
Tag string `json:"tag"`
}
@@ -98,6 +99,25 @@ type ProjectWorkspace struct {
CronPlans []CronPlan `json:"cronPlans"`
}
type CreateTaskInput struct {
Title string
Description string
Status string
DueAt *time.Time
}
type CreateFileSourceInput struct {
Title string
FilePath string
}
type CreateCronPlanInput struct {
Title string
Schedule string
Enabled bool
NextRunAt *time.Time
}
func NewService() *Service {
return &Service{}
}
@@ -117,6 +137,75 @@ func (s *Service) ListProjects(ownerID uint) ([]models.SenlinAgentProject, error
return projects, err
}
func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput) (*models.SenlinAgentTask, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("task title is required")
}
status := strings.TrimSpace(input.Status)
if status == "" {
status = "open"
}
task := &models.SenlinAgentTask{
ProjectID: projectID,
CreatedBy: ownerID,
Title: title,
Description: strings.TrimSpace(input.Description),
Status: status,
DueAt: input.DueAt,
}
return task, models.DBService.Create(task).Error
}
func (s *Service) CreateFileSource(ownerID uint, projectID uint, input CreateFileSourceInput) (*models.SenlinAgentSource, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("source title is required")
}
filePath := strings.TrimSpace(input.FilePath)
if filePath == "" {
return nil, errors.New("source file path is required")
}
source := &models.SenlinAgentSource{
ProjectID: projectID,
CreatedBy: ownerID,
Kind: "file",
Title: title,
FilePath: filePath,
}
return source, models.DBService.Create(source).Error
}
func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronPlanInput) (*models.SenlinAgentCronPlan, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("cron plan title is required")
}
schedule := strings.TrimSpace(input.Schedule)
if schedule == "" {
return nil, errors.New("cron schedule is required")
}
plan := &models.SenlinAgentCronPlan{
ProjectID: projectID,
CreatedBy: ownerID,
Title: title,
Schedule: schedule,
Enabled: input.Enabled,
NextRunAt: input.NextRunAt,
LastResult: "Not run yet",
}
return plan, models.DBService.Create(plan).Error
}
func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) {
name = strings.TrimSpace(name)
if name == "" {
@@ -133,8 +222,7 @@ func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) {
}
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
var project models.SenlinAgentProject
if err := models.DBService.Where("id = ? AND owner_id = ?", projectID, ownerID).First(&project).Error; err != nil {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return Dashboard{}, err
}
dashboard := Dashboard{ProjectID: projectID}
@@ -211,6 +299,17 @@ func (s *Service) Workspace(ownerID uint, projectID uint) (ProjectWorkspace, err
}, nil
}
func ensureProjectOwner(ownerID uint, projectID uint) error {
var count int64
if err := models.DBService.Model(&models.SenlinAgentProject{}).Where("id = ? AND owner_id = ?", projectID, ownerID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return errors.New("project not found")
}
return nil
}
type workspaceCounts struct {
inbox int64
tasks int64
@@ -323,6 +422,7 @@ func (s *Service) workspaceTasks(projectID uint) ([]WorkTask, error) {
Completed: task.Status == "done",
Owner: owner,
Due: displayOptionalTime(task.DueAt),
CreatedAt: displayTime(task.CreatedAt),
Tag: "",
})
}

View File

@@ -105,6 +105,7 @@ func TestWorkspaceMatchesFrontendContract(t *testing.T) {
require.Len(t, workspace.Tasks, 2)
require.False(t, workspace.Tasks[0].Completed)
require.Equal(t, "David", workspace.Tasks[0].Owner)
require.NotEmpty(t, workspace.Tasks[0].CreatedAt)
require.Len(t, workspace.AISessions, 1)
require.Len(t, workspace.RecentSessions, 1)
require.Len(t, workspace.NotesSources, 2)
@@ -126,6 +127,94 @@ func TestWorkspaceRejectsProjectOwnedByAnotherUser(t *testing.T) {
require.Error(t, err)
}
func TestCreateTaskAddsTaskToOwnedProject(t *testing.T) {
database := newTestDB(t)
service := NewService()
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
dueAt := time.Date(2026, 7, 21, 9, 30, 0, 0, time.UTC)
task, err := service.CreateTask(1, project.ID, CreateTaskInput{
Title: "Follow up with client",
Description: "整理客户反馈并形成行动项",
Status: "",
DueAt: &dueAt,
})
require.NoError(t, err)
require.Equal(t, project.ID, task.ProjectID)
require.Equal(t, uint(1), task.CreatedBy)
require.Equal(t, "Follow up with client", task.Title)
require.Equal(t, "open", task.Status)
require.NotNil(t, task.DueAt)
workspace, err := service.Workspace(1, project.ID)
require.NoError(t, err)
require.Len(t, workspace.Tasks, 1)
require.Equal(t, "Follow up with client", workspace.Tasks[0].Title)
}
func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
newTestDB(t)
service := NewService()
project, err := service.CreateProject(2, "Beta", "")
require.NoError(t, err)
_, err = service.CreateTask(1, project.ID, CreateTaskInput{Title: "Nope"})
require.Error(t, err)
}
func TestCreateFileSourceAddsSourceToOwnedProject(t *testing.T) {
database := newTestDB(t)
service := NewService()
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
source, err := service.CreateFileSource(1, project.ID, CreateFileSourceInput{
Title: "客户访谈.pdf",
FilePath: "projects/1/客户访谈.pdf",
})
require.NoError(t, err)
require.Equal(t, project.ID, source.ProjectID)
require.Equal(t, uint(1), source.CreatedBy)
require.Equal(t, "file", source.Kind)
require.Equal(t, "客户访谈.pdf", source.Title)
require.Equal(t, "projects/1/客户访谈.pdf", source.FilePath)
var count int64
require.NoError(t, database.Model(&models.SenlinAgentSource{}).Where("project_id = ? AND kind = ?", project.ID, "file").Count(&count).Error)
require.Equal(t, int64(1), count)
}
func TestCreateCronPlanAddsPlanToOwnedProject(t *testing.T) {
database := newTestDB(t)
service := NewService()
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
nextRun := time.Date(2026, 7, 22, 8, 0, 0, 0, time.UTC)
plan, err := service.CreateCronPlan(1, project.ID, CreateCronPlanInput{
Title: "Daily inbox sweep",
Schedule: "0 9 * * *",
Enabled: true,
NextRunAt: &nextRun,
})
require.NoError(t, err)
require.Equal(t, project.ID, plan.ProjectID)
require.Equal(t, uint(1), plan.CreatedBy)
require.Equal(t, "Daily inbox sweep", plan.Title)
require.Equal(t, "0 9 * * *", plan.Schedule)
require.True(t, plan.Enabled)
require.NotNil(t, plan.NextRunAt)
workspace, err := service.Workspace(1, project.ID)
require.NoError(t, err)
require.Len(t, workspace.CronPlans, 1)
require.Equal(t, "Daily inbox sweep", workspace.CronPlans[0].Title)
}
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{})

View 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
}

View 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
}