feat: complete project workspace tags and notes UI
This commit is contained in:
@@ -5,13 +5,14 @@ const requiredFiles = [
|
||||
'src/pages/login.tsx',
|
||||
'src/pages/workspace-body.tsx',
|
||||
'src/pages/workspace-home.tsx',
|
||||
'src/pages/workspace-inbox.tsx',
|
||||
'src/pages/workspace-explore.tsx',
|
||||
'src/pages/projects/project-overview.tsx',
|
||||
'src/pages/projects/project-channel-page.tsx',
|
||||
'src/pages/projects/project-tasks.tsx',
|
||||
'src/pages/projects/project-ai.tsx',
|
||||
'src/pages/projects/project-notes.tsx',
|
||||
'src/pages/projects/project-cron.tsx',
|
||||
'src/pages/projects/project-new-channel.tsx',
|
||||
'src/pages/projects/project-rail.tsx',
|
||||
'src/pages/projects/project-sidebar.tsx',
|
||||
'src/pages/projects/project-topbar.tsx',
|
||||
|
||||
@@ -38,7 +38,7 @@ const collectMetrics = async () => page.evaluate(() => {
|
||||
const workspaceActions = [...document.querySelectorAll('.workspace-page .overview-head .arco-btn')]
|
||||
.map((button) => button.textContent?.trim())
|
||||
const dashboard = document.querySelector('.dashboard-button')
|
||||
const workspaceInbox = document.querySelector('.dashboard-button[title="Inbox"]')
|
||||
const workspaceExplore = document.querySelector('.dashboard-button[title="探索"]')
|
||||
const project = document.querySelector('.project-button')
|
||||
const create = document.querySelector('.create-project')
|
||||
const firstBadge = document.querySelector('.project-badge .arco-badge-number')
|
||||
@@ -84,8 +84,8 @@ const collectMetrics = async () => page.evaluate(() => {
|
||||
workspaceActions,
|
||||
dashboardButton: rect(dashboard),
|
||||
dashboardButtonActive: dashboard?.classList.contains('active') ?? false,
|
||||
workspaceInboxButton: rect(workspaceInbox),
|
||||
workspaceInboxButtonActive: workspaceInbox?.classList.contains('active') ?? false,
|
||||
workspaceExploreButton: rect(workspaceExplore),
|
||||
workspaceExploreButtonActive: workspaceExplore?.classList.contains('active') ?? false,
|
||||
projectButton: rect(project),
|
||||
projectButtonActive: project?.classList.contains('active') ?? false,
|
||||
createProjectButton: rect(create),
|
||||
@@ -111,10 +111,10 @@ await page.waitForTimeout(700)
|
||||
await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true })
|
||||
const workspaceMetrics = await collectMetrics()
|
||||
|
||||
await page.locator('.dashboard-button[title="Inbox"]').click()
|
||||
await page.locator('.dashboard-button[title="探索"]').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.screenshot({ path: 'test-results/workspace-inbox-react-acro-light.png', fullPage: true })
|
||||
const workspaceInboxMetrics = await collectMetrics()
|
||||
await page.screenshot({ path: 'test-results/workspace-explore-react-acro-light.png', fullPage: true })
|
||||
const workspaceExploreMetrics = await collectMetrics()
|
||||
|
||||
await page.locator('.project-button').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
@@ -133,7 +133,8 @@ for (const channel of [
|
||||
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
|
||||
{ label: 'AI 会话', pageClass: 'project-ai-page', expectedHeading: 'AI 会话' },
|
||||
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
|
||||
{ label: 'Cron 计划任务', pageClass: 'project-cron-page', expectedHeading: 'Cron 计划任务' },
|
||||
{ label: '计划任务', pageClass: 'project-cron-page', expectedHeading: '计划任务' },
|
||||
{ label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' },
|
||||
]) {
|
||||
await page.locator('.channel-button', { hasText: channel.label }).click()
|
||||
await page.waitForTimeout(250)
|
||||
@@ -158,7 +159,7 @@ await server.close()
|
||||
|
||||
console.log(JSON.stringify({
|
||||
workspaceMetrics,
|
||||
workspaceInboxMetrics,
|
||||
workspaceExploreMetrics,
|
||||
projectMetrics,
|
||||
channelSidebarHoverMetrics,
|
||||
stageHoverMetrics,
|
||||
@@ -180,8 +181,8 @@ if (metrics.overflowX) failures.push('expected no horizontal overflow')
|
||||
if (!metrics.workbenchMain) failures.push('missing workbench main')
|
||||
if (!metrics.hasWorkspacePage) failures.push('expected login to land on workspace page')
|
||||
if (!metrics.dashboardButtonActive) failures.push('expected dashboard button to be active after login')
|
||||
if (!metrics.workspaceInboxButton) failures.push('expected fixed workspace inbox button to render in project rail')
|
||||
if (metrics.workspaceInboxButtonActive) failures.push('expected fixed workspace inbox button not to be active after login')
|
||||
if (!metrics.workspaceExploreButton) failures.push('expected fixed workspace explore button to render in project rail')
|
||||
if (metrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button not to be active after login')
|
||||
if (metrics.projectButtonActive) failures.push('expected first project button not to be active on workspace landing page')
|
||||
if (metrics.channelSidebar) failures.push(`expected workspace to hide channel sidebar, got ${JSON.stringify(metrics.channelSidebar)}`)
|
||||
if (metrics.inspector) failures.push(`expected workspace to hide inspector, got ${JSON.stringify(metrics.inspector)}`)
|
||||
@@ -231,11 +232,11 @@ if (
|
||||
failures.push(`dashboard button size ${JSON.stringify(metrics.dashboardButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
|
||||
}
|
||||
if (
|
||||
!metrics.workspaceInboxButton ||
|
||||
metrics.workspaceInboxButton.width !== metrics.projectButton?.width ||
|
||||
metrics.workspaceInboxButton.height !== metrics.projectButton?.height
|
||||
!metrics.workspaceExploreButton ||
|
||||
metrics.workspaceExploreButton.width !== metrics.projectButton?.width ||
|
||||
metrics.workspaceExploreButton.height !== metrics.projectButton?.height
|
||||
) {
|
||||
failures.push(`workspace inbox button size ${JSON.stringify(metrics.workspaceInboxButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
|
||||
failures.push(`workspace explore button size ${JSON.stringify(metrics.workspaceExploreButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
|
||||
}
|
||||
if (
|
||||
!metrics.createProjectButton ||
|
||||
@@ -245,14 +246,14 @@ if (
|
||||
failures.push(`create project button size ${JSON.stringify(metrics.createProjectButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
|
||||
}
|
||||
|
||||
if (!workspaceInboxMetrics.workspaceInboxButtonActive) failures.push('expected fixed workspace inbox button to become active when selected')
|
||||
if (workspaceInboxMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace inbox page')
|
||||
if (workspaceInboxMetrics.channelSidebar) failures.push(`expected workspace inbox to hide channel sidebar, got ${JSON.stringify(workspaceInboxMetrics.channelSidebar)}`)
|
||||
if (workspaceInboxMetrics.inspector) failures.push(`expected workspace inbox to hide inspector, got ${JSON.stringify(workspaceInboxMetrics.inspector)}`)
|
||||
if (!workspaceInboxMetrics.stage || !workspaceInboxMetrics.projectRail) {
|
||||
failures.push(`missing workspace inbox layout regions: rail=${JSON.stringify(workspaceInboxMetrics.projectRail)}, stage=${JSON.stringify(workspaceInboxMetrics.stage)}`)
|
||||
} else if (Math.abs(workspaceInboxMetrics.stage.left - workspaceInboxMetrics.projectRail.right) > 1) {
|
||||
failures.push(`expected workspace inbox stage to start after project rail, got stage.left=${workspaceInboxMetrics.stage.left}, rail.right=${workspaceInboxMetrics.projectRail.right}`)
|
||||
if (!workspaceExploreMetrics.workspaceExploreButtonActive) failures.push('expected fixed workspace explore button to become active when selected')
|
||||
if (workspaceExploreMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace explore page')
|
||||
if (workspaceExploreMetrics.channelSidebar) failures.push(`expected workspace explore to hide channel sidebar, got ${JSON.stringify(workspaceExploreMetrics.channelSidebar)}`)
|
||||
if (workspaceExploreMetrics.inspector) failures.push(`expected workspace explore to hide inspector, got ${JSON.stringify(workspaceExploreMetrics.inspector)}`)
|
||||
if (!workspaceExploreMetrics.stage || !workspaceExploreMetrics.projectRail) {
|
||||
failures.push(`missing workspace explore layout regions: rail=${JSON.stringify(workspaceExploreMetrics.projectRail)}, stage=${JSON.stringify(workspaceExploreMetrics.stage)}`)
|
||||
} else if (Math.abs(workspaceExploreMetrics.stage.left - workspaceExploreMetrics.projectRail.right) > 1) {
|
||||
failures.push(`expected workspace explore stage to start after project rail, got stage.left=${workspaceExploreMetrics.stage.left}, rail.right=${workspaceExploreMetrics.projectRail.right}`)
|
||||
}
|
||||
|
||||
if (!projectMetrics.hasProjectOverview) failures.push('expected first project click to show project overview page')
|
||||
|
||||
@@ -347,6 +347,11 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dashboard-button .arco-icon,
|
||||
.create-project .arco-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.create-project {
|
||||
font-size: 11px;
|
||||
white-space: normal;
|
||||
@@ -399,6 +404,18 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.project-title-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
background: var(--senlin-soft-blue);
|
||||
color: var(--senlin-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.channel-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -429,6 +446,11 @@
|
||||
box-shadow: inset 3px 0 0 var(--senlin-primary);
|
||||
}
|
||||
|
||||
.channel-badge-muted .arco-badge-number {
|
||||
background: #c9cdd4;
|
||||
color: #4e5969;
|
||||
}
|
||||
|
||||
.channel-left {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -452,6 +474,17 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tag-create-button.arco-btn {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 12px;
|
||||
color: rgb(var(--arcoblue-6));
|
||||
}
|
||||
|
||||
.tag-create-button .arco-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.recent-sessions button {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
@@ -495,6 +528,85 @@
|
||||
margin: 3px 0 0;
|
||||
}
|
||||
|
||||
.project-overview-hero {
|
||||
min-height: 112px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--senlin-border);
|
||||
border-radius: 8px;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
color: #fff;
|
||||
padding: 16px 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-overview-identity {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.project-overview-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 42%);
|
||||
border-radius: 8px;
|
||||
background: rgb(255 255 255 / 18%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.project-overview-identity h3,
|
||||
.project-overview-identity .arco-typography {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.project-overview-identity h3 {
|
||||
margin: 0 0 3px;
|
||||
}
|
||||
|
||||
.project-overview-identity .arco-typography {
|
||||
max-width: 680px;
|
||||
margin: 0;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.project-overview-share {
|
||||
max-width: min(520px, 42vw);
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.project-overview-share .arco-typography {
|
||||
color: rgb(255 255 255 / 82%);
|
||||
}
|
||||
|
||||
.project-overview-share .arco-btn {
|
||||
height: 30px;
|
||||
max-width: 100%;
|
||||
border-color: rgb(255 255 255 / 36%);
|
||||
background: rgb(255 255 255 / 18%);
|
||||
color: #fff;
|
||||
padding: 0 10px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.project-overview-share .arco-btn > span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -579,6 +691,61 @@
|
||||
grid-template-columns: minmax(260px, 1fr) 82px 100px 100px 130px;
|
||||
}
|
||||
|
||||
.overview-task-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.overview-task-card {
|
||||
min-width: 0;
|
||||
min-height: 132px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--senlin-border);
|
||||
border-radius: 6px;
|
||||
background: var(--senlin-surface);
|
||||
color: var(--senlin-text);
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.overview-task-card:hover {
|
||||
border-color: color-mix(in srgb, var(--senlin-primary) 42%, var(--senlin-border));
|
||||
box-shadow: 0 8px 22px color-mix(in srgb, var(--senlin-primary) 12%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.overview-task-card.completed {
|
||||
background: color-mix(in srgb, var(--senlin-surface) 92%, #00b42a);
|
||||
}
|
||||
|
||||
.overview-task-title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.overview-task-title svg {
|
||||
color: var(--senlin-primary);
|
||||
}
|
||||
|
||||
.overview-task-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
color: var(--senlin-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.workspace-task-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
@@ -646,6 +813,109 @@
|
||||
grid-template-columns: minmax(260px, 1fr) 80px 160px 80px;
|
||||
}
|
||||
|
||||
.project-file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 8px 18px;
|
||||
}
|
||||
|
||||
.project-file-grid.compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.project-file-tile {
|
||||
min-width: 0;
|
||||
height: 76px;
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--senlin-text);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-file-tile:hover,
|
||||
.project-file-tile:focus-visible {
|
||||
border-color: #bfdbff;
|
||||
background: #e8f3ff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.theme-dark .project-file-tile:hover,
|
||||
.theme-dark .project-file-tile:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--senlin-primary) 56%, var(--senlin-border));
|
||||
background: color-mix(in srgb, var(--senlin-primary) 18%, var(--senlin-surface));
|
||||
}
|
||||
|
||||
.project-file-icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
background: #eef2f7;
|
||||
color: #4e5969;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.project-file-icon.folder {
|
||||
background: #fff3cf;
|
||||
color: #f0a020;
|
||||
}
|
||||
|
||||
.project-file-icon.pdf {
|
||||
background: #fff1f0;
|
||||
color: #f53f3f;
|
||||
}
|
||||
|
||||
.project-file-icon.sheet {
|
||||
background: #e8ffea;
|
||||
color: #00b42a;
|
||||
}
|
||||
|
||||
.project-file-icon.doc,
|
||||
.project-file-icon.note {
|
||||
background: #e8f3ff;
|
||||
color: #165dff;
|
||||
}
|
||||
|
||||
.project-file-icon.image {
|
||||
background: #f2f3f5;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.project-file-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.project-file-name,
|
||||
.project-file-type,
|
||||
.project-file-meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-file-name {
|
||||
color: var(--senlin-text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-file-type,
|
||||
.project-file-meta {
|
||||
color: var(--senlin-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-channel-page {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -815,6 +1085,10 @@
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.channel-editor-card .arco-card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.action-modal .arco-modal-content {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -16,10 +16,14 @@ export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): Proje
|
||||
const project: Project = {
|
||||
id: String(payload.project.id),
|
||||
name: payload.project.name,
|
||||
short: payload.project.initials,
|
||||
identifier: payload.project.identifier ?? '',
|
||||
icon: payload.project.icon ?? '',
|
||||
background: payload.project.background ?? '',
|
||||
description: payload.project.description,
|
||||
short: projectIconLabel(payload.project.icon ?? '', payload.project.initials),
|
||||
badge: payload.project.unreadCount,
|
||||
urgent: payload.project.unreadCount > 20,
|
||||
color: projectColors[index % projectColors.length],
|
||||
color: projectColor(payload.project.background ?? '', index),
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -35,6 +39,22 @@ export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): Proje
|
||||
}
|
||||
}
|
||||
|
||||
function projectIconLabel(icon: string, fallback: string) {
|
||||
const trimmed = icon.trim()
|
||||
if (!trimmed) return fallback
|
||||
const chars = Array.from(trimmed)
|
||||
if (chars.length <= 2) return trimmed
|
||||
return chars.slice(0, 2).join('').toUpperCase()
|
||||
}
|
||||
|
||||
function projectColor(background: string, index: number) {
|
||||
const trimmed = background.trim()
|
||||
if (trimmed.startsWith('#') || trimmed.startsWith('rgb') || trimmed.startsWith('hsl')) {
|
||||
return trimmed
|
||||
}
|
||||
return projectColors[index % projectColors.length]
|
||||
}
|
||||
|
||||
function mapChannel(channel: BackendChannel): ProjectChannel {
|
||||
return {
|
||||
id: channel.id,
|
||||
@@ -79,7 +99,7 @@ function channelLabel(channel: BackendChannel) {
|
||||
case 'notes_sources':
|
||||
return '笔记资料'
|
||||
case 'cron':
|
||||
return 'Cron 计划任务'
|
||||
return '计划任务'
|
||||
default:
|
||||
return channel.title
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ export type BackendProject = {
|
||||
id?: number
|
||||
Name?: string
|
||||
name?: string
|
||||
Identifier?: string
|
||||
identifier?: string
|
||||
Icon?: string
|
||||
icon?: string
|
||||
Background?: string
|
||||
background?: string
|
||||
Description?: string
|
||||
description?: string
|
||||
}
|
||||
@@ -12,6 +18,9 @@ export type BackendProject = {
|
||||
export type BackendWorkspaceProject = {
|
||||
id: number
|
||||
name: string
|
||||
identifier?: string
|
||||
icon?: string
|
||||
background?: string
|
||||
description: string
|
||||
initials: string
|
||||
unreadCount: number
|
||||
@@ -88,6 +97,13 @@ export type BackendProjectWorkspace = {
|
||||
cronPlans: BackendCronPlan[]
|
||||
}
|
||||
|
||||
export type BackendTag = {
|
||||
ID?: number
|
||||
id?: number
|
||||
Name?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export async function fetchProjects(session: ApiSession) {
|
||||
return apiRequest<BackendProject[]>('/api/projects', { token: session.token })
|
||||
}
|
||||
@@ -96,8 +112,15 @@ export async function fetchProjectWorkspace(session: ApiSession, projectId: stri
|
||||
return apiRequest<BackendProjectWorkspace>(`/api/projects/${projectId}/workspace`, { token: session.token })
|
||||
}
|
||||
|
||||
export async function fetchProjectTags(session: ApiSession, projectId: string | number) {
|
||||
return apiRequest<BackendTag[]>(`/api/projects/${projectId}/tags`, { token: session.token })
|
||||
}
|
||||
|
||||
export type CreateProjectInput = {
|
||||
name: string
|
||||
identifier?: string
|
||||
icon?: string
|
||||
background?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
@@ -106,6 +129,15 @@ export type CreateTaskInput = {
|
||||
description?: string
|
||||
status?: string
|
||||
dueAt?: string
|
||||
tag?: string
|
||||
}
|
||||
|
||||
export type UpdateTaskInput = {
|
||||
title: string
|
||||
description?: string
|
||||
completed: boolean
|
||||
nextProjectId?: number
|
||||
tag?: string
|
||||
}
|
||||
|
||||
export type UploadSourceInput = {
|
||||
@@ -120,6 +152,10 @@ export type CreateCronPlanInput = {
|
||||
nextRunAt?: string
|
||||
}
|
||||
|
||||
export type CreateProjectTagInput = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export async function createProject(session: ApiSession, input: CreateProjectInput) {
|
||||
return apiRequest<BackendProject>('/api/projects', {
|
||||
method: 'POST',
|
||||
@@ -136,6 +172,14 @@ export async function createTask(session: ApiSession, projectId: string | number
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateTask(session: ApiSession, projectId: string | number, taskId: string | number, input: UpdateTaskInput) {
|
||||
return apiRequest<BackendTask>(`/api/projects/${projectId}/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadSource(session: ApiSession, projectId: string | number, input: UploadSourceInput) {
|
||||
const body = new FormData()
|
||||
body.append('file', input.file)
|
||||
@@ -154,3 +198,11 @@ export async function createCronPlan(session: ApiSession, projectId: string | nu
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createProjectTag(session: ApiSession, projectId: string | number, input: CreateProjectTagInput) {
|
||||
return apiRequest<BackendTag>(`/api/projects/${projectId}/tags`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,14 +7,18 @@ import { mapWorkspace } from '../api/mappers'
|
||||
import {
|
||||
createCronPlan,
|
||||
createProject,
|
||||
createProjectTag,
|
||||
createTask,
|
||||
fetchProjectWorkspace,
|
||||
fetchProjects,
|
||||
updateTask,
|
||||
uploadSource,
|
||||
} from '../api/projects'
|
||||
import { LoginPage } from '../pages/login'
|
||||
import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals'
|
||||
import type { ProjectSettingsUpdate } from '../pages/projects/project-sidebar'
|
||||
import { ProjectPage } from '../pages/workspace-home'
|
||||
import type { WorkspaceTaskUpdate } from '../pages/workspace-body'
|
||||
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
|
||||
|
||||
function App() {
|
||||
@@ -26,13 +30,14 @@ function App() {
|
||||
const [activeProjectID, setActiveProjectID] = useState<string>('')
|
||||
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
|
||||
const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
|
||||
const [, setSelectedItem] = useState('Inbox 待处理(36)')
|
||||
const [, setSelectedItem] = useState('探索采集')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
|
||||
|
||||
const dark = theme === 'dark'
|
||||
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0]
|
||||
const activeTagOptions = activeWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
|
||||
|
||||
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID = activeProjectID) {
|
||||
const backendProjects = await fetchProjects(nextSession)
|
||||
@@ -102,7 +107,13 @@ function App() {
|
||||
return
|
||||
}
|
||||
void runAction(async () => {
|
||||
const created = await createProject(requireSession(), { name: draft.name.trim(), description: draft.description.trim() })
|
||||
const created = await createProject(requireSession(), {
|
||||
name: draft.name.trim(),
|
||||
identifier: draft.identifier.trim(),
|
||||
icon: draft.icon.trim(),
|
||||
background: draft.background.trim(),
|
||||
description: draft.description.trim(),
|
||||
})
|
||||
const nextProjectID = String(created.ID ?? created.id ?? '')
|
||||
await refreshAfterAction(nextProjectID)
|
||||
if (nextProjectID) {
|
||||
@@ -124,6 +135,7 @@ function App() {
|
||||
description: draft.description.trim(),
|
||||
status: 'open',
|
||||
dueAt: normalizeOptionalTime(draft.dueAt),
|
||||
tag: draft.tag.trim(),
|
||||
})
|
||||
await refreshAfterAction()
|
||||
setActiveChannel('tasks')
|
||||
@@ -167,6 +179,18 @@ function App() {
|
||||
}, '计划任务已创建')
|
||||
}
|
||||
|
||||
function handleCreateProjectTag(name: string) {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
Message.warning('请输入标签名称')
|
||||
return
|
||||
}
|
||||
void runAction(async () => {
|
||||
await createProjectTag(requireSession(), requireActiveProject(), { name: trimmedName })
|
||||
await refreshAfterAction()
|
||||
}, '标签已创建')
|
||||
}
|
||||
|
||||
function openTask(project: Project, taskID: string) {
|
||||
setActiveProjectID(project.id)
|
||||
setActiveView('project')
|
||||
@@ -174,6 +198,38 @@ function App() {
|
||||
setActiveTaskID(taskID)
|
||||
}
|
||||
|
||||
function handleUpdateWorkspaceTask(update: WorkspaceTaskUpdate) {
|
||||
void runAction(async () => {
|
||||
await updateTask(requireSession(), update.originalProjectId, update.taskId, {
|
||||
title: update.title,
|
||||
description: update.summary,
|
||||
completed: update.completed,
|
||||
nextProjectId: Number(update.nextProjectId),
|
||||
tag: update.tag,
|
||||
})
|
||||
await refreshAfterAction(update.nextProjectId)
|
||||
}, '任务已更新')
|
||||
}
|
||||
|
||||
function handleUpdateProject(update: ProjectSettingsUpdate) {
|
||||
setWorkspaces((current) =>
|
||||
current.map((workspace) => {
|
||||
if (workspace.project.id !== update.projectId) return workspace
|
||||
const nextProject = {
|
||||
...workspace.project,
|
||||
name: update.name,
|
||||
identifier: update.identifier,
|
||||
icon: update.icon,
|
||||
background: update.background,
|
||||
description: update.description,
|
||||
short: projectIconLabel(update.icon, update.identifier || update.name.slice(0, 2)),
|
||||
color: projectColor(update.background, workspace.project.color),
|
||||
}
|
||||
return { ...workspace, project: nextProject }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<main className={dark ? 'app theme-dark' : 'app'}>
|
||||
@@ -190,7 +246,7 @@ function App() {
|
||||
activeTaskID={activeTaskID}
|
||||
theme={theme}
|
||||
onSelectWorkspace={() => setActiveView('workspace')}
|
||||
onSelectWorkspaceInbox={() => setActiveView('workspace-inbox')}
|
||||
onSelectWorkspaceExplore={() => setActiveView('workspace-explore')}
|
||||
onSelectProject={(project) => {
|
||||
setActiveProjectID(project.id)
|
||||
setActiveView('project')
|
||||
@@ -209,6 +265,9 @@ function App() {
|
||||
onCreateTask={() => setActiveModal('task')}
|
||||
onUploadSource={() => setActiveModal('source')}
|
||||
onCreateCronPlan={() => setActiveModal('cron')}
|
||||
onUpdateWorkspaceTask={handleUpdateWorkspaceTask}
|
||||
onUpdateProject={handleUpdateProject}
|
||||
onCreateProjectTag={handleCreateProjectTag}
|
||||
/>
|
||||
) : (
|
||||
<Spin loading />
|
||||
@@ -221,10 +280,25 @@ function App() {
|
||||
onCreateTask={handleCreateTask}
|
||||
onUploadSource={handleUploadSource}
|
||||
onCreateCronPlan={handleCreateCronPlan}
|
||||
tagOptions={activeTagOptions}
|
||||
/>
|
||||
</main>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function projectIconLabel(icon: string, fallback: string) {
|
||||
const trimmed = icon.trim()
|
||||
if (!trimmed) return fallback
|
||||
const chars = Array.from(trimmed)
|
||||
if (chars.length <= 2) return trimmed
|
||||
return chars.slice(0, 2).join('').toUpperCase()
|
||||
}
|
||||
|
||||
function projectColor(background: string, fallback: string) {
|
||||
const trimmed = background.trim()
|
||||
if (trimmed.startsWith('#') || trimmed.startsWith('rgb') || trimmed.startsWith('hsl')) return trimmed
|
||||
return fallback
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Input, Modal, Space, Switch, Typography } from '@arco-design/web-react'
|
||||
import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
|
||||
|
||||
const { Text } = Typography
|
||||
const { TextArea } = Input
|
||||
@@ -8,6 +8,9 @@ export type ProjectActionModal = 'project' | 'task' | 'source' | 'cron' | null
|
||||
|
||||
export type ProjectDraft = {
|
||||
name: string
|
||||
identifier: string
|
||||
icon: string
|
||||
background: string
|
||||
description: string
|
||||
}
|
||||
|
||||
@@ -15,6 +18,7 @@ export type TaskDraft = {
|
||||
title: string
|
||||
description: string
|
||||
dueAt: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
export type SourceDraft = {
|
||||
@@ -37,6 +41,7 @@ export function ProjectActionModals({
|
||||
onCreateTask,
|
||||
onUploadSource,
|
||||
onCreateCronPlan,
|
||||
tagOptions,
|
||||
}: {
|
||||
activeModal: ProjectActionModal
|
||||
loading: boolean
|
||||
@@ -45,16 +50,17 @@ export function ProjectActionModals({
|
||||
onCreateTask: (draft: TaskDraft) => void
|
||||
onUploadSource: (draft: SourceDraft) => void
|
||||
onCreateCronPlan: (draft: CronDraft) => void
|
||||
tagOptions: string[]
|
||||
}) {
|
||||
const [project, setProject] = useState<ProjectDraft>({ name: '', description: '' })
|
||||
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', dueAt: '' })
|
||||
const [project, setProject] = useState<ProjectDraft>({ name: '', identifier: '', icon: '', background: '', description: '' })
|
||||
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', dueAt: '', tag: '' })
|
||||
const [source, setSource] = useState<SourceDraft>({ title: '', file: null })
|
||||
const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
|
||||
|
||||
useEffect(() => {
|
||||
if (activeModal === null) {
|
||||
setProject({ name: '', description: '' })
|
||||
setTask({ title: '', description: '', dueAt: '' })
|
||||
setProject({ name: '', identifier: '', icon: '', background: '', description: '' })
|
||||
setTask({ title: '', description: '', dueAt: '', tag: '' })
|
||||
setSource({ title: '', file: null })
|
||||
setCron({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
|
||||
}
|
||||
@@ -76,7 +82,19 @@ export function ProjectActionModals({
|
||||
<Input placeholder="例如:项目 A3" value={project.name} onChange={(name) => setProject((draft) => ({ ...draft, name }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>项目说明</Text>
|
||||
<Text>项目标识</Text>
|
||||
<Input placeholder="例如:A3 / EXP / DATA" value={project.identifier} onChange={(identifier) => setProject((draft) => ({ ...draft, identifier }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>项目图标</Text>
|
||||
<Input placeholder="例如:A3 / 🚀 / compass" value={project.icon} onChange={(icon) => setProject((draft) => ({ ...draft, icon }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>项目背景</Text>
|
||||
<Input placeholder="例如:#165DFF 或背景图片 URL" value={project.background} onChange={(background) => setProject((draft) => ({ ...draft, background }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>项目简介</Text>
|
||||
<TextArea rows={4} placeholder="项目目标、背景或协作说明" value={project.description} onChange={(description) => setProject((draft) => ({ ...draft, description }))} />
|
||||
</label>
|
||||
</Space>
|
||||
@@ -103,6 +121,19 @@ export function ProjectActionModals({
|
||||
<Text>截止时间</Text>
|
||||
<Input placeholder="2026-07-21T09:30:00Z,可留空" value={task.dueAt} onChange={(dueAt) => setTask((draft) => ({ ...draft, dueAt }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>所属标签</Text>
|
||||
<Select
|
||||
allowCreate
|
||||
placeholder="选择或输入标签"
|
||||
value={task.tag}
|
||||
onChange={(tag) => setTask((draft) => ({ ...draft, tag }))}
|
||||
>
|
||||
{tagOptions.map((tag) => (
|
||||
<Select.Option key={tag} value={tag}>{tag}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ProjectAi } from './project-ai'
|
||||
import { ProjectCron } from './project-cron'
|
||||
import { ProjectNewChannel } from './project-new-channel'
|
||||
import { ProjectNotes } from './project-notes'
|
||||
import { ProjectOverview } from './project-overview'
|
||||
import { ProjectTasks } from './project-tasks'
|
||||
@@ -35,6 +36,8 @@ export function ProjectChannelPage({
|
||||
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
|
||||
case 'cron':
|
||||
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} />
|
||||
case 'new-channel':
|
||||
return <ProjectNewChannel activeWorkspace={activeWorkspace} />
|
||||
case 'overview':
|
||||
default:
|
||||
return <ProjectOverview activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
|
||||
<div className="project-channel-page project-cron-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>Cron 计划任务</Title>
|
||||
<Title heading={4}>计划任务</Title>
|
||||
<Text type="secondary">{project.name} 的定时任务、自动检查和周期性 AI 工作。</Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<IconPlus />} onClick={onCreateCronPlan}>新建计划</Button>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { IconFile, IconFileImage, IconFilePdf, IconFolder } from '@arco-design/web-react/icon'
|
||||
import type { NoteSource } from './project-types'
|
||||
|
||||
export type ProjectFileItem = NoteSource | {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
updated: string
|
||||
owner: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export function ProjectFileGrid({
|
||||
items,
|
||||
onSelectItem,
|
||||
compact = false,
|
||||
}: {
|
||||
items: ProjectFileItem[]
|
||||
onSelectItem: (title: string) => void
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={compact ? 'project-file-grid compact' : 'project-file-grid'}>
|
||||
{items.map((item) => {
|
||||
const fileKind = inferFileKind(item)
|
||||
return (
|
||||
<button className="project-file-tile" key={item.id} onClick={() => onSelectItem(item.title)}>
|
||||
<span className={`project-file-icon ${fileKind.kind}`}>{fileIcon(fileKind.kind)}</span>
|
||||
<span className="project-file-copy">
|
||||
<span className="project-file-name">{item.title}</span>
|
||||
<span className="project-file-type">{fileKind.label}</span>
|
||||
<span className="project-file-meta">{fileKind.meta}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function inferFileKind(item: ProjectFileItem) {
|
||||
const value = `${item.title} ${item.type} ${item.source ?? ''}`.toLowerCase()
|
||||
if (item.type === 'folder' || item.source === 'folder') return { kind: 'folder', label: '文件夹', meta: item.updated }
|
||||
if (value.includes('pdf')) return { kind: 'pdf', label: 'PDF 文档', meta: item.updated }
|
||||
if (value.includes('png') || value.includes('jpg') || value.includes('jpeg') || value.includes('图片')) return { kind: 'image', label: '图片文件', meta: item.updated }
|
||||
if (value.includes('excel') || value.includes('xlsx') || value.includes('表')) return { kind: 'sheet', label: '表格文件', meta: item.updated }
|
||||
if (value.includes('word') || value.includes('doc') || value.includes('文档')) return { kind: 'doc', label: '文档', meta: item.updated }
|
||||
if (value.includes('markdown') || value.includes('笔记')) return { kind: 'note', label: 'Markdown 笔记', meta: item.updated }
|
||||
return { kind: 'file', label: item.type || '文件', meta: item.updated }
|
||||
}
|
||||
|
||||
function fileIcon(kind: string) {
|
||||
if (kind === 'folder') return <IconFolder />
|
||||
if (kind === 'pdf') return <IconFilePdf />
|
||||
if (kind === 'image') return <IconFileImage />
|
||||
return <IconFile />
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button, Card, Input, Select, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconApps, IconLink, IconPlus } from '@arco-design/web-react/icon'
|
||||
import type { ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
export function ProjectNewChannel({ activeWorkspace }: { activeWorkspace: ProjectWorkspace }) {
|
||||
return (
|
||||
<div className="project-channel-page project-new-channel-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>新建频道</Title>
|
||||
<Text type="secondary">{activeWorkspace.project.name} 的频道入口配置,用于接入外部页面、资料集合或项目流程。</Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<IconPlus />}>保存频道</Button>
|
||||
</div>
|
||||
|
||||
<Card className="queue-section channel-editor-card" bordered>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
<label>
|
||||
<Text>频道名称</Text>
|
||||
<Input placeholder="例如:客户研究频道" />
|
||||
</label>
|
||||
<label>
|
||||
<Text>频道类型</Text>
|
||||
<Select defaultValue="link">
|
||||
<Select.Option value="link">外部链接</Select.Option>
|
||||
<Select.Option value="source">资料集合</Select.Option>
|
||||
<Select.Option value="workflow">项目流程</Select.Option>
|
||||
</Select>
|
||||
</label>
|
||||
<label>
|
||||
<Text>图标</Text>
|
||||
<Input placeholder="例如:link / user / file" prefix={<IconApps />} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>链接地址</Text>
|
||||
<Input placeholder="https://example.com/channel" prefix={<IconLink />} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>说明</Text>
|
||||
<TextArea rows={4} placeholder="频道用途、维护规则或采集说明" />
|
||||
</label>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>创建后可接入的内容</Title>
|
||||
<Tag color="gray">配置预览</Tag>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Tag color="arcoblue">外部资料库</Tag>
|
||||
<Tag color="green">项目流程页</Tag>
|
||||
<Tag color="orange">RSS/探索结果</Tag>
|
||||
<Tag color="purple">AI 会话上下文</Tag>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Card, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconFile, IconFolder, IconPlus, IconSearch } from '@arco-design/web-react/icon'
|
||||
import { Button, Card, Space, Typography } from '@arco-design/web-react'
|
||||
import { IconPlus, IconSearch } from '@arco-design/web-react/icon'
|
||||
import { ProjectFileGrid } from './project-file-grid'
|
||||
import type { ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
@@ -22,31 +23,26 @@ export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }:
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="folder-grid">
|
||||
{folders.map((folder) => (
|
||||
<Card className="folder-card" bordered key={folder}>
|
||||
<Space>
|
||||
<Tag color="arcoblue"><IconFolder /></Tag>
|
||||
<Text>{folder}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<ProjectFileGrid items={folderItems(folders)} onSelectItem={onSelectItem} />
|
||||
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>最近更新</Title>
|
||||
<Button type="text" size="mini">按更新时间排序</Button>
|
||||
</div>
|
||||
{notes.map((note) => (
|
||||
<button className="data-row note-row" key={note.title} onClick={() => onSelectItem(note.title)}>
|
||||
<span className="row-title"><IconFile /> {note.title}</span>
|
||||
<Tag>{note.type}</Tag>
|
||||
<span>{note.updated}</span>
|
||||
<span>{note.owner}</span>
|
||||
</button>
|
||||
))}
|
||||
<ProjectFileGrid items={notes} onSelectItem={onSelectItem} />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function folderItems(names: string[]) {
|
||||
return names.map((name) => ({
|
||||
id: `folder-${name}`,
|
||||
title: name,
|
||||
type: 'folder',
|
||||
updated: '文件夹',
|
||||
owner: '',
|
||||
source: 'folder',
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,43 +1,50 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconCalendar, IconCheckCircleFill, IconClockCircle, IconEmail, IconFile, IconMore, IconRobot } from '@arco-design/web-react/icon'
|
||||
import type { InboxItem, ProjectWorkspace } from './project-types'
|
||||
import { IconCheckCircleFill, IconClockCircle, IconFile, IconLink } from '@arco-design/web-react/icon'
|
||||
import { ProjectFileGrid } from './project-file-grid'
|
||||
import type { ProjectWorkspace, TaskItem } from './project-types'
|
||||
|
||||
const { Row, Col } = Grid
|
||||
const { Title, Text } = Typography
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
export function ProjectOverview({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
|
||||
const { inbox, tasks, aiSessions, notes, cronJobs } = activeWorkspace
|
||||
const { project, tasks, notes, cronJobs } = activeWorkspace
|
||||
const runningTasks = useMemo(() => tasks.filter((task) => !task.completed), [tasks])
|
||||
const completedTasks = useMemo(() => tasks.filter((task) => task.completed), [tasks])
|
||||
const shareURL = useMemo(() => projectShareURL(project.identifier || project.id), [project.id, project.identifier])
|
||||
const metrics = useMemo(() => [
|
||||
{ title: 'Inbox 待处理', value: inbox.length, delta: '实时接口', icon: <IconEmail />, color: 'arcoblue' },
|
||||
{ title: '进行中的任务', value: tasks.filter((task) => !task.completed).length, delta: '实时接口', icon: <IconCheckCircleFill />, color: 'green' },
|
||||
{ title: 'AI 会话活跃', value: aiSessions.length, delta: '实时接口', icon: <IconRobot />, color: 'purple' },
|
||||
{ title: '笔记资料总数', value: notes.length, delta: '实时接口', icon: <IconFile />, color: 'cyan' },
|
||||
{ title: '计划任务', value: cronJobs.length, delta: '实时接口', icon: <IconClockCircle />, color: 'orange' },
|
||||
], [aiSessions.length, cronJobs.length, inbox.length, notes.length, tasks])
|
||||
{ title: '进行中的任务', value: runningTasks.length, icon: <IconCheckCircleFill />, color: 'green' },
|
||||
{ title: '完成的任务', value: completedTasks.length, icon: <IconCheckCircleFill />, color: 'arcoblue' },
|
||||
{ title: '笔记资料总数', value: notes.length, icon: <IconFile />, color: 'cyan' },
|
||||
{ title: '计划任务', value: cronJobs.length, icon: <IconClockCircle />, color: 'orange' },
|
||||
], [completedTasks.length, cronJobs.length, notes.length, runningTasks.length])
|
||||
|
||||
return (
|
||||
<div className="overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>概况</Title>
|
||||
<div className="overview-page project-overview-page">
|
||||
<section className="project-overview-hero" style={{ background: projectHeroBackground(project.background, project.color) }}>
|
||||
<div className="project-overview-identity">
|
||||
<span className="project-overview-icon">{project.short}</span>
|
||||
<div>
|
||||
<Title heading={3}>{project.name}</Title>
|
||||
<Paragraph ellipsis={{ rows: 2 }}>{project.description || '暂无简介'}</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<IconCalendar />}>2026-07-20</Button>
|
||||
<Button icon={<IconMore />} />
|
||||
</Space>
|
||||
</div>
|
||||
<div className="project-overview-share">
|
||||
<Text>项目 URL</Text>
|
||||
<Button icon={<IconLink />} onClick={() => onSelectItem(shareURL)}>{shareURL}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Row gutter={12} className="metric-row">
|
||||
{metrics.map((metric) => (
|
||||
<Col span={24 / metrics.length} key={metric.title}>
|
||||
<Col span={6} key={metric.title}>
|
||||
<Card className="metric-card" bordered>
|
||||
<Space align="start">
|
||||
<Tag color={metric.color}>{metric.icon}</Tag>
|
||||
<div>
|
||||
<Text type="secondary">{metric.title}</Text>
|
||||
<Title heading={3}>{metric.value}</Title>
|
||||
<Text className="metric-delta">{metric.delta}</Text>
|
||||
<Text className="metric-delta">实时接口</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
@@ -45,69 +52,40 @@ export function ProjectOverview({ activeWorkspace, onSelectItem }: { activeWorks
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<QueueSection title={`Inbox 待处理(${inbox.length})`} items={inbox} onSelectItem={onSelectItem} />
|
||||
<TaskSection tasks={tasks} onSelectItem={onSelectItem} />
|
||||
<AISessionSection aiSessions={aiSessions} onSelectItem={onSelectItem} />
|
||||
<TaskCardSection title={`进行中的任务(${runningTasks.length})`} tasks={runningTasks} emptyText="当前没有进行中的任务" onSelectItem={onSelectItem} />
|
||||
<TaskCardSection title={`完成的任务(${completedTasks.length})`} tasks={completedTasks} emptyText="当前没有完成的任务" onSelectItem={onSelectItem} />
|
||||
<NoteSection notes={notes} onSelectItem={onSelectItem} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QueueSection({ title, items, onSelectItem }: { title: string; items: InboxItem[]; onSelectItem: (title: string) => void }) {
|
||||
function TaskCardSection({ title, tasks, emptyText, onSelectItem }: { title: string; tasks: TaskItem[]; emptyText: string; onSelectItem: (title: string) => void }) {
|
||||
return (
|
||||
<Card className="queue-section" bordered>
|
||||
<Card className="queue-section overview-task-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>{title}</Title>
|
||||
<Button type="text" size="mini">查看全部</Button>
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<button key={item.title} className="data-row" onClick={() => onSelectItem(item.title)}>
|
||||
<span className="row-title"><IconFile /> {item.title}</span>
|
||||
<span>{item.meta}</span>
|
||||
<Tag>{item.tag}</Tag>
|
||||
<span>{item.owner}</span>
|
||||
<time>{item.time}</time>
|
||||
</button>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskSection({ tasks, onSelectItem }: { tasks: ProjectWorkspace['tasks']; onSelectItem: (title: string) => void }) {
|
||||
return (
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>进行中的任务({tasks.length})</Title>
|
||||
<Button type="text" size="mini">查看全部</Button>
|
||||
</div>
|
||||
{tasks.map((task) => (
|
||||
<button key={task.title} className="data-row task-row" onClick={() => onSelectItem(task.title)}>
|
||||
<span className="row-title"><IconCheckCircleFill /> {task.title}</span>
|
||||
<Tag color="arcoblue">{task.tag}</Tag>
|
||||
<span>进度 {task.progress}</span>
|
||||
<span>负责人 {task.owner}</span>
|
||||
<time>创建 {task.createdAt}</time>
|
||||
</button>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AISessionSection({ aiSessions, onSelectItem }: { aiSessions: ProjectWorkspace['aiSessions']; onSelectItem: (title: string) => void }) {
|
||||
return (
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>AI 会话({aiSessions.length})</Title>
|
||||
<Button type="text" size="mini">查看全部</Button>
|
||||
</div>
|
||||
{aiSessions.map((session) => (
|
||||
<button key={session.title} className="data-row ai-row" onClick={() => onSelectItem(session.title)}>
|
||||
<span className="row-title"><IconRobot /> {session.title}</span>
|
||||
<span>{session.summary}</span>
|
||||
<span>{session.owner}</span>
|
||||
<time>{session.time}</time>
|
||||
</button>
|
||||
))}
|
||||
{tasks.length === 0 ? (
|
||||
<Text type="secondary">{emptyText}</Text>
|
||||
) : (
|
||||
<div className="overview-task-grid">
|
||||
{tasks.map((task) => (
|
||||
<button key={task.id} className={task.completed ? 'overview-task-card completed' : 'overview-task-card'} onClick={() => onSelectItem(task.title)}>
|
||||
<span className="overview-task-title">
|
||||
<IconCheckCircleFill />
|
||||
{task.title}
|
||||
</span>
|
||||
<Text type="secondary" ellipsis={{ showTooltip: true }}>{task.summary || '暂无任务内容'}</Text>
|
||||
<div className="overview-task-meta">
|
||||
<Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag || (task.completed ? '已完成' : '进行中')}</Tag>
|
||||
<span>进度 {task.progress}</span>
|
||||
<span>创建 {task.createdAt}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -116,17 +94,27 @@ function NoteSection({ notes, onSelectItem }: { notes: ProjectWorkspace['notes']
|
||||
return (
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>最近笔记资料(5)</Title>
|
||||
<Title heading={6}>最近笔记资料({notes.length})</Title>
|
||||
<Button type="text" size="mini">查看全部</Button>
|
||||
</div>
|
||||
{notes.map((note) => (
|
||||
<button key={note.title} className="data-row note-row" onClick={() => onSelectItem(note.title)}>
|
||||
<span className="row-title"><IconFile /> {note.title}</span>
|
||||
<Tag>{note.type}</Tag>
|
||||
<span>更新于 {note.updated}</span>
|
||||
<span>{note.owner}</span>
|
||||
</button>
|
||||
))}
|
||||
<ProjectFileGrid items={notes.slice(0, 8)} onSelectItem={onSelectItem} compact />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function projectShareURL(identifier: string) {
|
||||
const slug = encodeURIComponent(identifier.trim() || 'project')
|
||||
const origin = typeof window === 'undefined' ? 'https://senlinai.local' : window.location.origin
|
||||
return `${origin}/share/projects/${slug}`
|
||||
}
|
||||
|
||||
function projectHeroBackground(background: string, fallback: string) {
|
||||
const value = background.trim() || fallback
|
||||
if (value.startsWith('http')) {
|
||||
return `linear-gradient(135deg, color-mix(in srgb, ${fallback} 78%, transparent), color-mix(in srgb, ${fallback} 34%, transparent)), url("${value.replaceAll('"', '%22')}") center / cover`
|
||||
}
|
||||
if (value.startsWith('linear-gradient') || value.startsWith('radial-gradient')) {
|
||||
return value
|
||||
}
|
||||
return `linear-gradient(135deg, ${value}, color-mix(in srgb, ${value} 58%, white))`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { Badge, Button, Layout, Space } from '@arco-design/web-react'
|
||||
import { IconDashboard, IconEmail, IconPlus } from '@arco-design/web-react/icon'
|
||||
import { IconCompass, IconDashboard, IconPlus } from '@arco-design/web-react/icon'
|
||||
import type { Project, WorkbenchView } from './project-types'
|
||||
|
||||
const { Sider } = Layout
|
||||
@@ -10,7 +10,7 @@ export function ProjectRail({
|
||||
projects,
|
||||
activeView,
|
||||
onSelectWorkspace,
|
||||
onSelectWorkspaceInbox,
|
||||
onSelectWorkspaceExplore,
|
||||
onSelectProject,
|
||||
onCreateProject,
|
||||
}: {
|
||||
@@ -18,7 +18,7 @@ export function ProjectRail({
|
||||
projects: Project[]
|
||||
activeView: WorkbenchView
|
||||
onSelectWorkspace: () => void
|
||||
onSelectWorkspaceInbox: () => void
|
||||
onSelectWorkspaceExplore: () => void
|
||||
onSelectProject: (project: Project) => void
|
||||
onCreateProject: () => void
|
||||
}) {
|
||||
@@ -32,11 +32,11 @@ export function ProjectRail({
|
||||
title="工作台"
|
||||
/>
|
||||
<Button
|
||||
className={activeView === 'workspace-inbox' ? 'dashboard-button active' : 'dashboard-button'}
|
||||
icon={<IconEmail />}
|
||||
onClick={onSelectWorkspaceInbox}
|
||||
aria-label="Inbox"
|
||||
title="Inbox"
|
||||
className={activeView === 'workspace-explore' ? 'dashboard-button active' : 'dashboard-button'}
|
||||
icon={<IconCompass />}
|
||||
onClick={onSelectWorkspaceExplore}
|
||||
aria-label="探索"
|
||||
title="探索"
|
||||
/>
|
||||
<Space className="project-stack" direction="vertical" size={12}>
|
||||
{projects.map((project) => (
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { Badge, Button, Divider, Layout, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconApps, IconDashboard, IconMore, IconSettings } from '@arco-design/web-react/icon'
|
||||
import type { ChannelKey, ProjectWorkspace, WorkbenchView } from './project-types'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import { Badge, Button, Divider, Input, Layout, Modal, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconApps, IconDashboard, IconMore, IconPlus, IconSettings } from '@arco-design/web-react/icon'
|
||||
import type { ChannelKey, ProjectChannel, ProjectWorkspace, WorkbenchView } from './project-types'
|
||||
|
||||
const { Sider } = Layout
|
||||
const { Title, Text } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
export type ProjectSettingsUpdate = {
|
||||
projectId: string
|
||||
name: string
|
||||
identifier: string
|
||||
icon: string
|
||||
background: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function ProjectSidebar({
|
||||
activeView,
|
||||
@@ -11,24 +22,57 @@ export function ProjectSidebar({
|
||||
activeChannel,
|
||||
onSelectChannel,
|
||||
onSelectItem,
|
||||
onUpdateProject,
|
||||
onCreateProjectTag,
|
||||
}: {
|
||||
activeView: WorkbenchView
|
||||
activeWorkspace: ProjectWorkspace
|
||||
activeChannel: ChannelKey
|
||||
onSelectChannel: (key: ChannelKey) => void
|
||||
onSelectItem: (title: string) => void
|
||||
onUpdateProject: (update: ProjectSettingsUpdate) => void
|
||||
onCreateProjectTag: (name: string) => void
|
||||
}) {
|
||||
const workspaceMode = activeView === 'workspace'
|
||||
const channels = activeWorkspace.channels
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [draft, setDraft] = useState({
|
||||
name: activeWorkspace.project.name,
|
||||
identifier: activeWorkspace.project.identifier,
|
||||
icon: activeWorkspace.project.icon,
|
||||
background: activeWorkspace.project.background,
|
||||
description: activeWorkspace.project.description,
|
||||
})
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [tagName, setTagName] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsOpen) return
|
||||
setDraft({
|
||||
name: activeWorkspace.project.name,
|
||||
identifier: activeWorkspace.project.identifier,
|
||||
icon: activeWorkspace.project.icon,
|
||||
background: activeWorkspace.project.background,
|
||||
description: activeWorkspace.project.description,
|
||||
})
|
||||
}, [activeWorkspace.project, settingsOpen])
|
||||
|
||||
const projectIcon = activeWorkspace.project.short || activeWorkspace.project.identifier || activeWorkspace.project.name.slice(0, 2)
|
||||
const submitTag = () => {
|
||||
if (!tagName.trim()) return
|
||||
onCreateProjectTag(tagName.trim())
|
||||
setTagName('')
|
||||
setTagModalOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sider className="channel-sidebar" width={248}>
|
||||
<div className="project-title">
|
||||
<Space>
|
||||
{workspaceMode ? <IconApps /> : <IconDashboard />}
|
||||
{workspaceMode ? <IconApps /> : <span className="project-title-icon">{projectIcon}</span>}
|
||||
<Title heading={5}>{workspaceMode ? '工作台' : activeWorkspace.project.name}</Title>
|
||||
</Space>
|
||||
<Button icon={<IconSettings />} size="mini" />
|
||||
<Button icon={<IconSettings />} size="mini" onClick={() => setSettingsOpen(true)} />
|
||||
</div>
|
||||
|
||||
<div className="channel-list">
|
||||
@@ -44,16 +88,24 @@ export function ProjectSidebar({
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
channels.map((channel) => (
|
||||
<button
|
||||
key={channel.id}
|
||||
className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'}
|
||||
onClick={() => onSelectChannel(channel.key)}
|
||||
>
|
||||
<span className="channel-left">{channel.icon}<Text>{channel.label}</Text></span>
|
||||
{channel.count !== undefined ? <Badge count={channel.count} maxCount={999} /> : <IconMore />}
|
||||
</button>
|
||||
))
|
||||
<>
|
||||
{channels.flatMap((channel) => {
|
||||
const items = [renderChannelButton(channel, activeChannel, onSelectChannel)]
|
||||
if (channel.key === 'cron') {
|
||||
items.push(
|
||||
<button
|
||||
key="new-channel"
|
||||
className={activeChannel === 'new-channel' ? 'channel-button active' : 'channel-button'}
|
||||
onClick={() => onSelectChannel('new-channel')}
|
||||
>
|
||||
<span className="channel-left"><IconPlus /><Text>新建频道</Text></span>
|
||||
<IconMore />
|
||||
</button>,
|
||||
)
|
||||
}
|
||||
return items
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -62,7 +114,20 @@ export function ProjectSidebar({
|
||||
<Text className="section-title">标签</Text>
|
||||
<Space wrap>
|
||||
{activeWorkspace.tags.map((tag) => (
|
||||
<Tag key={tag} color={tag === '全部' ? 'arcoblue' : 'gray'}>{tag}</Tag>
|
||||
<Fragment key={tag}>
|
||||
<Tag color={isAllTag(tag) ? 'arcoblue' : 'gray'}>{tag}</Tag>
|
||||
{isAllTag(tag) && (
|
||||
<Button
|
||||
className="tag-create-button"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon={<IconPlus />}
|
||||
onClick={() => setTagModalOpen(true)}
|
||||
>
|
||||
新建
|
||||
</Button>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Space>
|
||||
</section>
|
||||
@@ -78,6 +143,86 @@ export function ProjectSidebar({
|
||||
))}
|
||||
</section>
|
||||
|
||||
<Modal
|
||||
className="action-modal"
|
||||
title="编辑项目"
|
||||
visible={settingsOpen}
|
||||
onCancel={() => setSettingsOpen(false)}
|
||||
onOk={() => {
|
||||
onUpdateProject({
|
||||
projectId: activeWorkspace.project.id,
|
||||
name: draft.name.trim() || activeWorkspace.project.name,
|
||||
identifier: draft.identifier.trim(),
|
||||
icon: draft.icon.trim(),
|
||||
background: draft.background.trim(),
|
||||
description: draft.description.trim(),
|
||||
})
|
||||
setSettingsOpen(false)
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
<label>
|
||||
<Text>名称</Text>
|
||||
<Input value={draft.name} onChange={(name) => setDraft((value) => ({ ...value, name }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>标识</Text>
|
||||
<Input value={draft.identifier} onChange={(identifier) => setDraft((value) => ({ ...value, identifier }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>图标</Text>
|
||||
<Input value={draft.icon} onChange={(icon) => setDraft((value) => ({ ...value, icon }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>背景</Text>
|
||||
<Input value={draft.background} onChange={(background) => setDraft((value) => ({ ...value, background }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>简介</Text>
|
||||
<TextArea rows={4} value={draft.description} onChange={(description) => setDraft((value) => ({ ...value, description }))} />
|
||||
</label>
|
||||
</Space>
|
||||
</Modal>
|
||||
<Modal
|
||||
className="action-modal"
|
||||
title="新建标签"
|
||||
visible={tagModalOpen}
|
||||
onCancel={() => {
|
||||
setTagModalOpen(false)
|
||||
setTagName('')
|
||||
}}
|
||||
onOk={submitTag}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
<label>
|
||||
<Text>标签名称</Text>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="例如:设计、客户、重要"
|
||||
value={tagName}
|
||||
onChange={setTagName}
|
||||
onPressEnter={submitTag}
|
||||
/>
|
||||
</label>
|
||||
</Space>
|
||||
</Modal>
|
||||
</Sider>
|
||||
)
|
||||
}
|
||||
|
||||
function isAllTag(tag: string) {
|
||||
return tag === '全部' || tag === 'all'
|
||||
}
|
||||
|
||||
function renderChannelButton(channel: ProjectChannel, activeChannel: ChannelKey, onSelectChannel: (key: ChannelKey) => void) {
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'}
|
||||
onClick={() => onSelectChannel(channel.key)}
|
||||
>
|
||||
<span className="channel-left">{channel.icon}<Text>{channel.label}</Text></span>
|
||||
{channel.key === 'overview' ? null : channel.count !== undefined ? <Badge className="channel-badge-muted" count={channel.count} maxCount={999} /> : <IconMore />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@ import type { ReactElement } from 'react'
|
||||
|
||||
export type Screen = 'login' | 'workbench'
|
||||
export type Theme = 'light' | 'dark'
|
||||
export type WorkbenchView = 'workspace' | 'workspace-inbox' | 'project'
|
||||
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | `custom:${string}`
|
||||
export type WorkbenchView = 'workspace' | 'workspace-explore' | 'project'
|
||||
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | 'new-channel' | `custom:${string}`
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
name: string
|
||||
identifier: string
|
||||
icon: string
|
||||
background: string
|
||||
description: string
|
||||
short: string
|
||||
badge: number
|
||||
urgent?: boolean
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card, Empty, Grid, Input, Modal, Select, Space, Switch, Tag, Typography } from '@arco-design/web-react'
|
||||
import {
|
||||
IconCalendar,
|
||||
IconCheckCircleFill,
|
||||
@@ -6,14 +7,44 @@ import {
|
||||
IconFile,
|
||||
IconRobot,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import type { Project, ProjectWorkspace } from './projects/project-types'
|
||||
import type { Project, ProjectWorkspace, TaskItem } from './projects/project-types'
|
||||
|
||||
const { Row, Col } = Grid
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectWorkspace[]; onOpenTask: (project: Project, taskID: string) => void }) {
|
||||
export type WorkspaceTaskUpdate = {
|
||||
originalProjectId: string
|
||||
nextProjectId: string
|
||||
taskId: string
|
||||
title: string
|
||||
summary: string
|
||||
tag: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
type WorkspaceTask = TaskItem & {
|
||||
project: Project
|
||||
}
|
||||
|
||||
type TaskDraft = {
|
||||
title: string
|
||||
summary: string
|
||||
projectId: string
|
||||
tag: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
export function WorkspacePage({
|
||||
workspaces,
|
||||
onUpdateTask,
|
||||
}: {
|
||||
workspaces: ProjectWorkspace[]
|
||||
onOpenTask: (project: Project, taskID: string) => void
|
||||
onUpdateTask: (update: WorkspaceTaskUpdate) => void
|
||||
}) {
|
||||
const projects = workspaces.map((workspace) => workspace.project)
|
||||
const unfinishedTasks = workspaces.flatMap((workspace) =>
|
||||
const unfinishedTasks: WorkspaceTask[] = workspaces.flatMap((workspace) =>
|
||||
workspace.tasks
|
||||
.filter((task) => !task.completed)
|
||||
.map((task) => ({
|
||||
@@ -21,6 +52,28 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
|
||||
project: workspace.project,
|
||||
})),
|
||||
)
|
||||
const [editingTask, setEditingTask] = useState<WorkspaceTask | null>(null)
|
||||
const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', projectId: '', tag: '', completed: false })
|
||||
const tagOptions = useMemo(() => {
|
||||
const tags = new Set<string>()
|
||||
const selectedWorkspace = workspaces.find((workspace) => workspace.project.id === draft.projectId)
|
||||
selectedWorkspace?.tags
|
||||
.filter((tag) => tag !== 'all' && tag !== '全部')
|
||||
.forEach((tag) => tags.add(tag))
|
||||
if (draft.tag) tags.add(draft.tag)
|
||||
return Array.from(tags)
|
||||
}, [draft.projectId, draft.tag, workspaces])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingTask) return
|
||||
setDraft({
|
||||
title: editingTask.title,
|
||||
summary: editingTask.summary,
|
||||
projectId: editingTask.project.id,
|
||||
tag: editingTask.tag,
|
||||
completed: editingTask.completed,
|
||||
})
|
||||
}, [editingTask])
|
||||
const aiSessions = workspaces.flatMap((workspace) => workspace.aiSessions)
|
||||
const notes = workspaces.flatMap((workspace) => workspace.notes)
|
||||
const urgentProjects = projects.filter((project) => project.urgent).length
|
||||
@@ -65,7 +118,7 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
|
||||
{unfinishedTasks.length ? (
|
||||
<div className="workspace-task-list">
|
||||
{unfinishedTasks.map((task) => (
|
||||
<button key={`${task.project.id}-${task.id}`} className="workspace-task-card" onClick={() => onOpenTask(task.project, task.id)}>
|
||||
<button key={`${task.project.id}-${task.id}`} className="workspace-task-card" onClick={() => setEditingTask(task)}>
|
||||
<div className="workspace-task-main">
|
||||
<span className="workspace-task-title">
|
||||
<IconCheckCircleFill />
|
||||
@@ -87,6 +140,62 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
|
||||
<Empty description="暂无未完成任务" />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
className="action-modal"
|
||||
title="编辑任务"
|
||||
visible={Boolean(editingTask)}
|
||||
onCancel={() => setEditingTask(null)}
|
||||
onOk={() => {
|
||||
if (!editingTask) return
|
||||
onUpdateTask({
|
||||
originalProjectId: editingTask.project.id,
|
||||
nextProjectId: draft.projectId,
|
||||
taskId: editingTask.id,
|
||||
title: draft.title.trim() || editingTask.title,
|
||||
summary: draft.summary.trim(),
|
||||
tag: draft.tag.trim(),
|
||||
completed: draft.completed,
|
||||
})
|
||||
setEditingTask(null)
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
<label>
|
||||
<Text>任务标题</Text>
|
||||
<Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>任务内容</Text>
|
||||
<TextArea rows={4} value={draft.summary} onChange={(summary) => setDraft((value) => ({ ...value, summary }))} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>所属项目</Text>
|
||||
<Select value={draft.projectId} onChange={(projectId) => setDraft((value) => ({ ...value, projectId }))}>
|
||||
{projects.map((project) => (
|
||||
<Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<label>
|
||||
<Text>所属标签</Text>
|
||||
<Select
|
||||
allowCreate
|
||||
value={draft.tag}
|
||||
placeholder="选择或输入标签"
|
||||
onChange={(tag) => setDraft((value) => ({ ...value, tag }))}
|
||||
>
|
||||
{tagOptions.map((tag) => (
|
||||
<Select.Option key={tag} value={tag}>{tag}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<label className="action-switch">
|
||||
<Text>完成状态</Text>
|
||||
<Switch checked={draft.completed} checkedText="完成" uncheckedText="未完成" onChange={(completed) => setDraft((value) => ({ ...value, completed }))} />
|
||||
</label>
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
116
apps/senlinai-acro-react/src/pages/workspace-explore.tsx
Normal file
116
apps/senlinai-acro-react/src/pages/workspace-explore.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Button, Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconBook, IconCompass, IconLink, IconLoop, IconRefresh, IconStar } from '@arco-design/web-react/icon'
|
||||
import type { ProjectWorkspace } from './projects/project-types'
|
||||
|
||||
const { Row, Col } = Grid
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
const rssSources = [
|
||||
{ name: '产品更新', url: 'https://example.com/product.xml', status: '运行中', count: 18, color: 'arcoblue' },
|
||||
{ name: '行业情报', url: 'https://example.com/market.xml', status: '运行中', count: 24, color: 'green' },
|
||||
{ name: '技术博客', url: 'https://example.com/engineering.xml', status: '待同步', count: 9, color: 'orange' },
|
||||
]
|
||||
|
||||
export function WorkspaceExplorePage({ workspaces, onSelectItem }: { workspaces: ProjectWorkspace[]; onSelectItem: (title: string) => void }) {
|
||||
const articles = workspaces.flatMap((workspace) => workspace.inbox.map((item) => ({ ...item, project: workspace.project })))
|
||||
const selected = articles[0]
|
||||
const pendingCount = articles.filter((item) => item.status === 'open').length
|
||||
const sourceCount = rssSources.length
|
||||
|
||||
return (
|
||||
<div className="workspace-explore-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>探索</Title>
|
||||
<Text type="secondary">集中管理多个 RSS 源,采集外部内容、沉淀线索,并分发到对应项目。</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<IconRefresh />}>同步 RSS</Button>
|
||||
<Button type="primary" icon={<IconLink />}>添加源</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={8}>
|
||||
<Card className="compact-card" bordered>
|
||||
<Space><Tag color="arcoblue">{sourceCount}</Tag><Text>RSS 源</Text></Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="compact-card" bordered>
|
||||
<Space><Tag color="red">{pendingCount}</Tag><Text>待处理采集</Text></Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="compact-card" bordered>
|
||||
<Space><Tag color="green">{articles.length}</Tag><Text>采集条目</Text></Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>RSS 源</Title>
|
||||
<Button type="text" size="mini">按最近同步排序</Button>
|
||||
</div>
|
||||
{rssSources.map((source) => (
|
||||
<button className="data-row rss-source-row" key={source.url} onClick={() => onSelectItem(source.name)}>
|
||||
<span className="row-title"><IconLink /> {source.name}</span>
|
||||
<Tag color={source.color}>{source.status}</Tag>
|
||||
<span>{source.url}</span>
|
||||
<span>{source.count} 条</span>
|
||||
</button>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{articles.length ? (
|
||||
<div className="mail-layout">
|
||||
<Card className="mail-list queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>采集队列</Title>
|
||||
<Button type="text" size="mini">全部标记已读</Button>
|
||||
</div>
|
||||
{articles.map((item, index) => (
|
||||
<button
|
||||
className={index === 0 ? 'mail-item active' : 'mail-item'}
|
||||
key={`${item.project.id}-${item.id}`}
|
||||
onClick={() => onSelectItem(item.title)}
|
||||
>
|
||||
<span><IconCompass /> {item.title}</span>
|
||||
<Text type="secondary">{item.meta}</Text>
|
||||
<div>
|
||||
<Tag color={item.project.urgent ? 'red' : 'arcoblue'}>{item.project.name}</Tag>
|
||||
<time>{item.time}</time>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card className="mail-detail queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>{selected.title}</Title>
|
||||
<Space>
|
||||
<Button type="text" icon={<IconStar />} />
|
||||
<Button type="text" icon={<IconLoop />} />
|
||||
</Space>
|
||||
</div>
|
||||
<Text type="secondary">{selected.meta} · {selected.project.name}</Text>
|
||||
<Paragraph>{selected.summary || '暂无采集正文'}</Paragraph>
|
||||
<div className="reply-box">
|
||||
<Text type="secondary">归档到项目</Text>
|
||||
<Space>
|
||||
<Button icon={<IconBook />}>转为资料</Button>
|
||||
<Button>生成任务</Button>
|
||||
<Button type="primary">加入项目</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="queue-section" bordered>
|
||||
<Empty description="暂无 RSS 采集条目" />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Layout } from '@arco-design/web-react'
|
||||
import { WorkspacePage } from './workspace-body'
|
||||
import { WorkspaceInboxPage } from './workspace-inbox'
|
||||
import { WorkspacePage, type WorkspaceTaskUpdate } from './workspace-body'
|
||||
import { WorkspaceExplorePage } from './workspace-explore'
|
||||
import { ProjectChannelPage } from './projects/project-channel-page'
|
||||
import { ProjectRail } from './projects/project-rail'
|
||||
import { ProjectSidebar } from './projects/project-sidebar'
|
||||
import { ProjectSidebar, type ProjectSettingsUpdate } from './projects/project-sidebar'
|
||||
import { ProjectStatusbar } from './projects/project-statusbar'
|
||||
import { ProjectTopbar } from './projects/project-topbar'
|
||||
import type { ChannelKey, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
|
||||
@@ -19,7 +19,7 @@ export function ProjectPage({
|
||||
theme,
|
||||
onSelectProject,
|
||||
onSelectWorkspace,
|
||||
onSelectWorkspaceInbox,
|
||||
onSelectWorkspaceExplore,
|
||||
onSelectChannel,
|
||||
onSelectItem,
|
||||
onOpenTask,
|
||||
@@ -29,6 +29,9 @@ export function ProjectPage({
|
||||
onCreateTask,
|
||||
onUploadSource,
|
||||
onCreateCronPlan,
|
||||
onUpdateWorkspaceTask,
|
||||
onUpdateProject,
|
||||
onCreateProjectTag,
|
||||
}: {
|
||||
activeView: WorkbenchView
|
||||
activeWorkspace: ProjectWorkspace
|
||||
@@ -38,7 +41,7 @@ export function ProjectPage({
|
||||
theme: Theme
|
||||
onSelectProject: (project: Project) => void
|
||||
onSelectWorkspace: () => void
|
||||
onSelectWorkspaceInbox: () => void
|
||||
onSelectWorkspaceExplore: () => void
|
||||
onSelectChannel: (key: ChannelKey) => void
|
||||
onSelectItem: (title: string) => void
|
||||
onOpenTask: (project: Project, taskID: string) => void
|
||||
@@ -48,6 +51,9 @@ export function ProjectPage({
|
||||
onCreateTask: () => void
|
||||
onUploadSource: () => void
|
||||
onCreateCronPlan: () => void
|
||||
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => void
|
||||
onUpdateProject: (update: ProjectSettingsUpdate) => void
|
||||
onCreateProjectTag: (name: string) => void
|
||||
}) {
|
||||
const isProject = activeView === 'project'
|
||||
const projects = workspaces.map((workspace) => workspace.project)
|
||||
@@ -62,7 +68,7 @@ export function ProjectPage({
|
||||
projects={projects}
|
||||
activeView={activeView}
|
||||
onSelectWorkspace={onSelectWorkspace}
|
||||
onSelectWorkspaceInbox={onSelectWorkspaceInbox}
|
||||
onSelectWorkspaceExplore={onSelectWorkspaceExplore}
|
||||
onSelectProject={onSelectProject}
|
||||
onCreateProject={onCreateProject}
|
||||
/>
|
||||
@@ -74,14 +80,16 @@ export function ProjectPage({
|
||||
activeChannel={activeChannel}
|
||||
onSelectChannel={onSelectChannel}
|
||||
onSelectItem={onSelectItem}
|
||||
onUpdateProject={onUpdateProject}
|
||||
onCreateProjectTag={onCreateProjectTag}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Content className="stage">
|
||||
{activeView === 'workspace' ? (
|
||||
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} />
|
||||
) : activeView === 'workspace-inbox' ? (
|
||||
<WorkspaceInboxPage workspaces={workspaces} onSelectItem={onSelectItem} />
|
||||
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} onUpdateTask={onUpdateWorkspaceTask} />
|
||||
) : activeView === 'workspace-explore' ? (
|
||||
<WorkspaceExplorePage workspaces={workspaces} onSelectItem={onSelectItem} />
|
||||
) : (
|
||||
<ProjectChannelPage
|
||||
activeChannel={activeChannel}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Button, Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconArchive, IconEmail, IconRefresh, IconSend, IconStar } from '@arco-design/web-react/icon'
|
||||
import type { ProjectWorkspace } from './projects/project-types'
|
||||
|
||||
const { Row, Col } = Grid
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
export function WorkspaceInboxPage({ workspaces, onSelectItem }: { workspaces: ProjectWorkspace[]; onSelectItem: (title: string) => void }) {
|
||||
const messages = workspaces.flatMap((workspace) => workspace.inbox.map((item) => ({ ...item, project: workspace.project })))
|
||||
const selected = messages[0]
|
||||
const openCount = messages.filter((item) => item.status === 'open').length
|
||||
const handledCount = messages.length - openCount
|
||||
|
||||
return (
|
||||
<div className="workspace-inbox-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>Inbox</Title>
|
||||
<Text type="secondary">接收来自外部的消息流,统一处理客户反馈、系统通知和待归档线索。</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<IconRefresh />}>刷新</Button>
|
||||
<Button type="primary" icon={<IconArchive />}>批量归档</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={8}>
|
||||
<Card className="compact-card" bordered>
|
||||
<Space><Tag color="red">{openCount}</Tag><Text>未处理</Text></Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="compact-card" bordered>
|
||||
<Space><Tag color="arcoblue">{messages.length}</Tag><Text>全部消息</Text></Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="compact-card" bordered>
|
||||
<Space><Tag color="green">{handledCount}</Tag><Text>已处理</Text></Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{messages.length ? (
|
||||
<div className="mail-layout">
|
||||
<Card className="mail-list queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>外部消息流</Title>
|
||||
<Button type="text" size="mini">全部标记已读</Button>
|
||||
</div>
|
||||
{messages.map((item, index) => (
|
||||
<button
|
||||
className={index === 0 ? 'mail-item active' : 'mail-item'}
|
||||
key={`${item.project.id}-${item.id}`}
|
||||
onClick={() => onSelectItem(item.title)}
|
||||
>
|
||||
<span><IconEmail /> {item.title}</span>
|
||||
<Text type="secondary">{item.meta}</Text>
|
||||
<div>
|
||||
<Tag color={item.project.urgent ? 'red' : 'arcoblue'}>{item.project.name}</Tag>
|
||||
<time>{item.time}</time>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card className="mail-detail queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>{selected.title}</Title>
|
||||
<Space>
|
||||
<Button type="text" icon={<IconStar />} />
|
||||
<Button type="text" icon={<IconSend />} />
|
||||
</Space>
|
||||
</div>
|
||||
<Text type="secondary">{selected.meta} · {selected.project.name}</Text>
|
||||
<Paragraph>{selected.summary || '暂无消息正文'}</Paragraph>
|
||||
<div className="reply-box">
|
||||
<Text type="secondary">快速处理</Text>
|
||||
<Space>
|
||||
<Button>生成任务</Button>
|
||||
<Button>转为笔记</Button>
|
||||
<Button type="primary">回复</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="queue-section" bordered>
|
||||
<Empty description="暂无外部消息" />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user