Compare commits
9 Commits
443a9feaac
...
c1a144cb44
| Author | SHA1 | Date | |
|---|---|---|---|
| c1a144cb44 | |||
| df12201caa | |||
| c64daa8657 | |||
| 875b326ddc | |||
| 215268e836 | |||
| a299da63e7 | |||
| a65012c1b3 | |||
| d15ca2eb5f | |||
| fd8d188f29 |
@@ -3,16 +3,23 @@ import { existsSync, readFileSync } from 'node:fs'
|
|||||||
const requiredFiles = [
|
const requiredFiles = [
|
||||||
'src/app/App.tsx',
|
'src/app/App.tsx',
|
||||||
'src/pages/login.tsx',
|
'src/pages/login.tsx',
|
||||||
'src/pages/workspace.tsx',
|
'src/pages/workspace-body.tsx',
|
||||||
'src/pages/project.tsx',
|
'src/pages/workspace-home.tsx',
|
||||||
|
'src/pages/workspace-inbox.tsx',
|
||||||
'src/pages/projects/project-overview.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-rail.tsx',
|
||||||
'src/pages/projects/project-sidebar.tsx',
|
'src/pages/projects/project-sidebar.tsx',
|
||||||
'src/pages/projects/project-topbar.tsx',
|
'src/pages/projects/project-topbar.tsx',
|
||||||
'src/pages/projects/project-inspector.tsx',
|
|
||||||
'src/pages/projects/project-statusbar.tsx',
|
'src/pages/projects/project-statusbar.tsx',
|
||||||
'src/pages/projects/project-types.ts',
|
'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}`)
|
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')
|
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')) {
|
if (existsSync('src/app/App.tsx')) {
|
||||||
const appSource = readFileSync('src/app/App.tsx', 'utf8')
|
const appSource = readFileSync('src/app/App.tsx', 'utf8')
|
||||||
for (const forbidden of ['function LoginPage', 'function Workbench', 'function OverviewContent']) {
|
for (const forbidden of ['function LoginPage', 'function Workbench', 'function OverviewContent']) {
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ const collectMetrics = async () => page.evaluate(() => {
|
|||||||
const rail = document.querySelector('.project-rail')
|
const rail = document.querySelector('.project-rail')
|
||||||
const railChildren = rail?.querySelector('.arco-layout-sider-children')
|
const railChildren = rail?.querySelector('.arco-layout-sider-children')
|
||||||
const sidebar = document.querySelector('.channel-sidebar')
|
const sidebar = document.querySelector('.channel-sidebar')
|
||||||
|
const channelList = document.querySelector('.channel-list')
|
||||||
const stage = document.querySelector('.stage')
|
const stage = document.querySelector('.stage')
|
||||||
const inspector = document.querySelector('.inspector')
|
const inspector = document.querySelector('.inspector')
|
||||||
const workspaceActions = [...document.querySelectorAll('.workspace-page .overview-head .arco-btn')]
|
const workspaceActions = [...document.querySelectorAll('.workspace-page .overview-head .arco-btn')]
|
||||||
.map((button) => button.textContent?.trim())
|
.map((button) => button.textContent?.trim())
|
||||||
const dashboard = document.querySelector('.dashboard-button')
|
const dashboard = document.querySelector('.dashboard-button')
|
||||||
|
const workspaceInbox = document.querySelector('.dashboard-button[title="Inbox"]')
|
||||||
const project = document.querySelector('.project-button')
|
const project = document.querySelector('.project-button')
|
||||||
const create = document.querySelector('.create-project')
|
const create = document.querySelector('.create-project')
|
||||||
const firstBadge = document.querySelector('.project-badge .arco-badge-number')
|
const firstBadge = document.querySelector('.project-badge .arco-badge-number')
|
||||||
@@ -62,6 +64,7 @@ const collectMetrics = async () => page.evaluate(() => {
|
|||||||
scrollHeight: node.scrollHeight,
|
scrollHeight: node.scrollHeight,
|
||||||
overflowX: getComputedStyle(node).overflowX,
|
overflowX: getComputedStyle(node).overflowX,
|
||||||
overflowY: getComputedStyle(node).overflowY,
|
overflowY: getComputedStyle(node).overflowY,
|
||||||
|
scrollbarWidth: getComputedStyle(node).scrollbarWidth,
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
|
|
||||||
@@ -81,6 +84,8 @@ const collectMetrics = async () => page.evaluate(() => {
|
|||||||
workspaceActions,
|
workspaceActions,
|
||||||
dashboardButton: rect(dashboard),
|
dashboardButton: rect(dashboard),
|
||||||
dashboardButtonActive: dashboard?.classList.contains('active') ?? false,
|
dashboardButtonActive: dashboard?.classList.contains('active') ?? false,
|
||||||
|
workspaceInboxButton: rect(workspaceInbox),
|
||||||
|
workspaceInboxButtonActive: workspaceInbox?.classList.contains('active') ?? false,
|
||||||
projectButton: rect(project),
|
projectButton: rect(project),
|
||||||
projectButtonActive: project?.classList.contains('active') ?? false,
|
projectButtonActive: project?.classList.contains('active') ?? false,
|
||||||
createProjectButton: rect(create),
|
createProjectButton: rect(create),
|
||||||
@@ -89,6 +94,10 @@ const collectMetrics = async () => page.evaluate(() => {
|
|||||||
verticalGaps,
|
verticalGaps,
|
||||||
projectRailOverflow: overflowState(rail),
|
projectRailOverflow: overflowState(rail),
|
||||||
projectRailChildrenOverflow: overflowState(railChildren),
|
projectRailChildrenOverflow: overflowState(railChildren),
|
||||||
|
channelSidebarOverflow: overflowState(sidebar),
|
||||||
|
channelListOverflow: overflowState(channelList),
|
||||||
|
stageOverflow: overflowState(stage),
|
||||||
|
inspectorOverflow: overflowState(inspector),
|
||||||
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||||
viewportWidth: window.innerWidth,
|
viewportWidth: window.innerWidth,
|
||||||
}
|
}
|
||||||
@@ -102,18 +111,61 @@ await page.waitForTimeout(700)
|
|||||||
await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true })
|
await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true })
|
||||||
const workspaceMetrics = await collectMetrics()
|
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.locator('.project-button').first().click()
|
||||||
await page.waitForTimeout(500)
|
await page.waitForTimeout(500)
|
||||||
await page.screenshot({ path: 'test-results/project-react-acro-light.png', fullPage: true })
|
await page.screenshot({ path: 'test-results/project-react-acro-light.png', fullPage: true })
|
||||||
const projectMetrics = await collectMetrics()
|
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.locator('.topbar-actions .arco-btn').first().click()
|
||||||
await page.screenshot({ path: 'test-results/project-react-acro-dark.png', fullPage: true })
|
await page.screenshot({ path: 'test-results/project-react-acro-dark.png', fullPage: true })
|
||||||
|
|
||||||
await browser.close()
|
await browser.close()
|
||||||
await server.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 failures = []
|
||||||
const metrics = workspaceMetrics
|
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.workbenchMain) failures.push('missing workbench main')
|
||||||
if (!metrics.hasWorkspacePage) failures.push('expected login to land on workspace page')
|
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.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.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.channelSidebar) failures.push(`expected workspace to hide channel sidebar, got ${JSON.stringify(metrics.channelSidebar)}`)
|
||||||
if (metrics.inspector) failures.push(`expected workspace to hide inspector, got ${JSON.stringify(metrics.inspector)}`)
|
if (metrics.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)}`)
|
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 (
|
if (
|
||||||
!metrics.createProjectButton ||
|
!metrics.createProjectButton ||
|
||||||
metrics.createProjectButton.width !== metrics.projectButton?.width ||
|
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)}`)
|
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.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.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.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.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(
|
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 {
|
} 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)
|
.every((region) => Math.abs(region.top - projectMetrics.projectRail.top) <= 1)
|
||||||
if (!sameTop) {
|
if (!sameTop) {
|
||||||
failures.push(
|
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) {
|
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) {
|
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}`)
|
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) {
|
if (Math.abs(projectMetrics.stage.right - projectMetrics.viewportWidth) > 1) {
|
||||||
failures.push(`expected inspector to sit after stage, got inspector.left=${projectMetrics.inspector.left}, stage.right=${projectMetrics.stage.right}`)
|
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)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -271,6 +271,35 @@
|
|||||||
justify-content: flex-end;
|
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 {
|
.workbench-main {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -350,7 +379,10 @@
|
|||||||
|
|
||||||
.channel-sidebar {
|
.channel-sidebar {
|
||||||
min-height: 0;
|
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;
|
padding: 16px;
|
||||||
border-right: 1px solid var(--senlin-border);
|
border-right: 1px solid var(--senlin-border);
|
||||||
background: var(--senlin-panel);
|
background: var(--senlin-panel);
|
||||||
@@ -370,6 +402,10 @@
|
|||||||
.channel-list {
|
.channel-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
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 {
|
.channel-button {
|
||||||
@@ -434,6 +470,10 @@
|
|||||||
.stage {
|
.stage {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: auto;
|
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;
|
padding: 16px;
|
||||||
background: var(--senlin-bg);
|
background: var(--senlin-bg);
|
||||||
}
|
}
|
||||||
@@ -539,6 +579,65 @@
|
|||||||
grid-template-columns: minmax(260px, 1fr) 82px 100px 100px 130px;
|
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 {
|
.ai-row {
|
||||||
grid-template-columns: minmax(260px, 1fr) minmax(260px, 1.2fr) 80px 90px;
|
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;
|
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 {
|
.inspector {
|
||||||
min-height: 0;
|
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;
|
padding: 0 16px 16px;
|
||||||
border-left: 1px solid var(--senlin-border);
|
border-left: 1px solid var(--senlin-border);
|
||||||
background: var(--senlin-panel);
|
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 {
|
.inspector .arco-tabs-header {
|
||||||
margin-bottom: 12px;
|
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 {
|
.discussion-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
@@ -610,6 +971,13 @@
|
|||||||
width: 100%;
|
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 {
|
.property-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
59
apps/senlinai-acro-react/src/api/client.ts
Normal file
59
apps/senlinai-acro-react/src/api/client.ts
Normal 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
|
||||||
|
}
|
||||||
197
apps/senlinai-acro-react/src/api/mappers.tsx
Normal file
197
apps/senlinai-acro-react/src/api/mappers.tsx
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
156
apps/senlinai-acro-react/src/api/projects.ts
Normal file
156
apps/senlinai-acro-react/src/api/projects.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,44 +1,227 @@
|
|||||||
import { useState } from 'react'
|
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 '@arco-design/web-react/dist/css/arco.css'
|
||||||
import '../App.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 { LoginPage } from '../pages/login'
|
||||||
import { ProjectPage } from '../pages/project'
|
import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals'
|
||||||
import { projects } from '../pages/projects/project-data'
|
import { ProjectPage } from '../pages/workspace-home'
|
||||||
import type { ChannelKey, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
|
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [screen, setScreen] = useState<Screen>('login')
|
const [screen, setScreen] = useState<Screen>('login')
|
||||||
const [theme, setTheme] = useState<Theme>('light')
|
const [theme, setTheme] = useState<Theme>('light')
|
||||||
const [activeView, setActiveView] = useState<WorkbenchView>('workspace')
|
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 [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 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 (
|
return (
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
<main className={dark ? 'app theme-dark' : 'app'}>
|
<main className={dark ? 'app theme-dark' : 'app'}>
|
||||||
{screen === 'login' ? (
|
{screen === 'login' ? (
|
||||||
<LoginPage onLogin={() => setScreen('workbench')} />
|
<Spin loading={loading} style={{ width: '100%' }}>
|
||||||
) : (
|
<LoginPage onLogin={handleLogin} />
|
||||||
|
</Spin>
|
||||||
|
) : activeWorkspace && session ? (
|
||||||
<ProjectPage
|
<ProjectPage
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
activeProject={activeProject}
|
activeWorkspace={activeWorkspace}
|
||||||
|
workspaces={workspaces}
|
||||||
activeChannel={activeChannel}
|
activeChannel={activeChannel}
|
||||||
selectedItem={selectedItem}
|
activeTaskID={activeTaskID}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onSelectWorkspace={() => setActiveView('workspace')}
|
onSelectWorkspace={() => setActiveView('workspace')}
|
||||||
|
onSelectWorkspaceInbox={() => setActiveView('workspace-inbox')}
|
||||||
onSelectProject={(project) => {
|
onSelectProject={(project) => {
|
||||||
setActiveProject(project)
|
setActiveProjectID(project.id)
|
||||||
setActiveView('project')
|
setActiveView('project')
|
||||||
|
setActiveChannel('overview')
|
||||||
|
setActiveTaskID(null)
|
||||||
|
}}
|
||||||
|
onSelectChannel={(channel) => {
|
||||||
|
setActiveChannel(channel)
|
||||||
|
setActiveTaskID(null)
|
||||||
}}
|
}}
|
||||||
onSelectChannel={setActiveChannel}
|
|
||||||
onSelectItem={setSelectedItem}
|
onSelectItem={setSelectedItem}
|
||||||
|
onOpenTask={openTask}
|
||||||
|
onCloseTask={() => setActiveTaskID(null)}
|
||||||
onToggleTheme={() => setTheme(dark ? 'light' : 'dark')}
|
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>
|
</main>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
import type { ReactElement } from 'react'
|
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 { 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
|
const { Title, Text, Paragraph } = Typography
|
||||||
|
|
||||||
export function LoginPage({ onLogin }: { onLogin: () => void }) {
|
type ConnectionStatus = 'checking' | 'online' | 'offline'
|
||||||
const [server, setServer] = useState('https://10.0.0.15:8080')
|
|
||||||
|
export function LoginPage({ onLogin }: { onLogin: (input: { server: string; email: string; password: string }) => void }) {
|
||||||
|
const [server, setServer] = useState('http://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 (
|
return (
|
||||||
<section className="login-screen">
|
<section className="login-screen">
|
||||||
@@ -14,15 +39,14 @@ export function LoginPage({ onLogin }: { onLogin: () => void }) {
|
|||||||
<div className="login-brand-panel">
|
<div className="login-brand-panel">
|
||||||
<div className="brand-lockup large">
|
<div className="brand-lockup large">
|
||||||
<span className="brand-symbol">S</span>
|
<span className="brand-symbol">S</span>
|
||||||
<Title heading={2}>SenlinAI</Title>
|
<Title heading={2}>森林Agent</Title>
|
||||||
</div>
|
</div>
|
||||||
<Text className="login-slogan">私有部署 · 安全可控的智能协作平台</Text>
|
<Text className="login-slogan">私有部署 · 安全可控的智能协作平台</Text>
|
||||||
<Text type="secondary">知识沉淀 · 团队协作 · AI 助理</Text>
|
<Text type="secondary">知识沉淀 · 团队协作 · AI Agent</Text>
|
||||||
<Space className="login-footer-links" size={24}>
|
<Space className="login-footer-links" size={24}>
|
||||||
<Text type="secondary">v1.0.0</Text>
|
<Text type="secondary">v1.0.0</Text>
|
||||||
<Text type="secondary">隐私政策</Text>
|
<Text type="secondary">隐私政策</Text>
|
||||||
<Text type="secondary">服务协议</Text>
|
<Text type="secondary">服务协议</Text>
|
||||||
<Text type="secondary">关于 SenlinAI</Text>
|
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -32,12 +56,14 @@ export function LoginPage({ onLogin }: { onLogin: () => void }) {
|
|||||||
<Form layout="vertical" className="login-form">
|
<Form layout="vertical" className="login-form">
|
||||||
<Form.Item label="服务器地址(IP 或域名优先)">
|
<Form.Item label="服务器地址(IP 或域名优先)">
|
||||||
<Input value={server} onChange={setServer} placeholder="例如:https://10.0.0.15:8080" suffix={<IconLaunch />} />
|
<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>
|
||||||
|
|
||||||
<Form.Item label="账号">
|
<Form.Item label="账号">
|
||||||
<Input placeholder="请输入用户名或邮箱" />
|
<Input value={email} onChange={setEmail} placeholder="请输入用户名或邮箱" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="密码">
|
<Form.Item label="密码">
|
||||||
<Input.Password placeholder="请输入密码" />
|
<Input.Password value={password} onChange={setPassword} placeholder="请输入密码" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<div className="login-row">
|
<div className="login-row">
|
||||||
<Space size={8}>
|
<Space size={8}>
|
||||||
@@ -46,17 +72,9 @@ export function LoginPage({ onLogin }: { onLogin: () => void }) {
|
|||||||
</Space>
|
</Space>
|
||||||
<Button type="text" size="mini">无法连接?</Button>
|
<Button type="text" size="mini">无法连接?</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button type="primary" long size="large" onClick={onLogin}>
|
<Button type="primary" long size="large" onClick={() => onLogin({ server, email, password })}>
|
||||||
登录
|
登录
|
||||||
</Button>
|
</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>
|
</Form>
|
||||||
</div>
|
</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 }) {
|
function ValuePoint({ icon, title, desc }: { icon: ReactElement; title: string; desc: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="value-point">
|
<div className="value-point">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
70
apps/senlinai-acro-react/src/pages/projects/project-ai.tsx
Normal file
70
apps/senlinai-acro-react/src/pages/projects/project-ai.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
}
|
||||||
|
}
|
||||||
60
apps/senlinai-acro-react/src/pages/projects/project-cron.tsx
Normal file
60
apps/senlinai-acro-react/src/pages/projects/project-cron.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { IconClockCircle, IconEmail, IconFile, IconHome, IconLink, IconList, IconRobot, IconUserGroup } from '@arco-design/web-react/icon'
|
|
||||||
import type { Project, ProjectChannel } from './project-types'
|
|
||||||
|
|
||||||
export const projects: Project[] = [
|
|
||||||
{ id: 'a1', name: '项目 A1', short: 'A1', badge: 635, urgent: true, color: '#165DFF' },
|
|
||||||
{ id: 'a2', name: '项目 A2', short: 'PM', badge: 36, color: '#00B42A' },
|
|
||||||
{ id: 'b1', name: '项目 B1', short: 'DS', badge: 4, urgent: true, color: '#722ED1' },
|
|
||||||
{ id: 'c3', name: '项目 C3', short: 'OP', badge: 45, color: '#FF7D00' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const channels: ProjectChannel[] = [
|
|
||||||
{ key: 'overview', label: '概况', count: 635, icon: <IconHome /> },
|
|
||||||
{ key: 'inbox', label: 'Inbox 消息流', count: 36, icon: <IconEmail /> },
|
|
||||||
{ key: 'tasks', label: '工作计划', count: 12, icon: <IconList /> },
|
|
||||||
{ key: 'ai', label: 'AI 会话', count: 4, icon: <IconRobot /> },
|
|
||||||
{ key: 'notes', label: '笔记资料', count: 343, icon: <IconFile /> },
|
|
||||||
{ key: 'cron', label: 'Cron 计划任务', count: 45, icon: <IconClockCircle /> },
|
|
||||||
{ key: 'research', label: '客户研究频道', icon: <IconUserGroup /> },
|
|
||||||
{ key: 'design', label: '产品设计频道', icon: <IconLink /> },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const inbox = [
|
|
||||||
{ title: '竞争对手定价更新监控', meta: '来源:产品研究 · 竞品追踪', owner: '李明', time: '2 小时前', tag: '市场情报' },
|
|
||||||
{ title: '客户反馈:导出功能报错', meta: '来源:客户支持 · 工单 #CS-1287', owner: '王芳', time: '4 小时前', tag: '客户反馈' },
|
|
||||||
{ title: 'API 调用策略评估建议', meta: '来源:后端架构讨论', owner: '张伟', time: '6 小时前', tag: '架构' },
|
|
||||||
{ title: '更新需求文档 v1.3.2', meta: '来源:产品需求 · 文档评审', owner: '陈晨', time: '昨天', tag: '需求文档' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const tasks = [
|
|
||||||
{ title: '智能报表导出功能', owner: '张伟', progress: '60%', due: '2026-07-24', tag: '进行中' },
|
|
||||||
{ title: '企业版权限体系梳理', owner: '李明', progress: '30%', due: '2026-07-28', tag: '进行中' },
|
|
||||||
{ title: 'Q3 营销策略制定', owner: '王芳', progress: '45%', due: '2026-07-31', tag: '进行中' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const aiSessions = [
|
|
||||||
{ title: '生成 Q3 营销策略初稿', summary: '基于市场分析和竞品数据,输出 Q3 营销策略重点...', owner: '张伟', time: '今天 08:47' },
|
|
||||||
{ title: '产品定价模型讨论', summary: '针对企业版与标准版定价模型进行对比分析...', owner: '李明', time: '昨天 16:05' },
|
|
||||||
{ title: '客户流失原因分析', summary: '分析近三个月客户流失数据与反馈原因...', owner: '王芳', time: '昨天 10:22' },
|
|
||||||
{ title: '行业趋势与机会洞察', summary: '围绕 AI 在企业协同领域的趋势与机会...', owner: '张伟', time: '7 月 18 日' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const notes = [
|
|
||||||
{ title: '用户权限体系设计文档 v2.1', type: '文档', updated: '2026-07-19', owner: '李明' },
|
|
||||||
{ title: '导出功能技术方案评审', type: '文档', updated: '2026-07-18', owner: '张伟' },
|
|
||||||
{ title: '2026 Q3 竞品功能对比表', type: '表格', updated: '2026-07-18', owner: '王芳' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const discussions = [
|
|
||||||
{ name: '李明', time: '2026-07-20 09:12', text: '关于导出报错的问题,已在测试环境复现,初步定位为权限校验逻辑异常。' },
|
|
||||||
{ name: '张伟', time: '2026-07-20 09:18', text: '@李明 麻烦同步修复预计完成时间,我这边需要同步给客户。' },
|
|
||||||
{ name: '王芳', time: '2026-07-20 10:05', text: '补充:客户反馈影响范围主要集中在企业版用户。' },
|
|
||||||
{ name: '系统通知', time: '2026-07-20 10:30', text: '已将此讨论关联到任务:智能报表导出功能。' },
|
|
||||||
]
|
|
||||||
@@ -1,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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-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 { 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 { Row, Col } = Grid
|
||||||
const { Title, Text } = Typography
|
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(() => [
|
const metrics = useMemo(() => [
|
||||||
{ title: 'Inbox 待处理', value: 36, delta: '较昨日 -8', icon: <IconEmail />, color: 'arcoblue' },
|
{ title: 'Inbox 待处理', value: inbox.length, delta: '实时接口', icon: <IconEmail />, color: 'arcoblue' },
|
||||||
{ title: '进行中的任务', value: 12, delta: '较昨日 -2', icon: <IconCheckCircleFill />, color: 'green' },
|
{ title: '进行中的任务', value: tasks.filter((task) => !task.completed).length, delta: '实时接口', icon: <IconCheckCircleFill />, color: 'green' },
|
||||||
{ title: 'AI 会话活跃', value: 4, delta: '较昨日 +1', icon: <IconRobot />, color: 'purple' },
|
{ title: 'AI 会话活跃', value: aiSessions.length, delta: '实时接口', icon: <IconRobot />, color: 'purple' },
|
||||||
{ title: '笔记资料总数', value: 343, delta: '较昨日 +5', icon: <IconFile />, color: 'cyan' },
|
{ title: '笔记资料总数', value: notes.length, delta: '实时接口', icon: <IconFile />, color: 'cyan' },
|
||||||
{ title: '计划任务', value: 45, delta: '较昨日 +0', icon: <IconClockCircle />, color: 'orange' },
|
{ title: '计划任务', value: cronJobs.length, delta: '实时接口', icon: <IconClockCircle />, color: 'orange' },
|
||||||
], [])
|
], [aiSessions.length, cronJobs.length, inbox.length, notes.length, tasks])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overview-page">
|
<div className="overview-page">
|
||||||
@@ -44,15 +45,15 @@ export function ProjectOverview({ onSelectItem }: { onSelectItem: (title: string
|
|||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<QueueSection title="Inbox 待处理(36)" items={inbox} onSelectItem={onSelectItem} />
|
<QueueSection title={`Inbox 待处理(${inbox.length})`} items={inbox} onSelectItem={onSelectItem} />
|
||||||
<TaskSection onSelectItem={onSelectItem} />
|
<TaskSection tasks={tasks} onSelectItem={onSelectItem} />
|
||||||
<AISessionSection onSelectItem={onSelectItem} />
|
<AISessionSection aiSessions={aiSessions} onSelectItem={onSelectItem} />
|
||||||
<NoteSection onSelectItem={onSelectItem} />
|
<NoteSection notes={notes} onSelectItem={onSelectItem} />
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<Card className="queue-section" bordered>
|
<Card className="queue-section" bordered>
|
||||||
<div className="section-header">
|
<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 (
|
return (
|
||||||
<Card className="queue-section" bordered>
|
<Card className="queue-section" bordered>
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<Title heading={6}>进行中的任务(12)</Title>
|
<Title heading={6}>进行中的任务({tasks.length})</Title>
|
||||||
<Button type="text" size="mini">查看全部</Button>
|
<Button type="text" size="mini">查看全部</Button>
|
||||||
</div>
|
</div>
|
||||||
{tasks.map((task) => (
|
{tasks.map((task) => (
|
||||||
@@ -85,18 +86,18 @@ function TaskSection({ onSelectItem }: { onSelectItem: (title: string) => void }
|
|||||||
<Tag color="arcoblue">{task.tag}</Tag>
|
<Tag color="arcoblue">{task.tag}</Tag>
|
||||||
<span>进度 {task.progress}</span>
|
<span>进度 {task.progress}</span>
|
||||||
<span>负责人 {task.owner}</span>
|
<span>负责人 {task.owner}</span>
|
||||||
<time>截止 {task.due}</time>
|
<time>创建 {task.createdAt}</time>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AISessionSection({ onSelectItem }: { onSelectItem: (title: string) => void }) {
|
function AISessionSection({ aiSessions, onSelectItem }: { aiSessions: ProjectWorkspace['aiSessions']; onSelectItem: (title: string) => void }) {
|
||||||
return (
|
return (
|
||||||
<Card className="queue-section" bordered>
|
<Card className="queue-section" bordered>
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<Title heading={6}>AI 会话(4)</Title>
|
<Title heading={6}>AI 会话({aiSessions.length})</Title>
|
||||||
<Button type="text" size="mini">查看全部</Button>
|
<Button type="text" size="mini">查看全部</Button>
|
||||||
</div>
|
</div>
|
||||||
{aiSessions.map((session) => (
|
{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 (
|
return (
|
||||||
<Card className="queue-section" bordered>
|
<Card className="queue-section" bordered>
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties } from 'react'
|
||||||
import { Badge, Button, Layout, Space } from '@arco-design/web-react'
|
import { Badge, Button, Layout, Space } from '@arco-design/web-react'
|
||||||
import { IconDashboard, IconPlus } from '@arco-design/web-react/icon'
|
import { IconDashboard, IconEmail, IconPlus } from '@arco-design/web-react/icon'
|
||||||
import { projects } from './project-data'
|
|
||||||
import type { Project, WorkbenchView } from './project-types'
|
import type { Project, WorkbenchView } from './project-types'
|
||||||
|
|
||||||
const { Sider } = Layout
|
const { Sider } = Layout
|
||||||
|
|
||||||
export function ProjectRail({
|
export function ProjectRail({
|
||||||
activeProject,
|
activeProject,
|
||||||
|
projects,
|
||||||
activeView,
|
activeView,
|
||||||
onSelectWorkspace,
|
onSelectWorkspace,
|
||||||
|
onSelectWorkspaceInbox,
|
||||||
onSelectProject,
|
onSelectProject,
|
||||||
|
onCreateProject,
|
||||||
}: {
|
}: {
|
||||||
activeProject: Project
|
activeProject: Project
|
||||||
|
projects: Project[]
|
||||||
activeView: WorkbenchView
|
activeView: WorkbenchView
|
||||||
onSelectWorkspace: () => void
|
onSelectWorkspace: () => void
|
||||||
|
onSelectWorkspaceInbox: () => void
|
||||||
onSelectProject: (project: Project) => void
|
onSelectProject: (project: Project) => void
|
||||||
|
onCreateProject: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Sider className="project-rail" width={96}>
|
<Sider className="project-rail" width={96}>
|
||||||
@@ -26,16 +31,28 @@ export function ProjectRail({
|
|||||||
aria-label="工作台"
|
aria-label="工作台"
|
||||||
title="工作台"
|
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}>
|
<Space className="project-stack" direction="vertical" size={12}>
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<Badge key={project.id} count={project.badge} dot={false} className={project.urgent ? 'project-badge urgent' : 'project-badge'}>
|
<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}
|
{project.short}
|
||||||
</button>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
<Button className="create-project" aria-label="新建项目" icon={<IconPlus />} title="新建项目" />
|
<Button className="create-project" aria-label="新建项目" icon={<IconPlus />} title="新建项目" onClick={onCreateProject} />
|
||||||
</Sider>
|
</Sider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import { Badge, Button, Divider, Layout, Space, Tag, Typography } from '@arco-design/web-react'
|
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 { IconApps, IconDashboard, IconMore, IconSettings } from '@arco-design/web-react/icon'
|
||||||
import { channels } from './project-data'
|
import type { ChannelKey, ProjectWorkspace, WorkbenchView } from './project-types'
|
||||||
import type { ChannelKey, Project, WorkbenchView } from './project-types'
|
|
||||||
|
|
||||||
const { Sider } = Layout
|
const { Sider } = Layout
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
export function ProjectSidebar({
|
export function ProjectSidebar({
|
||||||
activeView,
|
activeView,
|
||||||
activeProject,
|
activeWorkspace,
|
||||||
activeChannel,
|
activeChannel,
|
||||||
onSelectChannel,
|
onSelectChannel,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
}: {
|
}: {
|
||||||
activeView: WorkbenchView
|
activeView: WorkbenchView
|
||||||
activeProject: Project
|
activeWorkspace: ProjectWorkspace
|
||||||
activeChannel: ChannelKey
|
activeChannel: ChannelKey
|
||||||
onSelectChannel: (key: ChannelKey) => void
|
onSelectChannel: (key: ChannelKey) => void
|
||||||
onSelectItem: (title: string) => void
|
onSelectItem: (title: string) => void
|
||||||
}) {
|
}) {
|
||||||
const workspaceMode = activeView === 'workspace'
|
const workspaceMode = activeView === 'workspace'
|
||||||
|
const channels = activeWorkspace.channels
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sider className="channel-sidebar" width={248}>
|
<Sider className="channel-sidebar" width={248}>
|
||||||
<div className="project-title">
|
<div className="project-title">
|
||||||
<Space>
|
<Space>
|
||||||
{workspaceMode ? <IconApps /> : <IconDashboard />}
|
{workspaceMode ? <IconApps /> : <IconDashboard />}
|
||||||
<Title heading={5}>{workspaceMode ? '工作台' : activeProject.name}</Title>
|
<Title heading={5}>{workspaceMode ? '工作台' : activeWorkspace.project.name}</Title>
|
||||||
</Space>
|
</Space>
|
||||||
<Button icon={<IconSettings />} size="mini" />
|
<Button icon={<IconSettings />} size="mini" />
|
||||||
</div>
|
</div>
|
||||||
@@ -36,7 +36,7 @@ export function ProjectSidebar({
|
|||||||
<>
|
<>
|
||||||
<button className="channel-button active">
|
<button className="channel-button active">
|
||||||
<span className="channel-left"><IconDashboard /><Text>工作台总览</Text></span>
|
<span className="channel-left"><IconDashboard /><Text>工作台总览</Text></span>
|
||||||
<Badge count={4} />
|
<Badge count={channels.length} />
|
||||||
</button>
|
</button>
|
||||||
<button className="channel-button">
|
<button className="channel-button">
|
||||||
<span className="channel-left"><IconApps /><Text>全部项目</Text></span>
|
<span className="channel-left"><IconApps /><Text>全部项目</Text></span>
|
||||||
@@ -46,7 +46,7 @@ export function ProjectSidebar({
|
|||||||
) : (
|
) : (
|
||||||
channels.map((channel) => (
|
channels.map((channel) => (
|
||||||
<button
|
<button
|
||||||
key={channel.key}
|
key={channel.id}
|
||||||
className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'}
|
className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'}
|
||||||
onClick={() => onSelectChannel(channel.key)}
|
onClick={() => onSelectChannel(channel.key)}
|
||||||
>
|
>
|
||||||
@@ -61,7 +61,7 @@ export function ProjectSidebar({
|
|||||||
<section className="tag-cloud">
|
<section className="tag-cloud">
|
||||||
<Text className="section-title">标签</Text>
|
<Text className="section-title">标签</Text>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{['全部', '需求', '设计', '研发', '运营', 'AI'].map((tag) => (
|
{activeWorkspace.tags.map((tag) => (
|
||||||
<Tag key={tag} color={tag === '全部' ? 'arcoblue' : 'gray'}>{tag}</Tag>
|
<Tag key={tag} color={tag === '全部' ? 'arcoblue' : 'gray'}>{tag}</Tag>
|
||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -70,10 +70,10 @@ export function ProjectSidebar({
|
|||||||
<Divider />
|
<Divider />
|
||||||
<section className="recent-sessions">
|
<section className="recent-sessions">
|
||||||
<Text className="section-title">最近会话</Text>
|
<Text className="section-title">最近会话</Text>
|
||||||
{['需求评审要点整理', '生成 Q3 营销策略初稿', 'npx 安装 skill 至 codex', '产品定价模型讨论'].map((title, index) => (
|
{activeWorkspace.recentSessions.slice(0, 4).map((session) => (
|
||||||
<button key={title} onClick={() => onSelectItem(title)}>
|
<button key={session.id} onClick={() => onSelectItem(session.title)}>
|
||||||
<Text>{title}</Text>
|
<Text>{session.title}</Text>
|
||||||
<Text type="secondary">{index === 0 ? '09:15' : index === 1 ? '08:47' : '昨天'}</Text>
|
<Text type="secondary">{session.time}</Text>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
133
apps/senlinai-acro-react/src/pages/projects/project-tasks.tsx
Normal file
133
apps/senlinai-acro-react/src/pages/projects/project-tasks.tsx
Normal 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) : ['暂无已完成任务'] },
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Badge, Button, Input, Layout, Space, Typography } from '@arco-design/web-react'
|
import { 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 { IconApps, IconMoon, IconSearch, IconSun } from '@arco-design/web-react/icon'
|
||||||
import type { Theme } from './project-types'
|
import type { Theme } from './project-types'
|
||||||
|
|
||||||
const { Header } = Layout
|
const { Header } = Layout
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
export function ProjectTopbar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => void }) {
|
export function ProjectTopbar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => void }) {
|
||||||
|
const desktop = isDesktopRuntime()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Header className="topbar">
|
<Header className="topbar">
|
||||||
<div className="brand-lockup">
|
<div className="brand-lockup">
|
||||||
@@ -18,12 +20,26 @@ export function ProjectTopbar({ theme, onToggleTheme }: { theme: Theme; onToggle
|
|||||||
</div>
|
</div>
|
||||||
<Space className="topbar-actions">
|
<Space className="topbar-actions">
|
||||||
<Button aria-label={theme === 'dark' ? '切换浅色模式' : '切换深色模式'} icon={theme === 'dark' ? <IconSun /> : <IconMoon />} onClick={onToggleTheme} />
|
<Button aria-label={theme === 'dark' ? '切换浅色模式' : '切换深色模式'} icon={theme === 'dark' ? <IconSun /> : <IconMoon />} onClick={onToggleTheme} />
|
||||||
<Button icon={<IconArrowLeft />} disabled />
|
{desktop && (
|
||||||
<Button icon={<IconArrowRight />} disabled />
|
<>
|
||||||
<Badge count={8} dot><Button icon={<IconNotification />} /></Badge>
|
<Button aria-label="停靠左边" icon={<DockIcon side="left" />} />
|
||||||
<Button icon={<IconQuestionCircle />} />
|
<Button aria-label="停靠右边" icon={<DockIcon side="right" />} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Button icon={<IconApps />} />
|
<Button icon={<IconApps />} />
|
||||||
</Space>
|
</Space>
|
||||||
</Header>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { ReactElement } from 'react'
|
|||||||
|
|
||||||
export type Screen = 'login' | 'workbench'
|
export type Screen = 'login' | 'workbench'
|
||||||
export type Theme = 'light' | 'dark'
|
export type Theme = 'light' | 'dark'
|
||||||
export type WorkbenchView = 'workspace' | 'project'
|
export type WorkbenchView = 'workspace' | 'workspace-inbox' | 'project'
|
||||||
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | 'research' | 'design'
|
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | `custom:${string}`
|
||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
id: string
|
id: string
|
||||||
@@ -16,7 +16,73 @@ export type Project = {
|
|||||||
|
|
||||||
export type ProjectChannel = {
|
export type ProjectChannel = {
|
||||||
key: ChannelKey
|
key: ChannelKey
|
||||||
|
id: string
|
||||||
label: string
|
label: string
|
||||||
count?: number
|
count?: number
|
||||||
icon: ReactElement
|
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[]
|
||||||
}
|
}
|
||||||
|
|||||||
92
apps/senlinai-acro-react/src/pages/workspace-body.tsx
Normal file
92
apps/senlinai-acro-react/src/pages/workspace-body.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
apps/senlinai-acro-react/src/pages/workspace-home.tsx
Normal file
105
apps/senlinai-acro-react/src/pages/workspace-home.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
apps/senlinai-acro-react/src/pages/workspace-inbox.tsx
Normal file
95
apps/senlinai-acro-react/src/pages/workspace-inbox.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"senlinai-agent/backend/internal/config"
|
"senlinai-agent/backend/internal/config"
|
||||||
"senlinai-agent/backend/internal/httpx"
|
"senlinai-agent/backend/internal/httpx"
|
||||||
"senlinai-agent/backend/internal/logic/auth"
|
"senlinai-agent/backend/internal/logic/auth"
|
||||||
|
"senlinai-agent/backend/internal/logic/files"
|
||||||
"senlinai-agent/backend/internal/logic/inbox"
|
"senlinai-agent/backend/internal/logic/inbox"
|
||||||
"senlinai-agent/backend/internal/logic/projects"
|
"senlinai-agent/backend/internal/logic/projects"
|
||||||
"senlinai-agent/backend/internal/models"
|
"senlinai-agent/backend/internal/models"
|
||||||
@@ -20,9 +21,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authService := auth.NewService(cfg.AuthSecret)
|
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{}))
|
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 {
|
if err := appRouter.Run(":" + cfg.Port); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
37
backend/cmd/seed/main.go
Normal file
37
backend/cmd/seed/main.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"senlinai-agent/backend/internal/config"
|
||||||
|
"senlinai-agent/backend/internal/models"
|
||||||
|
"senlinai-agent/backend/internal/seed"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
if dsn := envOrDefault("DATABASE_DSN", envOrDefault("DATABASE_URL", "")); dsn != "" {
|
||||||
|
cfg.DSN = dsn
|
||||||
|
}
|
||||||
|
if err := models.New(cfg.DSN); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
result, err := seed.Demo(models.DBService, seed.DemoOptions{
|
||||||
|
Email: envOrDefault("SENLIN_SEED_EMAIL", "demo@senlin.ai"),
|
||||||
|
DisplayName: envOrDefault("SENLIN_SEED_NAME", "演示用户"),
|
||||||
|
Password: envOrDefault("SENLIN_SEED_PASSWORD", "password123"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("seeded %s with %d projects\n", result.User.Email, len(result.Projects))
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOrDefault(key string, fallback string) string {
|
||||||
|
value := os.Getenv(key)
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package httpx
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"senlinai-agent/backend/internal/config"
|
"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"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
api := router.Group("/api")
|
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 {
|
if tokenVerifier != nil {
|
||||||
api.Use(auth.RequireUser(tokenVerifier))
|
api.Use(auth.RequireUser(tokenVerifier))
|
||||||
}
|
}
|
||||||
@@ -47,6 +51,12 @@ func cors() gin.HandlerFunc {
|
|||||||
allowedOrigins := map[string]bool{
|
allowedOrigins := map[string]bool{
|
||||||
"http://localhost:5173": true,
|
"http://localhost:5173": true,
|
||||||
"http://127.0.0.1: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,
|
"http://tauri.localhost": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package httpx
|
package httpx
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -32,6 +34,19 @@ func TestNewRouterRegistersFeatureRoutesUnderAPI(t *testing.T) {
|
|||||||
require.JSONEq(t, `{"pong":true}`, rec.Body.String())
|
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) {
|
func TestRouterAddsCORSHeadersForLocalWebClient(t *testing.T) {
|
||||||
router := NewRouter(config.Config{Env: "test"}, testRegistrar{})
|
router := NewRouter(config.Config{Env: "test"}, testRegistrar{})
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
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})
|
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
|
||||||
|
}
|
||||||
|
|||||||
36
backend/internal/logic/auth/handlers.go
Normal file
36
backend/internal/logic/auth/handlers.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service *Service) *Handler {
|
||||||
|
return &Handler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Register(router gin.IRouter) {
|
||||||
|
router.POST("/auth/login", h.login)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) login(c *gin.Context) {
|
||||||
|
var input struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := h.service.Login(input.Email, input.Password)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||||
|
}
|
||||||
72
backend/internal/logic/auth/handlers_test.go
Normal file
72
backend/internal/logic/auth/handlers_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"senlinai-agent/backend/internal/config"
|
||||||
|
"senlinai-agent/backend/internal/httpx"
|
||||||
|
"senlinai-agent/backend/internal/logic/auth"
|
||||||
|
"senlinai-agent/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoginHandlerReturnsSessionToken(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
service := auth.NewService("test-secret")
|
||||||
|
invite, err := service.CreateInvite(1, "demo@senlin.ai")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = service.RegisterWithInvite(invite, "demo@senlin.ai", "Demo User", "password123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
router := httpx.NewProtectedRouter(config.Config{Env: "test"}, service.VerifySession, auth.NewHandler(service))
|
||||||
|
|
||||||
|
body, err := json.Marshal(gin.H{"email": "demo@senlin.ai", "password": "password123"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
var response struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response))
|
||||||
|
require.NotEmpty(t, response.Token)
|
||||||
|
userID, err := service.VerifySession(response.Token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, uint(1), userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginHandlerRejectsInvalidCredentials(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
service := auth.NewService("test-secret")
|
||||||
|
require.NoError(t, models.DBService.Create(&models.SenlinAgentUser{Email: "demo@senlin.ai", DisplayName: "Demo User", PasswordHash: "not-a-bcrypt-hash"}).Error)
|
||||||
|
router := httpx.NewProtectedRouter(config.Config{Env: "test"}, service.VerifySession, auth.NewHandler(service))
|
||||||
|
|
||||||
|
body, err := json.Marshal(gin.H{"email": "demo@senlin.ai", "password": "wrong"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
database, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, models.AutoMigrate(database))
|
||||||
|
models.DBService = database
|
||||||
|
return database
|
||||||
|
}
|
||||||
@@ -11,6 +11,11 @@ const CurrentUserIDKey = "currentUserID"
|
|||||||
|
|
||||||
func RequireUser(tokenVerifier func(string) (uint, error)) gin.HandlerFunc {
|
func RequireUser(tokenVerifier func(string) (uint, error)) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
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")
|
header := c.GetHeader("Authorization")
|
||||||
token := strings.TrimPrefix(header, "Bearer ")
|
token := strings.TrimPrefix(header, "Bearer ")
|
||||||
if token == "" || token == header {
|
if token == "" || token == header {
|
||||||
|
|||||||
@@ -3,17 +3,25 @@ package projects
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"senlinai-agent/backend/internal/logic/auth"
|
"senlinai-agent/backend/internal/logic/auth"
|
||||||
|
"senlinai-agent/backend/internal/logic/files"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
service *Service
|
service *Service
|
||||||
|
fileService *files.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(service *Service) *Handler {
|
func NewHandler(service *Service, fileServices ...*files.Service) *Handler {
|
||||||
return &Handler{service: service}
|
var fileService *files.Service
|
||||||
|
if len(fileServices) > 0 {
|
||||||
|
fileService = fileServices[0]
|
||||||
|
}
|
||||||
|
return &Handler{service: service, fileService: fileService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Register(router gin.IRouter) {
|
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", h.listProjects)
|
||||||
router.GET("/projects/:id/dashboard", h.dashboard)
|
router.GET("/projects/:id/dashboard", h.dashboard)
|
||||||
router.GET("/projects/:id/workspace", h.workspace)
|
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) {
|
func (h *Handler) createProject(c *gin.Context) {
|
||||||
@@ -96,3 +107,149 @@ func (h *Handler) workspace(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, workspace)
|
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
|
||||||
|
}
|
||||||
|
|||||||
96
backend/internal/logic/projects/handlers_test.go
Normal file
96
backend/internal/logic/projects/handlers_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ type WorkTask struct {
|
|||||||
Completed bool `json:"completed"`
|
Completed bool `json:"completed"`
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Due string `json:"due"`
|
Due string `json:"due"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +99,25 @@ type ProjectWorkspace struct {
|
|||||||
CronPlans []CronPlan `json:"cronPlans"`
|
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 {
|
func NewService() *Service {
|
||||||
return &Service{}
|
return &Service{}
|
||||||
}
|
}
|
||||||
@@ -117,6 +137,75 @@ func (s *Service) ListProjects(ownerID uint) ([]models.SenlinAgentProject, error
|
|||||||
return projects, err
|
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) {
|
func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if 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) {
|
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
|
||||||
var project models.SenlinAgentProject
|
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||||
if err := models.DBService.Where("id = ? AND owner_id = ?", projectID, ownerID).First(&project).Error; err != nil {
|
|
||||||
return Dashboard{}, err
|
return Dashboard{}, err
|
||||||
}
|
}
|
||||||
dashboard := Dashboard{ProjectID: projectID}
|
dashboard := Dashboard{ProjectID: projectID}
|
||||||
@@ -211,6 +299,17 @@ func (s *Service) Workspace(ownerID uint, projectID uint) (ProjectWorkspace, err
|
|||||||
}, nil
|
}, 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 {
|
type workspaceCounts struct {
|
||||||
inbox int64
|
inbox int64
|
||||||
tasks int64
|
tasks int64
|
||||||
@@ -323,6 +422,7 @@ func (s *Service) workspaceTasks(projectID uint) ([]WorkTask, error) {
|
|||||||
Completed: task.Status == "done",
|
Completed: task.Status == "done",
|
||||||
Owner: owner,
|
Owner: owner,
|
||||||
Due: displayOptionalTime(task.DueAt),
|
Due: displayOptionalTime(task.DueAt),
|
||||||
|
CreatedAt: displayTime(task.CreatedAt),
|
||||||
Tag: "",
|
Tag: "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ func TestWorkspaceMatchesFrontendContract(t *testing.T) {
|
|||||||
require.Len(t, workspace.Tasks, 2)
|
require.Len(t, workspace.Tasks, 2)
|
||||||
require.False(t, workspace.Tasks[0].Completed)
|
require.False(t, workspace.Tasks[0].Completed)
|
||||||
require.Equal(t, "David", workspace.Tasks[0].Owner)
|
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.AISessions, 1)
|
||||||
require.Len(t, workspace.RecentSessions, 1)
|
require.Len(t, workspace.RecentSessions, 1)
|
||||||
require.Len(t, workspace.NotesSources, 2)
|
require.Len(t, workspace.NotesSources, 2)
|
||||||
@@ -126,6 +127,94 @@ func TestWorkspaceRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
|||||||
require.Error(t, err)
|
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 {
|
func newTestDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||||
|
|||||||
249
backend/internal/seed/demo.go
Normal file
249
backend/internal/seed/demo.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package seed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"senlinai-agent/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DemoOptions struct {
|
||||||
|
Email string
|
||||||
|
DisplayName string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DemoResult struct {
|
||||||
|
User models.SenlinAgentUser
|
||||||
|
Projects []models.SenlinAgentProject
|
||||||
|
}
|
||||||
|
|
||||||
|
func Demo(database *gorm.DB, options DemoOptions) (DemoResult, error) {
|
||||||
|
email := strings.ToLower(strings.TrimSpace(options.Email))
|
||||||
|
if email == "" {
|
||||||
|
email = "demo@senlin.ai"
|
||||||
|
}
|
||||||
|
displayName := strings.TrimSpace(options.DisplayName)
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = "演示用户"
|
||||||
|
}
|
||||||
|
password := options.Password
|
||||||
|
if password == "" {
|
||||||
|
password = "password123"
|
||||||
|
}
|
||||||
|
|
||||||
|
var result DemoResult
|
||||||
|
err := database.Transaction(func(tx *gorm.DB) error {
|
||||||
|
user, err := upsertUser(tx, email, displayName, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.User = user
|
||||||
|
|
||||||
|
projects, err := seedProjects(tx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.Projects = projects
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertUser(tx *gorm.DB, email string, displayName string, password string) (models.SenlinAgentUser, error) {
|
||||||
|
var user models.SenlinAgentUser
|
||||||
|
err := tx.Where("email = ?", email).First(&user).Error
|
||||||
|
if err == nil {
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
if err != gorm.ErrRecordNotFound {
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
user = models.SenlinAgentUser{
|
||||||
|
Email: email,
|
||||||
|
DisplayName: displayName,
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
Role: "user",
|
||||||
|
}
|
||||||
|
return user, tx.Create(&user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedProjects(tx *gorm.DB, ownerID uint) ([]models.SenlinAgentProject, error) {
|
||||||
|
definitions := []struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
}{
|
||||||
|
{Name: "项目 A1", Description: "企业智能协作平台的核心工作空间"},
|
||||||
|
{Name: "项目 A2", Description: "客户成功与交付跟踪"},
|
||||||
|
{Name: "数据中台", Description: "内部数据治理与指标分析"},
|
||||||
|
{Name: "运营自动化", Description: "定时任务和业务流程自动化"},
|
||||||
|
}
|
||||||
|
|
||||||
|
projects := make([]models.SenlinAgentProject, 0, len(definitions))
|
||||||
|
for _, definition := range definitions {
|
||||||
|
project, err := firstOrCreateProject(tx, ownerID, definition.Name, definition.Description)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
projects = append(projects, project)
|
||||||
|
}
|
||||||
|
if len(projects) == 0 {
|
||||||
|
return projects, nil
|
||||||
|
}
|
||||||
|
if err := seedProjectA1(tx, ownerID, projects[0].ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, project := range projects[1:] {
|
||||||
|
if err := seedCompactProject(tx, ownerID, project.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return projects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOrCreateProject(tx *gorm.DB, ownerID uint, name string, description string) (models.SenlinAgentProject, error) {
|
||||||
|
var project models.SenlinAgentProject
|
||||||
|
err := tx.Where("owner_id = ? AND name = ?", ownerID, name).First(&project).Error
|
||||||
|
if err == nil {
|
||||||
|
return project, nil
|
||||||
|
}
|
||||||
|
if err != gorm.ErrRecordNotFound {
|
||||||
|
return project, err
|
||||||
|
}
|
||||||
|
project = models.SenlinAgentProject{OwnerID: ownerID, Name: name, Description: description}
|
||||||
|
return project, tx.Create(&project).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedProjectA1(tx *gorm.DB, userID uint, projectID uint) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
nextRun := now.Add(6 * time.Hour)
|
||||||
|
dueSoon := now.Add(72 * time.Hour)
|
||||||
|
dueLater := now.Add(7 * 24 * time.Hour)
|
||||||
|
|
||||||
|
for _, tag := range []string{"UI", "知识碎片", "客户反馈", "自动化"} {
|
||||||
|
if err := firstOrCreateTag(tx, projectID, tag); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range []models.SenlinAgentInboxItem{
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, SourceType: "产品研究", Title: "竞争对手定价更新监控", Body: "跟进企业版价格调整和功能组合变化。", Status: "open"},
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, SourceType: "客户支持", Title: "客户反馈:导出功能报错", Body: "客户在导出智能报表时遇到权限校验异常。", Status: "open"},
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, SourceType: "架构讨论", Title: "API 调用策略评估建议", Body: "评估批量任务调度和 AI 限流策略。", Status: "open"},
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, SourceType: "需求文档", Title: "更新需求文档 v1.3.2", Body: "补充项目频道和属性弹层的交互说明。", Status: "open"},
|
||||||
|
} {
|
||||||
|
if err := firstOrCreateInbox(tx, item); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, task := range []models.SenlinAgentTask{
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, Title: "智能报表导出功能", Description: "修复权限校验并补充导出失败提示。", Status: "open", DueAt: &dueSoon, SortOrder: 1},
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, Title: "企业版权限体系梳理", Description: "整理角色、项目和任务分享边界。", Status: "open", DueAt: &dueLater, SortOrder: 2},
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, Title: "Q3 营销策略制定", Description: "汇总竞品情报和客户反馈,生成策略草案。", Status: "open", DueAt: &dueLater, SortOrder: 3},
|
||||||
|
} {
|
||||||
|
if err := firstOrCreateTask(tx, task); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, session := range []models.SenlinAgentAISession{
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, Title: "生成 Q3 营销策略初稿", Context: "基于市场分析和竞品数据,输出 Q3 营销策略重点。"},
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, Title: "产品定价模型讨论", Context: "对企业版与标准版定价模型进行对比分析。"},
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, Title: "客户流失原因分析", Context: "分析近三个月客户流失数据与反馈原因。"},
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, Title: "行业趋势与机会洞察", Context: "围绕 AI 在企业协同领域的趋势与机会。"},
|
||||||
|
} {
|
||||||
|
if err := firstOrCreateAISession(tx, session); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, note := range []models.SenlinAgentNote{
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, Title: "用户权限体系设计文档 v2.1", Markdown: "# 权限体系\n\n围绕项目、任务和资料分享做保守授权。"},
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, Title: "导出功能技术方案评审", Markdown: "# 导出功能\n\n记录权限校验和任务队列方案。"},
|
||||||
|
} {
|
||||||
|
if err := firstOrCreateNote(tx, note); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, source := range []models.SenlinAgentSource{
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, Kind: "file", Title: "2026 Q3 竞品功能对比表", FilePath: "projects/a1/competitors.xlsx", ContentText: "竞品功能矩阵和价格摘要"},
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, Kind: "link", Title: "客户访谈记录索引", URL: "https://example.com/interviews", ContentText: "客户访谈和问题归类"},
|
||||||
|
} {
|
||||||
|
if err := firstOrCreateSource(tx, source); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, plan := range []models.SenlinAgentCronPlan{
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, Title: "每日 Inbox 摘要", Schedule: "0 9 * * *", NextRunAt: &nextRun, Enabled: true, LastResult: "上次执行成功"},
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, Title: "每周任务风险扫描", Schedule: "0 10 * * 1", NextRunAt: &dueSoon, Enabled: true, LastResult: "等待下次执行"},
|
||||||
|
{ProjectID: projectID, CreatedBy: userID, Title: "月底知识归档提醒", Schedule: "0 18 28 * *", NextRunAt: &dueLater, Enabled: false, LastResult: "已暂停"},
|
||||||
|
} {
|
||||||
|
if err := firstOrCreateCronPlan(tx, plan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, channel := range []models.SenlinAgentProjectChannel{
|
||||||
|
{ProjectID: projectID, Title: "客户研究频道", Icon: "user", URL: "https://example.com/research", SortOrder: 7},
|
||||||
|
{ProjectID: projectID, Title: "产品设计频道", Icon: "link", URL: "https://example.com/design", SortOrder: 8},
|
||||||
|
} {
|
||||||
|
if err := firstOrCreateChannel(tx, channel); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedCompactProject(tx *gorm.DB, userID uint, projectID uint) error {
|
||||||
|
item := models.SenlinAgentInboxItem{ProjectID: projectID, CreatedBy: userID, SourceType: "手动收集", Title: "待处理事项", Body: "项目启动资料待整理。", Status: "open"}
|
||||||
|
if err := firstOrCreateInbox(tx, item); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
task := models.SenlinAgentTask{ProjectID: projectID, CreatedBy: userID, Title: "项目例会纪要整理", Description: "归档本周同步内容。", Status: "open"}
|
||||||
|
return firstOrCreateTask(tx, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOrCreateTag(tx *gorm.DB, projectID uint, name string) error {
|
||||||
|
return firstOrCreate(tx, &models.SenlinAgentTag{}, "project_id = ? AND name = ?", []any{projectID, name}, models.SenlinAgentTag{ProjectID: projectID, Name: name})
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOrCreateInbox(tx *gorm.DB, item models.SenlinAgentInboxItem) error {
|
||||||
|
return firstOrCreate(tx, &models.SenlinAgentInboxItem{}, "project_id = ? AND title = ?", []any{item.ProjectID, item.Title}, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOrCreateTask(tx *gorm.DB, task models.SenlinAgentTask) error {
|
||||||
|
return firstOrCreate(tx, &models.SenlinAgentTask{}, "project_id = ? AND title = ?", []any{task.ProjectID, task.Title}, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOrCreateAISession(tx *gorm.DB, session models.SenlinAgentAISession) error {
|
||||||
|
return firstOrCreate(tx, &models.SenlinAgentAISession{}, "project_id = ? AND title = ?", []any{session.ProjectID, session.Title}, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOrCreateNote(tx *gorm.DB, note models.SenlinAgentNote) error {
|
||||||
|
return firstOrCreate(tx, &models.SenlinAgentNote{}, "project_id = ? AND title = ?", []any{note.ProjectID, note.Title}, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOrCreateSource(tx *gorm.DB, source models.SenlinAgentSource) error {
|
||||||
|
return firstOrCreate(tx, &models.SenlinAgentSource{}, "project_id = ? AND title = ?", []any{source.ProjectID, source.Title}, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOrCreateCronPlan(tx *gorm.DB, plan models.SenlinAgentCronPlan) error {
|
||||||
|
return firstOrCreate(tx, &models.SenlinAgentCronPlan{}, "project_id = ? AND title = ?", []any{plan.ProjectID, plan.Title}, plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOrCreateChannel(tx *gorm.DB, channel models.SenlinAgentProjectChannel) error {
|
||||||
|
return firstOrCreate(tx, &models.SenlinAgentProjectChannel{}, "project_id = ? AND title = ?", []any{channel.ProjectID, channel.Title}, channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOrCreate[T any](tx *gorm.DB, model *T, where string, args []any, create T) error {
|
||||||
|
err := tx.Where(where, args...).First(model).Error
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != gorm.ErrRecordNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&create).Error
|
||||||
|
}
|
||||||
65
backend/internal/seed/demo_test.go
Normal file
65
backend/internal/seed/demo_test.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package seed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"senlinai-agent/backend/internal/logic/auth"
|
||||||
|
"senlinai-agent/backend/internal/logic/projects"
|
||||||
|
"senlinai-agent/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDemoSeedCreatesFrontendWorkspaceData(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
|
||||||
|
result, err := Demo(database, DemoOptions{
|
||||||
|
Email: "demo@senlin.ai",
|
||||||
|
DisplayName: "演示用户",
|
||||||
|
Password: "password123",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "demo@senlin.ai", result.User.Email)
|
||||||
|
require.Len(t, result.Projects, 4)
|
||||||
|
|
||||||
|
token, err := auth.NewService("test-secret").Login("demo@senlin.ai", "password123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, token)
|
||||||
|
|
||||||
|
workspace, err := projects.NewService().Workspace(result.User.ID, result.Projects[0].ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "项目 A1", workspace.Project.Name)
|
||||||
|
require.GreaterOrEqual(t, len(workspace.Inbox), 4)
|
||||||
|
require.GreaterOrEqual(t, len(workspace.Tasks), 3)
|
||||||
|
require.GreaterOrEqual(t, len(workspace.AISessions), 4)
|
||||||
|
require.GreaterOrEqual(t, len(workspace.NotesSources), 3)
|
||||||
|
require.GreaterOrEqual(t, len(workspace.CronPlans), 3)
|
||||||
|
require.Len(t, workspace.Channels, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDemoSeedIsIdempotent(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
|
||||||
|
first, err := Demo(database, DemoOptions{Email: "demo@senlin.ai", DisplayName: "演示用户", Password: "password123"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
second, err := Demo(database, DemoOptions{Email: "demo@senlin.ai", DisplayName: "演示用户", Password: "password123"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Equal(t, first.User.ID, second.User.ID)
|
||||||
|
require.Len(t, second.Projects, 4)
|
||||||
|
var userCount int64
|
||||||
|
require.NoError(t, database.Model(&models.SenlinAgentUser{}).Where("email = ?", "demo@senlin.ai").Count(&userCount).Error)
|
||||||
|
require.Equal(t, int64(1), userCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, models.AutoMigrate(database))
|
||||||
|
models.DBService = database
|
||||||
|
return database
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user