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/workspace-body.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-channel-page.tsx',
|
||||
'src/pages/projects/project-tasks.tsx',
|
||||
'src/pages/projects/project-ai.tsx',
|
||||
'src/pages/projects/project-notes.tsx',
|
||||
'src/pages/projects/project-cron.tsx',
|
||||
'src/pages/projects/project-new-channel.tsx',
|
||||
'src/pages/projects/project-rail.tsx',
|
||||
'src/pages/projects/project-sidebar.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')]
|
||||
.map((button) => button.textContent?.trim())
|
||||
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 create = document.querySelector('.create-project')
|
||||
const firstBadge = document.querySelector('.project-badge .arco-badge-number')
|
||||
@@ -84,8 +84,8 @@ const collectMetrics = async () => page.evaluate(() => {
|
||||
workspaceActions,
|
||||
dashboardButton: rect(dashboard),
|
||||
dashboardButtonActive: dashboard?.classList.contains('active') ?? false,
|
||||
workspaceInboxButton: rect(workspaceInbox),
|
||||
workspaceInboxButtonActive: workspaceInbox?.classList.contains('active') ?? false,
|
||||
workspaceExploreButton: rect(workspaceExplore),
|
||||
workspaceExploreButtonActive: workspaceExplore?.classList.contains('active') ?? false,
|
||||
projectButton: rect(project),
|
||||
projectButtonActive: project?.classList.contains('active') ?? false,
|
||||
createProjectButton: rect(create),
|
||||
@@ -111,10 +111,10 @@ await page.waitForTimeout(700)
|
||||
await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true })
|
||||
const workspaceMetrics = await collectMetrics()
|
||||
|
||||
await page.locator('.dashboard-button[title="Inbox"]').click()
|
||||
await page.locator('.dashboard-button[title="探索"]').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.screenshot({ path: 'test-results/workspace-inbox-react-acro-light.png', fullPage: true })
|
||||
const workspaceInboxMetrics = await collectMetrics()
|
||||
await page.screenshot({ path: 'test-results/workspace-explore-react-acro-light.png', fullPage: true })
|
||||
const workspaceExploreMetrics = await collectMetrics()
|
||||
|
||||
await page.locator('.project-button').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
@@ -133,7 +133,8 @@ for (const channel of [
|
||||
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
|
||||
{ label: 'AI 会话', pageClass: 'project-ai-page', expectedHeading: 'AI 会话' },
|
||||
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
|
||||
{ label: 'Cron 计划任务', pageClass: 'project-cron-page', expectedHeading: 'Cron 计划任务' },
|
||||
{ label: '计划任务', pageClass: 'project-cron-page', expectedHeading: '计划任务' },
|
||||
{ label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' },
|
||||
]) {
|
||||
await page.locator('.channel-button', { hasText: channel.label }).click()
|
||||
await page.waitForTimeout(250)
|
||||
@@ -158,7 +159,7 @@ await server.close()
|
||||
|
||||
console.log(JSON.stringify({
|
||||
workspaceMetrics,
|
||||
workspaceInboxMetrics,
|
||||
workspaceExploreMetrics,
|
||||
projectMetrics,
|
||||
channelSidebarHoverMetrics,
|
||||
stageHoverMetrics,
|
||||
@@ -180,8 +181,8 @@ if (metrics.overflowX) failures.push('expected no horizontal overflow')
|
||||
if (!metrics.workbenchMain) failures.push('missing workbench main')
|
||||
if (!metrics.hasWorkspacePage) failures.push('expected login to land on workspace page')
|
||||
if (!metrics.dashboardButtonActive) failures.push('expected dashboard button to be active after login')
|
||||
if (!metrics.workspaceInboxButton) failures.push('expected fixed workspace inbox button to render in project rail')
|
||||
if (metrics.workspaceInboxButtonActive) failures.push('expected fixed workspace inbox button not to be active after login')
|
||||
if (!metrics.workspaceExploreButton) failures.push('expected fixed workspace explore button to render in project rail')
|
||||
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.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)}`)
|
||||
@@ -231,11 +232,11 @@ if (
|
||||
failures.push(`dashboard button size ${JSON.stringify(metrics.dashboardButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
|
||||
}
|
||||
if (
|
||||
!metrics.workspaceInboxButton ||
|
||||
metrics.workspaceInboxButton.width !== metrics.projectButton?.width ||
|
||||
metrics.workspaceInboxButton.height !== metrics.projectButton?.height
|
||||
!metrics.workspaceExploreButton ||
|
||||
metrics.workspaceExploreButton.width !== metrics.projectButton?.width ||
|
||||
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 (
|
||||
!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)}`)
|
||||
}
|
||||
|
||||
if (!workspaceInboxMetrics.workspaceInboxButtonActive) failures.push('expected fixed workspace inbox button to become active when selected')
|
||||
if (workspaceInboxMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace inbox page')
|
||||
if (workspaceInboxMetrics.channelSidebar) failures.push(`expected workspace inbox to hide channel sidebar, got ${JSON.stringify(workspaceInboxMetrics.channelSidebar)}`)
|
||||
if (workspaceInboxMetrics.inspector) failures.push(`expected workspace inbox to hide inspector, got ${JSON.stringify(workspaceInboxMetrics.inspector)}`)
|
||||
if (!workspaceInboxMetrics.stage || !workspaceInboxMetrics.projectRail) {
|
||||
failures.push(`missing workspace inbox layout regions: rail=${JSON.stringify(workspaceInboxMetrics.projectRail)}, stage=${JSON.stringify(workspaceInboxMetrics.stage)}`)
|
||||
} else if (Math.abs(workspaceInboxMetrics.stage.left - workspaceInboxMetrics.projectRail.right) > 1) {
|
||||
failures.push(`expected workspace inbox stage to start after project rail, got stage.left=${workspaceInboxMetrics.stage.left}, rail.right=${workspaceInboxMetrics.projectRail.right}`)
|
||||
if (!workspaceExploreMetrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button to become active when selected')
|
||||
if (workspaceExploreMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace explore page')
|
||||
if (workspaceExploreMetrics.channelSidebar) failures.push(`expected workspace explore to hide channel sidebar, got ${JSON.stringify(workspaceExploreMetrics.channelSidebar)}`)
|
||||
if (workspaceExploreMetrics.inspector) failures.push(`expected workspace explore to hide inspector, got ${JSON.stringify(workspaceExploreMetrics.inspector)}`)
|
||||
if (!workspaceExploreMetrics.stage || !workspaceExploreMetrics.projectRail) {
|
||||
failures.push(`missing workspace explore layout regions: rail=${JSON.stringify(workspaceExploreMetrics.projectRail)}, stage=${JSON.stringify(workspaceExploreMetrics.stage)}`)
|
||||
} else if (Math.abs(workspaceExploreMetrics.stage.left - workspaceExploreMetrics.projectRail.right) > 1) {
|
||||
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')
|
||||
|
||||
@@ -347,6 +347,11 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dashboard-button .arco-icon,
|
||||
.create-project .arco-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.create-project {
|
||||
font-size: 11px;
|
||||
white-space: normal;
|
||||
@@ -399,6 +404,18 @@
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -429,6 +446,11 @@
|
||||
box-shadow: inset 3px 0 0 var(--senlin-primary);
|
||||
}
|
||||
|
||||
.channel-badge-muted .arco-badge-number {
|
||||
background: #c9cdd4;
|
||||
color: #4e5969;
|
||||
}
|
||||
|
||||
.channel-left {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -452,6 +474,17 @@
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
@@ -495,6 +528,85 @@
|
||||
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 {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -579,6 +691,61 @@
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
@@ -646,6 +813,109 @@
|
||||
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 {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -815,6 +1085,10 @@
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.channel-editor-card .arco-card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.action-modal .arco-modal-content {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -16,10 +16,14 @@ export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): Proje
|
||||
const project: Project = {
|
||||
id: String(payload.project.id),
|
||||
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,
|
||||
urgent: payload.project.unreadCount > 20,
|
||||
color: projectColors[index % projectColors.length],
|
||||
color: projectColor(payload.project.background ?? '', index),
|
||||
}
|
||||
|
||||
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 {
|
||||
return {
|
||||
id: channel.id,
|
||||
@@ -79,7 +99,7 @@ function channelLabel(channel: BackendChannel) {
|
||||
case 'notes_sources':
|
||||
return '笔记资料'
|
||||
case 'cron':
|
||||
return 'Cron 计划任务'
|
||||
return '计划任务'
|
||||
default:
|
||||
return channel.title
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ export type BackendProject = {
|
||||
id?: number
|
||||
Name?: string
|
||||
name?: string
|
||||
Identifier?: string
|
||||
identifier?: string
|
||||
Icon?: string
|
||||
icon?: string
|
||||
Background?: string
|
||||
background?: string
|
||||
Description?: string
|
||||
description?: string
|
||||
}
|
||||
@@ -12,6 +18,9 @@ export type BackendProject = {
|
||||
export type BackendWorkspaceProject = {
|
||||
id: number
|
||||
name: string
|
||||
identifier?: string
|
||||
icon?: string
|
||||
background?: string
|
||||
description: string
|
||||
initials: string
|
||||
unreadCount: number
|
||||
@@ -88,6 +97,13 @@ export type BackendProjectWorkspace = {
|
||||
cronPlans: BackendCronPlan[]
|
||||
}
|
||||
|
||||
export type BackendTag = {
|
||||
ID?: number
|
||||
id?: number
|
||||
Name?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export async function fetchProjects(session: ApiSession) {
|
||||
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 })
|
||||
}
|
||||
|
||||
export async function fetchProjectTags(session: ApiSession, projectId: string | number) {
|
||||
return apiRequest<BackendTag[]>(`/api/projects/${projectId}/tags`, { token: session.token })
|
||||
}
|
||||
|
||||
export type CreateProjectInput = {
|
||||
name: string
|
||||
identifier?: string
|
||||
icon?: string
|
||||
background?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
@@ -106,6 +129,15 @@ export type CreateTaskInput = {
|
||||
description?: string
|
||||
status?: string
|
||||
dueAt?: string
|
||||
tag?: string
|
||||
}
|
||||
|
||||
export type UpdateTaskInput = {
|
||||
title: string
|
||||
description?: string
|
||||
completed: boolean
|
||||
nextProjectId?: number
|
||||
tag?: string
|
||||
}
|
||||
|
||||
export type UploadSourceInput = {
|
||||
@@ -120,6 +152,10 @@ export type CreateCronPlanInput = {
|
||||
nextRunAt?: string
|
||||
}
|
||||
|
||||
export type CreateProjectTagInput = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export async function createProject(session: ApiSession, input: CreateProjectInput) {
|
||||
return apiRequest<BackendProject>('/api/projects', {
|
||||
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) {
|
||||
const body = new FormData()
|
||||
body.append('file', input.file)
|
||||
@@ -154,3 +198,11 @@ export async function createCronPlan(session: ApiSession, projectId: string | nu
|
||||
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 {
|
||||
createCronPlan,
|
||||
createProject,
|
||||
createProjectTag,
|
||||
createTask,
|
||||
fetchProjectWorkspace,
|
||||
fetchProjects,
|
||||
updateTask,
|
||||
uploadSource,
|
||||
} from '../api/projects'
|
||||
import { LoginPage } from '../pages/login'
|
||||
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 type { WorkspaceTaskUpdate } from '../pages/workspace-body'
|
||||
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
|
||||
|
||||
function App() {
|
||||
@@ -26,13 +30,14 @@ function App() {
|
||||
const [activeProjectID, setActiveProjectID] = useState<string>('')
|
||||
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
|
||||
const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
|
||||
const [, setSelectedItem] = useState('Inbox 待处理(36)')
|
||||
const [, setSelectedItem] = useState('探索采集')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
|
||||
|
||||
const dark = theme === 'dark'
|
||||
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0]
|
||||
const activeTagOptions = activeWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
|
||||
|
||||
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID = activeProjectID) {
|
||||
const backendProjects = await fetchProjects(nextSession)
|
||||
@@ -102,7 +107,13 @@ function App() {
|
||||
return
|
||||
}
|
||||
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 ?? '')
|
||||
await refreshAfterAction(nextProjectID)
|
||||
if (nextProjectID) {
|
||||
@@ -124,6 +135,7 @@ function App() {
|
||||
description: draft.description.trim(),
|
||||
status: 'open',
|
||||
dueAt: normalizeOptionalTime(draft.dueAt),
|
||||
tag: draft.tag.trim(),
|
||||
})
|
||||
await refreshAfterAction()
|
||||
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) {
|
||||
setActiveProjectID(project.id)
|
||||
setActiveView('project')
|
||||
@@ -174,6 +198,38 @@ function App() {
|
||||
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 (
|
||||
<ConfigProvider>
|
||||
<main className={dark ? 'app theme-dark' : 'app'}>
|
||||
@@ -190,7 +246,7 @@ function App() {
|
||||
activeTaskID={activeTaskID}
|
||||
theme={theme}
|
||||
onSelectWorkspace={() => setActiveView('workspace')}
|
||||
onSelectWorkspaceInbox={() => setActiveView('workspace-inbox')}
|
||||
onSelectWorkspaceExplore={() => setActiveView('workspace-explore')}
|
||||
onSelectProject={(project) => {
|
||||
setActiveProjectID(project.id)
|
||||
setActiveView('project')
|
||||
@@ -209,6 +265,9 @@ function App() {
|
||||
onCreateTask={() => setActiveModal('task')}
|
||||
onUploadSource={() => setActiveModal('source')}
|
||||
onCreateCronPlan={() => setActiveModal('cron')}
|
||||
onUpdateWorkspaceTask={handleUpdateWorkspaceTask}
|
||||
onUpdateProject={handleUpdateProject}
|
||||
onCreateProjectTag={handleCreateProjectTag}
|
||||
/>
|
||||
) : (
|
||||
<Spin loading />
|
||||
@@ -221,10 +280,25 @@ function App() {
|
||||
onCreateTask={handleCreateTask}
|
||||
onUploadSource={handleUploadSource}
|
||||
onCreateCronPlan={handleCreateCronPlan}
|
||||
tagOptions={activeTagOptions}
|
||||
/>
|
||||
</main>
|
||||
</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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { TextArea } = Input
|
||||
@@ -8,6 +8,9 @@ export type ProjectActionModal = 'project' | 'task' | 'source' | 'cron' | null
|
||||
|
||||
export type ProjectDraft = {
|
||||
name: string
|
||||
identifier: string
|
||||
icon: string
|
||||
background: string
|
||||
description: string
|
||||
}
|
||||
|
||||
@@ -15,6 +18,7 @@ export type TaskDraft = {
|
||||
title: string
|
||||
description: string
|
||||
dueAt: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
export type SourceDraft = {
|
||||
@@ -37,6 +41,7 @@ export function ProjectActionModals({
|
||||
onCreateTask,
|
||||
onUploadSource,
|
||||
onCreateCronPlan,
|
||||
tagOptions,
|
||||
}: {
|
||||
activeModal: ProjectActionModal
|
||||
loading: boolean
|
||||
@@ -45,16 +50,17 @@ export function ProjectActionModals({
|
||||
onCreateTask: (draft: TaskDraft) => void
|
||||
onUploadSource: (draft: SourceDraft) => void
|
||||
onCreateCronPlan: (draft: CronDraft) => void
|
||||
tagOptions: string[]
|
||||
}) {
|
||||
const [project, setProject] = useState<ProjectDraft>({ name: '', description: '' })
|
||||
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', dueAt: '' })
|
||||
const [project, setProject] = useState<ProjectDraft>({ name: '', identifier: '', icon: '', background: '', description: '' })
|
||||
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', dueAt: '', tag: '' })
|
||||
const [source, setSource] = useState<SourceDraft>({ title: '', file: null })
|
||||
const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
|
||||
|
||||
useEffect(() => {
|
||||
if (activeModal === null) {
|
||||
setProject({ name: '', description: '' })
|
||||
setTask({ title: '', description: '', dueAt: '' })
|
||||
setProject({ name: '', identifier: '', icon: '', background: '', description: '' })
|
||||
setTask({ title: '', description: '', dueAt: '', tag: '' })
|
||||
setSource({ title: '', file: null })
|
||||
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 }))} />
|
||||
</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 }))} />
|
||||
</label>
|
||||
</Space>
|
||||
@@ -103,6 +121,19 @@ export function ProjectActionModals({
|
||||
<Text>截止时间</Text>
|
||||
<Input placeholder="2026-07-21T09:30:00Z,可留空" value={task.dueAt} onChange={(dueAt) => setTask((draft) => ({ ...draft, dueAt }))} />
|
||||
</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>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ProjectAi } from './project-ai'
|
||||
import { ProjectCron } from './project-cron'
|
||||
import { ProjectNewChannel } from './project-new-channel'
|
||||
import { ProjectNotes } from './project-notes'
|
||||
import { ProjectOverview } from './project-overview'
|
||||
import { ProjectTasks } from './project-tasks'
|
||||
@@ -35,6 +36,8 @@ export function ProjectChannelPage({
|
||||
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
|
||||
case 'cron':
|
||||
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} />
|
||||
case 'new-channel':
|
||||
return <ProjectNewChannel activeWorkspace={activeWorkspace} />
|
||||
case 'overview':
|
||||
default:
|
||||
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="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>Cron 计划任务</Title>
|
||||
<Title heading={4}>计划任务</Title>
|
||||
<Text type="secondary">{project.name} 的定时任务、自动检查和周期性 AI 工作。</Text>
|
||||
</div>
|
||||
<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 { IconFile, IconFolder, IconPlus, IconSearch } from '@arco-design/web-react/icon'
|
||||
import { Button, Card, Space, Typography } from '@arco-design/web-react'
|
||||
import { IconPlus, IconSearch } from '@arco-design/web-react/icon'
|
||||
import { ProjectFileGrid } from './project-file-grid'
|
||||
import type { ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
@@ -22,31 +23,26 @@ export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }:
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="folder-grid">
|
||||
{folders.map((folder) => (
|
||||
<Card className="folder-card" bordered key={folder}>
|
||||
<Space>
|
||||
<Tag color="arcoblue"><IconFolder /></Tag>
|
||||
<Text>{folder}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<ProjectFileGrid items={folderItems(folders)} onSelectItem={onSelectItem} />
|
||||
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>最近更新</Title>
|
||||
<Button type="text" size="mini">按更新时间排序</Button>
|
||||
</div>
|
||||
{notes.map((note) => (
|
||||
<button className="data-row note-row" key={note.title} onClick={() => onSelectItem(note.title)}>
|
||||
<span className="row-title"><IconFile /> {note.title}</span>
|
||||
<Tag>{note.type}</Tag>
|
||||
<span>{note.updated}</span>
|
||||
<span>{note.owner}</span>
|
||||
</button>
|
||||
))}
|
||||
<ProjectFileGrid items={notes} onSelectItem={onSelectItem} />
|
||||
</Card>
|
||||
</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 { 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 type { InboxItem, ProjectWorkspace } from './project-types'
|
||||
import { IconCheckCircleFill, IconClockCircle, IconFile, IconLink } from '@arco-design/web-react/icon'
|
||||
import { ProjectFileGrid } from './project-file-grid'
|
||||
import type { ProjectWorkspace, TaskItem } from './project-types'
|
||||
|
||||
const { Row, Col } = Grid
|
||||
const { Title, Text } = Typography
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
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(() => [
|
||||
{ title: 'Inbox 待处理', value: inbox.length, delta: '实时接口', icon: <IconEmail />, color: 'arcoblue' },
|
||||
{ title: '进行中的任务', value: tasks.filter((task) => !task.completed).length, delta: '实时接口', icon: <IconCheckCircleFill />, color: 'green' },
|
||||
{ title: 'AI 会话活跃', value: aiSessions.length, delta: '实时接口', icon: <IconRobot />, color: 'purple' },
|
||||
{ title: '笔记资料总数', value: notes.length, delta: '实时接口', icon: <IconFile />, color: 'cyan' },
|
||||
{ title: '计划任务', value: cronJobs.length, delta: '实时接口', icon: <IconClockCircle />, color: 'orange' },
|
||||
], [aiSessions.length, cronJobs.length, inbox.length, notes.length, tasks])
|
||||
{ title: '进行中的任务', value: runningTasks.length, icon: <IconCheckCircleFill />, color: 'green' },
|
||||
{ title: '完成的任务', value: completedTasks.length, icon: <IconCheckCircleFill />, color: 'arcoblue' },
|
||||
{ title: '笔记资料总数', value: notes.length, icon: <IconFile />, color: 'cyan' },
|
||||
{ title: '计划任务', value: cronJobs.length, icon: <IconClockCircle />, color: 'orange' },
|
||||
], [completedTasks.length, cronJobs.length, notes.length, runningTasks.length])
|
||||
|
||||
return (
|
||||
<div className="overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>概况</Title>
|
||||
<div className="overview-page project-overview-page">
|
||||
<section className="project-overview-hero" style={{ background: projectHeroBackground(project.background, project.color) }}>
|
||||
<div className="project-overview-identity">
|
||||
<span className="project-overview-icon">{project.short}</span>
|
||||
<div>
|
||||
<Title heading={3}>{project.name}</Title>
|
||||
<Paragraph ellipsis={{ rows: 2 }}>{project.description || '暂无简介'}</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<IconCalendar />}>2026-07-20</Button>
|
||||
<Button icon={<IconMore />} />
|
||||
</Space>
|
||||
</div>
|
||||
<div className="project-overview-share">
|
||||
<Text>项目 URL</Text>
|
||||
<Button icon={<IconLink />} onClick={() => onSelectItem(shareURL)}>{shareURL}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Row gutter={12} className="metric-row">
|
||||
{metrics.map((metric) => (
|
||||
<Col span={24 / metrics.length} key={metric.title}>
|
||||
<Col span={6} key={metric.title}>
|
||||
<Card className="metric-card" bordered>
|
||||
<Space align="start">
|
||||
<Tag color={metric.color}>{metric.icon}</Tag>
|
||||
<div>
|
||||
<Text type="secondary">{metric.title}</Text>
|
||||
<Title heading={3}>{metric.value}</Title>
|
||||
<Text className="metric-delta">{metric.delta}</Text>
|
||||
<Text className="metric-delta">实时接口</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
@@ -45,69 +52,40 @@ export function ProjectOverview({ activeWorkspace, onSelectItem }: { activeWorks
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<QueueSection title={`Inbox 待处理(${inbox.length})`} items={inbox} onSelectItem={onSelectItem} />
|
||||
<TaskSection tasks={tasks} onSelectItem={onSelectItem} />
|
||||
<AISessionSection aiSessions={aiSessions} onSelectItem={onSelectItem} />
|
||||
<TaskCardSection title={`进行中的任务(${runningTasks.length})`} tasks={runningTasks} emptyText="当前没有进行中的任务" onSelectItem={onSelectItem} />
|
||||
<TaskCardSection title={`完成的任务(${completedTasks.length})`} tasks={completedTasks} emptyText="当前没有完成的任务" onSelectItem={onSelectItem} />
|
||||
<NoteSection notes={notes} onSelectItem={onSelectItem} />
|
||||
</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 (
|
||||
<Card className="queue-section" bordered>
|
||||
<Card className="queue-section overview-task-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>{title}</Title>
|
||||
<Button type="text" size="mini">查看全部</Button>
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<button key={item.title} className="data-row" onClick={() => onSelectItem(item.title)}>
|
||||
<span className="row-title"><IconFile /> {item.title}</span>
|
||||
<span>{item.meta}</span>
|
||||
<Tag>{item.tag}</Tag>
|
||||
<span>{item.owner}</span>
|
||||
<time>{item.time}</time>
|
||||
</button>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskSection({ tasks, onSelectItem }: { tasks: ProjectWorkspace['tasks']; onSelectItem: (title: string) => void }) {
|
||||
return (
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>进行中的任务({tasks.length})</Title>
|
||||
<Button type="text" size="mini">查看全部</Button>
|
||||
</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>
|
||||
))}
|
||||
{tasks.length === 0 ? (
|
||||
<Text type="secondary">{emptyText}</Text>
|
||||
) : (
|
||||
<div className="overview-task-grid">
|
||||
{tasks.map((task) => (
|
||||
<button key={task.id} className={task.completed ? 'overview-task-card completed' : 'overview-task-card'} onClick={() => onSelectItem(task.title)}>
|
||||
<span className="overview-task-title">
|
||||
<IconCheckCircleFill />
|
||||
{task.title}
|
||||
</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>
|
||||
<span>进度 {task.progress}</span>
|
||||
<span>创建 {task.createdAt}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -116,17 +94,27 @@ function NoteSection({ notes, onSelectItem }: { notes: ProjectWorkspace['notes']
|
||||
return (
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>最近笔记资料(5)</Title>
|
||||
<Title heading={6}>最近笔记资料({notes.length})</Title>
|
||||
<Button type="text" size="mini">查看全部</Button>
|
||||
</div>
|
||||
{notes.map((note) => (
|
||||
<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>
|
||||
))}
|
||||
<ProjectFileGrid items={notes.slice(0, 8)} onSelectItem={onSelectItem} compact />
|
||||
</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 { 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'
|
||||
|
||||
const { Sider } = Layout
|
||||
@@ -10,7 +10,7 @@ export function ProjectRail({
|
||||
projects,
|
||||
activeView,
|
||||
onSelectWorkspace,
|
||||
onSelectWorkspaceInbox,
|
||||
onSelectWorkspaceExplore,
|
||||
onSelectProject,
|
||||
onCreateProject,
|
||||
}: {
|
||||
@@ -18,7 +18,7 @@ export function ProjectRail({
|
||||
projects: Project[]
|
||||
activeView: WorkbenchView
|
||||
onSelectWorkspace: () => void
|
||||
onSelectWorkspaceInbox: () => void
|
||||
onSelectWorkspaceExplore: () => void
|
||||
onSelectProject: (project: Project) => void
|
||||
onCreateProject: () => void
|
||||
}) {
|
||||
@@ -32,11 +32,11 @@ export function ProjectRail({
|
||||
title="工作台"
|
||||
/>
|
||||
<Button
|
||||
className={activeView === 'workspace-inbox' ? 'dashboard-button active' : 'dashboard-button'}
|
||||
icon={<IconEmail />}
|
||||
onClick={onSelectWorkspaceInbox}
|
||||
aria-label="Inbox"
|
||||
title="Inbox"
|
||||
className={activeView === 'workspace-explore' ? 'dashboard-button active' : 'dashboard-button'}
|
||||
icon={<IconCompass />}
|
||||
onClick={onSelectWorkspaceExplore}
|
||||
aria-label="探索"
|
||||
title="探索"
|
||||
/>
|
||||
<Space className="project-stack" direction="vertical" size={12}>
|
||||
{projects.map((project) => (
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { Badge, Button, Divider, Layout, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconApps, IconDashboard, IconMore, IconSettings } from '@arco-design/web-react/icon'
|
||||
import type { ChannelKey, ProjectWorkspace, WorkbenchView } from './project-types'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import { Badge, Button, Divider, Input, Layout, Modal, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
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 { Title, Text } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
export type ProjectSettingsUpdate = {
|
||||
projectId: string
|
||||
name: string
|
||||
identifier: string
|
||||
icon: string
|
||||
background: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function ProjectSidebar({
|
||||
activeView,
|
||||
@@ -11,24 +22,57 @@ export function ProjectSidebar({
|
||||
activeChannel,
|
||||
onSelectChannel,
|
||||
onSelectItem,
|
||||
onUpdateProject,
|
||||
onCreateProjectTag,
|
||||
}: {
|
||||
activeView: WorkbenchView
|
||||
activeWorkspace: ProjectWorkspace
|
||||
activeChannel: ChannelKey
|
||||
onSelectChannel: (key: ChannelKey) => void
|
||||
onSelectItem: (title: string) => void
|
||||
onUpdateProject: (update: ProjectSettingsUpdate) => void
|
||||
onCreateProjectTag: (name: string) => void
|
||||
}) {
|
||||
const workspaceMode = activeView === 'workspace'
|
||||
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 (
|
||||
<Sider className="channel-sidebar" width={248}>
|
||||
<div className="project-title">
|
||||
<Space>
|
||||
{workspaceMode ? <IconApps /> : <IconDashboard />}
|
||||
{workspaceMode ? <IconApps /> : <span className="project-title-icon">{projectIcon}</span>}
|
||||
<Title heading={5}>{workspaceMode ? '工作台' : activeWorkspace.project.name}</Title>
|
||||
</Space>
|
||||
<Button icon={<IconSettings />} size="mini" />
|
||||
<Button icon={<IconSettings />} size="mini" onClick={() => setSettingsOpen(true)} />
|
||||
</div>
|
||||
|
||||
<div className="channel-list">
|
||||
@@ -44,16 +88,24 @@ export function ProjectSidebar({
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
channels.map((channel) => (
|
||||
<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.count !== undefined ? <Badge count={channel.count} maxCount={999} /> : <IconMore />}
|
||||
</button>
|
||||
))
|
||||
<>
|
||||
{channels.flatMap((channel) => {
|
||||
const items = [renderChannelButton(channel, activeChannel, onSelectChannel)]
|
||||
if (channel.key === 'cron') {
|
||||
items.push(
|
||||
<button
|
||||
key="new-channel"
|
||||
className={activeChannel === 'new-channel' ? 'channel-button active' : 'channel-button'}
|
||||
onClick={() => onSelectChannel('new-channel')}
|
||||
>
|
||||
<span className="channel-left"><IconPlus /><Text>新建频道</Text></span>
|
||||
<IconMore />
|
||||
</button>,
|
||||
)
|
||||
}
|
||||
return items
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -62,7 +114,20 @@ export function ProjectSidebar({
|
||||
<Text className="section-title">标签</Text>
|
||||
<Space wrap>
|
||||
{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>
|
||||
</section>
|
||||
@@ -78,6 +143,86 @@ export function ProjectSidebar({
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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 Theme = 'light' | 'dark'
|
||||
export type WorkbenchView = 'workspace' | 'workspace-inbox' | 'project'
|
||||
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | `custom:${string}`
|
||||
export type WorkbenchView = 'workspace' | 'workspace-explore' | 'project'
|
||||
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | 'new-channel' | `custom:${string}`
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
name: string
|
||||
identifier: string
|
||||
icon: string
|
||||
background: string
|
||||
description: string
|
||||
short: string
|
||||
badge: number
|
||||
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 {
|
||||
IconCalendar,
|
||||
IconCheckCircleFill,
|
||||
@@ -6,14 +7,44 @@ import {
|
||||
IconFile,
|
||||
IconRobot,
|
||||
} 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 { 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 unfinishedTasks = workspaces.flatMap((workspace) =>
|
||||
const unfinishedTasks: WorkspaceTask[] = workspaces.flatMap((workspace) =>
|
||||
workspace.tasks
|
||||
.filter((task) => !task.completed)
|
||||
.map((task) => ({
|
||||
@@ -21,6 +52,28 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
|
||||
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 notes = workspaces.flatMap((workspace) => workspace.notes)
|
||||
const urgentProjects = projects.filter((project) => project.urgent).length
|
||||
@@ -65,7 +118,7 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
|
||||
{unfinishedTasks.length ? (
|
||||
<div className="workspace-task-list">
|
||||
{unfinishedTasks.map((task) => (
|
||||
<button key={`${task.project.id}-${task.id}`} className="workspace-task-card" onClick={() => onOpenTask(task.project, task.id)}>
|
||||
<button key={`${task.project.id}-${task.id}`} className="workspace-task-card" onClick={() => setEditingTask(task)}>
|
||||
<div className="workspace-task-main">
|
||||
<span className="workspace-task-title">
|
||||
<IconCheckCircleFill />
|
||||
@@ -87,6 +140,62 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
|
||||
<Empty description="暂无未完成任务" />
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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 { WorkspacePage } from './workspace-body'
|
||||
import { WorkspaceInboxPage } from './workspace-inbox'
|
||||
import { WorkspacePage, type WorkspaceTaskUpdate } from './workspace-body'
|
||||
import { WorkspaceExplorePage } from './workspace-explore'
|
||||
import { ProjectChannelPage } from './projects/project-channel-page'
|
||||
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 { ProjectTopbar } from './projects/project-topbar'
|
||||
import type { ChannelKey, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
|
||||
@@ -19,7 +19,7 @@ export function ProjectPage({
|
||||
theme,
|
||||
onSelectProject,
|
||||
onSelectWorkspace,
|
||||
onSelectWorkspaceInbox,
|
||||
onSelectWorkspaceExplore,
|
||||
onSelectChannel,
|
||||
onSelectItem,
|
||||
onOpenTask,
|
||||
@@ -29,6 +29,9 @@ export function ProjectPage({
|
||||
onCreateTask,
|
||||
onUploadSource,
|
||||
onCreateCronPlan,
|
||||
onUpdateWorkspaceTask,
|
||||
onUpdateProject,
|
||||
onCreateProjectTag,
|
||||
}: {
|
||||
activeView: WorkbenchView
|
||||
activeWorkspace: ProjectWorkspace
|
||||
@@ -38,7 +41,7 @@ export function ProjectPage({
|
||||
theme: Theme
|
||||
onSelectProject: (project: Project) => void
|
||||
onSelectWorkspace: () => void
|
||||
onSelectWorkspaceInbox: () => void
|
||||
onSelectWorkspaceExplore: () => void
|
||||
onSelectChannel: (key: ChannelKey) => void
|
||||
onSelectItem: (title: string) => void
|
||||
onOpenTask: (project: Project, taskID: string) => void
|
||||
@@ -48,6 +51,9 @@ export function ProjectPage({
|
||||
onCreateTask: () => void
|
||||
onUploadSource: () => void
|
||||
onCreateCronPlan: () => void
|
||||
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => void
|
||||
onUpdateProject: (update: ProjectSettingsUpdate) => void
|
||||
onCreateProjectTag: (name: string) => void
|
||||
}) {
|
||||
const isProject = activeView === 'project'
|
||||
const projects = workspaces.map((workspace) => workspace.project)
|
||||
@@ -62,7 +68,7 @@ export function ProjectPage({
|
||||
projects={projects}
|
||||
activeView={activeView}
|
||||
onSelectWorkspace={onSelectWorkspace}
|
||||
onSelectWorkspaceInbox={onSelectWorkspaceInbox}
|
||||
onSelectWorkspaceExplore={onSelectWorkspaceExplore}
|
||||
onSelectProject={onSelectProject}
|
||||
onCreateProject={onCreateProject}
|
||||
/>
|
||||
@@ -74,14 +80,16 @@ export function ProjectPage({
|
||||
activeChannel={activeChannel}
|
||||
onSelectChannel={onSelectChannel}
|
||||
onSelectItem={onSelectItem}
|
||||
onUpdateProject={onUpdateProject}
|
||||
onCreateProjectTag={onCreateProjectTag}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Content className="stage">
|
||||
{activeView === 'workspace' ? (
|
||||
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} />
|
||||
) : activeView === 'workspace-inbox' ? (
|
||||
<WorkspaceInboxPage workspaces={workspaces} onSelectItem={onSelectItem} />
|
||||
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} onUpdateTask={onUpdateWorkspaceTask} />
|
||||
) : activeView === 'workspace-explore' ? (
|
||||
<WorkspaceExplorePage workspaces={workspaces} onSelectItem={onSelectItem} />
|
||||
) : (
|
||||
<ProjectChannelPage
|
||||
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/:id/dashboard", h.dashboard)
|
||||
router.GET("/projects/:id/workspace", h.workspace)
|
||||
router.GET("/projects/:id/tags", h.listTags)
|
||||
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/cron-plans", h.createCronPlan)
|
||||
router.POST("/projects/:id/tags", h.createTag)
|
||||
}
|
||||
|
||||
func (h *Handler) createProject(c *gin.Context) {
|
||||
@@ -42,13 +45,22 @@ func (h *Handler) createProject(c *gin.Context) {
|
||||
}
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
Icon string `json:"icon"`
|
||||
Background string `json:"background"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -108,6 +120,24 @@ func (h *Handler) workspace(c *gin.Context) {
|
||||
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) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
@@ -123,6 +153,7 @@ func (h *Handler) createTask(c *gin.Context) {
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
DueAt string `json:"dueAt"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
@@ -138,6 +169,7 @@ func (h *Handler) createTask(c *gin.Context) {
|
||||
Description: input.Description,
|
||||
Status: input.Status,
|
||||
DueAt: dueAt,
|
||||
Tag: input.Tag,
|
||||
})
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
@@ -233,6 +306,31 @@ func (h *Handler) createCronPlan(c *gin.Context) {
|
||||
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) {
|
||||
projectID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -242,6 +340,15 @@ func parseProjectID(c *gin.Context) (uint, bool) {
|
||||
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) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -17,9 +17,35 @@ import (
|
||||
"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) {
|
||||
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)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tasks", bytes.NewReader(body))
|
||||
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.Equal(t, "open", task.Status)
|
||||
require.NotNil(t, task.DueAt)
|
||||
require.NotNil(t, task.TagID)
|
||||
}
|
||||
|
||||
func TestUploadSourceHandlerStoresFileAndSource(t *testing.T) {
|
||||
@@ -79,6 +106,60 @@ func TestCreateCronPlanHandlerPersistsPlan(t *testing.T) {
|
||||
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) {
|
||||
t.Helper()
|
||||
newTestDB(t)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
@@ -23,6 +24,9 @@ type Dashboard struct {
|
||||
type WorkbenchProject struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Identifier string `json:"identifier"`
|
||||
Icon string `json:"icon"`
|
||||
Background string `json:"background"`
|
||||
Description string `json:"description"`
|
||||
Initials string `json:"initials"`
|
||||
UnreadCount int64 `json:"unreadCount"`
|
||||
@@ -99,11 +103,29 @@ type ProjectWorkspace struct {
|
||||
CronPlans []CronPlan `json:"cronPlans"`
|
||||
}
|
||||
|
||||
type CreateProjectInput struct {
|
||||
Name string
|
||||
Identifier string
|
||||
Icon string
|
||||
Background string
|
||||
Description string
|
||||
}
|
||||
|
||||
type CreateTaskInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Status string
|
||||
DueAt *time.Time
|
||||
Tag string
|
||||
}
|
||||
|
||||
type UpdateTaskInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Status string
|
||||
Completed bool
|
||||
NextProjectID uint
|
||||
Tag string
|
||||
}
|
||||
|
||||
type CreateFileSourceInput struct {
|
||||
@@ -123,11 +145,26 @@ func NewService() *Service {
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -149,9 +186,14 @@ func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput
|
||||
if status == "" {
|
||||
status = "open"
|
||||
}
|
||||
tagID, err := s.findOrCreateTagID(projectID, input.Tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task := &models.SenlinAgentTask{
|
||||
ProjectID: projectID,
|
||||
CreatedBy: ownerID,
|
||||
TagID: tagID,
|
||||
Title: title,
|
||||
Description: strings.TrimSpace(input.Description),
|
||||
Status: status,
|
||||
@@ -160,6 +202,58 @@ func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput
|
||||
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) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
@@ -206,21 +300,55 @@ func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronP
|
||||
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) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
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}
|
||||
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) {
|
||||
var tags []models.SenlinAgentTag
|
||||
err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error
|
||||
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) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return Dashboard{}, err
|
||||
@@ -284,8 +412,11 @@ func (s *Service) Workspace(ownerID uint, projectID uint) (ProjectWorkspace, err
|
||||
Project: WorkbenchProject{
|
||||
ID: project.ID,
|
||||
Name: project.Name,
|
||||
Identifier: project.Identifier,
|
||||
Icon: project.Icon,
|
||||
Background: project.Background,
|
||||
Description: project.Description,
|
||||
Initials: projectInitials(project.Name),
|
||||
Initials: defaultString(project.Identifier, projectInitials(project.Name)),
|
||||
UnreadCount: counts.inbox,
|
||||
},
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
tagNames, err := taskTagNames(tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]WorkTask, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
owner, err := userDisplayName(task.AssigneeID, task.CreatedBy)
|
||||
@@ -423,12 +558,58 @@ func (s *Service) workspaceTasks(projectID uint) ([]WorkTask, error) {
|
||||
Owner: owner,
|
||||
Due: displayOptionalTime(task.DueAt),
|
||||
CreatedAt: displayTime(task.CreatedAt),
|
||||
Tag: "",
|
||||
Tag: tagNames[task.ID],
|
||||
})
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
|
||||
@@ -30,6 +30,33 @@ func TestProjectTagsAreScopedToProject(t *testing.T) {
|
||||
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) {
|
||||
database := newTestDB(t)
|
||||
service := NewService()
|
||||
@@ -140,6 +167,7 @@ func TestCreateTaskAddsTaskToOwnedProject(t *testing.T) {
|
||||
Description: "整理客户反馈并形成行动项",
|
||||
Status: "",
|
||||
DueAt: &dueAt,
|
||||
Tag: "客户反馈",
|
||||
})
|
||||
|
||||
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, "open", task.Status)
|
||||
require.NotNil(t, task.DueAt)
|
||||
require.NotNil(t, task.TagID)
|
||||
workspace, err := service.Workspace(1, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workspace.Tasks, 1)
|
||||
require.Equal(t, "Follow up with client", workspace.Tasks[0].Title)
|
||||
require.Equal(t, "客户反馈", workspace.Tasks[0].Tag)
|
||||
require.Equal(t, []string{"all", "客户反馈"}, workspace.Tags)
|
||||
}
|
||||
|
||||
func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||
@@ -165,6 +196,58 @@ func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||
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) {
|
||||
database := newTestDB(t)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if err := resolveOptionalIdentity(tx, &SenlinAgentTag{}, m.TagID, &m.TagIdentity); err != nil {
|
||||
return err
|
||||
}
|
||||
return resolveOptionalIdentity(tx, &SenlinAgentInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ type SenlinAgentProject struct {
|
||||
OwnerID uint `gorm:"index;not null"`
|
||||
OwnerIdentity string `gorm:"type:char(36);index"`
|
||||
Name string `gorm:"not null"`
|
||||
Identifier string `gorm:"index"`
|
||||
Icon string
|
||||
Background string
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
@@ -11,6 +11,8 @@ type SenlinAgentTask struct {
|
||||
CreatedByIdentity string `gorm:"type:char(36);index"`
|
||||
AssigneeID *uint `gorm:"index"`
|
||||
AssigneeIdentity *string `gorm:"type:char(36);index"`
|
||||
TagID *uint `gorm:"index"`
|
||||
TagIdentity *string `gorm:"type:char(36);index"`
|
||||
SourceInboxItemID *uint `gorm:"index"`
|
||||
SourceInboxItemIdentity *string `gorm:"type:char(36);index"`
|
||||
Title string `gorm:"not null"`
|
||||
|
||||
@@ -131,6 +131,18 @@ func seedProjectA1(tx *gorm.DB, userID uint, projectID uint) error {
|
||||
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{
|
||||
{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{
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "智能报表导出功能", Description: "修复权限校验并补充导出失败提示。", Status: "open", DueAt: &dueSoon, SortOrder: 1},
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "企业版权限体系梳理", Description: "整理角色、项目和任务分享边界。", Status: "open", DueAt: &dueLater, SortOrder: 2},
|
||||
{ProjectID: projectID, CreatedBy: userID, Title: "Q3 营销策略制定", Description: "汇总竞品情报和客户反馈,生成策略草案。", Status: "open", DueAt: &dueLater, SortOrder: 3},
|
||||
{ProjectID: projectID, CreatedBy: userID, TagID: &uiTagID, Title: "智能报表导出功能", Description: "修复权限校验并补充导出失败提示。", Status: "open", DueAt: &dueSoon, SortOrder: 1},
|
||||
{ProjectID: projectID, CreatedBy: userID, TagID: &automationTagID, Title: "企业版权限体系梳理", Description: "整理角色、项目和任务分享边界。", Status: "open", DueAt: &dueLater, SortOrder: 2},
|
||||
{ProjectID: projectID, CreatedBy: userID, TagID: &feedbackTagID, Title: "Q3 营销策略制定", Description: "汇总竞品情报和客户反馈,生成策略草案。", Status: "open", DueAt: &dueLater, SortOrder: 3},
|
||||
} {
|
||||
if err := firstOrCreateTask(tx, task); err != nil {
|
||||
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})
|
||||
}
|
||||
|
||||
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 {
|
||||
return firstOrCreate(tx, &models.SenlinAgentInboxItem{}, "project_id = ? AND title = ?", []any{item.ProjectID, item.Title}, item)
|
||||
}
|
||||
|
||||
func firstOrCreateTask(tx *gorm.DB, task models.SenlinAgentTask) error {
|
||||
return firstOrCreate(tx, &models.SenlinAgentTask{}, "project_id = ? AND title = ?", []any{task.ProjectID, task.Title}, task)
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user