feat: complete project workspace tags and notes UI

This commit is contained in:
2026-07-21 00:46:09 +08:00
parent 54958382f1
commit c613d4c997
28 changed files with 1621 additions and 284 deletions

View File

@@ -5,13 +5,14 @@ const requiredFiles = [
'src/pages/login.tsx', 'src/pages/login.tsx',
'src/pages/workspace-body.tsx', 'src/pages/workspace-body.tsx',
'src/pages/workspace-home.tsx', 'src/pages/workspace-home.tsx',
'src/pages/workspace-inbox.tsx', 'src/pages/workspace-explore.tsx',
'src/pages/projects/project-overview.tsx', 'src/pages/projects/project-overview.tsx',
'src/pages/projects/project-channel-page.tsx', 'src/pages/projects/project-channel-page.tsx',
'src/pages/projects/project-tasks.tsx', 'src/pages/projects/project-tasks.tsx',
'src/pages/projects/project-ai.tsx', 'src/pages/projects/project-ai.tsx',
'src/pages/projects/project-notes.tsx', 'src/pages/projects/project-notes.tsx',
'src/pages/projects/project-cron.tsx', 'src/pages/projects/project-cron.tsx',
'src/pages/projects/project-new-channel.tsx',
'src/pages/projects/project-rail.tsx', 'src/pages/projects/project-rail.tsx',
'src/pages/projects/project-sidebar.tsx', 'src/pages/projects/project-sidebar.tsx',
'src/pages/projects/project-topbar.tsx', 'src/pages/projects/project-topbar.tsx',

View File

@@ -38,7 +38,7 @@ const collectMetrics = async () => page.evaluate(() => {
const workspaceActions = [...document.querySelectorAll('.workspace-page .overview-head .arco-btn')] const workspaceActions = [...document.querySelectorAll('.workspace-page .overview-head .arco-btn')]
.map((button) => button.textContent?.trim()) .map((button) => button.textContent?.trim())
const dashboard = document.querySelector('.dashboard-button') const dashboard = document.querySelector('.dashboard-button')
const workspaceInbox = document.querySelector('.dashboard-button[title="Inbox"]') const workspaceExplore = document.querySelector('.dashboard-button[title="探索"]')
const project = document.querySelector('.project-button') const project = document.querySelector('.project-button')
const create = document.querySelector('.create-project') const create = document.querySelector('.create-project')
const firstBadge = document.querySelector('.project-badge .arco-badge-number') const firstBadge = document.querySelector('.project-badge .arco-badge-number')
@@ -84,8 +84,8 @@ const collectMetrics = async () => page.evaluate(() => {
workspaceActions, workspaceActions,
dashboardButton: rect(dashboard), dashboardButton: rect(dashboard),
dashboardButtonActive: dashboard?.classList.contains('active') ?? false, dashboardButtonActive: dashboard?.classList.contains('active') ?? false,
workspaceInboxButton: rect(workspaceInbox), workspaceExploreButton: rect(workspaceExplore),
workspaceInboxButtonActive: workspaceInbox?.classList.contains('active') ?? false, workspaceExploreButtonActive: workspaceExplore?.classList.contains('active') ?? false,
projectButton: rect(project), projectButton: rect(project),
projectButtonActive: project?.classList.contains('active') ?? false, projectButtonActive: project?.classList.contains('active') ?? false,
createProjectButton: rect(create), createProjectButton: rect(create),
@@ -111,10 +111,10 @@ await page.waitForTimeout(700)
await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true }) await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true })
const workspaceMetrics = await collectMetrics() const workspaceMetrics = await collectMetrics()
await page.locator('.dashboard-button[title="Inbox"]').click() await page.locator('.dashboard-button[title="探索"]').click()
await page.waitForTimeout(500) await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/workspace-inbox-react-acro-light.png', fullPage: true }) await page.screenshot({ path: 'test-results/workspace-explore-react-acro-light.png', fullPage: true })
const workspaceInboxMetrics = await collectMetrics() const workspaceExploreMetrics = await collectMetrics()
await page.locator('.project-button').first().click() await page.locator('.project-button').first().click()
await page.waitForTimeout(500) await page.waitForTimeout(500)
@@ -133,7 +133,8 @@ for (const channel of [
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' }, { label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
{ label: 'AI 会话', pageClass: 'project-ai-page', expectedHeading: 'AI 会话' }, { label: 'AI 会话', pageClass: 'project-ai-page', expectedHeading: 'AI 会话' },
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' }, { label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
{ label: 'Cron 计划任务', pageClass: 'project-cron-page', expectedHeading: 'Cron 计划任务' }, { label: '计划任务', pageClass: 'project-cron-page', expectedHeading: '计划任务' },
{ label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' },
]) { ]) {
await page.locator('.channel-button', { hasText: channel.label }).click() await page.locator('.channel-button', { hasText: channel.label }).click()
await page.waitForTimeout(250) await page.waitForTimeout(250)
@@ -158,7 +159,7 @@ await server.close()
console.log(JSON.stringify({ console.log(JSON.stringify({
workspaceMetrics, workspaceMetrics,
workspaceInboxMetrics, workspaceExploreMetrics,
projectMetrics, projectMetrics,
channelSidebarHoverMetrics, channelSidebarHoverMetrics,
stageHoverMetrics, stageHoverMetrics,
@@ -180,8 +181,8 @@ if (metrics.overflowX) failures.push('expected no horizontal overflow')
if (!metrics.workbenchMain) failures.push('missing workbench main') if (!metrics.workbenchMain) failures.push('missing workbench main')
if (!metrics.hasWorkspacePage) failures.push('expected login to land on workspace page') if (!metrics.hasWorkspacePage) failures.push('expected login to land on workspace page')
if (!metrics.dashboardButtonActive) failures.push('expected dashboard button to be active after login') if (!metrics.dashboardButtonActive) failures.push('expected dashboard button to be active after login')
if (!metrics.workspaceInboxButton) failures.push('expected fixed workspace inbox button to render in project rail') if (!metrics.workspaceExploreButton) failures.push('expected fixed workspace explore button to render in project rail')
if (metrics.workspaceInboxButtonActive) failures.push('expected fixed workspace inbox button not to be active after login') if (metrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button not to be active after login')
if (metrics.projectButtonActive) failures.push('expected first project button not to be active on workspace landing page') if (metrics.projectButtonActive) failures.push('expected first project button not to be active on workspace landing page')
if (metrics.channelSidebar) failures.push(`expected workspace to hide channel sidebar, got ${JSON.stringify(metrics.channelSidebar)}`) if (metrics.channelSidebar) failures.push(`expected workspace to hide channel sidebar, got ${JSON.stringify(metrics.channelSidebar)}`)
if (metrics.inspector) failures.push(`expected workspace to hide inspector, got ${JSON.stringify(metrics.inspector)}`) if (metrics.inspector) failures.push(`expected workspace to hide inspector, got ${JSON.stringify(metrics.inspector)}`)
@@ -231,11 +232,11 @@ if (
failures.push(`dashboard button size ${JSON.stringify(metrics.dashboardButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`) failures.push(`dashboard button size ${JSON.stringify(metrics.dashboardButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
} }
if ( if (
!metrics.workspaceInboxButton || !metrics.workspaceExploreButton ||
metrics.workspaceInboxButton.width !== metrics.projectButton?.width || metrics.workspaceExploreButton.width !== metrics.projectButton?.width ||
metrics.workspaceInboxButton.height !== metrics.projectButton?.height metrics.workspaceExploreButton.height !== metrics.projectButton?.height
) { ) {
failures.push(`workspace inbox button size ${JSON.stringify(metrics.workspaceInboxButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`) failures.push(`workspace explore button size ${JSON.stringify(metrics.workspaceExploreButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
} }
if ( if (
!metrics.createProjectButton || !metrics.createProjectButton ||
@@ -245,14 +246,14 @@ if (
failures.push(`create project button size ${JSON.stringify(metrics.createProjectButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`) failures.push(`create project button size ${JSON.stringify(metrics.createProjectButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
} }
if (!workspaceInboxMetrics.workspaceInboxButtonActive) failures.push('expected fixed workspace inbox button to become active when selected') if (!workspaceExploreMetrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button to become active when selected')
if (workspaceInboxMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace inbox page') if (workspaceExploreMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace explore page')
if (workspaceInboxMetrics.channelSidebar) failures.push(`expected workspace inbox to hide channel sidebar, got ${JSON.stringify(workspaceInboxMetrics.channelSidebar)}`) if (workspaceExploreMetrics.channelSidebar) failures.push(`expected workspace explore to hide channel sidebar, got ${JSON.stringify(workspaceExploreMetrics.channelSidebar)}`)
if (workspaceInboxMetrics.inspector) failures.push(`expected workspace inbox to hide inspector, got ${JSON.stringify(workspaceInboxMetrics.inspector)}`) if (workspaceExploreMetrics.inspector) failures.push(`expected workspace explore to hide inspector, got ${JSON.stringify(workspaceExploreMetrics.inspector)}`)
if (!workspaceInboxMetrics.stage || !workspaceInboxMetrics.projectRail) { if (!workspaceExploreMetrics.stage || !workspaceExploreMetrics.projectRail) {
failures.push(`missing workspace inbox layout regions: rail=${JSON.stringify(workspaceInboxMetrics.projectRail)}, stage=${JSON.stringify(workspaceInboxMetrics.stage)}`) failures.push(`missing workspace explore layout regions: rail=${JSON.stringify(workspaceExploreMetrics.projectRail)}, stage=${JSON.stringify(workspaceExploreMetrics.stage)}`)
} else if (Math.abs(workspaceInboxMetrics.stage.left - workspaceInboxMetrics.projectRail.right) > 1) { } else if (Math.abs(workspaceExploreMetrics.stage.left - workspaceExploreMetrics.projectRail.right) > 1) {
failures.push(`expected workspace inbox stage to start after project rail, got stage.left=${workspaceInboxMetrics.stage.left}, rail.right=${workspaceInboxMetrics.projectRail.right}`) failures.push(`expected workspace explore stage to start after project rail, got stage.left=${workspaceExploreMetrics.stage.left}, rail.right=${workspaceExploreMetrics.projectRail.right}`)
} }
if (!projectMetrics.hasProjectOverview) failures.push('expected first project click to show project overview page') if (!projectMetrics.hasProjectOverview) failures.push('expected first project click to show project overview page')

View File

@@ -347,6 +347,11 @@
color: white; color: white;
} }
.dashboard-button .arco-icon,
.create-project .arco-icon {
font-size: 24px;
}
.create-project { .create-project {
font-size: 11px; font-size: 11px;
white-space: normal; white-space: normal;
@@ -399,6 +404,18 @@
margin: 0; margin: 0;
} }
.project-title-icon {
width: 24px;
height: 24px;
display: inline-grid;
place-items: center;
border-radius: 6px;
background: var(--senlin-soft-blue);
color: var(--senlin-primary);
font-size: 12px;
font-weight: 700;
}
.channel-list { .channel-list {
display: grid; display: grid;
gap: 4px; gap: 4px;
@@ -429,6 +446,11 @@
box-shadow: inset 3px 0 0 var(--senlin-primary); box-shadow: inset 3px 0 0 var(--senlin-primary);
} }
.channel-badge-muted .arco-badge-number {
background: #c9cdd4;
color: #4e5969;
}
.channel-left { .channel-left {
min-width: 0; min-width: 0;
display: flex; display: flex;
@@ -452,6 +474,17 @@
font-weight: 700; font-weight: 700;
} }
.tag-create-button.arco-btn {
height: 24px;
padding: 0 8px;
border-radius: 12px;
color: rgb(var(--arcoblue-6));
}
.tag-create-button .arco-icon {
font-size: 12px;
}
.recent-sessions button { .recent-sessions button {
display: grid; display: grid;
gap: 2px; gap: 2px;
@@ -495,6 +528,85 @@
margin: 3px 0 0; margin: 3px 0 0;
} }
.project-overview-hero {
min-height: 112px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 14px;
border: 1px solid var(--senlin-border);
border-radius: 8px;
background-position: center;
background-size: cover;
color: #fff;
padding: 16px 18px;
overflow: hidden;
}
.project-overview-identity {
min-width: 0;
display: flex;
align-items: center;
gap: 14px;
}
.project-overview-icon {
width: 44px;
height: 44px;
flex: 0 0 auto;
display: inline-grid;
place-items: center;
border: 1px solid rgb(255 255 255 / 42%);
border-radius: 8px;
background: rgb(255 255 255 / 18%);
color: #fff;
font-size: 16px;
font-weight: 700;
backdrop-filter: blur(10px);
}
.project-overview-identity h3,
.project-overview-identity .arco-typography {
color: #fff;
}
.project-overview-identity h3 {
margin: 0 0 3px;
}
.project-overview-identity .arco-typography {
max-width: 680px;
margin: 0;
opacity: 0.88;
}
.project-overview-share {
max-width: min(520px, 42vw);
display: grid;
justify-items: end;
gap: 4px;
}
.project-overview-share .arco-typography {
color: rgb(255 255 255 / 82%);
}
.project-overview-share .arco-btn {
height: 30px;
max-width: 100%;
border-color: rgb(255 255 255 / 36%);
background: rgb(255 255 255 / 18%);
color: #fff;
padding: 0 10px;
backdrop-filter: blur(10px);
}
.project-overview-share .arco-btn > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-row { .metric-row {
min-width: 0; min-width: 0;
} }
@@ -579,6 +691,61 @@
grid-template-columns: minmax(260px, 1fr) 82px 100px 100px 130px; grid-template-columns: minmax(260px, 1fr) 82px 100px 100px 130px;
} }
.overview-task-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
padding-top: 6px;
}
.overview-task-card {
min-width: 0;
min-height: 132px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 10px;
border: 1px solid var(--senlin-border);
border-radius: 6px;
background: var(--senlin-surface);
color: var(--senlin-text);
padding: 14px;
text-align: left;
cursor: pointer;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.overview-task-card:hover {
border-color: color-mix(in srgb, var(--senlin-primary) 42%, var(--senlin-border));
box-shadow: 0 8px 22px color-mix(in srgb, var(--senlin-primary) 12%, transparent);
transform: translateY(-1px);
}
.overview-task-card.completed {
background: color-mix(in srgb, var(--senlin-surface) 92%, #00b42a);
}
.overview-task-title {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.overview-task-title svg {
color: var(--senlin-primary);
}
.overview-task-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
color: var(--senlin-muted);
font-size: 12px;
}
.workspace-task-list { .workspace-task-list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -646,6 +813,109 @@
grid-template-columns: minmax(260px, 1fr) 80px 160px 80px; grid-template-columns: minmax(260px, 1fr) 80px 160px 80px;
} }
.project-file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 8px 18px;
}
.project-file-grid.compact {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.project-file-tile {
min-width: 0;
height: 76px;
display: grid;
grid-template-columns: 56px minmax(0, 1fr);
align-items: center;
gap: 12px;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
color: var(--senlin-text);
padding: 8px 10px;
text-align: left;
cursor: pointer;
}
.project-file-tile:hover,
.project-file-tile:focus-visible {
border-color: #bfdbff;
background: #e8f3ff;
outline: none;
}
.theme-dark .project-file-tile:hover,
.theme-dark .project-file-tile:focus-visible {
border-color: color-mix(in srgb, var(--senlin-primary) 56%, var(--senlin-border));
background: color-mix(in srgb, var(--senlin-primary) 18%, var(--senlin-surface));
}
.project-file-icon {
width: 54px;
height: 54px;
display: inline-grid;
place-items: center;
border-radius: 6px;
background: #eef2f7;
color: #4e5969;
font-size: 30px;
}
.project-file-icon.folder {
background: #fff3cf;
color: #f0a020;
}
.project-file-icon.pdf {
background: #fff1f0;
color: #f53f3f;
}
.project-file-icon.sheet {
background: #e8ffea;
color: #00b42a;
}
.project-file-icon.doc,
.project-file-icon.note {
background: #e8f3ff;
color: #165dff;
}
.project-file-icon.image {
background: #f2f3f5;
color: #86909c;
}
.project-file-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.project-file-name,
.project-file-type,
.project-file-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-file-name {
color: var(--senlin-text);
font-size: 14px;
font-weight: 500;
}
.project-file-type,
.project-file-meta {
color: var(--senlin-muted);
font-size: 12px;
}
.project-channel-page { .project-channel-page {
min-width: 0; min-width: 0;
} }
@@ -815,6 +1085,10 @@
padding: 12px; padding: 12px;
} }
.channel-editor-card .arco-card-body {
padding: 14px;
}
.action-modal .arco-modal-content { .action-modal .arco-modal-content {
border-radius: 8px; border-radius: 8px;
} }

View File

@@ -16,10 +16,14 @@ export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): Proje
const project: Project = { const project: Project = {
id: String(payload.project.id), id: String(payload.project.id),
name: payload.project.name, name: payload.project.name,
short: payload.project.initials, identifier: payload.project.identifier ?? '',
icon: payload.project.icon ?? '',
background: payload.project.background ?? '',
description: payload.project.description,
short: projectIconLabel(payload.project.icon ?? '', payload.project.initials),
badge: payload.project.unreadCount, badge: payload.project.unreadCount,
urgent: payload.project.unreadCount > 20, urgent: payload.project.unreadCount > 20,
color: projectColors[index % projectColors.length], color: projectColor(payload.project.background ?? '', index),
} }
return { return {
@@ -35,6 +39,22 @@ export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): Proje
} }
} }
function projectIconLabel(icon: string, fallback: string) {
const trimmed = icon.trim()
if (!trimmed) return fallback
const chars = Array.from(trimmed)
if (chars.length <= 2) return trimmed
return chars.slice(0, 2).join('').toUpperCase()
}
function projectColor(background: string, index: number) {
const trimmed = background.trim()
if (trimmed.startsWith('#') || trimmed.startsWith('rgb') || trimmed.startsWith('hsl')) {
return trimmed
}
return projectColors[index % projectColors.length]
}
function mapChannel(channel: BackendChannel): ProjectChannel { function mapChannel(channel: BackendChannel): ProjectChannel {
return { return {
id: channel.id, id: channel.id,
@@ -79,7 +99,7 @@ function channelLabel(channel: BackendChannel) {
case 'notes_sources': case 'notes_sources':
return '笔记资料' return '笔记资料'
case 'cron': case 'cron':
return 'Cron 计划任务' return '计划任务'
default: default:
return channel.title return channel.title
} }

View File

@@ -5,6 +5,12 @@ export type BackendProject = {
id?: number id?: number
Name?: string Name?: string
name?: string name?: string
Identifier?: string
identifier?: string
Icon?: string
icon?: string
Background?: string
background?: string
Description?: string Description?: string
description?: string description?: string
} }
@@ -12,6 +18,9 @@ export type BackendProject = {
export type BackendWorkspaceProject = { export type BackendWorkspaceProject = {
id: number id: number
name: string name: string
identifier?: string
icon?: string
background?: string
description: string description: string
initials: string initials: string
unreadCount: number unreadCount: number
@@ -88,6 +97,13 @@ export type BackendProjectWorkspace = {
cronPlans: BackendCronPlan[] cronPlans: BackendCronPlan[]
} }
export type BackendTag = {
ID?: number
id?: number
Name?: string
name?: string
}
export async function fetchProjects(session: ApiSession) { export async function fetchProjects(session: ApiSession) {
return apiRequest<BackendProject[]>('/api/projects', { token: session.token }) return apiRequest<BackendProject[]>('/api/projects', { token: session.token })
} }
@@ -96,8 +112,15 @@ export async function fetchProjectWorkspace(session: ApiSession, projectId: stri
return apiRequest<BackendProjectWorkspace>(`/api/projects/${projectId}/workspace`, { token: session.token }) return apiRequest<BackendProjectWorkspace>(`/api/projects/${projectId}/workspace`, { token: session.token })
} }
export async function fetchProjectTags(session: ApiSession, projectId: string | number) {
return apiRequest<BackendTag[]>(`/api/projects/${projectId}/tags`, { token: session.token })
}
export type CreateProjectInput = { export type CreateProjectInput = {
name: string name: string
identifier?: string
icon?: string
background?: string
description?: string description?: string
} }
@@ -106,6 +129,15 @@ export type CreateTaskInput = {
description?: string description?: string
status?: string status?: string
dueAt?: string dueAt?: string
tag?: string
}
export type UpdateTaskInput = {
title: string
description?: string
completed: boolean
nextProjectId?: number
tag?: string
} }
export type UploadSourceInput = { export type UploadSourceInput = {
@@ -120,6 +152,10 @@ export type CreateCronPlanInput = {
nextRunAt?: string nextRunAt?: string
} }
export type CreateProjectTagInput = {
name: string
}
export async function createProject(session: ApiSession, input: CreateProjectInput) { export async function createProject(session: ApiSession, input: CreateProjectInput) {
return apiRequest<BackendProject>('/api/projects', { return apiRequest<BackendProject>('/api/projects', {
method: 'POST', method: 'POST',
@@ -136,6 +172,14 @@ export async function createTask(session: ApiSession, projectId: string | number
}) })
} }
export async function updateTask(session: ApiSession, projectId: string | number, taskId: string | number, input: UpdateTaskInput) {
return apiRequest<BackendTask>(`/api/projects/${projectId}/tasks/${taskId}`, {
method: 'PATCH',
token: session.token,
body: input,
})
}
export async function uploadSource(session: ApiSession, projectId: string | number, input: UploadSourceInput) { export async function uploadSource(session: ApiSession, projectId: string | number, input: UploadSourceInput) {
const body = new FormData() const body = new FormData()
body.append('file', input.file) body.append('file', input.file)
@@ -154,3 +198,11 @@ export async function createCronPlan(session: ApiSession, projectId: string | nu
body: input, body: input,
}) })
} }
export async function createProjectTag(session: ApiSession, projectId: string | number, input: CreateProjectTagInput) {
return apiRequest<BackendTag>(`/api/projects/${projectId}/tags`, {
method: 'POST',
token: session.token,
body: input,
})
}

View File

@@ -7,14 +7,18 @@ import { mapWorkspace } from '../api/mappers'
import { import {
createCronPlan, createCronPlan,
createProject, createProject,
createProjectTag,
createTask, createTask,
fetchProjectWorkspace, fetchProjectWorkspace,
fetchProjects, fetchProjects,
updateTask,
uploadSource, uploadSource,
} from '../api/projects' } from '../api/projects'
import { LoginPage } from '../pages/login' import { LoginPage } from '../pages/login'
import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals' import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals'
import type { ProjectSettingsUpdate } from '../pages/projects/project-sidebar'
import { ProjectPage } from '../pages/workspace-home' import { ProjectPage } from '../pages/workspace-home'
import type { WorkspaceTaskUpdate } from '../pages/workspace-body'
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types' import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
function App() { function App() {
@@ -26,13 +30,14 @@ function App() {
const [activeProjectID, setActiveProjectID] = useState<string>('') const [activeProjectID, setActiveProjectID] = useState<string>('')
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview') const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
const [activeTaskID, setActiveTaskID] = useState<string | null>(null) const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
const [, setSelectedItem] = useState('Inbox 待处理36') const [, setSelectedItem] = useState('探索采集')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false) const [actionLoading, setActionLoading] = useState(false)
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null) const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
const dark = theme === 'dark' const dark = theme === 'dark'
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0] const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0]
const activeTagOptions = activeWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID = activeProjectID) { async function loadWorkspaces(nextSession: ApiSession, preferredProjectID = activeProjectID) {
const backendProjects = await fetchProjects(nextSession) const backendProjects = await fetchProjects(nextSession)
@@ -102,7 +107,13 @@ function App() {
return return
} }
void runAction(async () => { void runAction(async () => {
const created = await createProject(requireSession(), { name: draft.name.trim(), description: draft.description.trim() }) const created = await createProject(requireSession(), {
name: draft.name.trim(),
identifier: draft.identifier.trim(),
icon: draft.icon.trim(),
background: draft.background.trim(),
description: draft.description.trim(),
})
const nextProjectID = String(created.ID ?? created.id ?? '') const nextProjectID = String(created.ID ?? created.id ?? '')
await refreshAfterAction(nextProjectID) await refreshAfterAction(nextProjectID)
if (nextProjectID) { if (nextProjectID) {
@@ -124,6 +135,7 @@ function App() {
description: draft.description.trim(), description: draft.description.trim(),
status: 'open', status: 'open',
dueAt: normalizeOptionalTime(draft.dueAt), dueAt: normalizeOptionalTime(draft.dueAt),
tag: draft.tag.trim(),
}) })
await refreshAfterAction() await refreshAfterAction()
setActiveChannel('tasks') setActiveChannel('tasks')
@@ -167,6 +179,18 @@ function App() {
}, '计划任务已创建') }, '计划任务已创建')
} }
function handleCreateProjectTag(name: string) {
const trimmedName = name.trim()
if (!trimmedName) {
Message.warning('请输入标签名称')
return
}
void runAction(async () => {
await createProjectTag(requireSession(), requireActiveProject(), { name: trimmedName })
await refreshAfterAction()
}, '标签已创建')
}
function openTask(project: Project, taskID: string) { function openTask(project: Project, taskID: string) {
setActiveProjectID(project.id) setActiveProjectID(project.id)
setActiveView('project') setActiveView('project')
@@ -174,6 +198,38 @@ function App() {
setActiveTaskID(taskID) setActiveTaskID(taskID)
} }
function handleUpdateWorkspaceTask(update: WorkspaceTaskUpdate) {
void runAction(async () => {
await updateTask(requireSession(), update.originalProjectId, update.taskId, {
title: update.title,
description: update.summary,
completed: update.completed,
nextProjectId: Number(update.nextProjectId),
tag: update.tag,
})
await refreshAfterAction(update.nextProjectId)
}, '任务已更新')
}
function handleUpdateProject(update: ProjectSettingsUpdate) {
setWorkspaces((current) =>
current.map((workspace) => {
if (workspace.project.id !== update.projectId) return workspace
const nextProject = {
...workspace.project,
name: update.name,
identifier: update.identifier,
icon: update.icon,
background: update.background,
description: update.description,
short: projectIconLabel(update.icon, update.identifier || update.name.slice(0, 2)),
color: projectColor(update.background, workspace.project.color),
}
return { ...workspace, project: nextProject }
}),
)
}
return ( return (
<ConfigProvider> <ConfigProvider>
<main className={dark ? 'app theme-dark' : 'app'}> <main className={dark ? 'app theme-dark' : 'app'}>
@@ -190,7 +246,7 @@ function App() {
activeTaskID={activeTaskID} activeTaskID={activeTaskID}
theme={theme} theme={theme}
onSelectWorkspace={() => setActiveView('workspace')} onSelectWorkspace={() => setActiveView('workspace')}
onSelectWorkspaceInbox={() => setActiveView('workspace-inbox')} onSelectWorkspaceExplore={() => setActiveView('workspace-explore')}
onSelectProject={(project) => { onSelectProject={(project) => {
setActiveProjectID(project.id) setActiveProjectID(project.id)
setActiveView('project') setActiveView('project')
@@ -209,6 +265,9 @@ function App() {
onCreateTask={() => setActiveModal('task')} onCreateTask={() => setActiveModal('task')}
onUploadSource={() => setActiveModal('source')} onUploadSource={() => setActiveModal('source')}
onCreateCronPlan={() => setActiveModal('cron')} onCreateCronPlan={() => setActiveModal('cron')}
onUpdateWorkspaceTask={handleUpdateWorkspaceTask}
onUpdateProject={handleUpdateProject}
onCreateProjectTag={handleCreateProjectTag}
/> />
) : ( ) : (
<Spin loading /> <Spin loading />
@@ -221,10 +280,25 @@ function App() {
onCreateTask={handleCreateTask} onCreateTask={handleCreateTask}
onUploadSource={handleUploadSource} onUploadSource={handleUploadSource}
onCreateCronPlan={handleCreateCronPlan} onCreateCronPlan={handleCreateCronPlan}
tagOptions={activeTagOptions}
/> />
</main> </main>
</ConfigProvider> </ConfigProvider>
) )
} }
function projectIconLabel(icon: string, fallback: string) {
const trimmed = icon.trim()
if (!trimmed) return fallback
const chars = Array.from(trimmed)
if (chars.length <= 2) return trimmed
return chars.slice(0, 2).join('').toUpperCase()
}
function projectColor(background: string, fallback: string) {
const trimmed = background.trim()
if (trimmed.startsWith('#') || trimmed.startsWith('rgb') || trimmed.startsWith('hsl')) return trimmed
return fallback
}
export default App export default App

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Input, Modal, Space, Switch, Typography } from '@arco-design/web-react' import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
const { Text } = Typography const { Text } = Typography
const { TextArea } = Input const { TextArea } = Input
@@ -8,6 +8,9 @@ export type ProjectActionModal = 'project' | 'task' | 'source' | 'cron' | null
export type ProjectDraft = { export type ProjectDraft = {
name: string name: string
identifier: string
icon: string
background: string
description: string description: string
} }
@@ -15,6 +18,7 @@ export type TaskDraft = {
title: string title: string
description: string description: string
dueAt: string dueAt: string
tag: string
} }
export type SourceDraft = { export type SourceDraft = {
@@ -37,6 +41,7 @@ export function ProjectActionModals({
onCreateTask, onCreateTask,
onUploadSource, onUploadSource,
onCreateCronPlan, onCreateCronPlan,
tagOptions,
}: { }: {
activeModal: ProjectActionModal activeModal: ProjectActionModal
loading: boolean loading: boolean
@@ -45,16 +50,17 @@ export function ProjectActionModals({
onCreateTask: (draft: TaskDraft) => void onCreateTask: (draft: TaskDraft) => void
onUploadSource: (draft: SourceDraft) => void onUploadSource: (draft: SourceDraft) => void
onCreateCronPlan: (draft: CronDraft) => void onCreateCronPlan: (draft: CronDraft) => void
tagOptions: string[]
}) { }) {
const [project, setProject] = useState<ProjectDraft>({ name: '', description: '' }) const [project, setProject] = useState<ProjectDraft>({ name: '', identifier: '', icon: '', background: '', description: '' })
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', dueAt: '' }) const [task, setTask] = useState<TaskDraft>({ title: '', description: '', dueAt: '', tag: '' })
const [source, setSource] = useState<SourceDraft>({ title: '', file: null }) const [source, setSource] = useState<SourceDraft>({ title: '', file: null })
const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' }) const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
useEffect(() => { useEffect(() => {
if (activeModal === null) { if (activeModal === null) {
setProject({ name: '', description: '' }) setProject({ name: '', identifier: '', icon: '', background: '', description: '' })
setTask({ title: '', description: '', dueAt: '' }) setTask({ title: '', description: '', dueAt: '', tag: '' })
setSource({ title: '', file: null }) setSource({ title: '', file: null })
setCron({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' }) setCron({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
} }
@@ -76,7 +82,19 @@ export function ProjectActionModals({
<Input placeholder="例如:项目 A3" value={project.name} onChange={(name) => setProject((draft) => ({ ...draft, name }))} /> <Input placeholder="例如:项目 A3" value={project.name} onChange={(name) => setProject((draft) => ({ ...draft, name }))} />
</label> </label>
<label> <label>
<Text></Text> <Text></Text>
<Input placeholder="例如A3 / EXP / DATA" value={project.identifier} onChange={(identifier) => setProject((draft) => ({ ...draft, identifier }))} />
</label>
<label>
<Text></Text>
<Input placeholder="例如A3 / 🚀 / compass" value={project.icon} onChange={(icon) => setProject((draft) => ({ ...draft, icon }))} />
</label>
<label>
<Text></Text>
<Input placeholder="例如:#165DFF 或背景图片 URL" value={project.background} onChange={(background) => setProject((draft) => ({ ...draft, background }))} />
</label>
<label>
<Text></Text>
<TextArea rows={4} placeholder="项目目标、背景或协作说明" value={project.description} onChange={(description) => setProject((draft) => ({ ...draft, description }))} /> <TextArea rows={4} placeholder="项目目标、背景或协作说明" value={project.description} onChange={(description) => setProject((draft) => ({ ...draft, description }))} />
</label> </label>
</Space> </Space>
@@ -103,6 +121,19 @@ export function ProjectActionModals({
<Text></Text> <Text></Text>
<Input placeholder="2026-07-21T09:30:00Z可留空" value={task.dueAt} onChange={(dueAt) => setTask((draft) => ({ ...draft, dueAt }))} /> <Input placeholder="2026-07-21T09:30:00Z可留空" value={task.dueAt} onChange={(dueAt) => setTask((draft) => ({ ...draft, dueAt }))} />
</label> </label>
<label>
<Text></Text>
<Select
allowCreate
placeholder="选择或输入标签"
value={task.tag}
onChange={(tag) => setTask((draft) => ({ ...draft, tag }))}
>
{tagOptions.map((tag) => (
<Select.Option key={tag} value={tag}>{tag}</Select.Option>
))}
</Select>
</label>
</Space> </Space>
</Modal> </Modal>

View File

@@ -1,5 +1,6 @@
import { ProjectAi } from './project-ai' import { ProjectAi } from './project-ai'
import { ProjectCron } from './project-cron' import { ProjectCron } from './project-cron'
import { ProjectNewChannel } from './project-new-channel'
import { ProjectNotes } from './project-notes' import { ProjectNotes } from './project-notes'
import { ProjectOverview } from './project-overview' import { ProjectOverview } from './project-overview'
import { ProjectTasks } from './project-tasks' import { ProjectTasks } from './project-tasks'
@@ -35,6 +36,8 @@ export function ProjectChannelPage({
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} /> return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
case 'cron': case 'cron':
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} /> return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} />
case 'new-channel':
return <ProjectNewChannel activeWorkspace={activeWorkspace} />
case 'overview': case 'overview':
default: default:
return <ProjectOverview activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} /> return <ProjectOverview activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />

View File

@@ -13,7 +13,7 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
<div className="project-channel-page project-cron-page overview-page"> <div className="project-channel-page project-cron-page overview-page">
<div className="overview-head"> <div className="overview-head">
<div> <div>
<Title heading={4}>Cron </Title> <Title heading={4}></Title>
<Text type="secondary">{project.name} AI </Text> <Text type="secondary">{project.name} AI </Text>
</div> </div>
<Button type="primary" icon={<IconPlus />} onClick={onCreateCronPlan}></Button> <Button type="primary" icon={<IconPlus />} onClick={onCreateCronPlan}></Button>

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { Button, Card, Space, Tag, Typography } from '@arco-design/web-react' import { Button, Card, Space, Typography } from '@arco-design/web-react'
import { IconFile, IconFolder, IconPlus, IconSearch } from '@arco-design/web-react/icon' import { IconPlus, IconSearch } from '@arco-design/web-react/icon'
import { ProjectFileGrid } from './project-file-grid'
import type { ProjectWorkspace } from './project-types' import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography const { Title, Text } = Typography
@@ -22,31 +23,26 @@ export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }:
</Space> </Space>
</div> </div>
<div className="folder-grid"> <ProjectFileGrid items={folderItems(folders)} onSelectItem={onSelectItem} />
{folders.map((folder) => (
<Card className="folder-card" bordered key={folder}>
<Space>
<Tag color="arcoblue"><IconFolder /></Tag>
<Text>{folder}</Text>
</Space>
</Card>
))}
</div>
<Card className="queue-section" bordered> <Card className="queue-section" bordered>
<div className="section-header"> <div className="section-header">
<Title heading={6}></Title> <Title heading={6}></Title>
<Button type="text" size="mini"></Button> <Button type="text" size="mini"></Button>
</div> </div>
{notes.map((note) => ( <ProjectFileGrid items={notes} onSelectItem={onSelectItem} />
<button className="data-row note-row" key={note.title} onClick={() => onSelectItem(note.title)}>
<span className="row-title"><IconFile /> {note.title}</span>
<Tag>{note.type}</Tag>
<span>{note.updated}</span>
<span>{note.owner}</span>
</button>
))}
</Card> </Card>
</div> </div>
) )
} }
function folderItems(names: string[]) {
return names.map((name) => ({
id: `folder-${name}`,
title: name,
type: 'folder',
updated: '文件夹',
owner: '',
source: 'folder',
}))
}

View File

@@ -1,43 +1,50 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react' import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { IconCalendar, IconCheckCircleFill, IconClockCircle, IconEmail, IconFile, IconMore, IconRobot } from '@arco-design/web-react/icon' import { IconCheckCircleFill, IconClockCircle, IconFile, IconLink } from '@arco-design/web-react/icon'
import type { InboxItem, ProjectWorkspace } from './project-types' import { ProjectFileGrid } from './project-file-grid'
import type { ProjectWorkspace, TaskItem } from './project-types'
const { Row, Col } = Grid const { Row, Col } = Grid
const { Title, Text } = Typography const { Title, Text, Paragraph } = Typography
export function ProjectOverview({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) { export function ProjectOverview({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
const { inbox, tasks, aiSessions, notes, cronJobs } = activeWorkspace const { project, tasks, notes, cronJobs } = activeWorkspace
const runningTasks = useMemo(() => tasks.filter((task) => !task.completed), [tasks])
const completedTasks = useMemo(() => tasks.filter((task) => task.completed), [tasks])
const shareURL = useMemo(() => projectShareURL(project.identifier || project.id), [project.id, project.identifier])
const metrics = useMemo(() => [ const metrics = useMemo(() => [
{ title: 'Inbox 待处理', value: inbox.length, delta: '实时接口', icon: <IconEmail />, color: 'arcoblue' }, { title: '进行中的任务', value: runningTasks.length, icon: <IconCheckCircleFill />, color: 'green' },
{ title: '进行中的任务', value: tasks.filter((task) => !task.completed).length, delta: '实时接口', icon: <IconCheckCircleFill />, color: 'green' }, { title: '完成的任务', value: completedTasks.length, icon: <IconCheckCircleFill />, color: 'arcoblue' },
{ title: 'AI 会话活跃', value: aiSessions.length, delta: '实时接口', icon: <IconRobot />, color: 'purple' }, { title: '笔记资料总数', value: notes.length, icon: <IconFile />, color: 'cyan' },
{ title: '笔记资料总数', value: notes.length, delta: '实时接口', icon: <IconFile />, color: 'cyan' }, { title: '计划任务', value: cronJobs.length, icon: <IconClockCircle />, color: 'orange' },
{ title: '计划任务', value: cronJobs.length, delta: '实时接口', icon: <IconClockCircle />, color: 'orange' }, ], [completedTasks.length, cronJobs.length, notes.length, runningTasks.length])
], [aiSessions.length, cronJobs.length, inbox.length, notes.length, tasks])
return ( return (
<div className="overview-page"> <div className="overview-page project-overview-page">
<div className="overview-head"> <section className="project-overview-hero" style={{ background: projectHeroBackground(project.background, project.color) }}>
<div> <div className="project-overview-identity">
<Title heading={4}></Title> <span className="project-overview-icon">{project.short}</span>
<div>
<Title heading={3}>{project.name}</Title>
<Paragraph ellipsis={{ rows: 2 }}>{project.description || '暂无简介'}</Paragraph>
</div>
</div> </div>
<Space> <div className="project-overview-share">
<Button icon={<IconCalendar />}>2026-07-20</Button> <Text> URL</Text>
<Button icon={<IconMore />} /> <Button icon={<IconLink />} onClick={() => onSelectItem(shareURL)}>{shareURL}</Button>
</Space> </div>
</div> </section>
<Row gutter={12} className="metric-row"> <Row gutter={12} className="metric-row">
{metrics.map((metric) => ( {metrics.map((metric) => (
<Col span={24 / metrics.length} key={metric.title}> <Col span={6} key={metric.title}>
<Card className="metric-card" bordered> <Card className="metric-card" bordered>
<Space align="start"> <Space align="start">
<Tag color={metric.color}>{metric.icon}</Tag> <Tag color={metric.color}>{metric.icon}</Tag>
<div> <div>
<Text type="secondary">{metric.title}</Text> <Text type="secondary">{metric.title}</Text>
<Title heading={3}>{metric.value}</Title> <Title heading={3}>{metric.value}</Title>
<Text className="metric-delta">{metric.delta}</Text> <Text className="metric-delta"></Text>
</div> </div>
</Space> </Space>
</Card> </Card>
@@ -45,69 +52,40 @@ export function ProjectOverview({ activeWorkspace, onSelectItem }: { activeWorks
))} ))}
</Row> </Row>
<QueueSection title={`Inbox 待处理(${inbox.length}`} items={inbox} onSelectItem={onSelectItem} /> <TaskCardSection title={`进行中的任务(${runningTasks.length}`} tasks={runningTasks} emptyText="当前没有进行中的任务" onSelectItem={onSelectItem} />
<TaskSection tasks={tasks} onSelectItem={onSelectItem} /> <TaskCardSection title={`完成的任务(${completedTasks.length}`} tasks={completedTasks} emptyText="当前没有完成的任务" onSelectItem={onSelectItem} />
<AISessionSection aiSessions={aiSessions} onSelectItem={onSelectItem} />
<NoteSection notes={notes} onSelectItem={onSelectItem} /> <NoteSection notes={notes} onSelectItem={onSelectItem} />
</div> </div>
) )
} }
function QueueSection({ title, items, onSelectItem }: { title: string; items: InboxItem[]; onSelectItem: (title: string) => void }) { function TaskCardSection({ title, tasks, emptyText, onSelectItem }: { title: string; tasks: TaskItem[]; emptyText: string; onSelectItem: (title: string) => void }) {
return ( return (
<Card className="queue-section" bordered> <Card className="queue-section overview-task-section" bordered>
<div className="section-header"> <div className="section-header">
<Title heading={6}>{title}</Title> <Title heading={6}>{title}</Title>
<Button type="text" size="mini"></Button> <Button type="text" size="mini"></Button>
</div> </div>
{items.map((item) => ( {tasks.length === 0 ? (
<button key={item.title} className="data-row" onClick={() => onSelectItem(item.title)}> <Text type="secondary">{emptyText}</Text>
<span className="row-title"><IconFile /> {item.title}</span> ) : (
<span>{item.meta}</span> <div className="overview-task-grid">
<Tag>{item.tag}</Tag> {tasks.map((task) => (
<span>{item.owner}</span> <button key={task.id} className={task.completed ? 'overview-task-card completed' : 'overview-task-card'} onClick={() => onSelectItem(task.title)}>
<time>{item.time}</time> <span className="overview-task-title">
</button> <IconCheckCircleFill />
))} {task.title}
</Card> </span>
) <Text type="secondary" ellipsis={{ showTooltip: true }}>{task.summary || '暂无任务内容'}</Text>
} <div className="overview-task-meta">
<Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag || (task.completed ? '已完成' : '进行中')}</Tag>
function TaskSection({ tasks, onSelectItem }: { tasks: ProjectWorkspace['tasks']; onSelectItem: (title: string) => void }) { <span> {task.progress}</span>
return ( <span> {task.createdAt}</span>
<Card className="queue-section" bordered> </div>
<div className="section-header"> </button>
<Title heading={6}>{tasks.length}</Title> ))}
<Button type="text" size="mini"></Button> </div>
</div> )}
{tasks.map((task) => (
<button key={task.title} className="data-row task-row" onClick={() => onSelectItem(task.title)}>
<span className="row-title"><IconCheckCircleFill /> {task.title}</span>
<Tag color="arcoblue">{task.tag}</Tag>
<span> {task.progress}</span>
<span> {task.owner}</span>
<time> {task.createdAt}</time>
</button>
))}
</Card>
)
}
function AISessionSection({ aiSessions, onSelectItem }: { aiSessions: ProjectWorkspace['aiSessions']; onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>AI {aiSessions.length}</Title>
<Button type="text" size="mini"></Button>
</div>
{aiSessions.map((session) => (
<button key={session.title} className="data-row ai-row" onClick={() => onSelectItem(session.title)}>
<span className="row-title"><IconRobot /> {session.title}</span>
<span>{session.summary}</span>
<span>{session.owner}</span>
<time>{session.time}</time>
</button>
))}
</Card> </Card>
) )
} }
@@ -116,17 +94,27 @@ function NoteSection({ notes, onSelectItem }: { notes: ProjectWorkspace['notes']
return ( return (
<Card className="queue-section" bordered> <Card className="queue-section" bordered>
<div className="section-header"> <div className="section-header">
<Title heading={6}>5</Title> <Title heading={6}>{notes.length}</Title>
<Button type="text" size="mini"></Button> <Button type="text" size="mini"></Button>
</div> </div>
{notes.map((note) => ( <ProjectFileGrid items={notes.slice(0, 8)} onSelectItem={onSelectItem} compact />
<button key={note.title} className="data-row note-row" onClick={() => onSelectItem(note.title)}>
<span className="row-title"><IconFile /> {note.title}</span>
<Tag>{note.type}</Tag>
<span> {note.updated}</span>
<span>{note.owner}</span>
</button>
))}
</Card> </Card>
) )
} }
function projectShareURL(identifier: string) {
const slug = encodeURIComponent(identifier.trim() || 'project')
const origin = typeof window === 'undefined' ? 'https://senlinai.local' : window.location.origin
return `${origin}/share/projects/${slug}`
}
function projectHeroBackground(background: string, fallback: string) {
const value = background.trim() || fallback
if (value.startsWith('http')) {
return `linear-gradient(135deg, color-mix(in srgb, ${fallback} 78%, transparent), color-mix(in srgb, ${fallback} 34%, transparent)), url("${value.replaceAll('"', '%22')}") center / cover`
}
if (value.startsWith('linear-gradient') || value.startsWith('radial-gradient')) {
return value
}
return `linear-gradient(135deg, ${value}, color-mix(in srgb, ${value} 58%, white))`
}

View File

@@ -1,6 +1,6 @@
import type { CSSProperties } from 'react' import type { CSSProperties } from 'react'
import { Badge, Button, Layout, Space } from '@arco-design/web-react' import { Badge, Button, Layout, Space } from '@arco-design/web-react'
import { IconDashboard, IconEmail, IconPlus } from '@arco-design/web-react/icon' import { IconCompass, IconDashboard, IconPlus } from '@arco-design/web-react/icon'
import type { Project, WorkbenchView } from './project-types' import type { Project, WorkbenchView } from './project-types'
const { Sider } = Layout const { Sider } = Layout
@@ -10,7 +10,7 @@ export function ProjectRail({
projects, projects,
activeView, activeView,
onSelectWorkspace, onSelectWorkspace,
onSelectWorkspaceInbox, onSelectWorkspaceExplore,
onSelectProject, onSelectProject,
onCreateProject, onCreateProject,
}: { }: {
@@ -18,7 +18,7 @@ export function ProjectRail({
projects: Project[] projects: Project[]
activeView: WorkbenchView activeView: WorkbenchView
onSelectWorkspace: () => void onSelectWorkspace: () => void
onSelectWorkspaceInbox: () => void onSelectWorkspaceExplore: () => void
onSelectProject: (project: Project) => void onSelectProject: (project: Project) => void
onCreateProject: () => void onCreateProject: () => void
}) { }) {
@@ -32,11 +32,11 @@ export function ProjectRail({
title="工作台" title="工作台"
/> />
<Button <Button
className={activeView === 'workspace-inbox' ? 'dashboard-button active' : 'dashboard-button'} className={activeView === 'workspace-explore' ? 'dashboard-button active' : 'dashboard-button'}
icon={<IconEmail />} icon={<IconCompass />}
onClick={onSelectWorkspaceInbox} onClick={onSelectWorkspaceExplore}
aria-label="Inbox" aria-label="探索"
title="Inbox" title="探索"
/> />
<Space className="project-stack" direction="vertical" size={12}> <Space className="project-stack" direction="vertical" size={12}>
{projects.map((project) => ( {projects.map((project) => (

View File

@@ -1,9 +1,20 @@
import { Badge, Button, Divider, Layout, Space, Tag, Typography } from '@arco-design/web-react' import { Fragment, useEffect, useState } from 'react'
import { IconApps, IconDashboard, IconMore, IconSettings } from '@arco-design/web-react/icon' import { Badge, Button, Divider, Input, Layout, Modal, Space, Tag, Typography } from '@arco-design/web-react'
import type { ChannelKey, ProjectWorkspace, WorkbenchView } from './project-types' import { IconApps, IconDashboard, IconMore, IconPlus, IconSettings } from '@arco-design/web-react/icon'
import type { ChannelKey, ProjectChannel, ProjectWorkspace, WorkbenchView } from './project-types'
const { Sider } = Layout const { Sider } = Layout
const { Title, Text } = Typography const { Title, Text } = Typography
const { TextArea } = Input
export type ProjectSettingsUpdate = {
projectId: string
name: string
identifier: string
icon: string
background: string
description: string
}
export function ProjectSidebar({ export function ProjectSidebar({
activeView, activeView,
@@ -11,24 +22,57 @@ export function ProjectSidebar({
activeChannel, activeChannel,
onSelectChannel, onSelectChannel,
onSelectItem, onSelectItem,
onUpdateProject,
onCreateProjectTag,
}: { }: {
activeView: WorkbenchView activeView: WorkbenchView
activeWorkspace: ProjectWorkspace activeWorkspace: ProjectWorkspace
activeChannel: ChannelKey activeChannel: ChannelKey
onSelectChannel: (key: ChannelKey) => void onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void onSelectItem: (title: string) => void
onUpdateProject: (update: ProjectSettingsUpdate) => void
onCreateProjectTag: (name: string) => void
}) { }) {
const workspaceMode = activeView === 'workspace' const workspaceMode = activeView === 'workspace'
const channels = activeWorkspace.channels const channels = activeWorkspace.channels
const [settingsOpen, setSettingsOpen] = useState(false)
const [draft, setDraft] = useState({
name: activeWorkspace.project.name,
identifier: activeWorkspace.project.identifier,
icon: activeWorkspace.project.icon,
background: activeWorkspace.project.background,
description: activeWorkspace.project.description,
})
const [tagModalOpen, setTagModalOpen] = useState(false)
const [tagName, setTagName] = useState('')
useEffect(() => {
if (!settingsOpen) return
setDraft({
name: activeWorkspace.project.name,
identifier: activeWorkspace.project.identifier,
icon: activeWorkspace.project.icon,
background: activeWorkspace.project.background,
description: activeWorkspace.project.description,
})
}, [activeWorkspace.project, settingsOpen])
const projectIcon = activeWorkspace.project.short || activeWorkspace.project.identifier || activeWorkspace.project.name.slice(0, 2)
const submitTag = () => {
if (!tagName.trim()) return
onCreateProjectTag(tagName.trim())
setTagName('')
setTagModalOpen(false)
}
return ( return (
<Sider className="channel-sidebar" width={248}> <Sider className="channel-sidebar" width={248}>
<div className="project-title"> <div className="project-title">
<Space> <Space>
{workspaceMode ? <IconApps /> : <IconDashboard />} {workspaceMode ? <IconApps /> : <span className="project-title-icon">{projectIcon}</span>}
<Title heading={5}>{workspaceMode ? '工作台' : activeWorkspace.project.name}</Title> <Title heading={5}>{workspaceMode ? '工作台' : activeWorkspace.project.name}</Title>
</Space> </Space>
<Button icon={<IconSettings />} size="mini" /> <Button icon={<IconSettings />} size="mini" onClick={() => setSettingsOpen(true)} />
</div> </div>
<div className="channel-list"> <div className="channel-list">
@@ -44,16 +88,24 @@ export function ProjectSidebar({
</button> </button>
</> </>
) : ( ) : (
channels.map((channel) => ( <>
<button {channels.flatMap((channel) => {
key={channel.id} const items = [renderChannelButton(channel, activeChannel, onSelectChannel)]
className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'} if (channel.key === 'cron') {
onClick={() => onSelectChannel(channel.key)} items.push(
> <button
<span className="channel-left">{channel.icon}<Text>{channel.label}</Text></span> key="new-channel"
{channel.count !== undefined ? <Badge count={channel.count} maxCount={999} /> : <IconMore />} className={activeChannel === 'new-channel' ? 'channel-button active' : 'channel-button'}
</button> onClick={() => onSelectChannel('new-channel')}
)) >
<span className="channel-left"><IconPlus /><Text></Text></span>
<IconMore />
</button>,
)
}
return items
})}
</>
)} )}
</div> </div>
@@ -62,7 +114,20 @@ export function ProjectSidebar({
<Text className="section-title"></Text> <Text className="section-title"></Text>
<Space wrap> <Space wrap>
{activeWorkspace.tags.map((tag) => ( {activeWorkspace.tags.map((tag) => (
<Tag key={tag} color={tag === '全部' ? 'arcoblue' : 'gray'}>{tag}</Tag> <Fragment key={tag}>
<Tag color={isAllTag(tag) ? 'arcoblue' : 'gray'}>{tag}</Tag>
{isAllTag(tag) && (
<Button
className="tag-create-button"
size="mini"
type="text"
icon={<IconPlus />}
onClick={() => setTagModalOpen(true)}
>
</Button>
)}
</Fragment>
))} ))}
</Space> </Space>
</section> </section>
@@ -78,6 +143,86 @@ export function ProjectSidebar({
))} ))}
</section> </section>
<Modal
className="action-modal"
title="编辑项目"
visible={settingsOpen}
onCancel={() => setSettingsOpen(false)}
onOk={() => {
onUpdateProject({
projectId: activeWorkspace.project.id,
name: draft.name.trim() || activeWorkspace.project.name,
identifier: draft.identifier.trim(),
icon: draft.icon.trim(),
background: draft.background.trim(),
description: draft.description.trim(),
})
setSettingsOpen(false)
}}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input value={draft.name} onChange={(name) => setDraft((value) => ({ ...value, name }))} />
</label>
<label>
<Text></Text>
<Input value={draft.identifier} onChange={(identifier) => setDraft((value) => ({ ...value, identifier }))} />
</label>
<label>
<Text></Text>
<Input value={draft.icon} onChange={(icon) => setDraft((value) => ({ ...value, icon }))} />
</label>
<label>
<Text></Text>
<Input value={draft.background} onChange={(background) => setDraft((value) => ({ ...value, background }))} />
</label>
<label>
<Text></Text>
<TextArea rows={4} value={draft.description} onChange={(description) => setDraft((value) => ({ ...value, description }))} />
</label>
</Space>
</Modal>
<Modal
className="action-modal"
title="新建标签"
visible={tagModalOpen}
onCancel={() => {
setTagModalOpen(false)
setTagName('')
}}
onOk={submitTag}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input
autoFocus
placeholder="例如:设计、客户、重要"
value={tagName}
onChange={setTagName}
onPressEnter={submitTag}
/>
</label>
</Space>
</Modal>
</Sider> </Sider>
) )
} }
function isAllTag(tag: string) {
return tag === '全部' || tag === 'all'
}
function renderChannelButton(channel: ProjectChannel, activeChannel: ChannelKey, onSelectChannel: (key: ChannelKey) => void) {
return (
<button
key={channel.id}
className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'}
onClick={() => onSelectChannel(channel.key)}
>
<span className="channel-left">{channel.icon}<Text>{channel.label}</Text></span>
{channel.key === 'overview' ? null : channel.count !== undefined ? <Badge className="channel-badge-muted" count={channel.count} maxCount={999} /> : <IconMore />}
</button>
)
}

View File

@@ -2,12 +2,16 @@ import type { ReactElement } from 'react'
export type Screen = 'login' | 'workbench' export type Screen = 'login' | 'workbench'
export type Theme = 'light' | 'dark' export type Theme = 'light' | 'dark'
export type WorkbenchView = 'workspace' | 'workspace-inbox' | 'project' export type WorkbenchView = 'workspace' | 'workspace-explore' | 'project'
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | `custom:${string}` export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | 'new-channel' | `custom:${string}`
export type Project = { export type Project = {
id: string id: string
name: string name: string
identifier: string
icon: string
background: string
description: string
short: string short: string
badge: number badge: number
urgent?: boolean urgent?: boolean

View File

@@ -1,4 +1,5 @@
import { Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react' import { useEffect, useMemo, useState } from 'react'
import { Card, Empty, Grid, Input, Modal, Select, Space, Switch, Tag, Typography } from '@arco-design/web-react'
import { import {
IconCalendar, IconCalendar,
IconCheckCircleFill, IconCheckCircleFill,
@@ -6,14 +7,44 @@ import {
IconFile, IconFile,
IconRobot, IconRobot,
} from '@arco-design/web-react/icon' } from '@arco-design/web-react/icon'
import type { Project, ProjectWorkspace } from './projects/project-types' import type { Project, ProjectWorkspace, TaskItem } from './projects/project-types'
const { Row, Col } = Grid const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography const { Title, Text, Paragraph } = Typography
const { TextArea } = Input
export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectWorkspace[]; onOpenTask: (project: Project, taskID: string) => void }) { export type WorkspaceTaskUpdate = {
originalProjectId: string
nextProjectId: string
taskId: string
title: string
summary: string
tag: string
completed: boolean
}
type WorkspaceTask = TaskItem & {
project: Project
}
type TaskDraft = {
title: string
summary: string
projectId: string
tag: string
completed: boolean
}
export function WorkspacePage({
workspaces,
onUpdateTask,
}: {
workspaces: ProjectWorkspace[]
onOpenTask: (project: Project, taskID: string) => void
onUpdateTask: (update: WorkspaceTaskUpdate) => void
}) {
const projects = workspaces.map((workspace) => workspace.project) const projects = workspaces.map((workspace) => workspace.project)
const unfinishedTasks = workspaces.flatMap((workspace) => const unfinishedTasks: WorkspaceTask[] = workspaces.flatMap((workspace) =>
workspace.tasks workspace.tasks
.filter((task) => !task.completed) .filter((task) => !task.completed)
.map((task) => ({ .map((task) => ({
@@ -21,6 +52,28 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
project: workspace.project, project: workspace.project,
})), })),
) )
const [editingTask, setEditingTask] = useState<WorkspaceTask | null>(null)
const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', projectId: '', tag: '', completed: false })
const tagOptions = useMemo(() => {
const tags = new Set<string>()
const selectedWorkspace = workspaces.find((workspace) => workspace.project.id === draft.projectId)
selectedWorkspace?.tags
.filter((tag) => tag !== 'all' && tag !== '全部')
.forEach((tag) => tags.add(tag))
if (draft.tag) tags.add(draft.tag)
return Array.from(tags)
}, [draft.projectId, draft.tag, workspaces])
useEffect(() => {
if (!editingTask) return
setDraft({
title: editingTask.title,
summary: editingTask.summary,
projectId: editingTask.project.id,
tag: editingTask.tag,
completed: editingTask.completed,
})
}, [editingTask])
const aiSessions = workspaces.flatMap((workspace) => workspace.aiSessions) const aiSessions = workspaces.flatMap((workspace) => workspace.aiSessions)
const notes = workspaces.flatMap((workspace) => workspace.notes) const notes = workspaces.flatMap((workspace) => workspace.notes)
const urgentProjects = projects.filter((project) => project.urgent).length const urgentProjects = projects.filter((project) => project.urgent).length
@@ -65,7 +118,7 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
{unfinishedTasks.length ? ( {unfinishedTasks.length ? (
<div className="workspace-task-list"> <div className="workspace-task-list">
{unfinishedTasks.map((task) => ( {unfinishedTasks.map((task) => (
<button key={`${task.project.id}-${task.id}`} className="workspace-task-card" onClick={() => onOpenTask(task.project, task.id)}> <button key={`${task.project.id}-${task.id}`} className="workspace-task-card" onClick={() => setEditingTask(task)}>
<div className="workspace-task-main"> <div className="workspace-task-main">
<span className="workspace-task-title"> <span className="workspace-task-title">
<IconCheckCircleFill /> <IconCheckCircleFill />
@@ -87,6 +140,62 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
<Empty description="暂无未完成任务" /> <Empty description="暂无未完成任务" />
)} )}
</Card> </Card>
<Modal
className="action-modal"
title="编辑任务"
visible={Boolean(editingTask)}
onCancel={() => setEditingTask(null)}
onOk={() => {
if (!editingTask) return
onUpdateTask({
originalProjectId: editingTask.project.id,
nextProjectId: draft.projectId,
taskId: editingTask.id,
title: draft.title.trim() || editingTask.title,
summary: draft.summary.trim(),
tag: draft.tag.trim(),
completed: draft.completed,
})
setEditingTask(null)
}}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} />
</label>
<label>
<Text></Text>
<TextArea rows={4} value={draft.summary} onChange={(summary) => setDraft((value) => ({ ...value, summary }))} />
</label>
<label>
<Text></Text>
<Select value={draft.projectId} onChange={(projectId) => setDraft((value) => ({ ...value, projectId }))}>
{projects.map((project) => (
<Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>
))}
</Select>
</label>
<label>
<Text></Text>
<Select
allowCreate
value={draft.tag}
placeholder="选择或输入标签"
onChange={(tag) => setDraft((value) => ({ ...value, tag }))}
>
{tagOptions.map((tag) => (
<Select.Option key={tag} value={tag}>{tag}</Select.Option>
))}
</Select>
</label>
<label className="action-switch">
<Text></Text>
<Switch checked={draft.completed} checkedText="完成" uncheckedText="未完成" onChange={(completed) => setDraft((value) => ({ ...value, completed }))} />
</label>
</Space>
</Modal>
</div> </div>
) )
} }

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

View File

@@ -1,9 +1,9 @@
import { Layout } from '@arco-design/web-react' import { Layout } from '@arco-design/web-react'
import { WorkspacePage } from './workspace-body' import { WorkspacePage, type WorkspaceTaskUpdate } from './workspace-body'
import { WorkspaceInboxPage } from './workspace-inbox' import { WorkspaceExplorePage } from './workspace-explore'
import { ProjectChannelPage } from './projects/project-channel-page' import { ProjectChannelPage } from './projects/project-channel-page'
import { ProjectRail } from './projects/project-rail' import { ProjectRail } from './projects/project-rail'
import { ProjectSidebar } from './projects/project-sidebar' import { ProjectSidebar, type ProjectSettingsUpdate } from './projects/project-sidebar'
import { ProjectStatusbar } from './projects/project-statusbar' import { ProjectStatusbar } from './projects/project-statusbar'
import { ProjectTopbar } from './projects/project-topbar' import { ProjectTopbar } from './projects/project-topbar'
import type { ChannelKey, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types' import type { ChannelKey, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
@@ -19,7 +19,7 @@ export function ProjectPage({
theme, theme,
onSelectProject, onSelectProject,
onSelectWorkspace, onSelectWorkspace,
onSelectWorkspaceInbox, onSelectWorkspaceExplore,
onSelectChannel, onSelectChannel,
onSelectItem, onSelectItem,
onOpenTask, onOpenTask,
@@ -29,6 +29,9 @@ export function ProjectPage({
onCreateTask, onCreateTask,
onUploadSource, onUploadSource,
onCreateCronPlan, onCreateCronPlan,
onUpdateWorkspaceTask,
onUpdateProject,
onCreateProjectTag,
}: { }: {
activeView: WorkbenchView activeView: WorkbenchView
activeWorkspace: ProjectWorkspace activeWorkspace: ProjectWorkspace
@@ -38,7 +41,7 @@ export function ProjectPage({
theme: Theme theme: Theme
onSelectProject: (project: Project) => void onSelectProject: (project: Project) => void
onSelectWorkspace: () => void onSelectWorkspace: () => void
onSelectWorkspaceInbox: () => void onSelectWorkspaceExplore: () => void
onSelectChannel: (key: ChannelKey) => void onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void onSelectItem: (title: string) => void
onOpenTask: (project: Project, taskID: string) => void onOpenTask: (project: Project, taskID: string) => void
@@ -48,6 +51,9 @@ export function ProjectPage({
onCreateTask: () => void onCreateTask: () => void
onUploadSource: () => void onUploadSource: () => void
onCreateCronPlan: () => void onCreateCronPlan: () => void
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => void
onUpdateProject: (update: ProjectSettingsUpdate) => void
onCreateProjectTag: (name: string) => void
}) { }) {
const isProject = activeView === 'project' const isProject = activeView === 'project'
const projects = workspaces.map((workspace) => workspace.project) const projects = workspaces.map((workspace) => workspace.project)
@@ -62,7 +68,7 @@ export function ProjectPage({
projects={projects} projects={projects}
activeView={activeView} activeView={activeView}
onSelectWorkspace={onSelectWorkspace} onSelectWorkspace={onSelectWorkspace}
onSelectWorkspaceInbox={onSelectWorkspaceInbox} onSelectWorkspaceExplore={onSelectWorkspaceExplore}
onSelectProject={onSelectProject} onSelectProject={onSelectProject}
onCreateProject={onCreateProject} onCreateProject={onCreateProject}
/> />
@@ -74,14 +80,16 @@ export function ProjectPage({
activeChannel={activeChannel} activeChannel={activeChannel}
onSelectChannel={onSelectChannel} onSelectChannel={onSelectChannel}
onSelectItem={onSelectItem} onSelectItem={onSelectItem}
onUpdateProject={onUpdateProject}
onCreateProjectTag={onCreateProjectTag}
/> />
)} )}
<Content className="stage"> <Content className="stage">
{activeView === 'workspace' ? ( {activeView === 'workspace' ? (
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} /> <WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} onUpdateTask={onUpdateWorkspaceTask} />
) : activeView === 'workspace-inbox' ? ( ) : activeView === 'workspace-explore' ? (
<WorkspaceInboxPage workspaces={workspaces} onSelectItem={onSelectItem} /> <WorkspaceExplorePage workspaces={workspaces} onSelectItem={onSelectItem} />
) : ( ) : (
<ProjectChannelPage <ProjectChannelPage
activeChannel={activeChannel} activeChannel={activeChannel}

View File

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

View File

@@ -29,9 +29,12 @@ func (h *Handler) Register(router gin.IRouter) {
router.GET("/projects", h.listProjects) router.GET("/projects", h.listProjects)
router.GET("/projects/:id/dashboard", h.dashboard) router.GET("/projects/:id/dashboard", h.dashboard)
router.GET("/projects/:id/workspace", h.workspace) router.GET("/projects/:id/workspace", h.workspace)
router.GET("/projects/:id/tags", h.listTags)
router.POST("/projects/:id/tasks", h.createTask) router.POST("/projects/:id/tasks", h.createTask)
router.PATCH("/projects/:id/tasks/:taskID", h.updateTask)
router.POST("/projects/:id/sources", h.uploadSource) router.POST("/projects/:id/sources", h.uploadSource)
router.POST("/projects/:id/cron-plans", h.createCronPlan) router.POST("/projects/:id/cron-plans", h.createCronPlan)
router.POST("/projects/:id/tags", h.createTag)
} }
func (h *Handler) createProject(c *gin.Context) { func (h *Handler) createProject(c *gin.Context) {
@@ -42,13 +45,22 @@ func (h *Handler) createProject(c *gin.Context) {
} }
var input struct { var input struct {
Name string `json:"name"` Name string `json:"name"`
Identifier string `json:"identifier"`
Icon string `json:"icon"`
Background string `json:"background"`
Description string `json:"description"` Description string `json:"description"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return return
} }
project, err := h.service.CreateProject(userID, input.Name, input.Description) project, err := h.service.CreateProjectWithInput(userID, CreateProjectInput{
Name: input.Name,
Identifier: input.Identifier,
Icon: input.Icon,
Background: input.Background,
Description: input.Description,
})
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -108,6 +120,24 @@ func (h *Handler) workspace(c *gin.Context) {
c.JSON(http.StatusOK, workspace) c.JSON(http.StatusOK, workspace)
} }
func (h *Handler) listTags(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
projectID, ok := parseProjectID(c)
if !ok {
return
}
tags, err := h.service.ListProjectTags(userID, projectID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tags)
}
func (h *Handler) createTask(c *gin.Context) { func (h *Handler) createTask(c *gin.Context) {
userID, ok := auth.CurrentUserID(c) userID, ok := auth.CurrentUserID(c)
if !ok { if !ok {
@@ -123,6 +153,7 @@ func (h *Handler) createTask(c *gin.Context) {
Description string `json:"description"` Description string `json:"description"`
Status string `json:"status"` Status string `json:"status"`
DueAt string `json:"dueAt"` DueAt string `json:"dueAt"`
Tag string `json:"tag"`
} }
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
@@ -138,6 +169,7 @@ func (h *Handler) createTask(c *gin.Context) {
Description: input.Description, Description: input.Description,
Status: input.Status, Status: input.Status,
DueAt: dueAt, DueAt: dueAt,
Tag: input.Tag,
}) })
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -146,6 +178,47 @@ func (h *Handler) createTask(c *gin.Context) {
c.JSON(http.StatusCreated, task) c.JSON(http.StatusCreated, task)
} }
func (h *Handler) updateTask(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
projectID, ok := parseProjectID(c)
if !ok {
return
}
taskID, ok := parseTaskID(c)
if !ok {
return
}
var input struct {
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Completed bool `json:"completed"`
NextProjectID uint `json:"nextProjectId"`
Tag string `json:"tag"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
task, err := h.service.UpdateTask(userID, projectID, taskID, UpdateTaskInput{
Title: input.Title,
Description: input.Description,
Status: input.Status,
Completed: input.Completed,
NextProjectID: input.NextProjectID,
Tag: input.Tag,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, task)
}
func (h *Handler) uploadSource(c *gin.Context) { func (h *Handler) uploadSource(c *gin.Context) {
userID, ok := auth.CurrentUserID(c) userID, ok := auth.CurrentUserID(c)
if !ok { if !ok {
@@ -233,6 +306,31 @@ func (h *Handler) createCronPlan(c *gin.Context) {
c.JSON(http.StatusCreated, plan) c.JSON(http.StatusCreated, plan)
} }
func (h *Handler) createTag(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
projectID, ok := parseProjectID(c)
if !ok {
return
}
var input struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
tag, err := h.service.CreateProjectTag(userID, projectID, input.Name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, tag)
}
func parseProjectID(c *gin.Context) (uint, bool) { func parseProjectID(c *gin.Context) (uint, bool) {
projectID, err := strconv.ParseUint(c.Param("id"), 10, 64) projectID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@@ -242,6 +340,15 @@ func parseProjectID(c *gin.Context) (uint, bool) {
return uint(projectID), true return uint(projectID), true
} }
func parseTaskID(c *gin.Context) (uint, bool) {
taskID, err := strconv.ParseUint(c.Param("taskID"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task id"})
return 0, false
}
return uint(taskID), true
}
func parseOptionalTime(value string) (*time.Time, error) { func parseOptionalTime(value string) (*time.Time, error) {
trimmed := strings.TrimSpace(value) trimmed := strings.TrimSpace(value)
if trimmed == "" { if trimmed == "" {

View File

@@ -17,9 +17,35 @@ import (
"senlinai-agent/backend/internal/models" "senlinai-agent/backend/internal/models"
) )
func TestCreateProjectHandlerPersistsMetadata(t *testing.T) {
router, _, _ := newProjectsHandlerTestRouter(t)
body, err := json.Marshal(gin.H{
"name": "Explore",
"identifier": "EXP",
"icon": "compass",
"background": "#165DFF",
"description": "RSS exploration workspace",
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
var project models.SenlinAgentProject
require.NoError(t, models.DBService.Where("owner_id = ? AND identifier = ?", 1, "EXP").First(&project).Error)
require.Equal(t, "Explore", project.Name)
require.Equal(t, "compass", project.Icon)
require.Equal(t, "#165DFF", project.Background)
require.Equal(t, "RSS exploration workspace", project.Description)
}
func TestCreateTaskHandlerPersistsTask(t *testing.T) { func TestCreateTaskHandlerPersistsTask(t *testing.T) {
router, project, _ := newProjectsHandlerTestRouter(t) router, project, _ := newProjectsHandlerTestRouter(t)
body, err := json.Marshal(gin.H{"title": "整理需求", "description": "形成任务清单", "dueAt": "2026-07-21T09:30:00Z"}) body, err := json.Marshal(gin.H{"title": "整理需求", "description": "形成任务清单", "dueAt": "2026-07-21T09:30:00Z", "tag": "需求"})
require.NoError(t, err) require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tasks", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tasks", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -33,6 +59,7 @@ func TestCreateTaskHandlerPersistsTask(t *testing.T) {
require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "整理需求").First(&task).Error) require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "整理需求").First(&task).Error)
require.Equal(t, "open", task.Status) require.Equal(t, "open", task.Status)
require.NotNil(t, task.DueAt) require.NotNil(t, task.DueAt)
require.NotNil(t, task.TagID)
} }
func TestUploadSourceHandlerStoresFileAndSource(t *testing.T) { func TestUploadSourceHandlerStoresFileAndSource(t *testing.T) {
@@ -79,6 +106,60 @@ func TestCreateCronPlanHandlerPersistsPlan(t *testing.T) {
require.NotNil(t, plan.NextRunAt) require.NotNil(t, plan.NextRunAt)
} }
func TestCreateTagHandlerPersistsProjectTag(t *testing.T) {
router, project, _ := newProjectsHandlerTestRouter(t)
body, err := json.Marshal(gin.H{"name": "Design"})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tags", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
var tag models.SenlinAgentTag
require.NoError(t, models.DBService.Where("project_id = ? AND name = ?", project.ID, "Design").First(&tag).Error)
require.Equal(t, project.ID, tag.ProjectID)
}
func TestListTagsHandlerReturnsProjectTags(t *testing.T) {
router, project, _ := newProjectsHandlerTestRouter(t)
require.NoError(t, models.DBService.Create(&models.SenlinAgentTag{ProjectID: project.ID, Name: "Design"}).Error)
req := httptest.NewRequest(http.MethodGet, "/api/projects/1/tags", nil)
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var tags []models.SenlinAgentTag
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &tags))
require.Len(t, tags, 1)
require.Equal(t, "Design", tags[0].Name)
}
func TestUpdateTaskHandlerPersistsTagAndStatus(t *testing.T) {
router, project, _ := newProjectsHandlerTestRouter(t)
task := models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: 1, Title: "Draft", Status: "open"}
require.NoError(t, models.DBService.Create(&task).Error)
body, err := json.Marshal(gin.H{"title": "Draft v2", "description": "Updated", "completed": true, "tag": "Important"})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPatch, "/api/projects/1/tasks/1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var updated models.SenlinAgentTask
require.NoError(t, models.DBService.First(&updated, task.ID).Error)
require.Equal(t, "Draft v2", updated.Title)
require.Equal(t, "done", updated.Status)
require.NotNil(t, updated.TagID)
}
func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgentProject, string) { func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgentProject, string) {
t.Helper() t.Helper()
newTestDB(t) newTestDB(t)

View File

@@ -6,6 +6,7 @@ import (
"strings" "strings"
"time" "time"
"gorm.io/gorm"
"senlinai-agent/backend/internal/models" "senlinai-agent/backend/internal/models"
) )
@@ -23,6 +24,9 @@ type Dashboard struct {
type WorkbenchProject struct { type WorkbenchProject struct {
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Identifier string `json:"identifier"`
Icon string `json:"icon"`
Background string `json:"background"`
Description string `json:"description"` Description string `json:"description"`
Initials string `json:"initials"` Initials string `json:"initials"`
UnreadCount int64 `json:"unreadCount"` UnreadCount int64 `json:"unreadCount"`
@@ -99,11 +103,29 @@ type ProjectWorkspace struct {
CronPlans []CronPlan `json:"cronPlans"` CronPlans []CronPlan `json:"cronPlans"`
} }
type CreateProjectInput struct {
Name string
Identifier string
Icon string
Background string
Description string
}
type CreateTaskInput struct { type CreateTaskInput struct {
Title string Title string
Description string Description string
Status string Status string
DueAt *time.Time DueAt *time.Time
Tag string
}
type UpdateTaskInput struct {
Title string
Description string
Status string
Completed bool
NextProjectID uint
Tag string
} }
type CreateFileSourceInput struct { type CreateFileSourceInput struct {
@@ -123,11 +145,26 @@ func NewService() *Service {
} }
func (s *Service) CreateProject(ownerID uint, name string, description string) (*models.SenlinAgentProject, error) { func (s *Service) CreateProject(ownerID uint, name string, description string) (*models.SenlinAgentProject, error) {
name = strings.TrimSpace(name) return s.CreateProjectWithInput(ownerID, CreateProjectInput{Name: name, Description: description})
}
func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectInput) (*models.SenlinAgentProject, error) {
name := strings.TrimSpace(input.Name)
if name == "" { if name == "" {
return nil, errors.New("project name is required") return nil, errors.New("project name is required")
} }
project := &models.SenlinAgentProject{OwnerID: ownerID, Name: name, Description: description} identifier := strings.TrimSpace(input.Identifier)
if identifier == "" {
identifier = projectInitials(name)
}
project := &models.SenlinAgentProject{
OwnerID: ownerID,
Name: name,
Identifier: identifier,
Icon: strings.TrimSpace(input.Icon),
Background: strings.TrimSpace(input.Background),
Description: strings.TrimSpace(input.Description),
}
return project, models.DBService.Create(project).Error return project, models.DBService.Create(project).Error
} }
@@ -149,9 +186,14 @@ func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput
if status == "" { if status == "" {
status = "open" status = "open"
} }
tagID, err := s.findOrCreateTagID(projectID, input.Tag)
if err != nil {
return nil, err
}
task := &models.SenlinAgentTask{ task := &models.SenlinAgentTask{
ProjectID: projectID, ProjectID: projectID,
CreatedBy: ownerID, CreatedBy: ownerID,
TagID: tagID,
Title: title, Title: title,
Description: strings.TrimSpace(input.Description), Description: strings.TrimSpace(input.Description),
Status: status, Status: status,
@@ -160,6 +202,58 @@ func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput
return task, models.DBService.Create(task).Error return task, models.DBService.Create(task).Error
} }
func (s *Service) UpdateTask(ownerID uint, projectID uint, taskID uint, input UpdateTaskInput) (*models.SenlinAgentTask, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
var task models.SenlinAgentTask
if err := models.DBService.Where("id = ? AND project_id = ?", taskID, projectID).First(&task).Error; err != nil {
return nil, err
}
nextProjectID := input.NextProjectID
if nextProjectID == 0 {
nextProjectID = task.ProjectID
}
if err := ensureProjectOwner(ownerID, nextProjectID); err != nil {
return nil, err
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("task title is required")
}
tagID, err := s.findOrCreateTagID(nextProjectID, input.Tag)
if err != nil {
return nil, err
}
projectIdentity, err := projectIdentity(nextProjectID)
if err != nil {
return nil, err
}
tagIdentity, err := optionalTagIdentity(tagID)
if err != nil {
return nil, err
}
status := strings.TrimSpace(input.Status)
if status == "" {
if input.Completed {
status = "done"
} else {
status = "open"
}
}
task.ProjectID = nextProjectID
task.ProjectIdentity = projectIdentity
task.Title = title
task.Description = strings.TrimSpace(input.Description)
task.Status = status
task.TagID = tagID
task.TagIdentity = tagIdentity
if err := models.DBService.Save(&task).Error; err != nil {
return nil, err
}
return &task, nil
}
func (s *Service) CreateFileSource(ownerID uint, projectID uint, input CreateFileSourceInput) (*models.SenlinAgentSource, error) { func (s *Service) CreateFileSource(ownerID uint, projectID uint, input CreateFileSourceInput) (*models.SenlinAgentSource, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil { if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err return nil, err
@@ -206,21 +300,55 @@ func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronP
return plan, models.DBService.Create(plan).Error return plan, models.DBService.Create(plan).Error
} }
func (s *Service) CreateProjectTag(ownerID uint, projectID uint, name string) (*models.SenlinAgentTag, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
return s.CreateTag(projectID, name)
}
func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) { func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if name == "" { if name == "" {
return nil, errors.New("tag name is required") return nil, errors.New("tag name is required")
} }
var existing models.SenlinAgentTag
err := models.DBService.Where("project_id = ? AND name = ?", projectID, name).First(&existing).Error
if err == nil {
return &existing, nil
}
if err != gorm.ErrRecordNotFound {
return nil, err
}
tag := &models.SenlinAgentTag{ProjectID: projectID, Name: name} tag := &models.SenlinAgentTag{ProjectID: projectID, Name: name}
return tag, models.DBService.Create(tag).Error return tag, models.DBService.Create(tag).Error
} }
func (s *Service) ListProjectTags(ownerID uint, projectID uint) ([]models.SenlinAgentTag, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
return s.ListTags(projectID)
}
func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) { func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) {
var tags []models.SenlinAgentTag var tags []models.SenlinAgentTag
err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error
return tags, err return tags, err
} }
func (s *Service) findOrCreateTagID(projectID uint, name string) (*uint, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, nil
}
tag, err := s.CreateTag(projectID, name)
if err != nil {
return nil, err
}
return &tag.ID, nil
}
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) { func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil { if err := ensureProjectOwner(ownerID, projectID); err != nil {
return Dashboard{}, err return Dashboard{}, err
@@ -284,8 +412,11 @@ func (s *Service) Workspace(ownerID uint, projectID uint) (ProjectWorkspace, err
Project: WorkbenchProject{ Project: WorkbenchProject{
ID: project.ID, ID: project.ID,
Name: project.Name, Name: project.Name,
Identifier: project.Identifier,
Icon: project.Icon,
Background: project.Background,
Description: project.Description, Description: project.Description,
Initials: projectInitials(project.Name), Initials: defaultString(project.Identifier, projectInitials(project.Name)),
UnreadCount: counts.inbox, UnreadCount: counts.inbox,
}, },
Channels: channels, Channels: channels,
@@ -409,6 +540,10 @@ func (s *Service) workspaceTasks(projectID uint) ([]WorkTask, error) {
if err := models.DBService.Where("project_id = ?", projectID).Order("CASE WHEN status = 'done' THEN 1 ELSE 0 END asc").Order("sort_order asc, updated_at desc, id desc").Limit(50).Find(&tasks).Error; err != nil { if err := models.DBService.Where("project_id = ?", projectID).Order("CASE WHEN status = 'done' THEN 1 ELSE 0 END asc").Order("sort_order asc, updated_at desc, id desc").Limit(50).Find(&tasks).Error; err != nil {
return nil, err return nil, err
} }
tagNames, err := taskTagNames(tasks)
if err != nil {
return nil, err
}
items := make([]WorkTask, 0, len(tasks)) items := make([]WorkTask, 0, len(tasks))
for _, task := range tasks { for _, task := range tasks {
owner, err := userDisplayName(task.AssigneeID, task.CreatedBy) owner, err := userDisplayName(task.AssigneeID, task.CreatedBy)
@@ -423,12 +558,58 @@ func (s *Service) workspaceTasks(projectID uint) ([]WorkTask, error) {
Owner: owner, Owner: owner,
Due: displayOptionalTime(task.DueAt), Due: displayOptionalTime(task.DueAt),
CreatedAt: displayTime(task.CreatedAt), CreatedAt: displayTime(task.CreatedAt),
Tag: "", Tag: tagNames[task.ID],
}) })
} }
return items, nil return items, nil
} }
func taskTagNames(tasks []models.SenlinAgentTask) (map[uint]string, error) {
names := make(map[uint]string)
tagIDs := make([]uint, 0)
for _, task := range tasks {
if task.TagID != nil {
tagIDs = append(tagIDs, *task.TagID)
}
}
if len(tagIDs) == 0 {
return names, nil
}
var tags []models.SenlinAgentTag
if err := models.DBService.Where("id IN ?", tagIDs).Find(&tags).Error; err != nil {
return nil, err
}
tagByID := make(map[uint]string, len(tags))
for _, tag := range tags {
tagByID[tag.ID] = tag.Name
}
for _, task := range tasks {
if task.TagID != nil {
names[task.ID] = tagByID[*task.TagID]
}
}
return names, nil
}
func projectIdentity(projectID uint) (string, error) {
var project models.SenlinAgentProject
if err := models.DBService.Select("identity").First(&project, projectID).Error; err != nil {
return "", err
}
return project.Identity, nil
}
func optionalTagIdentity(tagID *uint) (*string, error) {
if tagID == nil {
return nil, nil
}
var tag models.SenlinAgentTag
if err := models.DBService.Select("identity").First(&tag, *tagID).Error; err != nil {
return nil, err
}
return &tag.Identity, nil
}
func (s *Service) workspaceAISessions(projectID uint) ([]AISessionItem, error) { func (s *Service) workspaceAISessions(projectID uint) ([]AISessionItem, error) {
var sessions []models.SenlinAgentAISession var sessions []models.SenlinAgentAISession
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sessions).Error; err != nil { if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sessions).Error; err != nil {

View File

@@ -30,6 +30,33 @@ func TestProjectTagsAreScopedToProject(t *testing.T) {
require.Equal(t, first.ID, firstTags[0].ProjectID) require.Equal(t, first.ID, firstTags[0].ProjectID)
} }
func TestCreateProjectPersistsWorkspaceMetadata(t *testing.T) {
newTestDB(t)
service := NewService()
project, err := service.CreateProjectWithInput(1, CreateProjectInput{
Name: "探索项目",
Identifier: "EXP",
Icon: "compass",
Background: "#165DFF",
Description: "RSS 采集和线索沉淀",
})
require.NoError(t, err)
require.Equal(t, "探索项目", project.Name)
require.Equal(t, "EXP", project.Identifier)
require.Equal(t, "compass", project.Icon)
require.Equal(t, "#165DFF", project.Background)
require.Equal(t, "RSS 采集和线索沉淀", project.Description)
workspace, err := service.Workspace(1, project.ID)
require.NoError(t, err)
require.Equal(t, "EXP", workspace.Project.Identifier)
require.Equal(t, "compass", workspace.Project.Icon)
require.Equal(t, "#165DFF", workspace.Project.Background)
require.Equal(t, "RSS 采集和线索沉淀", workspace.Project.Description)
}
func TestDashboardCountsOnlyRequestedProject(t *testing.T) { func TestDashboardCountsOnlyRequestedProject(t *testing.T) {
database := newTestDB(t) database := newTestDB(t)
service := NewService() service := NewService()
@@ -140,6 +167,7 @@ func TestCreateTaskAddsTaskToOwnedProject(t *testing.T) {
Description: "整理客户反馈并形成行动项", Description: "整理客户反馈并形成行动项",
Status: "", Status: "",
DueAt: &dueAt, DueAt: &dueAt,
Tag: "客户反馈",
}) })
require.NoError(t, err) require.NoError(t, err)
@@ -148,10 +176,13 @@ func TestCreateTaskAddsTaskToOwnedProject(t *testing.T) {
require.Equal(t, "Follow up with client", task.Title) require.Equal(t, "Follow up with client", task.Title)
require.Equal(t, "open", task.Status) require.Equal(t, "open", task.Status)
require.NotNil(t, task.DueAt) require.NotNil(t, task.DueAt)
require.NotNil(t, task.TagID)
workspace, err := service.Workspace(1, project.ID) workspace, err := service.Workspace(1, project.ID)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, workspace.Tasks, 1) require.Len(t, workspace.Tasks, 1)
require.Equal(t, "Follow up with client", workspace.Tasks[0].Title) require.Equal(t, "Follow up with client", workspace.Tasks[0].Title)
require.Equal(t, "客户反馈", workspace.Tasks[0].Tag)
require.Equal(t, []string{"all", "客户反馈"}, workspace.Tags)
} }
func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) { func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
@@ -165,6 +196,58 @@ func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
require.Error(t, err) require.Error(t, err)
} }
func TestUpdateTaskPersistsTagAndCompletion(t *testing.T) {
newTestDB(t)
service := NewService()
require.NoError(t, models.DBService.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
task, err := service.CreateTask(1, project.ID, CreateTaskInput{Title: "Draft proposal", Tag: "客户"})
require.NoError(t, err)
updated, err := service.UpdateTask(1, project.ID, task.ID, UpdateTaskInput{
Title: "Draft proposal v2",
Description: "Updated scope",
Completed: true,
Tag: "重要",
})
require.NoError(t, err)
require.Equal(t, "Draft proposal v2", updated.Title)
require.Equal(t, "done", updated.Status)
workspace, err := service.Workspace(1, project.ID)
require.NoError(t, err)
require.Equal(t, "重要", workspace.Tasks[0].Tag)
require.Equal(t, []string{"all", "客户", "重要"}, workspace.Tags)
}
func TestCreateProjectTagAddsTagToOwnedProject(t *testing.T) {
newTestDB(t)
service := NewService()
project, err := service.CreateProject(1, "Alpha", "")
require.NoError(t, err)
tag, err := service.CreateProjectTag(1, project.ID, "Design")
require.NoError(t, err)
require.Equal(t, project.ID, tag.ProjectID)
require.Equal(t, "Design", tag.Name)
workspace, err := service.Workspace(1, project.ID)
require.NoError(t, err)
require.Equal(t, []string{"all", "Design"}, workspace.Tags)
}
func TestCreateProjectTagRejectsProjectOwnedByAnotherUser(t *testing.T) {
newTestDB(t)
service := NewService()
project, err := service.CreateProject(2, "Beta", "")
require.NoError(t, err)
_, err = service.CreateProjectTag(1, project.ID, "Hidden")
require.Error(t, err)
}
func TestCreateFileSourceAddsSourceToOwnedProject(t *testing.T) { func TestCreateFileSourceAddsSourceToOwnedProject(t *testing.T) {
database := newTestDB(t) database := newTestDB(t)
service := NewService() service := NewService()

View File

@@ -41,6 +41,9 @@ func (m *SenlinAgentTask) BeforeCreate(tx *gorm.DB) error {
if err := resolveOptionalIdentity(tx, &SenlinAgentUser{}, m.AssigneeID, &m.AssigneeIdentity); err != nil { if err := resolveOptionalIdentity(tx, &SenlinAgentUser{}, m.AssigneeID, &m.AssigneeIdentity); err != nil {
return err return err
} }
if err := resolveOptionalIdentity(tx, &SenlinAgentTag{}, m.TagID, &m.TagIdentity); err != nil {
return err
}
return resolveOptionalIdentity(tx, &SenlinAgentInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity) return resolveOptionalIdentity(tx, &SenlinAgentInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity)
} }

View File

@@ -8,6 +8,9 @@ type SenlinAgentProject struct {
OwnerID uint `gorm:"index;not null"` OwnerID uint `gorm:"index;not null"`
OwnerIdentity string `gorm:"type:char(36);index"` OwnerIdentity string `gorm:"type:char(36);index"`
Name string `gorm:"not null"` Name string `gorm:"not null"`
Identifier string `gorm:"index"`
Icon string
Background string
Description string Description string
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time

View File

@@ -11,6 +11,8 @@ type SenlinAgentTask struct {
CreatedByIdentity string `gorm:"type:char(36);index"` CreatedByIdentity string `gorm:"type:char(36);index"`
AssigneeID *uint `gorm:"index"` AssigneeID *uint `gorm:"index"`
AssigneeIdentity *string `gorm:"type:char(36);index"` AssigneeIdentity *string `gorm:"type:char(36);index"`
TagID *uint `gorm:"index"`
TagIdentity *string `gorm:"type:char(36);index"`
SourceInboxItemID *uint `gorm:"index"` SourceInboxItemID *uint `gorm:"index"`
SourceInboxItemIdentity *string `gorm:"type:char(36);index"` SourceInboxItemIdentity *string `gorm:"type:char(36);index"`
Title string `gorm:"not null"` Title string `gorm:"not null"`

View File

@@ -131,6 +131,18 @@ func seedProjectA1(tx *gorm.DB, userID uint, projectID uint) error {
return err return err
} }
} }
uiTagID, err := tagID(tx, projectID, "UI")
if err != nil {
return err
}
feedbackTagID, err := tagID(tx, projectID, "客户反馈")
if err != nil {
return err
}
automationTagID, err := tagID(tx, projectID, "自动化")
if err != nil {
return err
}
for _, item := range []models.SenlinAgentInboxItem{ for _, item := range []models.SenlinAgentInboxItem{
{ProjectID: projectID, CreatedBy: userID, SourceType: "产品研究", Title: "竞争对手定价更新监控", Body: "跟进企业版价格调整和功能组合变化。", Status: "open"}, {ProjectID: projectID, CreatedBy: userID, SourceType: "产品研究", Title: "竞争对手定价更新监控", Body: "跟进企业版价格调整和功能组合变化。", Status: "open"},
{ProjectID: projectID, CreatedBy: userID, SourceType: "客户支持", Title: "客户反馈:导出功能报错", Body: "客户在导出智能报表时遇到权限校验异常。", Status: "open"}, {ProjectID: projectID, CreatedBy: userID, SourceType: "客户支持", Title: "客户反馈:导出功能报错", Body: "客户在导出智能报表时遇到权限校验异常。", Status: "open"},
@@ -142,9 +154,9 @@ func seedProjectA1(tx *gorm.DB, userID uint, projectID uint) error {
} }
} }
for _, task := range []models.SenlinAgentTask{ for _, task := range []models.SenlinAgentTask{
{ProjectID: projectID, CreatedBy: userID, Title: "智能报表导出功能", Description: "修复权限校验并补充导出失败提示。", Status: "open", DueAt: &dueSoon, SortOrder: 1}, {ProjectID: projectID, CreatedBy: userID, TagID: &uiTagID, Title: "智能报表导出功能", Description: "修复权限校验并补充导出失败提示。", Status: "open", DueAt: &dueSoon, SortOrder: 1},
{ProjectID: projectID, CreatedBy: userID, Title: "企业版权限体系梳理", Description: "整理角色、项目和任务分享边界。", Status: "open", DueAt: &dueLater, SortOrder: 2}, {ProjectID: projectID, CreatedBy: userID, TagID: &automationTagID, Title: "企业版权限体系梳理", Description: "整理角色、项目和任务分享边界。", Status: "open", DueAt: &dueLater, SortOrder: 2},
{ProjectID: projectID, CreatedBy: userID, Title: "Q3 营销策略制定", Description: "汇总竞品情报和客户反馈,生成策略草案。", Status: "open", DueAt: &dueLater, SortOrder: 3}, {ProjectID: projectID, CreatedBy: userID, TagID: &feedbackTagID, Title: "Q3 营销策略制定", Description: "汇总竞品情报和客户反馈,生成策略草案。", Status: "open", DueAt: &dueLater, SortOrder: 3},
} { } {
if err := firstOrCreateTask(tx, task); err != nil { if err := firstOrCreateTask(tx, task); err != nil {
return err return err
@@ -209,12 +221,31 @@ func firstOrCreateTag(tx *gorm.DB, projectID uint, name string) error {
return firstOrCreate(tx, &models.SenlinAgentTag{}, "project_id = ? AND name = ?", []any{projectID, name}, models.SenlinAgentTag{ProjectID: projectID, Name: name}) return firstOrCreate(tx, &models.SenlinAgentTag{}, "project_id = ? AND name = ?", []any{projectID, name}, models.SenlinAgentTag{ProjectID: projectID, Name: name})
} }
func tagID(tx *gorm.DB, projectID uint, name string) (uint, error) {
var tag models.SenlinAgentTag
if err := tx.Where("project_id = ? AND name = ?", projectID, name).First(&tag).Error; err != nil {
return 0, err
}
return tag.ID, nil
}
func firstOrCreateInbox(tx *gorm.DB, item models.SenlinAgentInboxItem) error { func firstOrCreateInbox(tx *gorm.DB, item models.SenlinAgentInboxItem) error {
return firstOrCreate(tx, &models.SenlinAgentInboxItem{}, "project_id = ? AND title = ?", []any{item.ProjectID, item.Title}, item) return firstOrCreate(tx, &models.SenlinAgentInboxItem{}, "project_id = ? AND title = ?", []any{item.ProjectID, item.Title}, item)
} }
func firstOrCreateTask(tx *gorm.DB, task models.SenlinAgentTask) error { func firstOrCreateTask(tx *gorm.DB, task models.SenlinAgentTask) error {
return firstOrCreate(tx, &models.SenlinAgentTask{}, "project_id = ? AND title = ?", []any{task.ProjectID, task.Title}, task) var existing models.SenlinAgentTask
err := tx.Where("project_id = ? AND title = ?", task.ProjectID, task.Title).First(&existing).Error
if err == nil {
if task.TagID != nil && existing.TagID == nil {
return tx.Model(&existing).Update("tag_id", *task.TagID).Error
}
return nil
}
if err != gorm.ErrRecordNotFound {
return err
}
return tx.Create(&task).Error
} }
func firstOrCreateAISession(tx *gorm.DB, session models.SenlinAgentAISession) error { func firstOrCreateAISession(tx *gorm.DB, session models.SenlinAgentAISession) error {