feat: complete project workspace tags and notes UI
This commit is contained in:
@@ -5,13 +5,14 @@ const requiredFiles = [
|
|||||||
'src/pages/login.tsx',
|
'src/pages/login.tsx',
|
||||||
'src/pages/workspace-body.tsx',
|
'src/pages/workspace-body.tsx',
|
||||||
'src/pages/workspace-home.tsx',
|
'src/pages/workspace-home.tsx',
|
||||||
'src/pages/workspace-inbox.tsx',
|
'src/pages/workspace-explore.tsx',
|
||||||
'src/pages/projects/project-overview.tsx',
|
'src/pages/projects/project-overview.tsx',
|
||||||
'src/pages/projects/project-channel-page.tsx',
|
'src/pages/projects/project-channel-page.tsx',
|
||||||
'src/pages/projects/project-tasks.tsx',
|
'src/pages/projects/project-tasks.tsx',
|
||||||
'src/pages/projects/project-ai.tsx',
|
'src/pages/projects/project-ai.tsx',
|
||||||
'src/pages/projects/project-notes.tsx',
|
'src/pages/projects/project-notes.tsx',
|
||||||
'src/pages/projects/project-cron.tsx',
|
'src/pages/projects/project-cron.tsx',
|
||||||
|
'src/pages/projects/project-new-channel.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',
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const collectMetrics = async () => page.evaluate(() => {
|
|||||||
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 workspaceExplore = document.querySelector('.dashboard-button[title="探索"]')
|
||||||
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')
|
||||||
@@ -84,8 +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),
|
workspaceExploreButton: rect(workspaceExplore),
|
||||||
workspaceInboxButtonActive: workspaceInbox?.classList.contains('active') ?? false,
|
workspaceExploreButtonActive: workspaceExplore?.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),
|
||||||
@@ -111,10 +111,10 @@ 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.locator('.dashboard-button[title="探索"]').click()
|
||||||
await page.waitForTimeout(500)
|
await page.waitForTimeout(500)
|
||||||
await page.screenshot({ path: 'test-results/workspace-inbox-react-acro-light.png', fullPage: true })
|
await page.screenshot({ path: 'test-results/workspace-explore-react-acro-light.png', fullPage: true })
|
||||||
const workspaceInboxMetrics = await collectMetrics()
|
const workspaceExploreMetrics = await collectMetrics()
|
||||||
|
|
||||||
await page.locator('.project-button').first().click()
|
await page.locator('.project-button').first().click()
|
||||||
await page.waitForTimeout(500)
|
await page.waitForTimeout(500)
|
||||||
@@ -133,7 +133,8 @@ for (const channel of [
|
|||||||
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
|
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
|
||||||
{ label: 'AI 会话', pageClass: 'project-ai-page', expectedHeading: 'AI 会话' },
|
{ label: 'AI 会话', pageClass: 'project-ai-page', expectedHeading: 'AI 会话' },
|
||||||
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
|
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
|
||||||
{ label: 'Cron 计划任务', pageClass: 'project-cron-page', expectedHeading: 'Cron 计划任务' },
|
{ label: '计划任务', pageClass: 'project-cron-page', expectedHeading: '计划任务' },
|
||||||
|
{ label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' },
|
||||||
]) {
|
]) {
|
||||||
await page.locator('.channel-button', { hasText: channel.label }).click()
|
await page.locator('.channel-button', { hasText: channel.label }).click()
|
||||||
await page.waitForTimeout(250)
|
await page.waitForTimeout(250)
|
||||||
@@ -158,7 +159,7 @@ await server.close()
|
|||||||
|
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
workspaceMetrics,
|
workspaceMetrics,
|
||||||
workspaceInboxMetrics,
|
workspaceExploreMetrics,
|
||||||
projectMetrics,
|
projectMetrics,
|
||||||
channelSidebarHoverMetrics,
|
channelSidebarHoverMetrics,
|
||||||
stageHoverMetrics,
|
stageHoverMetrics,
|
||||||
@@ -180,8 +181,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.workspaceExploreButton) failures.push('expected fixed workspace explore button to render in project rail')
|
||||||
if (metrics.workspaceInboxButtonActive) failures.push('expected fixed workspace inbox button not to be active after login')
|
if (metrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button not to be active after login')
|
||||||
if (metrics.projectButtonActive) failures.push('expected first project button not to be active on workspace landing page')
|
if (metrics.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)}`)
|
||||||
@@ -231,11 +232,11 @@ 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 (
|
if (
|
||||||
!metrics.workspaceInboxButton ||
|
!metrics.workspaceExploreButton ||
|
||||||
metrics.workspaceInboxButton.width !== metrics.projectButton?.width ||
|
metrics.workspaceExploreButton.width !== metrics.projectButton?.width ||
|
||||||
metrics.workspaceInboxButton.height !== metrics.projectButton?.height
|
metrics.workspaceExploreButton.height !== metrics.projectButton?.height
|
||||||
) {
|
) {
|
||||||
failures.push(`workspace inbox button size ${JSON.stringify(metrics.workspaceInboxButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
|
failures.push(`workspace explore button size ${JSON.stringify(metrics.workspaceExploreButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!metrics.createProjectButton ||
|
!metrics.createProjectButton ||
|
||||||
@@ -245,14 +246,14 @@ 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 (!workspaceExploreMetrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button to become active when selected')
|
||||||
if (workspaceInboxMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace inbox page')
|
if (workspaceExploreMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace explore page')
|
||||||
if (workspaceInboxMetrics.channelSidebar) failures.push(`expected workspace inbox to hide channel sidebar, got ${JSON.stringify(workspaceInboxMetrics.channelSidebar)}`)
|
if (workspaceExploreMetrics.channelSidebar) failures.push(`expected workspace explore to hide channel sidebar, got ${JSON.stringify(workspaceExploreMetrics.channelSidebar)}`)
|
||||||
if (workspaceInboxMetrics.inspector) failures.push(`expected workspace inbox to hide inspector, got ${JSON.stringify(workspaceInboxMetrics.inspector)}`)
|
if (workspaceExploreMetrics.inspector) failures.push(`expected workspace explore to hide inspector, got ${JSON.stringify(workspaceExploreMetrics.inspector)}`)
|
||||||
if (!workspaceInboxMetrics.stage || !workspaceInboxMetrics.projectRail) {
|
if (!workspaceExploreMetrics.stage || !workspaceExploreMetrics.projectRail) {
|
||||||
failures.push(`missing workspace inbox layout regions: rail=${JSON.stringify(workspaceInboxMetrics.projectRail)}, stage=${JSON.stringify(workspaceInboxMetrics.stage)}`)
|
failures.push(`missing workspace explore layout regions: rail=${JSON.stringify(workspaceExploreMetrics.projectRail)}, stage=${JSON.stringify(workspaceExploreMetrics.stage)}`)
|
||||||
} else if (Math.abs(workspaceInboxMetrics.stage.left - workspaceInboxMetrics.projectRail.right) > 1) {
|
} else if (Math.abs(workspaceExploreMetrics.stage.left - workspaceExploreMetrics.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}`)
|
failures.push(`expected workspace explore stage to start after project rail, got stage.left=${workspaceExploreMetrics.stage.left}, rail.right=${workspaceExploreMetrics.projectRail.right}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!projectMetrics.hasProjectOverview) failures.push('expected first project click to show project overview page')
|
if (!projectMetrics.hasProjectOverview) failures.push('expected first project click to show project overview page')
|
||||||
|
|||||||
@@ -347,6 +347,11 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-button .arco-icon,
|
||||||
|
.create-project .arco-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.create-project {
|
.create-project {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
@@ -399,6 +404,18 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-title-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--senlin-soft-blue);
|
||||||
|
color: var(--senlin-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.channel-list {
|
.channel-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -429,6 +446,11 @@
|
|||||||
box-shadow: inset 3px 0 0 var(--senlin-primary);
|
box-shadow: inset 3px 0 0 var(--senlin-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-badge-muted .arco-badge-number {
|
||||||
|
background: #c9cdd4;
|
||||||
|
color: #4e5969;
|
||||||
|
}
|
||||||
|
|
||||||
.channel-left {
|
.channel-left {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -452,6 +474,17 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-create-button.arco-btn {
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: rgb(var(--arcoblue-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-create-button .arco-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.recent-sessions button {
|
.recent-sessions button {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -495,6 +528,85 @@
|
|||||||
margin: 3px 0 0;
|
margin: 3px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-overview-hero {
|
||||||
|
min-height: 112px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
border: 1px solid var(--senlin-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
color: #fff;
|
||||||
|
padding: 16px 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-overview-identity {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-overview-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgb(255 255 255 / 42%);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgb(255 255 255 / 18%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-overview-identity h3,
|
||||||
|
.project-overview-identity .arco-typography {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-overview-identity h3 {
|
||||||
|
margin: 0 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-overview-identity .arco-typography {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-overview-share {
|
||||||
|
max-width: min(520px, 42vw);
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-overview-share .arco-typography {
|
||||||
|
color: rgb(255 255 255 / 82%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-overview-share .arco-btn {
|
||||||
|
height: 30px;
|
||||||
|
max-width: 100%;
|
||||||
|
border-color: rgb(255 255 255 / 36%);
|
||||||
|
background: rgb(255 255 255 / 18%);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0 10px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-overview-share .arco-btn > span:last-child {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.metric-row {
|
.metric-row {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -579,6 +691,61 @@
|
|||||||
grid-template-columns: minmax(260px, 1fr) 82px 100px 100px 130px;
|
grid-template-columns: minmax(260px, 1fr) 82px 100px 100px 130px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-task-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-task-card {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 132px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-task-card.completed {
|
||||||
|
background: color-mix(in srgb, var(--senlin-surface) 92%, #00b42a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-task-title {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-task-title svg {
|
||||||
|
color: var(--senlin-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-task-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 10px;
|
||||||
|
color: var(--senlin-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.workspace-task-list {
|
.workspace-task-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
@@ -646,6 +813,109 @@
|
|||||||
grid-template-columns: minmax(260px, 1fr) 80px 160px 80px;
|
grid-template-columns: minmax(260px, 1fr) 80px 160px 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-file-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 8px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-file-grid.compact {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-file-tile {
|
||||||
|
min-width: 0;
|
||||||
|
height: 76px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--senlin-text);
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-file-tile:hover,
|
||||||
|
.project-file-tile:focus-visible {
|
||||||
|
border-color: #bfdbff;
|
||||||
|
background: #e8f3ff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .project-file-tile:hover,
|
||||||
|
.theme-dark .project-file-tile:focus-visible {
|
||||||
|
border-color: color-mix(in srgb, var(--senlin-primary) 56%, var(--senlin-border));
|
||||||
|
background: color-mix(in srgb, var(--senlin-primary) 18%, var(--senlin-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-file-icon {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eef2f7;
|
||||||
|
color: #4e5969;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-file-icon.folder {
|
||||||
|
background: #fff3cf;
|
||||||
|
color: #f0a020;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-file-icon.pdf {
|
||||||
|
background: #fff1f0;
|
||||||
|
color: #f53f3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-file-icon.sheet {
|
||||||
|
background: #e8ffea;
|
||||||
|
color: #00b42a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-file-icon.doc,
|
||||||
|
.project-file-icon.note {
|
||||||
|
background: #e8f3ff;
|
||||||
|
color: #165dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-file-icon.image {
|
||||||
|
background: #f2f3f5;
|
||||||
|
color: #86909c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-file-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-file-name,
|
||||||
|
.project-file-type,
|
||||||
|
.project-file-meta {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-file-name {
|
||||||
|
color: var(--senlin-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-file-type,
|
||||||
|
.project-file-meta {
|
||||||
|
color: var(--senlin-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.project-channel-page {
|
.project-channel-page {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -815,6 +1085,10 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-editor-card .arco-card-body {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.action-modal .arco-modal-content {
|
.action-modal .arco-modal-content {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,14 @@ export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): Proje
|
|||||||
const project: Project = {
|
const project: Project = {
|
||||||
id: String(payload.project.id),
|
id: String(payload.project.id),
|
||||||
name: payload.project.name,
|
name: payload.project.name,
|
||||||
short: payload.project.initials,
|
identifier: payload.project.identifier ?? '',
|
||||||
|
icon: payload.project.icon ?? '',
|
||||||
|
background: payload.project.background ?? '',
|
||||||
|
description: payload.project.description,
|
||||||
|
short: projectIconLabel(payload.project.icon ?? '', payload.project.initials),
|
||||||
badge: payload.project.unreadCount,
|
badge: payload.project.unreadCount,
|
||||||
urgent: payload.project.unreadCount > 20,
|
urgent: payload.project.unreadCount > 20,
|
||||||
color: projectColors[index % projectColors.length],
|
color: projectColor(payload.project.background ?? '', index),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -35,6 +39,22 @@ export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): Proje
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectIconLabel(icon: string, fallback: string) {
|
||||||
|
const trimmed = icon.trim()
|
||||||
|
if (!trimmed) return fallback
|
||||||
|
const chars = Array.from(trimmed)
|
||||||
|
if (chars.length <= 2) return trimmed
|
||||||
|
return chars.slice(0, 2).join('').toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectColor(background: string, index: number) {
|
||||||
|
const trimmed = background.trim()
|
||||||
|
if (trimmed.startsWith('#') || trimmed.startsWith('rgb') || trimmed.startsWith('hsl')) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return projectColors[index % projectColors.length]
|
||||||
|
}
|
||||||
|
|
||||||
function mapChannel(channel: BackendChannel): ProjectChannel {
|
function mapChannel(channel: BackendChannel): ProjectChannel {
|
||||||
return {
|
return {
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
@@ -79,7 +99,7 @@ function channelLabel(channel: BackendChannel) {
|
|||||||
case 'notes_sources':
|
case 'notes_sources':
|
||||||
return '笔记资料'
|
return '笔记资料'
|
||||||
case 'cron':
|
case 'cron':
|
||||||
return 'Cron 计划任务'
|
return '计划任务'
|
||||||
default:
|
default:
|
||||||
return channel.title
|
return channel.title
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ export type BackendProject = {
|
|||||||
id?: number
|
id?: number
|
||||||
Name?: string
|
Name?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
Identifier?: string
|
||||||
|
identifier?: string
|
||||||
|
Icon?: string
|
||||||
|
icon?: string
|
||||||
|
Background?: string
|
||||||
|
background?: string
|
||||||
Description?: string
|
Description?: string
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
@@ -12,6 +18,9 @@ export type BackendProject = {
|
|||||||
export type BackendWorkspaceProject = {
|
export type BackendWorkspaceProject = {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
identifier?: string
|
||||||
|
icon?: string
|
||||||
|
background?: string
|
||||||
description: string
|
description: string
|
||||||
initials: string
|
initials: string
|
||||||
unreadCount: number
|
unreadCount: number
|
||||||
@@ -88,6 +97,13 @@ export type BackendProjectWorkspace = {
|
|||||||
cronPlans: BackendCronPlan[]
|
cronPlans: BackendCronPlan[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BackendTag = {
|
||||||
|
ID?: number
|
||||||
|
id?: number
|
||||||
|
Name?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchProjects(session: ApiSession) {
|
export async function fetchProjects(session: ApiSession) {
|
||||||
return apiRequest<BackendProject[]>('/api/projects', { token: session.token })
|
return apiRequest<BackendProject[]>('/api/projects', { token: session.token })
|
||||||
}
|
}
|
||||||
@@ -96,8 +112,15 @@ export async function fetchProjectWorkspace(session: ApiSession, projectId: stri
|
|||||||
return apiRequest<BackendProjectWorkspace>(`/api/projects/${projectId}/workspace`, { token: session.token })
|
return apiRequest<BackendProjectWorkspace>(`/api/projects/${projectId}/workspace`, { token: session.token })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchProjectTags(session: ApiSession, projectId: string | number) {
|
||||||
|
return apiRequest<BackendTag[]>(`/api/projects/${projectId}/tags`, { token: session.token })
|
||||||
|
}
|
||||||
|
|
||||||
export type CreateProjectInput = {
|
export type CreateProjectInput = {
|
||||||
name: string
|
name: string
|
||||||
|
identifier?: string
|
||||||
|
icon?: string
|
||||||
|
background?: string
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +129,15 @@ export type CreateTaskInput = {
|
|||||||
description?: string
|
description?: string
|
||||||
status?: string
|
status?: string
|
||||||
dueAt?: string
|
dueAt?: string
|
||||||
|
tag?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateTaskInput = {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
completed: boolean
|
||||||
|
nextProjectId?: number
|
||||||
|
tag?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UploadSourceInput = {
|
export type UploadSourceInput = {
|
||||||
@@ -120,6 +152,10 @@ export type CreateCronPlanInput = {
|
|||||||
nextRunAt?: string
|
nextRunAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CreateProjectTagInput = {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function createProject(session: ApiSession, input: CreateProjectInput) {
|
export async function createProject(session: ApiSession, input: CreateProjectInput) {
|
||||||
return apiRequest<BackendProject>('/api/projects', {
|
return apiRequest<BackendProject>('/api/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -136,6 +172,14 @@ export async function createTask(session: ApiSession, projectId: string | number
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateTask(session: ApiSession, projectId: string | number, taskId: string | number, input: UpdateTaskInput) {
|
||||||
|
return apiRequest<BackendTask>(`/api/projects/${projectId}/tasks/${taskId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
token: session.token,
|
||||||
|
body: input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadSource(session: ApiSession, projectId: string | number, input: UploadSourceInput) {
|
export async function uploadSource(session: ApiSession, projectId: string | number, input: UploadSourceInput) {
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('file', input.file)
|
body.append('file', input.file)
|
||||||
@@ -154,3 +198,11 @@ export async function createCronPlan(session: ApiSession, projectId: string | nu
|
|||||||
body: input,
|
body: input,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createProjectTag(session: ApiSession, projectId: string | number, input: CreateProjectTagInput) {
|
||||||
|
return apiRequest<BackendTag>(`/api/projects/${projectId}/tags`, {
|
||||||
|
method: 'POST',
|
||||||
|
token: session.token,
|
||||||
|
body: input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,14 +7,18 @@ import { mapWorkspace } from '../api/mappers'
|
|||||||
import {
|
import {
|
||||||
createCronPlan,
|
createCronPlan,
|
||||||
createProject,
|
createProject,
|
||||||
|
createProjectTag,
|
||||||
createTask,
|
createTask,
|
||||||
fetchProjectWorkspace,
|
fetchProjectWorkspace,
|
||||||
fetchProjects,
|
fetchProjects,
|
||||||
|
updateTask,
|
||||||
uploadSource,
|
uploadSource,
|
||||||
} from '../api/projects'
|
} from '../api/projects'
|
||||||
import { LoginPage } from '../pages/login'
|
import { LoginPage } from '../pages/login'
|
||||||
import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals'
|
import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals'
|
||||||
|
import type { ProjectSettingsUpdate } from '../pages/projects/project-sidebar'
|
||||||
import { ProjectPage } from '../pages/workspace-home'
|
import { ProjectPage } from '../pages/workspace-home'
|
||||||
|
import type { WorkspaceTaskUpdate } from '../pages/workspace-body'
|
||||||
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
|
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -26,13 +30,14 @@ function App() {
|
|||||||
const [activeProjectID, setActiveProjectID] = useState<string>('')
|
const [activeProjectID, setActiveProjectID] = useState<string>('')
|
||||||
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
|
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
|
||||||
const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
|
const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
|
||||||
const [, setSelectedItem] = useState('Inbox 待处理(36)')
|
const [, setSelectedItem] = useState('探索采集')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [actionLoading, setActionLoading] = useState(false)
|
const [actionLoading, setActionLoading] = useState(false)
|
||||||
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
|
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]
|
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0]
|
||||||
|
const activeTagOptions = activeWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
|
||||||
|
|
||||||
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID = activeProjectID) {
|
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID = activeProjectID) {
|
||||||
const backendProjects = await fetchProjects(nextSession)
|
const backendProjects = await fetchProjects(nextSession)
|
||||||
@@ -102,7 +107,13 @@ function App() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
void runAction(async () => {
|
void runAction(async () => {
|
||||||
const created = await createProject(requireSession(), { name: draft.name.trim(), description: draft.description.trim() })
|
const created = await createProject(requireSession(), {
|
||||||
|
name: draft.name.trim(),
|
||||||
|
identifier: draft.identifier.trim(),
|
||||||
|
icon: draft.icon.trim(),
|
||||||
|
background: draft.background.trim(),
|
||||||
|
description: draft.description.trim(),
|
||||||
|
})
|
||||||
const nextProjectID = String(created.ID ?? created.id ?? '')
|
const nextProjectID = String(created.ID ?? created.id ?? '')
|
||||||
await refreshAfterAction(nextProjectID)
|
await refreshAfterAction(nextProjectID)
|
||||||
if (nextProjectID) {
|
if (nextProjectID) {
|
||||||
@@ -124,6 +135,7 @@ function App() {
|
|||||||
description: draft.description.trim(),
|
description: draft.description.trim(),
|
||||||
status: 'open',
|
status: 'open',
|
||||||
dueAt: normalizeOptionalTime(draft.dueAt),
|
dueAt: normalizeOptionalTime(draft.dueAt),
|
||||||
|
tag: draft.tag.trim(),
|
||||||
})
|
})
|
||||||
await refreshAfterAction()
|
await refreshAfterAction()
|
||||||
setActiveChannel('tasks')
|
setActiveChannel('tasks')
|
||||||
@@ -167,6 +179,18 @@ function App() {
|
|||||||
}, '计划任务已创建')
|
}, '计划任务已创建')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCreateProjectTag(name: string) {
|
||||||
|
const trimmedName = name.trim()
|
||||||
|
if (!trimmedName) {
|
||||||
|
Message.warning('请输入标签名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void runAction(async () => {
|
||||||
|
await createProjectTag(requireSession(), requireActiveProject(), { name: trimmedName })
|
||||||
|
await refreshAfterAction()
|
||||||
|
}, '标签已创建')
|
||||||
|
}
|
||||||
|
|
||||||
function openTask(project: Project, taskID: string) {
|
function openTask(project: Project, taskID: string) {
|
||||||
setActiveProjectID(project.id)
|
setActiveProjectID(project.id)
|
||||||
setActiveView('project')
|
setActiveView('project')
|
||||||
@@ -174,6 +198,38 @@ function App() {
|
|||||||
setActiveTaskID(taskID)
|
setActiveTaskID(taskID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleUpdateWorkspaceTask(update: WorkspaceTaskUpdate) {
|
||||||
|
void runAction(async () => {
|
||||||
|
await updateTask(requireSession(), update.originalProjectId, update.taskId, {
|
||||||
|
title: update.title,
|
||||||
|
description: update.summary,
|
||||||
|
completed: update.completed,
|
||||||
|
nextProjectId: Number(update.nextProjectId),
|
||||||
|
tag: update.tag,
|
||||||
|
})
|
||||||
|
await refreshAfterAction(update.nextProjectId)
|
||||||
|
}, '任务已更新')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateProject(update: ProjectSettingsUpdate) {
|
||||||
|
setWorkspaces((current) =>
|
||||||
|
current.map((workspace) => {
|
||||||
|
if (workspace.project.id !== update.projectId) return workspace
|
||||||
|
const nextProject = {
|
||||||
|
...workspace.project,
|
||||||
|
name: update.name,
|
||||||
|
identifier: update.identifier,
|
||||||
|
icon: update.icon,
|
||||||
|
background: update.background,
|
||||||
|
description: update.description,
|
||||||
|
short: projectIconLabel(update.icon, update.identifier || update.name.slice(0, 2)),
|
||||||
|
color: projectColor(update.background, workspace.project.color),
|
||||||
|
}
|
||||||
|
return { ...workspace, project: nextProject }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
<main className={dark ? 'app theme-dark' : 'app'}>
|
<main className={dark ? 'app theme-dark' : 'app'}>
|
||||||
@@ -190,7 +246,7 @@ function App() {
|
|||||||
activeTaskID={activeTaskID}
|
activeTaskID={activeTaskID}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onSelectWorkspace={() => setActiveView('workspace')}
|
onSelectWorkspace={() => setActiveView('workspace')}
|
||||||
onSelectWorkspaceInbox={() => setActiveView('workspace-inbox')}
|
onSelectWorkspaceExplore={() => setActiveView('workspace-explore')}
|
||||||
onSelectProject={(project) => {
|
onSelectProject={(project) => {
|
||||||
setActiveProjectID(project.id)
|
setActiveProjectID(project.id)
|
||||||
setActiveView('project')
|
setActiveView('project')
|
||||||
@@ -209,6 +265,9 @@ function App() {
|
|||||||
onCreateTask={() => setActiveModal('task')}
|
onCreateTask={() => setActiveModal('task')}
|
||||||
onUploadSource={() => setActiveModal('source')}
|
onUploadSource={() => setActiveModal('source')}
|
||||||
onCreateCronPlan={() => setActiveModal('cron')}
|
onCreateCronPlan={() => setActiveModal('cron')}
|
||||||
|
onUpdateWorkspaceTask={handleUpdateWorkspaceTask}
|
||||||
|
onUpdateProject={handleUpdateProject}
|
||||||
|
onCreateProjectTag={handleCreateProjectTag}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Spin loading />
|
<Spin loading />
|
||||||
@@ -221,10 +280,25 @@ function App() {
|
|||||||
onCreateTask={handleCreateTask}
|
onCreateTask={handleCreateTask}
|
||||||
onUploadSource={handleUploadSource}
|
onUploadSource={handleUploadSource}
|
||||||
onCreateCronPlan={handleCreateCronPlan}
|
onCreateCronPlan={handleCreateCronPlan}
|
||||||
|
tagOptions={activeTagOptions}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectIconLabel(icon: string, fallback: string) {
|
||||||
|
const trimmed = icon.trim()
|
||||||
|
if (!trimmed) return fallback
|
||||||
|
const chars = Array.from(trimmed)
|
||||||
|
if (chars.length <= 2) return trimmed
|
||||||
|
return chars.slice(0, 2).join('').toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectColor(background: string, fallback: string) {
|
||||||
|
const trimmed = background.trim()
|
||||||
|
if (trimmed.startsWith('#') || trimmed.startsWith('rgb') || trimmed.startsWith('hsl')) return trimmed
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Input, Modal, Space, Switch, Typography } from '@arco-design/web-react'
|
import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
const { TextArea } = Input
|
const { TextArea } = Input
|
||||||
@@ -8,6 +8,9 @@ export type ProjectActionModal = 'project' | 'task' | 'source' | 'cron' | null
|
|||||||
|
|
||||||
export type ProjectDraft = {
|
export type ProjectDraft = {
|
||||||
name: string
|
name: string
|
||||||
|
identifier: string
|
||||||
|
icon: string
|
||||||
|
background: string
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,6 +18,7 @@ export type TaskDraft = {
|
|||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
dueAt: string
|
dueAt: string
|
||||||
|
tag: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SourceDraft = {
|
export type SourceDraft = {
|
||||||
@@ -37,6 +41,7 @@ export function ProjectActionModals({
|
|||||||
onCreateTask,
|
onCreateTask,
|
||||||
onUploadSource,
|
onUploadSource,
|
||||||
onCreateCronPlan,
|
onCreateCronPlan,
|
||||||
|
tagOptions,
|
||||||
}: {
|
}: {
|
||||||
activeModal: ProjectActionModal
|
activeModal: ProjectActionModal
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -45,16 +50,17 @@ export function ProjectActionModals({
|
|||||||
onCreateTask: (draft: TaskDraft) => void
|
onCreateTask: (draft: TaskDraft) => void
|
||||||
onUploadSource: (draft: SourceDraft) => void
|
onUploadSource: (draft: SourceDraft) => void
|
||||||
onCreateCronPlan: (draft: CronDraft) => void
|
onCreateCronPlan: (draft: CronDraft) => void
|
||||||
|
tagOptions: string[]
|
||||||
}) {
|
}) {
|
||||||
const [project, setProject] = useState<ProjectDraft>({ name: '', description: '' })
|
const [project, setProject] = useState<ProjectDraft>({ name: '', identifier: '', icon: '', background: '', description: '' })
|
||||||
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', dueAt: '' })
|
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', dueAt: '', tag: '' })
|
||||||
const [source, setSource] = useState<SourceDraft>({ title: '', file: null })
|
const [source, setSource] = useState<SourceDraft>({ title: '', file: null })
|
||||||
const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
|
const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeModal === null) {
|
if (activeModal === null) {
|
||||||
setProject({ name: '', description: '' })
|
setProject({ name: '', identifier: '', icon: '', background: '', description: '' })
|
||||||
setTask({ title: '', description: '', dueAt: '' })
|
setTask({ title: '', description: '', dueAt: '', tag: '' })
|
||||||
setSource({ title: '', file: null })
|
setSource({ title: '', file: null })
|
||||||
setCron({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
|
setCron({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
|
||||||
}
|
}
|
||||||
@@ -76,7 +82,19 @@ export function ProjectActionModals({
|
|||||||
<Input placeholder="例如:项目 A3" value={project.name} onChange={(name) => setProject((draft) => ({ ...draft, name }))} />
|
<Input placeholder="例如:项目 A3" value={project.name} onChange={(name) => setProject((draft) => ({ ...draft, name }))} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<Text>项目说明</Text>
|
<Text>项目标识</Text>
|
||||||
|
<Input placeholder="例如:A3 / EXP / DATA" value={project.identifier} onChange={(identifier) => setProject((draft) => ({ ...draft, identifier }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<Text>项目图标</Text>
|
||||||
|
<Input placeholder="例如:A3 / 🚀 / compass" value={project.icon} onChange={(icon) => setProject((draft) => ({ ...draft, icon }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<Text>项目背景</Text>
|
||||||
|
<Input placeholder="例如:#165DFF 或背景图片 URL" value={project.background} onChange={(background) => setProject((draft) => ({ ...draft, background }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<Text>项目简介</Text>
|
||||||
<TextArea rows={4} placeholder="项目目标、背景或协作说明" value={project.description} onChange={(description) => setProject((draft) => ({ ...draft, description }))} />
|
<TextArea rows={4} placeholder="项目目标、背景或协作说明" value={project.description} onChange={(description) => setProject((draft) => ({ ...draft, description }))} />
|
||||||
</label>
|
</label>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -103,6 +121,19 @@ export function ProjectActionModals({
|
|||||||
<Text>截止时间</Text>
|
<Text>截止时间</Text>
|
||||||
<Input placeholder="2026-07-21T09:30:00Z,可留空" value={task.dueAt} onChange={(dueAt) => setTask((draft) => ({ ...draft, dueAt }))} />
|
<Input placeholder="2026-07-21T09:30:00Z,可留空" value={task.dueAt} onChange={(dueAt) => setTask((draft) => ({ ...draft, dueAt }))} />
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
<Text>所属标签</Text>
|
||||||
|
<Select
|
||||||
|
allowCreate
|
||||||
|
placeholder="选择或输入标签"
|
||||||
|
value={task.tag}
|
||||||
|
onChange={(tag) => setTask((draft) => ({ ...draft, tag }))}
|
||||||
|
>
|
||||||
|
{tagOptions.map((tag) => (
|
||||||
|
<Select.Option key={tag} value={tag}>{tag}</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ProjectAi } from './project-ai'
|
import { ProjectAi } from './project-ai'
|
||||||
import { ProjectCron } from './project-cron'
|
import { ProjectCron } from './project-cron'
|
||||||
|
import { ProjectNewChannel } from './project-new-channel'
|
||||||
import { ProjectNotes } from './project-notes'
|
import { ProjectNotes } from './project-notes'
|
||||||
import { ProjectOverview } from './project-overview'
|
import { ProjectOverview } from './project-overview'
|
||||||
import { ProjectTasks } from './project-tasks'
|
import { ProjectTasks } from './project-tasks'
|
||||||
@@ -35,6 +36,8 @@ export function ProjectChannelPage({
|
|||||||
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
|
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
|
||||||
case 'cron':
|
case 'cron':
|
||||||
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} />
|
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} />
|
||||||
|
case 'new-channel':
|
||||||
|
return <ProjectNewChannel activeWorkspace={activeWorkspace} />
|
||||||
case 'overview':
|
case 'overview':
|
||||||
default:
|
default:
|
||||||
return <ProjectOverview activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
|
return <ProjectOverview activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
|
|||||||
<div className="project-channel-page project-cron-page overview-page">
|
<div className="project-channel-page project-cron-page overview-page">
|
||||||
<div className="overview-head">
|
<div className="overview-head">
|
||||||
<div>
|
<div>
|
||||||
<Title heading={4}>Cron 计划任务</Title>
|
<Title heading={4}>计划任务</Title>
|
||||||
<Text type="secondary">{project.name} 的定时任务、自动检查和周期性 AI 工作。</Text>
|
<Text type="secondary">{project.name} 的定时任务、自动检查和周期性 AI 工作。</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button type="primary" icon={<IconPlus />} onClick={onCreateCronPlan}>新建计划</Button>
|
<Button type="primary" icon={<IconPlus />} onClick={onCreateCronPlan}>新建计划</Button>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { IconFile, IconFileImage, IconFilePdf, IconFolder } from '@arco-design/web-react/icon'
|
||||||
|
import type { NoteSource } from './project-types'
|
||||||
|
|
||||||
|
export type ProjectFileItem = NoteSource | {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
updated: string
|
||||||
|
owner: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectFileGrid({
|
||||||
|
items,
|
||||||
|
onSelectItem,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
items: ProjectFileItem[]
|
||||||
|
onSelectItem: (title: string) => void
|
||||||
|
compact?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={compact ? 'project-file-grid compact' : 'project-file-grid'}>
|
||||||
|
{items.map((item) => {
|
||||||
|
const fileKind = inferFileKind(item)
|
||||||
|
return (
|
||||||
|
<button className="project-file-tile" key={item.id} onClick={() => onSelectItem(item.title)}>
|
||||||
|
<span className={`project-file-icon ${fileKind.kind}`}>{fileIcon(fileKind.kind)}</span>
|
||||||
|
<span className="project-file-copy">
|
||||||
|
<span className="project-file-name">{item.title}</span>
|
||||||
|
<span className="project-file-type">{fileKind.label}</span>
|
||||||
|
<span className="project-file-meta">{fileKind.meta}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferFileKind(item: ProjectFileItem) {
|
||||||
|
const value = `${item.title} ${item.type} ${item.source ?? ''}`.toLowerCase()
|
||||||
|
if (item.type === 'folder' || item.source === 'folder') return { kind: 'folder', label: '文件夹', meta: item.updated }
|
||||||
|
if (value.includes('pdf')) return { kind: 'pdf', label: 'PDF 文档', meta: item.updated }
|
||||||
|
if (value.includes('png') || value.includes('jpg') || value.includes('jpeg') || value.includes('图片')) return { kind: 'image', label: '图片文件', meta: item.updated }
|
||||||
|
if (value.includes('excel') || value.includes('xlsx') || value.includes('表')) return { kind: 'sheet', label: '表格文件', meta: item.updated }
|
||||||
|
if (value.includes('word') || value.includes('doc') || value.includes('文档')) return { kind: 'doc', label: '文档', meta: item.updated }
|
||||||
|
if (value.includes('markdown') || value.includes('笔记')) return { kind: 'note', label: 'Markdown 笔记', meta: item.updated }
|
||||||
|
return { kind: 'file', label: item.type || '文件', meta: item.updated }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIcon(kind: string) {
|
||||||
|
if (kind === 'folder') return <IconFolder />
|
||||||
|
if (kind === 'pdf') return <IconFilePdf />
|
||||||
|
if (kind === 'image') return <IconFileImage />
|
||||||
|
return <IconFile />
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Button, Card, Input, Select, Space, Tag, Typography } from '@arco-design/web-react'
|
||||||
|
import { IconApps, IconLink, IconPlus } from '@arco-design/web-react/icon'
|
||||||
|
import type { ProjectWorkspace } from './project-types'
|
||||||
|
|
||||||
|
const { Title, Text } = Typography
|
||||||
|
const { TextArea } = Input
|
||||||
|
|
||||||
|
export function ProjectNewChannel({ activeWorkspace }: { activeWorkspace: ProjectWorkspace }) {
|
||||||
|
return (
|
||||||
|
<div className="project-channel-page project-new-channel-page overview-page">
|
||||||
|
<div className="overview-head">
|
||||||
|
<div>
|
||||||
|
<Title heading={4}>新建频道</Title>
|
||||||
|
<Text type="secondary">{activeWorkspace.project.name} 的频道入口配置,用于接入外部页面、资料集合或项目流程。</Text>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" icon={<IconPlus />}>保存频道</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="queue-section channel-editor-card" bordered>
|
||||||
|
<Space direction="vertical" size={12} className="action-form">
|
||||||
|
<label>
|
||||||
|
<Text>频道名称</Text>
|
||||||
|
<Input placeholder="例如:客户研究频道" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<Text>频道类型</Text>
|
||||||
|
<Select defaultValue="link">
|
||||||
|
<Select.Option value="link">外部链接</Select.Option>
|
||||||
|
<Select.Option value="source">资料集合</Select.Option>
|
||||||
|
<Select.Option value="workflow">项目流程</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<Text>图标</Text>
|
||||||
|
<Input placeholder="例如:link / user / file" prefix={<IconApps />} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<Text>链接地址</Text>
|
||||||
|
<Input placeholder="https://example.com/channel" prefix={<IconLink />} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<Text>说明</Text>
|
||||||
|
<TextArea rows={4} placeholder="频道用途、维护规则或采集说明" />
|
||||||
|
</label>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="queue-section" bordered>
|
||||||
|
<div className="section-header">
|
||||||
|
<Title heading={6}>创建后可接入的内容</Title>
|
||||||
|
<Tag color="gray">配置预览</Tag>
|
||||||
|
</div>
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color="arcoblue">外部资料库</Tag>
|
||||||
|
<Tag color="green">项目流程页</Tag>
|
||||||
|
<Tag color="orange">RSS/探索结果</Tag>
|
||||||
|
<Tag color="purple">AI 会话上下文</Tag>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button, Card, Space, Tag, Typography } from '@arco-design/web-react'
|
import { Button, Card, Space, Typography } from '@arco-design/web-react'
|
||||||
import { IconFile, IconFolder, IconPlus, IconSearch } from '@arco-design/web-react/icon'
|
import { IconPlus, IconSearch } from '@arco-design/web-react/icon'
|
||||||
|
import { ProjectFileGrid } from './project-file-grid'
|
||||||
import type { ProjectWorkspace } from './project-types'
|
import type { ProjectWorkspace } from './project-types'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
@@ -22,31 +23,26 @@ export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }:
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="folder-grid">
|
<ProjectFileGrid items={folderItems(folders)} onSelectItem={onSelectItem} />
|
||||||
{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>
|
<Card className="queue-section" bordered>
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<Title heading={6}>最近更新</Title>
|
<Title heading={6}>最近更新</Title>
|
||||||
<Button type="text" size="mini">按更新时间排序</Button>
|
<Button type="text" size="mini">按更新时间排序</Button>
|
||||||
</div>
|
</div>
|
||||||
{notes.map((note) => (
|
<ProjectFileGrid items={notes} onSelectItem={onSelectItem} />
|
||||||
<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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function folderItems(names: string[]) {
|
||||||
|
return names.map((name) => ({
|
||||||
|
id: `folder-${name}`,
|
||||||
|
title: name,
|
||||||
|
type: 'folder',
|
||||||
|
updated: '文件夹',
|
||||||
|
owner: '',
|
||||||
|
source: 'folder',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,43 +1,50 @@
|
|||||||
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 { IconCheckCircleFill, IconClockCircle, IconFile, IconLink } from '@arco-design/web-react/icon'
|
||||||
import type { InboxItem, ProjectWorkspace } from './project-types'
|
import { ProjectFileGrid } from './project-file-grid'
|
||||||
|
import type { ProjectWorkspace, TaskItem } from './project-types'
|
||||||
|
|
||||||
const { Row, Col } = Grid
|
const { Row, Col } = Grid
|
||||||
const { Title, Text } = Typography
|
const { Title, Text, Paragraph } = Typography
|
||||||
|
|
||||||
export function ProjectOverview({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
|
export function ProjectOverview({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
|
||||||
const { inbox, tasks, aiSessions, notes, cronJobs } = activeWorkspace
|
const { project, tasks, notes, cronJobs } = activeWorkspace
|
||||||
|
const runningTasks = useMemo(() => tasks.filter((task) => !task.completed), [tasks])
|
||||||
|
const completedTasks = useMemo(() => tasks.filter((task) => task.completed), [tasks])
|
||||||
|
const shareURL = useMemo(() => projectShareURL(project.identifier || project.id), [project.id, project.identifier])
|
||||||
const metrics = useMemo(() => [
|
const metrics = useMemo(() => [
|
||||||
{ title: 'Inbox 待处理', value: inbox.length, delta: '实时接口', icon: <IconEmail />, color: 'arcoblue' },
|
{ title: '进行中的任务', value: runningTasks.length, icon: <IconCheckCircleFill />, color: 'green' },
|
||||||
{ title: '进行中的任务', value: tasks.filter((task) => !task.completed).length, delta: '实时接口', icon: <IconCheckCircleFill />, color: 'green' },
|
{ title: '完成的任务', value: completedTasks.length, icon: <IconCheckCircleFill />, color: 'arcoblue' },
|
||||||
{ title: 'AI 会话活跃', value: aiSessions.length, delta: '实时接口', icon: <IconRobot />, color: 'purple' },
|
{ title: '笔记资料总数', value: notes.length, icon: <IconFile />, color: 'cyan' },
|
||||||
{ title: '笔记资料总数', value: notes.length, delta: '实时接口', icon: <IconFile />, color: 'cyan' },
|
{ title: '计划任务', value: cronJobs.length, icon: <IconClockCircle />, color: 'orange' },
|
||||||
{ title: '计划任务', value: cronJobs.length, delta: '实时接口', icon: <IconClockCircle />, color: 'orange' },
|
], [completedTasks.length, cronJobs.length, notes.length, runningTasks.length])
|
||||||
], [aiSessions.length, cronJobs.length, inbox.length, notes.length, tasks])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overview-page">
|
<div className="overview-page project-overview-page">
|
||||||
<div className="overview-head">
|
<section className="project-overview-hero" style={{ background: projectHeroBackground(project.background, project.color) }}>
|
||||||
<div>
|
<div className="project-overview-identity">
|
||||||
<Title heading={4}>概况</Title>
|
<span className="project-overview-icon">{project.short}</span>
|
||||||
|
<div>
|
||||||
|
<Title heading={3}>{project.name}</Title>
|
||||||
|
<Paragraph ellipsis={{ rows: 2 }}>{project.description || '暂无简介'}</Paragraph>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
<div className="project-overview-share">
|
||||||
<Button icon={<IconCalendar />}>2026-07-20</Button>
|
<Text>项目 URL</Text>
|
||||||
<Button icon={<IconMore />} />
|
<Button icon={<IconLink />} onClick={() => onSelectItem(shareURL)}>{shareURL}</Button>
|
||||||
</Space>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<Row gutter={12} className="metric-row">
|
<Row gutter={12} className="metric-row">
|
||||||
{metrics.map((metric) => (
|
{metrics.map((metric) => (
|
||||||
<Col span={24 / metrics.length} key={metric.title}>
|
<Col span={6} key={metric.title}>
|
||||||
<Card className="metric-card" bordered>
|
<Card className="metric-card" bordered>
|
||||||
<Space align="start">
|
<Space align="start">
|
||||||
<Tag color={metric.color}>{metric.icon}</Tag>
|
<Tag color={metric.color}>{metric.icon}</Tag>
|
||||||
<div>
|
<div>
|
||||||
<Text type="secondary">{metric.title}</Text>
|
<Text type="secondary">{metric.title}</Text>
|
||||||
<Title heading={3}>{metric.value}</Title>
|
<Title heading={3}>{metric.value}</Title>
|
||||||
<Text className="metric-delta">{metric.delta}</Text>
|
<Text className="metric-delta">实时接口</Text>
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -45,69 +52,40 @@ export function ProjectOverview({ activeWorkspace, onSelectItem }: { activeWorks
|
|||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<QueueSection title={`Inbox 待处理(${inbox.length})`} items={inbox} onSelectItem={onSelectItem} />
|
<TaskCardSection title={`进行中的任务(${runningTasks.length})`} tasks={runningTasks} emptyText="当前没有进行中的任务" onSelectItem={onSelectItem} />
|
||||||
<TaskSection tasks={tasks} onSelectItem={onSelectItem} />
|
<TaskCardSection title={`完成的任务(${completedTasks.length})`} tasks={completedTasks} emptyText="当前没有完成的任务" onSelectItem={onSelectItem} />
|
||||||
<AISessionSection aiSessions={aiSessions} onSelectItem={onSelectItem} />
|
|
||||||
<NoteSection notes={notes} onSelectItem={onSelectItem} />
|
<NoteSection notes={notes} onSelectItem={onSelectItem} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function QueueSection({ title, items, onSelectItem }: { title: string; items: InboxItem[]; onSelectItem: (title: string) => void }) {
|
function TaskCardSection({ title, tasks, emptyText, onSelectItem }: { title: string; tasks: TaskItem[]; emptyText: string; onSelectItem: (title: string) => void }) {
|
||||||
return (
|
return (
|
||||||
<Card className="queue-section" bordered>
|
<Card className="queue-section overview-task-section" bordered>
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<Title heading={6}>{title}</Title>
|
<Title heading={6}>{title}</Title>
|
||||||
<Button type="text" size="mini">查看全部</Button>
|
<Button type="text" size="mini">查看全部</Button>
|
||||||
</div>
|
</div>
|
||||||
{items.map((item) => (
|
{tasks.length === 0 ? (
|
||||||
<button key={item.title} className="data-row" onClick={() => onSelectItem(item.title)}>
|
<Text type="secondary">{emptyText}</Text>
|
||||||
<span className="row-title"><IconFile /> {item.title}</span>
|
) : (
|
||||||
<span>{item.meta}</span>
|
<div className="overview-task-grid">
|
||||||
<Tag>{item.tag}</Tag>
|
{tasks.map((task) => (
|
||||||
<span>{item.owner}</span>
|
<button key={task.id} className={task.completed ? 'overview-task-card completed' : 'overview-task-card'} onClick={() => onSelectItem(task.title)}>
|
||||||
<time>{item.time}</time>
|
<span className="overview-task-title">
|
||||||
</button>
|
<IconCheckCircleFill />
|
||||||
))}
|
{task.title}
|
||||||
</Card>
|
</span>
|
||||||
)
|
<Text type="secondary" ellipsis={{ showTooltip: true }}>{task.summary || '暂无任务内容'}</Text>
|
||||||
}
|
<div className="overview-task-meta">
|
||||||
|
<Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag || (task.completed ? '已完成' : '进行中')}</Tag>
|
||||||
function TaskSection({ tasks, onSelectItem }: { tasks: ProjectWorkspace['tasks']; onSelectItem: (title: string) => void }) {
|
<span>进度 {task.progress}</span>
|
||||||
return (
|
<span>创建 {task.createdAt}</span>
|
||||||
<Card className="queue-section" bordered>
|
</div>
|
||||||
<div className="section-header">
|
</button>
|
||||||
<Title heading={6}>进行中的任务({tasks.length})</Title>
|
))}
|
||||||
<Button type="text" size="mini">查看全部</Button>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{tasks.map((task) => (
|
|
||||||
<button key={task.title} className="data-row task-row" onClick={() => onSelectItem(task.title)}>
|
|
||||||
<span className="row-title"><IconCheckCircleFill /> {task.title}</span>
|
|
||||||
<Tag color="arcoblue">{task.tag}</Tag>
|
|
||||||
<span>进度 {task.progress}</span>
|
|
||||||
<span>负责人 {task.owner}</span>
|
|
||||||
<time>创建 {task.createdAt}</time>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AISessionSection({ aiSessions, onSelectItem }: { aiSessions: ProjectWorkspace['aiSessions']; onSelectItem: (title: string) => void }) {
|
|
||||||
return (
|
|
||||||
<Card className="queue-section" bordered>
|
|
||||||
<div className="section-header">
|
|
||||||
<Title heading={6}>AI 会话({aiSessions.length})</Title>
|
|
||||||
<Button type="text" size="mini">查看全部</Button>
|
|
||||||
</div>
|
|
||||||
{aiSessions.map((session) => (
|
|
||||||
<button key={session.title} className="data-row ai-row" onClick={() => onSelectItem(session.title)}>
|
|
||||||
<span className="row-title"><IconRobot /> {session.title}</span>
|
|
||||||
<span>{session.summary}</span>
|
|
||||||
<span>{session.owner}</span>
|
|
||||||
<time>{session.time}</time>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -116,17 +94,27 @@ function NoteSection({ notes, onSelectItem }: { notes: ProjectWorkspace['notes']
|
|||||||
return (
|
return (
|
||||||
<Card className="queue-section" bordered>
|
<Card className="queue-section" bordered>
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<Title heading={6}>最近笔记资料(5)</Title>
|
<Title heading={6}>最近笔记资料({notes.length})</Title>
|
||||||
<Button type="text" size="mini">查看全部</Button>
|
<Button type="text" size="mini">查看全部</Button>
|
||||||
</div>
|
</div>
|
||||||
{notes.map((note) => (
|
<ProjectFileGrid items={notes.slice(0, 8)} onSelectItem={onSelectItem} compact />
|
||||||
<button key={note.title} className="data-row note-row" 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>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectShareURL(identifier: string) {
|
||||||
|
const slug = encodeURIComponent(identifier.trim() || 'project')
|
||||||
|
const origin = typeof window === 'undefined' ? 'https://senlinai.local' : window.location.origin
|
||||||
|
return `${origin}/share/projects/${slug}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectHeroBackground(background: string, fallback: string) {
|
||||||
|
const value = background.trim() || fallback
|
||||||
|
if (value.startsWith('http')) {
|
||||||
|
return `linear-gradient(135deg, color-mix(in srgb, ${fallback} 78%, transparent), color-mix(in srgb, ${fallback} 34%, transparent)), url("${value.replaceAll('"', '%22')}") center / cover`
|
||||||
|
}
|
||||||
|
if (value.startsWith('linear-gradient') || value.startsWith('radial-gradient')) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return `linear-gradient(135deg, ${value}, color-mix(in srgb, ${value} 58%, white))`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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, IconEmail, IconPlus } from '@arco-design/web-react/icon'
|
import { IconCompass, IconDashboard, IconPlus } from '@arco-design/web-react/icon'
|
||||||
import type { Project, WorkbenchView } from './project-types'
|
import type { Project, WorkbenchView } from './project-types'
|
||||||
|
|
||||||
const { Sider } = Layout
|
const { Sider } = Layout
|
||||||
@@ -10,7 +10,7 @@ export function ProjectRail({
|
|||||||
projects,
|
projects,
|
||||||
activeView,
|
activeView,
|
||||||
onSelectWorkspace,
|
onSelectWorkspace,
|
||||||
onSelectWorkspaceInbox,
|
onSelectWorkspaceExplore,
|
||||||
onSelectProject,
|
onSelectProject,
|
||||||
onCreateProject,
|
onCreateProject,
|
||||||
}: {
|
}: {
|
||||||
@@ -18,7 +18,7 @@ export function ProjectRail({
|
|||||||
projects: Project[]
|
projects: Project[]
|
||||||
activeView: WorkbenchView
|
activeView: WorkbenchView
|
||||||
onSelectWorkspace: () => void
|
onSelectWorkspace: () => void
|
||||||
onSelectWorkspaceInbox: () => void
|
onSelectWorkspaceExplore: () => void
|
||||||
onSelectProject: (project: Project) => void
|
onSelectProject: (project: Project) => void
|
||||||
onCreateProject: () => void
|
onCreateProject: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -32,11 +32,11 @@ export function ProjectRail({
|
|||||||
title="工作台"
|
title="工作台"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className={activeView === 'workspace-inbox' ? 'dashboard-button active' : 'dashboard-button'}
|
className={activeView === 'workspace-explore' ? 'dashboard-button active' : 'dashboard-button'}
|
||||||
icon={<IconEmail />}
|
icon={<IconCompass />}
|
||||||
onClick={onSelectWorkspaceInbox}
|
onClick={onSelectWorkspaceExplore}
|
||||||
aria-label="Inbox"
|
aria-label="探索"
|
||||||
title="Inbox"
|
title="探索"
|
||||||
/>
|
/>
|
||||||
<Space className="project-stack" direction="vertical" size={12}>
|
<Space className="project-stack" direction="vertical" size={12}>
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import { Badge, Button, Divider, Layout, Space, Tag, Typography } from '@arco-design/web-react'
|
import { Fragment, useEffect, useState } from 'react'
|
||||||
import { IconApps, IconDashboard, IconMore, IconSettings } from '@arco-design/web-react/icon'
|
import { Badge, Button, Divider, Input, Layout, Modal, Space, Tag, Typography } from '@arco-design/web-react'
|
||||||
import type { ChannelKey, ProjectWorkspace, WorkbenchView } from './project-types'
|
import { IconApps, IconDashboard, IconMore, IconPlus, IconSettings } from '@arco-design/web-react/icon'
|
||||||
|
import type { ChannelKey, ProjectChannel, ProjectWorkspace, WorkbenchView } from './project-types'
|
||||||
|
|
||||||
const { Sider } = Layout
|
const { Sider } = Layout
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
|
const { TextArea } = Input
|
||||||
|
|
||||||
|
export type ProjectSettingsUpdate = {
|
||||||
|
projectId: string
|
||||||
|
name: string
|
||||||
|
identifier: string
|
||||||
|
icon: string
|
||||||
|
background: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
export function ProjectSidebar({
|
export function ProjectSidebar({
|
||||||
activeView,
|
activeView,
|
||||||
@@ -11,24 +22,57 @@ export function ProjectSidebar({
|
|||||||
activeChannel,
|
activeChannel,
|
||||||
onSelectChannel,
|
onSelectChannel,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
|
onUpdateProject,
|
||||||
|
onCreateProjectTag,
|
||||||
}: {
|
}: {
|
||||||
activeView: WorkbenchView
|
activeView: WorkbenchView
|
||||||
activeWorkspace: ProjectWorkspace
|
activeWorkspace: ProjectWorkspace
|
||||||
activeChannel: ChannelKey
|
activeChannel: ChannelKey
|
||||||
onSelectChannel: (key: ChannelKey) => void
|
onSelectChannel: (key: ChannelKey) => void
|
||||||
onSelectItem: (title: string) => void
|
onSelectItem: (title: string) => void
|
||||||
|
onUpdateProject: (update: ProjectSettingsUpdate) => void
|
||||||
|
onCreateProjectTag: (name: string) => void
|
||||||
}) {
|
}) {
|
||||||
const workspaceMode = activeView === 'workspace'
|
const workspaceMode = activeView === 'workspace'
|
||||||
const channels = activeWorkspace.channels
|
const channels = activeWorkspace.channels
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
const [draft, setDraft] = useState({
|
||||||
|
name: activeWorkspace.project.name,
|
||||||
|
identifier: activeWorkspace.project.identifier,
|
||||||
|
icon: activeWorkspace.project.icon,
|
||||||
|
background: activeWorkspace.project.background,
|
||||||
|
description: activeWorkspace.project.description,
|
||||||
|
})
|
||||||
|
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||||
|
const [tagName, setTagName] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settingsOpen) return
|
||||||
|
setDraft({
|
||||||
|
name: activeWorkspace.project.name,
|
||||||
|
identifier: activeWorkspace.project.identifier,
|
||||||
|
icon: activeWorkspace.project.icon,
|
||||||
|
background: activeWorkspace.project.background,
|
||||||
|
description: activeWorkspace.project.description,
|
||||||
|
})
|
||||||
|
}, [activeWorkspace.project, settingsOpen])
|
||||||
|
|
||||||
|
const projectIcon = activeWorkspace.project.short || activeWorkspace.project.identifier || activeWorkspace.project.name.slice(0, 2)
|
||||||
|
const submitTag = () => {
|
||||||
|
if (!tagName.trim()) return
|
||||||
|
onCreateProjectTag(tagName.trim())
|
||||||
|
setTagName('')
|
||||||
|
setTagModalOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
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 /> : <span className="project-title-icon">{projectIcon}</span>}
|
||||||
<Title heading={5}>{workspaceMode ? '工作台' : activeWorkspace.project.name}</Title>
|
<Title heading={5}>{workspaceMode ? '工作台' : activeWorkspace.project.name}</Title>
|
||||||
</Space>
|
</Space>
|
||||||
<Button icon={<IconSettings />} size="mini" />
|
<Button icon={<IconSettings />} size="mini" onClick={() => setSettingsOpen(true)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="channel-list">
|
<div className="channel-list">
|
||||||
@@ -44,16 +88,24 @@ export function ProjectSidebar({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
channels.map((channel) => (
|
<>
|
||||||
<button
|
{channels.flatMap((channel) => {
|
||||||
key={channel.id}
|
const items = [renderChannelButton(channel, activeChannel, onSelectChannel)]
|
||||||
className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'}
|
if (channel.key === 'cron') {
|
||||||
onClick={() => onSelectChannel(channel.key)}
|
items.push(
|
||||||
>
|
<button
|
||||||
<span className="channel-left">{channel.icon}<Text>{channel.label}</Text></span>
|
key="new-channel"
|
||||||
{channel.count !== undefined ? <Badge count={channel.count} maxCount={999} /> : <IconMore />}
|
className={activeChannel === 'new-channel' ? 'channel-button active' : 'channel-button'}
|
||||||
</button>
|
onClick={() => onSelectChannel('new-channel')}
|
||||||
))
|
>
|
||||||
|
<span className="channel-left"><IconPlus /><Text>新建频道</Text></span>
|
||||||
|
<IconMore />
|
||||||
|
</button>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -62,7 +114,20 @@ export function ProjectSidebar({
|
|||||||
<Text className="section-title">标签</Text>
|
<Text className="section-title">标签</Text>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{activeWorkspace.tags.map((tag) => (
|
{activeWorkspace.tags.map((tag) => (
|
||||||
<Tag key={tag} color={tag === '全部' ? 'arcoblue' : 'gray'}>{tag}</Tag>
|
<Fragment key={tag}>
|
||||||
|
<Tag color={isAllTag(tag) ? 'arcoblue' : 'gray'}>{tag}</Tag>
|
||||||
|
{isAllTag(tag) && (
|
||||||
|
<Button
|
||||||
|
className="tag-create-button"
|
||||||
|
size="mini"
|
||||||
|
type="text"
|
||||||
|
icon={<IconPlus />}
|
||||||
|
onClick={() => setTagModalOpen(true)}
|
||||||
|
>
|
||||||
|
新建
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
</section>
|
</section>
|
||||||
@@ -78,6 +143,86 @@ export function ProjectSidebar({
|
|||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
className="action-modal"
|
||||||
|
title="编辑项目"
|
||||||
|
visible={settingsOpen}
|
||||||
|
onCancel={() => setSettingsOpen(false)}
|
||||||
|
onOk={() => {
|
||||||
|
onUpdateProject({
|
||||||
|
projectId: activeWorkspace.project.id,
|
||||||
|
name: draft.name.trim() || activeWorkspace.project.name,
|
||||||
|
identifier: draft.identifier.trim(),
|
||||||
|
icon: draft.icon.trim(),
|
||||||
|
background: draft.background.trim(),
|
||||||
|
description: draft.description.trim(),
|
||||||
|
})
|
||||||
|
setSettingsOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={12} className="action-form">
|
||||||
|
<label>
|
||||||
|
<Text>名称</Text>
|
||||||
|
<Input value={draft.name} onChange={(name) => setDraft((value) => ({ ...value, name }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<Text>标识</Text>
|
||||||
|
<Input value={draft.identifier} onChange={(identifier) => setDraft((value) => ({ ...value, identifier }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<Text>图标</Text>
|
||||||
|
<Input value={draft.icon} onChange={(icon) => setDraft((value) => ({ ...value, icon }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<Text>背景</Text>
|
||||||
|
<Input value={draft.background} onChange={(background) => setDraft((value) => ({ ...value, background }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<Text>简介</Text>
|
||||||
|
<TextArea rows={4} value={draft.description} onChange={(description) => setDraft((value) => ({ ...value, description }))} />
|
||||||
|
</label>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
className="action-modal"
|
||||||
|
title="新建标签"
|
||||||
|
visible={tagModalOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setTagModalOpen(false)
|
||||||
|
setTagName('')
|
||||||
|
}}
|
||||||
|
onOk={submitTag}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={12} className="action-form">
|
||||||
|
<label>
|
||||||
|
<Text>标签名称</Text>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="例如:设计、客户、重要"
|
||||||
|
value={tagName}
|
||||||
|
onChange={setTagName}
|
||||||
|
onPressEnter={submitTag}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
</Sider>
|
</Sider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAllTag(tag: string) {
|
||||||
|
return tag === '全部' || tag === 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChannelButton(channel: ProjectChannel, activeChannel: ChannelKey, onSelectChannel: (key: ChannelKey) => void) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={channel.id}
|
||||||
|
className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'}
|
||||||
|
onClick={() => onSelectChannel(channel.key)}
|
||||||
|
>
|
||||||
|
<span className="channel-left">{channel.icon}<Text>{channel.label}</Text></span>
|
||||||
|
{channel.key === 'overview' ? null : channel.count !== undefined ? <Badge className="channel-badge-muted" count={channel.count} maxCount={999} /> : <IconMore />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ 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' | 'workspace-inbox' | 'project'
|
export type WorkbenchView = 'workspace' | 'workspace-explore' | 'project'
|
||||||
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | `custom:${string}`
|
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | 'new-channel' | `custom:${string}`
|
||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
identifier: string
|
||||||
|
icon: string
|
||||||
|
background: string
|
||||||
|
description: string
|
||||||
short: string
|
short: string
|
||||||
badge: number
|
badge: number
|
||||||
urgent?: boolean
|
urgent?: boolean
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Card, Empty, Grid, Input, Modal, Select, Space, Switch, Tag, Typography } from '@arco-design/web-react'
|
||||||
import {
|
import {
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconCheckCircleFill,
|
IconCheckCircleFill,
|
||||||
@@ -6,14 +7,44 @@ import {
|
|||||||
IconFile,
|
IconFile,
|
||||||
IconRobot,
|
IconRobot,
|
||||||
} from '@arco-design/web-react/icon'
|
} from '@arco-design/web-react/icon'
|
||||||
import type { Project, ProjectWorkspace } from './projects/project-types'
|
import type { Project, ProjectWorkspace, TaskItem } from './projects/project-types'
|
||||||
|
|
||||||
const { Row, Col } = Grid
|
const { Row, Col } = Grid
|
||||||
const { Title, Text, Paragraph } = Typography
|
const { Title, Text, Paragraph } = Typography
|
||||||
|
const { TextArea } = Input
|
||||||
|
|
||||||
export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectWorkspace[]; onOpenTask: (project: Project, taskID: string) => void }) {
|
export type WorkspaceTaskUpdate = {
|
||||||
|
originalProjectId: string
|
||||||
|
nextProjectId: string
|
||||||
|
taskId: string
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
tag: string
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceTask = TaskItem & {
|
||||||
|
project: Project
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskDraft = {
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
projectId: string
|
||||||
|
tag: string
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspacePage({
|
||||||
|
workspaces,
|
||||||
|
onUpdateTask,
|
||||||
|
}: {
|
||||||
|
workspaces: ProjectWorkspace[]
|
||||||
|
onOpenTask: (project: Project, taskID: string) => void
|
||||||
|
onUpdateTask: (update: WorkspaceTaskUpdate) => void
|
||||||
|
}) {
|
||||||
const projects = workspaces.map((workspace) => workspace.project)
|
const projects = workspaces.map((workspace) => workspace.project)
|
||||||
const unfinishedTasks = workspaces.flatMap((workspace) =>
|
const unfinishedTasks: WorkspaceTask[] = workspaces.flatMap((workspace) =>
|
||||||
workspace.tasks
|
workspace.tasks
|
||||||
.filter((task) => !task.completed)
|
.filter((task) => !task.completed)
|
||||||
.map((task) => ({
|
.map((task) => ({
|
||||||
@@ -21,6 +52,28 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
|
|||||||
project: workspace.project,
|
project: workspace.project,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
const [editingTask, setEditingTask] = useState<WorkspaceTask | null>(null)
|
||||||
|
const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', projectId: '', tag: '', completed: false })
|
||||||
|
const tagOptions = useMemo(() => {
|
||||||
|
const tags = new Set<string>()
|
||||||
|
const selectedWorkspace = workspaces.find((workspace) => workspace.project.id === draft.projectId)
|
||||||
|
selectedWorkspace?.tags
|
||||||
|
.filter((tag) => tag !== 'all' && tag !== '全部')
|
||||||
|
.forEach((tag) => tags.add(tag))
|
||||||
|
if (draft.tag) tags.add(draft.tag)
|
||||||
|
return Array.from(tags)
|
||||||
|
}, [draft.projectId, draft.tag, workspaces])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingTask) return
|
||||||
|
setDraft({
|
||||||
|
title: editingTask.title,
|
||||||
|
summary: editingTask.summary,
|
||||||
|
projectId: editingTask.project.id,
|
||||||
|
tag: editingTask.tag,
|
||||||
|
completed: editingTask.completed,
|
||||||
|
})
|
||||||
|
}, [editingTask])
|
||||||
const aiSessions = workspaces.flatMap((workspace) => workspace.aiSessions)
|
const aiSessions = workspaces.flatMap((workspace) => workspace.aiSessions)
|
||||||
const notes = workspaces.flatMap((workspace) => workspace.notes)
|
const notes = workspaces.flatMap((workspace) => workspace.notes)
|
||||||
const urgentProjects = projects.filter((project) => project.urgent).length
|
const urgentProjects = projects.filter((project) => project.urgent).length
|
||||||
@@ -65,7 +118,7 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
|
|||||||
{unfinishedTasks.length ? (
|
{unfinishedTasks.length ? (
|
||||||
<div className="workspace-task-list">
|
<div className="workspace-task-list">
|
||||||
{unfinishedTasks.map((task) => (
|
{unfinishedTasks.map((task) => (
|
||||||
<button key={`${task.project.id}-${task.id}`} className="workspace-task-card" onClick={() => onOpenTask(task.project, task.id)}>
|
<button key={`${task.project.id}-${task.id}`} className="workspace-task-card" onClick={() => setEditingTask(task)}>
|
||||||
<div className="workspace-task-main">
|
<div className="workspace-task-main">
|
||||||
<span className="workspace-task-title">
|
<span className="workspace-task-title">
|
||||||
<IconCheckCircleFill />
|
<IconCheckCircleFill />
|
||||||
@@ -87,6 +140,62 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
|
|||||||
<Empty description="暂无未完成任务" />
|
<Empty description="暂无未完成任务" />
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
className="action-modal"
|
||||||
|
title="编辑任务"
|
||||||
|
visible={Boolean(editingTask)}
|
||||||
|
onCancel={() => setEditingTask(null)}
|
||||||
|
onOk={() => {
|
||||||
|
if (!editingTask) return
|
||||||
|
onUpdateTask({
|
||||||
|
originalProjectId: editingTask.project.id,
|
||||||
|
nextProjectId: draft.projectId,
|
||||||
|
taskId: editingTask.id,
|
||||||
|
title: draft.title.trim() || editingTask.title,
|
||||||
|
summary: draft.summary.trim(),
|
||||||
|
tag: draft.tag.trim(),
|
||||||
|
completed: draft.completed,
|
||||||
|
})
|
||||||
|
setEditingTask(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={12} className="action-form">
|
||||||
|
<label>
|
||||||
|
<Text>任务标题</Text>
|
||||||
|
<Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<Text>任务内容</Text>
|
||||||
|
<TextArea rows={4} value={draft.summary} onChange={(summary) => setDraft((value) => ({ ...value, summary }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<Text>所属项目</Text>
|
||||||
|
<Select value={draft.projectId} onChange={(projectId) => setDraft((value) => ({ ...value, projectId }))}>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<Text>所属标签</Text>
|
||||||
|
<Select
|
||||||
|
allowCreate
|
||||||
|
value={draft.tag}
|
||||||
|
placeholder="选择或输入标签"
|
||||||
|
onChange={(tag) => setDraft((value) => ({ ...value, tag }))}
|
||||||
|
>
|
||||||
|
{tagOptions.map((tag) => (
|
||||||
|
<Select.Option key={tag} value={tag}>{tag}</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label className="action-switch">
|
||||||
|
<Text>完成状态</Text>
|
||||||
|
<Switch checked={draft.completed} checkedText="完成" uncheckedText="未完成" onChange={(completed) => setDraft((value) => ({ ...value, completed }))} />
|
||||||
|
</label>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
116
apps/senlinai-acro-react/src/pages/workspace-explore.tsx
Normal file
116
apps/senlinai-acro-react/src/pages/workspace-explore.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { Button, Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react'
|
||||||
|
import { IconBook, IconCompass, IconLink, IconLoop, IconRefresh, IconStar } from '@arco-design/web-react/icon'
|
||||||
|
import type { ProjectWorkspace } from './projects/project-types'
|
||||||
|
|
||||||
|
const { Row, Col } = Grid
|
||||||
|
const { Title, Text, Paragraph } = Typography
|
||||||
|
|
||||||
|
const rssSources = [
|
||||||
|
{ name: '产品更新', url: 'https://example.com/product.xml', status: '运行中', count: 18, color: 'arcoblue' },
|
||||||
|
{ name: '行业情报', url: 'https://example.com/market.xml', status: '运行中', count: 24, color: 'green' },
|
||||||
|
{ name: '技术博客', url: 'https://example.com/engineering.xml', status: '待同步', count: 9, color: 'orange' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function WorkspaceExplorePage({ workspaces, onSelectItem }: { workspaces: ProjectWorkspace[]; onSelectItem: (title: string) => void }) {
|
||||||
|
const articles = workspaces.flatMap((workspace) => workspace.inbox.map((item) => ({ ...item, project: workspace.project })))
|
||||||
|
const selected = articles[0]
|
||||||
|
const pendingCount = articles.filter((item) => item.status === 'open').length
|
||||||
|
const sourceCount = rssSources.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="workspace-explore-page overview-page">
|
||||||
|
<div className="overview-head">
|
||||||
|
<div>
|
||||||
|
<Title heading={4}>探索</Title>
|
||||||
|
<Text type="secondary">集中管理多个 RSS 源,采集外部内容、沉淀线索,并分发到对应项目。</Text>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Button icon={<IconRefresh />}>同步 RSS</Button>
|
||||||
|
<Button type="primary" icon={<IconLink />}>添加源</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card className="compact-card" bordered>
|
||||||
|
<Space><Tag color="arcoblue">{sourceCount}</Tag><Text>RSS 源</Text></Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card className="compact-card" bordered>
|
||||||
|
<Space><Tag color="red">{pendingCount}</Tag><Text>待处理采集</Text></Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card className="compact-card" bordered>
|
||||||
|
<Space><Tag color="green">{articles.length}</Tag><Text>采集条目</Text></Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card className="queue-section" bordered>
|
||||||
|
<div className="section-header">
|
||||||
|
<Title heading={6}>RSS 源</Title>
|
||||||
|
<Button type="text" size="mini">按最近同步排序</Button>
|
||||||
|
</div>
|
||||||
|
{rssSources.map((source) => (
|
||||||
|
<button className="data-row rss-source-row" key={source.url} onClick={() => onSelectItem(source.name)}>
|
||||||
|
<span className="row-title"><IconLink /> {source.name}</span>
|
||||||
|
<Tag color={source.color}>{source.status}</Tag>
|
||||||
|
<span>{source.url}</span>
|
||||||
|
<span>{source.count} 条</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{articles.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>
|
||||||
|
{articles.map((item, index) => (
|
||||||
|
<button
|
||||||
|
className={index === 0 ? 'mail-item active' : 'mail-item'}
|
||||||
|
key={`${item.project.id}-${item.id}`}
|
||||||
|
onClick={() => onSelectItem(item.title)}
|
||||||
|
>
|
||||||
|
<span><IconCompass /> {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={<IconLoop />} />
|
||||||
|
</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 icon={<IconBook />}>转为资料</Button>
|
||||||
|
<Button>生成任务</Button>
|
||||||
|
<Button type="primary">加入项目</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="queue-section" bordered>
|
||||||
|
<Empty description="暂无 RSS 采集条目" />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Layout } from '@arco-design/web-react'
|
import { Layout } from '@arco-design/web-react'
|
||||||
import { WorkspacePage } from './workspace-body'
|
import { WorkspacePage, type WorkspaceTaskUpdate } from './workspace-body'
|
||||||
import { WorkspaceInboxPage } from './workspace-inbox'
|
import { WorkspaceExplorePage } from './workspace-explore'
|
||||||
import { ProjectChannelPage } from './projects/project-channel-page'
|
import { ProjectChannelPage } from './projects/project-channel-page'
|
||||||
import { ProjectRail } from './projects/project-rail'
|
import { ProjectRail } from './projects/project-rail'
|
||||||
import { ProjectSidebar } from './projects/project-sidebar'
|
import { ProjectSidebar, type ProjectSettingsUpdate } from './projects/project-sidebar'
|
||||||
import { ProjectStatusbar } from './projects/project-statusbar'
|
import { ProjectStatusbar } from './projects/project-statusbar'
|
||||||
import { ProjectTopbar } from './projects/project-topbar'
|
import { ProjectTopbar } from './projects/project-topbar'
|
||||||
import type { ChannelKey, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
|
import type { ChannelKey, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
|
||||||
@@ -19,7 +19,7 @@ export function ProjectPage({
|
|||||||
theme,
|
theme,
|
||||||
onSelectProject,
|
onSelectProject,
|
||||||
onSelectWorkspace,
|
onSelectWorkspace,
|
||||||
onSelectWorkspaceInbox,
|
onSelectWorkspaceExplore,
|
||||||
onSelectChannel,
|
onSelectChannel,
|
||||||
onSelectItem,
|
onSelectItem,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
@@ -29,6 +29,9 @@ export function ProjectPage({
|
|||||||
onCreateTask,
|
onCreateTask,
|
||||||
onUploadSource,
|
onUploadSource,
|
||||||
onCreateCronPlan,
|
onCreateCronPlan,
|
||||||
|
onUpdateWorkspaceTask,
|
||||||
|
onUpdateProject,
|
||||||
|
onCreateProjectTag,
|
||||||
}: {
|
}: {
|
||||||
activeView: WorkbenchView
|
activeView: WorkbenchView
|
||||||
activeWorkspace: ProjectWorkspace
|
activeWorkspace: ProjectWorkspace
|
||||||
@@ -38,7 +41,7 @@ export function ProjectPage({
|
|||||||
theme: Theme
|
theme: Theme
|
||||||
onSelectProject: (project: Project) => void
|
onSelectProject: (project: Project) => void
|
||||||
onSelectWorkspace: () => void
|
onSelectWorkspace: () => void
|
||||||
onSelectWorkspaceInbox: () => void
|
onSelectWorkspaceExplore: () => void
|
||||||
onSelectChannel: (key: ChannelKey) => void
|
onSelectChannel: (key: ChannelKey) => void
|
||||||
onSelectItem: (title: string) => void
|
onSelectItem: (title: string) => void
|
||||||
onOpenTask: (project: Project, taskID: string) => void
|
onOpenTask: (project: Project, taskID: string) => void
|
||||||
@@ -48,6 +51,9 @@ export function ProjectPage({
|
|||||||
onCreateTask: () => void
|
onCreateTask: () => void
|
||||||
onUploadSource: () => void
|
onUploadSource: () => void
|
||||||
onCreateCronPlan: () => void
|
onCreateCronPlan: () => void
|
||||||
|
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => void
|
||||||
|
onUpdateProject: (update: ProjectSettingsUpdate) => void
|
||||||
|
onCreateProjectTag: (name: string) => void
|
||||||
}) {
|
}) {
|
||||||
const isProject = activeView === 'project'
|
const isProject = activeView === 'project'
|
||||||
const projects = workspaces.map((workspace) => workspace.project)
|
const projects = workspaces.map((workspace) => workspace.project)
|
||||||
@@ -62,7 +68,7 @@ export function ProjectPage({
|
|||||||
projects={projects}
|
projects={projects}
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
onSelectWorkspace={onSelectWorkspace}
|
onSelectWorkspace={onSelectWorkspace}
|
||||||
onSelectWorkspaceInbox={onSelectWorkspaceInbox}
|
onSelectWorkspaceExplore={onSelectWorkspaceExplore}
|
||||||
onSelectProject={onSelectProject}
|
onSelectProject={onSelectProject}
|
||||||
onCreateProject={onCreateProject}
|
onCreateProject={onCreateProject}
|
||||||
/>
|
/>
|
||||||
@@ -74,14 +80,16 @@ export function ProjectPage({
|
|||||||
activeChannel={activeChannel}
|
activeChannel={activeChannel}
|
||||||
onSelectChannel={onSelectChannel}
|
onSelectChannel={onSelectChannel}
|
||||||
onSelectItem={onSelectItem}
|
onSelectItem={onSelectItem}
|
||||||
|
onUpdateProject={onUpdateProject}
|
||||||
|
onCreateProjectTag={onCreateProjectTag}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Content className="stage">
|
<Content className="stage">
|
||||||
{activeView === 'workspace' ? (
|
{activeView === 'workspace' ? (
|
||||||
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} />
|
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} onUpdateTask={onUpdateWorkspaceTask} />
|
||||||
) : activeView === 'workspace-inbox' ? (
|
) : activeView === 'workspace-explore' ? (
|
||||||
<WorkspaceInboxPage workspaces={workspaces} onSelectItem={onSelectItem} />
|
<WorkspaceExplorePage workspaces={workspaces} onSelectItem={onSelectItem} />
|
||||||
) : (
|
) : (
|
||||||
<ProjectChannelPage
|
<ProjectChannelPage
|
||||||
activeChannel={activeChannel}
|
activeChannel={activeChannel}
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -29,9 +29,12 @@ 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.GET("/projects/:id/tags", h.listTags)
|
||||||
router.POST("/projects/:id/tasks", h.createTask)
|
router.POST("/projects/:id/tasks", h.createTask)
|
||||||
|
router.PATCH("/projects/:id/tasks/:taskID", h.updateTask)
|
||||||
router.POST("/projects/:id/sources", h.uploadSource)
|
router.POST("/projects/:id/sources", h.uploadSource)
|
||||||
router.POST("/projects/:id/cron-plans", h.createCronPlan)
|
router.POST("/projects/:id/cron-plans", h.createCronPlan)
|
||||||
|
router.POST("/projects/:id/tags", h.createTag)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) createProject(c *gin.Context) {
|
func (h *Handler) createProject(c *gin.Context) {
|
||||||
@@ -42,13 +45,22 @@ func (h *Handler) createProject(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var input struct {
|
var input struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Identifier string `json:"identifier"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Background string `json:"background"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
project, err := h.service.CreateProject(userID, input.Name, input.Description)
|
project, err := h.service.CreateProjectWithInput(userID, CreateProjectInput{
|
||||||
|
Name: input.Name,
|
||||||
|
Identifier: input.Identifier,
|
||||||
|
Icon: input.Icon,
|
||||||
|
Background: input.Background,
|
||||||
|
Description: input.Description,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -108,6 +120,24 @@ func (h *Handler) workspace(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, workspace)
|
c.JSON(http.StatusOK, workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) listTags(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
|
||||||
|
}
|
||||||
|
tags, err := h.service.ListProjectTags(userID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, tags)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) createTask(c *gin.Context) {
|
func (h *Handler) createTask(c *gin.Context) {
|
||||||
userID, ok := auth.CurrentUserID(c)
|
userID, ok := auth.CurrentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -123,6 +153,7 @@ func (h *Handler) createTask(c *gin.Context) {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
DueAt string `json:"dueAt"`
|
DueAt string `json:"dueAt"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
@@ -138,6 +169,7 @@ func (h *Handler) createTask(c *gin.Context) {
|
|||||||
Description: input.Description,
|
Description: input.Description,
|
||||||
Status: input.Status,
|
Status: input.Status,
|
||||||
DueAt: dueAt,
|
DueAt: dueAt,
|
||||||
|
Tag: input.Tag,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -146,6 +178,47 @@ func (h *Handler) createTask(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, task)
|
c.JSON(http.StatusCreated, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) updateTask(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
|
||||||
|
}
|
||||||
|
taskID, ok := parseTaskID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
NextProjectID uint `json:"nextProjectId"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task, err := h.service.UpdateTask(userID, projectID, taskID, UpdateTaskInput{
|
||||||
|
Title: input.Title,
|
||||||
|
Description: input.Description,
|
||||||
|
Status: input.Status,
|
||||||
|
Completed: input.Completed,
|
||||||
|
NextProjectID: input.NextProjectID,
|
||||||
|
Tag: input.Tag,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, task)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) uploadSource(c *gin.Context) {
|
func (h *Handler) uploadSource(c *gin.Context) {
|
||||||
userID, ok := auth.CurrentUserID(c)
|
userID, ok := auth.CurrentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -233,6 +306,31 @@ func (h *Handler) createCronPlan(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, plan)
|
c.JSON(http.StatusCreated, plan)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) createTag(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 {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tag, err := h.service.CreateProjectTag(userID, projectID, input.Name)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, tag)
|
||||||
|
}
|
||||||
|
|
||||||
func parseProjectID(c *gin.Context) (uint, bool) {
|
func parseProjectID(c *gin.Context) (uint, bool) {
|
||||||
projectID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
projectID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -242,6 +340,15 @@ func parseProjectID(c *gin.Context) (uint, bool) {
|
|||||||
return uint(projectID), true
|
return uint(projectID), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseTaskID(c *gin.Context) (uint, bool) {
|
||||||
|
taskID, err := strconv.ParseUint(c.Param("taskID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task id"})
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return uint(taskID), true
|
||||||
|
}
|
||||||
|
|
||||||
func parseOptionalTime(value string) (*time.Time, error) {
|
func parseOptionalTime(value string) (*time.Time, error) {
|
||||||
trimmed := strings.TrimSpace(value)
|
trimmed := strings.TrimSpace(value)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
|
|||||||
@@ -17,9 +17,35 @@ import (
|
|||||||
"senlinai-agent/backend/internal/models"
|
"senlinai-agent/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestCreateProjectHandlerPersistsMetadata(t *testing.T) {
|
||||||
|
router, _, _ := newProjectsHandlerTestRouter(t)
|
||||||
|
body, err := json.Marshal(gin.H{
|
||||||
|
"name": "Explore",
|
||||||
|
"identifier": "EXP",
|
||||||
|
"icon": "compass",
|
||||||
|
"background": "#165DFF",
|
||||||
|
"description": "RSS exploration workspace",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/projects", 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 project models.SenlinAgentProject
|
||||||
|
require.NoError(t, models.DBService.Where("owner_id = ? AND identifier = ?", 1, "EXP").First(&project).Error)
|
||||||
|
require.Equal(t, "Explore", project.Name)
|
||||||
|
require.Equal(t, "compass", project.Icon)
|
||||||
|
require.Equal(t, "#165DFF", project.Background)
|
||||||
|
require.Equal(t, "RSS exploration workspace", project.Description)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateTaskHandlerPersistsTask(t *testing.T) {
|
func TestCreateTaskHandlerPersistsTask(t *testing.T) {
|
||||||
router, project, _ := newProjectsHandlerTestRouter(t)
|
router, project, _ := newProjectsHandlerTestRouter(t)
|
||||||
body, err := json.Marshal(gin.H{"title": "整理需求", "description": "形成任务清单", "dueAt": "2026-07-21T09:30:00Z"})
|
body, err := json.Marshal(gin.H{"title": "整理需求", "description": "形成任务清单", "dueAt": "2026-07-21T09:30:00Z", "tag": "需求"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tasks", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tasks", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -33,6 +59,7 @@ func TestCreateTaskHandlerPersistsTask(t *testing.T) {
|
|||||||
require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "整理需求").First(&task).Error)
|
require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "整理需求").First(&task).Error)
|
||||||
require.Equal(t, "open", task.Status)
|
require.Equal(t, "open", task.Status)
|
||||||
require.NotNil(t, task.DueAt)
|
require.NotNil(t, task.DueAt)
|
||||||
|
require.NotNil(t, task.TagID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUploadSourceHandlerStoresFileAndSource(t *testing.T) {
|
func TestUploadSourceHandlerStoresFileAndSource(t *testing.T) {
|
||||||
@@ -79,6 +106,60 @@ func TestCreateCronPlanHandlerPersistsPlan(t *testing.T) {
|
|||||||
require.NotNil(t, plan.NextRunAt)
|
require.NotNil(t, plan.NextRunAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateTagHandlerPersistsProjectTag(t *testing.T) {
|
||||||
|
router, project, _ := newProjectsHandlerTestRouter(t)
|
||||||
|
body, err := json.Marshal(gin.H{"name": "Design"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tags", 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 tag models.SenlinAgentTag
|
||||||
|
require.NoError(t, models.DBService.Where("project_id = ? AND name = ?", project.ID, "Design").First(&tag).Error)
|
||||||
|
require.Equal(t, project.ID, tag.ProjectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListTagsHandlerReturnsProjectTags(t *testing.T) {
|
||||||
|
router, project, _ := newProjectsHandlerTestRouter(t)
|
||||||
|
require.NoError(t, models.DBService.Create(&models.SenlinAgentTag{ProjectID: project.ID, Name: "Design"}).Error)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/projects/1/tags", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer test-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
var tags []models.SenlinAgentTag
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &tags))
|
||||||
|
require.Len(t, tags, 1)
|
||||||
|
require.Equal(t, "Design", tags[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTaskHandlerPersistsTagAndStatus(t *testing.T) {
|
||||||
|
router, project, _ := newProjectsHandlerTestRouter(t)
|
||||||
|
task := models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: 1, Title: "Draft", Status: "open"}
|
||||||
|
require.NoError(t, models.DBService.Create(&task).Error)
|
||||||
|
body, err := json.Marshal(gin.H{"title": "Draft v2", "description": "Updated", "completed": true, "tag": "Important"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/projects/1/tasks/1", 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.StatusOK, rec.Code)
|
||||||
|
var updated models.SenlinAgentTask
|
||||||
|
require.NoError(t, models.DBService.First(&updated, task.ID).Error)
|
||||||
|
require.Equal(t, "Draft v2", updated.Title)
|
||||||
|
require.Equal(t, "done", updated.Status)
|
||||||
|
require.NotNil(t, updated.TagID)
|
||||||
|
}
|
||||||
|
|
||||||
func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgentProject, string) {
|
func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgentProject, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
newTestDB(t)
|
newTestDB(t)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
"senlinai-agent/backend/internal/models"
|
"senlinai-agent/backend/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,6 +24,9 @@ type Dashboard struct {
|
|||||||
type WorkbenchProject struct {
|
type WorkbenchProject struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Identifier string `json:"identifier"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Background string `json:"background"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Initials string `json:"initials"`
|
Initials string `json:"initials"`
|
||||||
UnreadCount int64 `json:"unreadCount"`
|
UnreadCount int64 `json:"unreadCount"`
|
||||||
@@ -99,11 +103,29 @@ type ProjectWorkspace struct {
|
|||||||
CronPlans []CronPlan `json:"cronPlans"`
|
CronPlans []CronPlan `json:"cronPlans"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateProjectInput struct {
|
||||||
|
Name string
|
||||||
|
Identifier string
|
||||||
|
Icon string
|
||||||
|
Background string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
type CreateTaskInput struct {
|
type CreateTaskInput struct {
|
||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
Status string
|
Status string
|
||||||
DueAt *time.Time
|
DueAt *time.Time
|
||||||
|
Tag string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateTaskInput struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Status string
|
||||||
|
Completed bool
|
||||||
|
NextProjectID uint
|
||||||
|
Tag string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateFileSourceInput struct {
|
type CreateFileSourceInput struct {
|
||||||
@@ -123,11 +145,26 @@ func NewService() *Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) CreateProject(ownerID uint, name string, description string) (*models.SenlinAgentProject, error) {
|
func (s *Service) CreateProject(ownerID uint, name string, description string) (*models.SenlinAgentProject, error) {
|
||||||
name = strings.TrimSpace(name)
|
return s.CreateProjectWithInput(ownerID, CreateProjectInput{Name: name, Description: description})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectInput) (*models.SenlinAgentProject, error) {
|
||||||
|
name := strings.TrimSpace(input.Name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, errors.New("project name is required")
|
return nil, errors.New("project name is required")
|
||||||
}
|
}
|
||||||
project := &models.SenlinAgentProject{OwnerID: ownerID, Name: name, Description: description}
|
identifier := strings.TrimSpace(input.Identifier)
|
||||||
|
if identifier == "" {
|
||||||
|
identifier = projectInitials(name)
|
||||||
|
}
|
||||||
|
project := &models.SenlinAgentProject{
|
||||||
|
OwnerID: ownerID,
|
||||||
|
Name: name,
|
||||||
|
Identifier: identifier,
|
||||||
|
Icon: strings.TrimSpace(input.Icon),
|
||||||
|
Background: strings.TrimSpace(input.Background),
|
||||||
|
Description: strings.TrimSpace(input.Description),
|
||||||
|
}
|
||||||
return project, models.DBService.Create(project).Error
|
return project, models.DBService.Create(project).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,9 +186,14 @@ func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput
|
|||||||
if status == "" {
|
if status == "" {
|
||||||
status = "open"
|
status = "open"
|
||||||
}
|
}
|
||||||
|
tagID, err := s.findOrCreateTagID(projectID, input.Tag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
task := &models.SenlinAgentTask{
|
task := &models.SenlinAgentTask{
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
CreatedBy: ownerID,
|
CreatedBy: ownerID,
|
||||||
|
TagID: tagID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: strings.TrimSpace(input.Description),
|
Description: strings.TrimSpace(input.Description),
|
||||||
Status: status,
|
Status: status,
|
||||||
@@ -160,6 +202,58 @@ func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput
|
|||||||
return task, models.DBService.Create(task).Error
|
return task, models.DBService.Create(task).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateTask(ownerID uint, projectID uint, taskID uint, input UpdateTaskInput) (*models.SenlinAgentTask, error) {
|
||||||
|
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var task models.SenlinAgentTask
|
||||||
|
if err := models.DBService.Where("id = ? AND project_id = ?", taskID, projectID).First(&task).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
nextProjectID := input.NextProjectID
|
||||||
|
if nextProjectID == 0 {
|
||||||
|
nextProjectID = task.ProjectID
|
||||||
|
}
|
||||||
|
if err := ensureProjectOwner(ownerID, nextProjectID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(input.Title)
|
||||||
|
if title == "" {
|
||||||
|
return nil, errors.New("task title is required")
|
||||||
|
}
|
||||||
|
tagID, err := s.findOrCreateTagID(nextProjectID, input.Tag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
projectIdentity, err := projectIdentity(nextProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tagIdentity, err := optionalTagIdentity(tagID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status := strings.TrimSpace(input.Status)
|
||||||
|
if status == "" {
|
||||||
|
if input.Completed {
|
||||||
|
status = "done"
|
||||||
|
} else {
|
||||||
|
status = "open"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task.ProjectID = nextProjectID
|
||||||
|
task.ProjectIdentity = projectIdentity
|
||||||
|
task.Title = title
|
||||||
|
task.Description = strings.TrimSpace(input.Description)
|
||||||
|
task.Status = status
|
||||||
|
task.TagID = tagID
|
||||||
|
task.TagIdentity = tagIdentity
|
||||||
|
if err := models.DBService.Save(&task).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &task, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) CreateFileSource(ownerID uint, projectID uint, input CreateFileSourceInput) (*models.SenlinAgentSource, error) {
|
func (s *Service) CreateFileSource(ownerID uint, projectID uint, input CreateFileSourceInput) (*models.SenlinAgentSource, error) {
|
||||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -206,21 +300,55 @@ func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronP
|
|||||||
return plan, models.DBService.Create(plan).Error
|
return plan, models.DBService.Create(plan).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateProjectTag(ownerID uint, projectID uint, name string) (*models.SenlinAgentTag, error) {
|
||||||
|
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.CreateTag(projectID, name)
|
||||||
|
}
|
||||||
|
|
||||||
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 == "" {
|
||||||
return nil, errors.New("tag name is required")
|
return nil, errors.New("tag name is required")
|
||||||
}
|
}
|
||||||
|
var existing models.SenlinAgentTag
|
||||||
|
err := models.DBService.Where("project_id = ? AND name = ?", projectID, name).First(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
return &existing, nil
|
||||||
|
}
|
||||||
|
if err != gorm.ErrRecordNotFound {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
tag := &models.SenlinAgentTag{ProjectID: projectID, Name: name}
|
tag := &models.SenlinAgentTag{ProjectID: projectID, Name: name}
|
||||||
return tag, models.DBService.Create(tag).Error
|
return tag, models.DBService.Create(tag).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListProjectTags(ownerID uint, projectID uint) ([]models.SenlinAgentTag, error) {
|
||||||
|
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.ListTags(projectID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) {
|
func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) {
|
||||||
var tags []models.SenlinAgentTag
|
var tags []models.SenlinAgentTag
|
||||||
err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error
|
err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error
|
||||||
return tags, err
|
return tags, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) findOrCreateTagID(projectID uint, name string) (*uint, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
tag, err := s.CreateTag(projectID, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &tag.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
|
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
|
||||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||||
return Dashboard{}, err
|
return Dashboard{}, err
|
||||||
@@ -284,8 +412,11 @@ func (s *Service) Workspace(ownerID uint, projectID uint) (ProjectWorkspace, err
|
|||||||
Project: WorkbenchProject{
|
Project: WorkbenchProject{
|
||||||
ID: project.ID,
|
ID: project.ID,
|
||||||
Name: project.Name,
|
Name: project.Name,
|
||||||
|
Identifier: project.Identifier,
|
||||||
|
Icon: project.Icon,
|
||||||
|
Background: project.Background,
|
||||||
Description: project.Description,
|
Description: project.Description,
|
||||||
Initials: projectInitials(project.Name),
|
Initials: defaultString(project.Identifier, projectInitials(project.Name)),
|
||||||
UnreadCount: counts.inbox,
|
UnreadCount: counts.inbox,
|
||||||
},
|
},
|
||||||
Channels: channels,
|
Channels: channels,
|
||||||
@@ -409,6 +540,10 @@ func (s *Service) workspaceTasks(projectID uint) ([]WorkTask, error) {
|
|||||||
if err := models.DBService.Where("project_id = ?", projectID).Order("CASE WHEN status = 'done' THEN 1 ELSE 0 END asc").Order("sort_order asc, updated_at desc, id desc").Limit(50).Find(&tasks).Error; err != nil {
|
if err := models.DBService.Where("project_id = ?", projectID).Order("CASE WHEN status = 'done' THEN 1 ELSE 0 END asc").Order("sort_order asc, updated_at desc, id desc").Limit(50).Find(&tasks).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
tagNames, err := taskTagNames(tasks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
items := make([]WorkTask, 0, len(tasks))
|
items := make([]WorkTask, 0, len(tasks))
|
||||||
for _, task := range tasks {
|
for _, task := range tasks {
|
||||||
owner, err := userDisplayName(task.AssigneeID, task.CreatedBy)
|
owner, err := userDisplayName(task.AssigneeID, task.CreatedBy)
|
||||||
@@ -423,12 +558,58 @@ func (s *Service) workspaceTasks(projectID uint) ([]WorkTask, error) {
|
|||||||
Owner: owner,
|
Owner: owner,
|
||||||
Due: displayOptionalTime(task.DueAt),
|
Due: displayOptionalTime(task.DueAt),
|
||||||
CreatedAt: displayTime(task.CreatedAt),
|
CreatedAt: displayTime(task.CreatedAt),
|
||||||
Tag: "",
|
Tag: tagNames[task.ID],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func taskTagNames(tasks []models.SenlinAgentTask) (map[uint]string, error) {
|
||||||
|
names := make(map[uint]string)
|
||||||
|
tagIDs := make([]uint, 0)
|
||||||
|
for _, task := range tasks {
|
||||||
|
if task.TagID != nil {
|
||||||
|
tagIDs = append(tagIDs, *task.TagID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tagIDs) == 0 {
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
var tags []models.SenlinAgentTag
|
||||||
|
if err := models.DBService.Where("id IN ?", tagIDs).Find(&tags).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tagByID := make(map[uint]string, len(tags))
|
||||||
|
for _, tag := range tags {
|
||||||
|
tagByID[tag.ID] = tag.Name
|
||||||
|
}
|
||||||
|
for _, task := range tasks {
|
||||||
|
if task.TagID != nil {
|
||||||
|
names[task.ID] = tagByID[*task.TagID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectIdentity(projectID uint) (string, error) {
|
||||||
|
var project models.SenlinAgentProject
|
||||||
|
if err := models.DBService.Select("identity").First(&project, projectID).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return project.Identity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalTagIdentity(tagID *uint) (*string, error) {
|
||||||
|
if tagID == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var tag models.SenlinAgentTag
|
||||||
|
if err := models.DBService.Select("identity").First(&tag, *tagID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &tag.Identity, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) workspaceAISessions(projectID uint) ([]AISessionItem, error) {
|
func (s *Service) workspaceAISessions(projectID uint) ([]AISessionItem, error) {
|
||||||
var sessions []models.SenlinAgentAISession
|
var sessions []models.SenlinAgentAISession
|
||||||
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sessions).Error; err != nil {
|
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sessions).Error; err != nil {
|
||||||
|
|||||||
@@ -30,6 +30,33 @@ func TestProjectTagsAreScopedToProject(t *testing.T) {
|
|||||||
require.Equal(t, first.ID, firstTags[0].ProjectID)
|
require.Equal(t, first.ID, firstTags[0].ProjectID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateProjectPersistsWorkspaceMetadata(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
service := NewService()
|
||||||
|
|
||||||
|
project, err := service.CreateProjectWithInput(1, CreateProjectInput{
|
||||||
|
Name: "探索项目",
|
||||||
|
Identifier: "EXP",
|
||||||
|
Icon: "compass",
|
||||||
|
Background: "#165DFF",
|
||||||
|
Description: "RSS 采集和线索沉淀",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "探索项目", project.Name)
|
||||||
|
require.Equal(t, "EXP", project.Identifier)
|
||||||
|
require.Equal(t, "compass", project.Icon)
|
||||||
|
require.Equal(t, "#165DFF", project.Background)
|
||||||
|
require.Equal(t, "RSS 采集和线索沉淀", project.Description)
|
||||||
|
|
||||||
|
workspace, err := service.Workspace(1, project.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "EXP", workspace.Project.Identifier)
|
||||||
|
require.Equal(t, "compass", workspace.Project.Icon)
|
||||||
|
require.Equal(t, "#165DFF", workspace.Project.Background)
|
||||||
|
require.Equal(t, "RSS 采集和线索沉淀", workspace.Project.Description)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDashboardCountsOnlyRequestedProject(t *testing.T) {
|
func TestDashboardCountsOnlyRequestedProject(t *testing.T) {
|
||||||
database := newTestDB(t)
|
database := newTestDB(t)
|
||||||
service := NewService()
|
service := NewService()
|
||||||
@@ -140,6 +167,7 @@ func TestCreateTaskAddsTaskToOwnedProject(t *testing.T) {
|
|||||||
Description: "整理客户反馈并形成行动项",
|
Description: "整理客户反馈并形成行动项",
|
||||||
Status: "",
|
Status: "",
|
||||||
DueAt: &dueAt,
|
DueAt: &dueAt,
|
||||||
|
Tag: "客户反馈",
|
||||||
})
|
})
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -148,10 +176,13 @@ func TestCreateTaskAddsTaskToOwnedProject(t *testing.T) {
|
|||||||
require.Equal(t, "Follow up with client", task.Title)
|
require.Equal(t, "Follow up with client", task.Title)
|
||||||
require.Equal(t, "open", task.Status)
|
require.Equal(t, "open", task.Status)
|
||||||
require.NotNil(t, task.DueAt)
|
require.NotNil(t, task.DueAt)
|
||||||
|
require.NotNil(t, task.TagID)
|
||||||
workspace, err := service.Workspace(1, project.ID)
|
workspace, err := service.Workspace(1, project.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, workspace.Tasks, 1)
|
require.Len(t, workspace.Tasks, 1)
|
||||||
require.Equal(t, "Follow up with client", workspace.Tasks[0].Title)
|
require.Equal(t, "Follow up with client", workspace.Tasks[0].Title)
|
||||||
|
require.Equal(t, "客户反馈", workspace.Tasks[0].Tag)
|
||||||
|
require.Equal(t, []string{"all", "客户反馈"}, workspace.Tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||||
@@ -165,6 +196,58 @@ func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateTaskPersistsTagAndCompletion(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
service := NewService()
|
||||||
|
require.NoError(t, models.DBService.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
|
||||||
|
project, err := service.CreateProject(1, "Alpha", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
task, err := service.CreateTask(1, project.ID, CreateTaskInput{Title: "Draft proposal", Tag: "客户"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updated, err := service.UpdateTask(1, project.ID, task.ID, UpdateTaskInput{
|
||||||
|
Title: "Draft proposal v2",
|
||||||
|
Description: "Updated scope",
|
||||||
|
Completed: true,
|
||||||
|
Tag: "重要",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Draft proposal v2", updated.Title)
|
||||||
|
require.Equal(t, "done", updated.Status)
|
||||||
|
workspace, err := service.Workspace(1, project.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "重要", workspace.Tasks[0].Tag)
|
||||||
|
require.Equal(t, []string{"all", "客户", "重要"}, workspace.Tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProjectTagAddsTagToOwnedProject(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
service := NewService()
|
||||||
|
project, err := service.CreateProject(1, "Alpha", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tag, err := service.CreateProjectTag(1, project.ID, "Design")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, project.ID, tag.ProjectID)
|
||||||
|
require.Equal(t, "Design", tag.Name)
|
||||||
|
workspace, err := service.Workspace(1, project.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"all", "Design"}, workspace.Tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProjectTagRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
service := NewService()
|
||||||
|
project, err := service.CreateProject(2, "Beta", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = service.CreateProjectTag(1, project.ID, "Hidden")
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateFileSourceAddsSourceToOwnedProject(t *testing.T) {
|
func TestCreateFileSourceAddsSourceToOwnedProject(t *testing.T) {
|
||||||
database := newTestDB(t)
|
database := newTestDB(t)
|
||||||
service := NewService()
|
service := NewService()
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ func (m *SenlinAgentTask) BeforeCreate(tx *gorm.DB) error {
|
|||||||
if err := resolveOptionalIdentity(tx, &SenlinAgentUser{}, m.AssigneeID, &m.AssigneeIdentity); err != nil {
|
if err := resolveOptionalIdentity(tx, &SenlinAgentUser{}, m.AssigneeID, &m.AssigneeIdentity); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := resolveOptionalIdentity(tx, &SenlinAgentTag{}, m.TagID, &m.TagIdentity); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return resolveOptionalIdentity(tx, &SenlinAgentInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity)
|
return resolveOptionalIdentity(tx, &SenlinAgentInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ type SenlinAgentProject struct {
|
|||||||
OwnerID uint `gorm:"index;not null"`
|
OwnerID uint `gorm:"index;not null"`
|
||||||
OwnerIdentity string `gorm:"type:char(36);index"`
|
OwnerIdentity string `gorm:"type:char(36);index"`
|
||||||
Name string `gorm:"not null"`
|
Name string `gorm:"not null"`
|
||||||
|
Identifier string `gorm:"index"`
|
||||||
|
Icon string
|
||||||
|
Background string
|
||||||
Description string
|
Description string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type SenlinAgentTask struct {
|
|||||||
CreatedByIdentity string `gorm:"type:char(36);index"`
|
CreatedByIdentity string `gorm:"type:char(36);index"`
|
||||||
AssigneeID *uint `gorm:"index"`
|
AssigneeID *uint `gorm:"index"`
|
||||||
AssigneeIdentity *string `gorm:"type:char(36);index"`
|
AssigneeIdentity *string `gorm:"type:char(36);index"`
|
||||||
|
TagID *uint `gorm:"index"`
|
||||||
|
TagIdentity *string `gorm:"type:char(36);index"`
|
||||||
SourceInboxItemID *uint `gorm:"index"`
|
SourceInboxItemID *uint `gorm:"index"`
|
||||||
SourceInboxItemIdentity *string `gorm:"type:char(36);index"`
|
SourceInboxItemIdentity *string `gorm:"type:char(36);index"`
|
||||||
Title string `gorm:"not null"`
|
Title string `gorm:"not null"`
|
||||||
|
|||||||
@@ -131,6 +131,18 @@ func seedProjectA1(tx *gorm.DB, userID uint, projectID uint) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
uiTagID, err := tagID(tx, projectID, "UI")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
feedbackTagID, err := tagID(tx, projectID, "客户反馈")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
automationTagID, err := tagID(tx, projectID, "自动化")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
for _, item := range []models.SenlinAgentInboxItem{
|
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: "客户反馈:导出功能报错", Body: "客户在导出智能报表时遇到权限校验异常。", Status: "open"},
|
{ProjectID: projectID, CreatedBy: userID, SourceType: "客户支持", Title: "客户反馈:导出功能报错", Body: "客户在导出智能报表时遇到权限校验异常。", Status: "open"},
|
||||||
@@ -142,9 +154,9 @@ func seedProjectA1(tx *gorm.DB, userID uint, projectID uint) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, task := range []models.SenlinAgentTask{
|
for _, task := range []models.SenlinAgentTask{
|
||||||
{ProjectID: projectID, CreatedBy: userID, Title: "智能报表导出功能", Description: "修复权限校验并补充导出失败提示。", Status: "open", DueAt: &dueSoon, SortOrder: 1},
|
{ProjectID: projectID, CreatedBy: userID, TagID: &uiTagID, Title: "智能报表导出功能", Description: "修复权限校验并补充导出失败提示。", Status: "open", DueAt: &dueSoon, SortOrder: 1},
|
||||||
{ProjectID: projectID, CreatedBy: userID, Title: "企业版权限体系梳理", Description: "整理角色、项目和任务分享边界。", Status: "open", DueAt: &dueLater, SortOrder: 2},
|
{ProjectID: projectID, CreatedBy: userID, TagID: &automationTagID, Title: "企业版权限体系梳理", Description: "整理角色、项目和任务分享边界。", Status: "open", DueAt: &dueLater, SortOrder: 2},
|
||||||
{ProjectID: projectID, CreatedBy: userID, Title: "Q3 营销策略制定", Description: "汇总竞品情报和客户反馈,生成策略草案。", Status: "open", DueAt: &dueLater, SortOrder: 3},
|
{ProjectID: projectID, CreatedBy: userID, TagID: &feedbackTagID, Title: "Q3 营销策略制定", Description: "汇总竞品情报和客户反馈,生成策略草案。", Status: "open", DueAt: &dueLater, SortOrder: 3},
|
||||||
} {
|
} {
|
||||||
if err := firstOrCreateTask(tx, task); err != nil {
|
if err := firstOrCreateTask(tx, task); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -209,12 +221,31 @@ 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})
|
return firstOrCreate(tx, &models.SenlinAgentTag{}, "project_id = ? AND name = ?", []any{projectID, name}, models.SenlinAgentTag{ProjectID: projectID, Name: name})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tagID(tx *gorm.DB, projectID uint, name string) (uint, error) {
|
||||||
|
var tag models.SenlinAgentTag
|
||||||
|
if err := tx.Where("project_id = ? AND name = ?", projectID, name).First(&tag).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return tag.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func firstOrCreateInbox(tx *gorm.DB, item models.SenlinAgentInboxItem) error {
|
func firstOrCreateInbox(tx *gorm.DB, item models.SenlinAgentInboxItem) error {
|
||||||
return firstOrCreate(tx, &models.SenlinAgentInboxItem{}, "project_id = ? AND title = ?", []any{item.ProjectID, item.Title}, item)
|
return firstOrCreate(tx, &models.SenlinAgentInboxItem{}, "project_id = ? AND title = ?", []any{item.ProjectID, item.Title}, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstOrCreateTask(tx *gorm.DB, task models.SenlinAgentTask) error {
|
func firstOrCreateTask(tx *gorm.DB, task models.SenlinAgentTask) error {
|
||||||
return firstOrCreate(tx, &models.SenlinAgentTask{}, "project_id = ? AND title = ?", []any{task.ProjectID, task.Title}, task)
|
var existing models.SenlinAgentTask
|
||||||
|
err := tx.Where("project_id = ? AND title = ?", task.ProjectID, task.Title).First(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
if task.TagID != nil && existing.TagID == nil {
|
||||||
|
return tx.Model(&existing).Update("tag_id", *task.TagID).Error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != gorm.ErrRecordNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&task).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstOrCreateAISession(tx *gorm.DB, session models.SenlinAgentAISession) error {
|
func firstOrCreateAISession(tx *gorm.DB, session models.SenlinAgentAISession) error {
|
||||||
|
|||||||
Reference in New Issue
Block a user