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/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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 { 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',
}))
}

View File

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

View File

@@ -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) => (

View File

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

View File

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

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

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 { 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}

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