fix(web): close final workbench interaction gaps

This commit is contained in:
2026-07-22 01:24:51 +08:00
parent 26c3e6fd8a
commit b1ce837845
23 changed files with 808 additions and 175 deletions

View File

@@ -50,9 +50,18 @@ test('interrupted successful response body rejects with a stable invalid_respons
}) })
test('204 response returns undefined without reading a body', async () => { test('204 response returns undefined without reading a body', async () => {
globalThis.fetch = async () => new Response(null, { status: 204 }) let textCalls = 0
globalThis.fetch = async () => ({
ok: true,
status: 204,
text: async () => {
textCalls += 1
throw new Error('204 body must not be read')
},
})
assert.equal(await apiRequest('/no-content'), undefined) assert.equal(await apiRequest('/no-content'), undefined)
assert.equal(textCalls, 0)
}) })
test('explicit void response returns undefined for a non-204 response', async () => { test('explicit void response returns undefined for a non-204 response', async () => {

View File

@@ -18,6 +18,7 @@ const requiredFiles = [
'src/pages/projects/project-sidebar.tsx', 'src/pages/projects/project-sidebar.tsx',
'src/pages/projects/project-topbar.tsx', 'src/pages/projects/project-topbar.tsx',
'src/pages/projects/project-statusbar.tsx', 'src/pages/projects/project-statusbar.tsx',
'src/pages/projects/workbench-item-preview.tsx',
'src/pages/projects/project-types.ts', 'src/pages/projects/project-types.ts',
'src/api/client.ts', 'src/api/client.ts',
'src/api/projects.ts', 'src/api/projects.ts',
@@ -26,6 +27,7 @@ const requiredFiles = [
'src/api/inbox.ts', 'src/api/inbox.ts',
'src/api/ai.ts', 'src/api/ai.ts',
'scripts/api-client.test.mjs', 'scripts/api-client.test.mjs',
'scripts/workspace-refresh-gate.test.mjs',
] ]
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`) const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
@@ -57,6 +59,9 @@ if (existsSync('src/app/App.tsx')) {
failures.push(`src/app/App.tsx still contains ${forbidden}`) failures.push(`src/app/App.tsx still contains ${forbidden}`)
} }
} }
for (const required of ['createWorkspaceRefreshGate', 'captureProjectInbox', 'refreshProjectWorkspace']) {
if (!appSource.includes(required)) failures.push(`src/app/App.tsx must include ${required}`)
}
} }
const apiFiles = existsSync('src/api') const apiFiles = existsSync('src/api')
@@ -92,6 +97,8 @@ for (const match of apiSource.matchAll(/export type (\w+DTO)\s*=\s*\{([\s\S]*?)\
if (/^\s*(?:id|\w+Id)\s*:\s*number\b/im.test(body)) failures.push(`${name} identities must be strings`) if (/^\s*(?:id|\w+Id)\s*:\s*number\b/im.test(body)) failures.push(`${name} identities must be strings`)
} }
if (/\|\s*undefined/.test(projectsSource)) failures.push('project DTOs must not use undefined fields') if (/\|\s*undefined/.test(projectsSource)) failures.push('project DTOs must not use undefined fields')
if (/\bfilePath\s*:/.test(projectsSource)) failures.push('public source DTO must not expose filePath')
if (!/\bstorageKey\s*:\s*string/.test(projectsSource)) failures.push('public source DTO must expose an opaque storageKey')
const searchSource = existsSync('src/api/search.ts') ? readFileSync('src/api/search.ts', 'utf8') : '' const searchSource = existsSync('src/api/search.ts') ? readFileSync('src/api/search.ts', 'utf8') : ''
if (searchSource && !searchSource.includes("'/api/v1/search'")) failures.push('search API must use /api/v1/search') if (searchSource && !searchSource.includes("'/api/v1/search'")) failures.push('search API must use /api/v1/search')
@@ -112,6 +119,12 @@ if (!inboxSource.includes('body: { suggestionIds }')) failures.push('inbox confi
const inboxPageSource = existsSync('src/pages/projects/project-inbox.tsx') ? readFileSync('src/pages/projects/project-inbox.tsx', 'utf8') : '' const inboxPageSource = existsSync('src/pages/projects/project-inbox.tsx') ? readFileSync('src/pages/projects/project-inbox.tsx', 'utf8') : ''
if (inboxPageSource && !inboxPageSource.includes('确认创建')) failures.push('project inbox must expose the single confirmation action') if (inboxPageSource && !inboxPageSource.includes('确认创建')) failures.push('project inbox must expose the single confirmation action')
for (const required of ['收集到 Inbox', 'captureDraft', 'onCapture']) {
if (!inboxPageSource.includes(required)) failures.push(`project inbox capture flow must include ${required}`)
}
const channelPageSource = existsSync('src/pages/projects/project-channel-page.tsx') ? readFileSync('src/pages/projects/project-channel-page.tsx', 'utf8') : ''
if (!channelPageSource.includes("activeChannel.startsWith('custom:')")) failures.push('custom_link channels must render an explicit unavailable state')
const aiApiSource = existsSync('src/api/ai.ts') ? readFileSync('src/api/ai.ts', 'utf8') : '' const aiApiSource = existsSync('src/api/ai.ts') ? readFileSync('src/api/ai.ts', 'utf8') : ''
for (const required of ['/api/v1/projects/', '/ai-sessions', 'listAISessions', 'createAISession']) { for (const required of ['/api/v1/projects/', '/ai-sessions', 'listAISessions', 'createAISession']) {
@@ -151,6 +164,23 @@ const unsupportedControls = [
file: 'src/pages/projects/project-topbar.tsx', file: 'src/pages/projects/project-topbar.tsx',
forbidden: ['停靠左边', '停靠右边', 'DockIcon', 'isDesktopRuntime'], forbidden: ['停靠左边', '停靠右边', 'DockIcon', 'isDesktopRuntime'],
}, },
{
file: 'src/pages/projects/project-cron.tsx',
forbidden: ['<Switch', '最近一次自动任务', '资料索引刷新成功'],
},
{
file: 'src/pages/projects/project-overview.tsx',
forbidden: ['/share/projects/', 'projectShareURL', '查看全部'],
},
{
file: 'src/pages/projects/project-notes.tsx',
forbidden: ['搜索资料', '按更新时间排序'],
},
{
file: 'src/pages/login.tsx',
required: 'localStorage',
forbidden: ['无法连接?', '隐私政策', '服务协议', 'check-dot'],
},
] ]
for (const check of unsupportedControls) { for (const check of unsupportedControls) {
const source = existsSync(check.file) ? readFileSync(check.file, 'utf8') : '' const source = existsSync(check.file) ? readFileSync(check.file, 'utf8') : ''
@@ -179,6 +209,9 @@ if (!appStyles.includes('.brand-icon {')) failures.push('brand-icon must have an
if (!appStyles.includes('.brand-lockup.large .brand-icon')) failures.push('login brand icon must have an explicit size') if (!appStyles.includes('.brand-lockup.large .brand-icon')) failures.push('login brand icon must have an explicit size')
if (!appStyles.includes('.rail-brand {')) failures.push('rail brand button must have explicit layout') if (!appStyles.includes('.rail-brand {')) failures.push('rail brand button must have explicit layout')
if (appStyles.includes('.brand-symbol')) failures.push('legacy brand-symbol styles must be removed') if (appStyles.includes('.brand-symbol')) failures.push('legacy brand-symbol styles must be removed')
if (!appStyles.includes('@media (max-width: 1179px)')) failures.push('channel sidebar must collapse below 1180px')
if (!appStyles.includes('.project-stack-scroll')) failures.push('long project rails need a dedicated scroll region')
if (!appStyles.includes('.mobile-search-button')) failures.push('mobile workbench must retain a global search entry')
for (const file of ['src/pages/login.tsx', 'src/pages/projects/project-topbar.tsx', 'src/pages/projects/project-rail.tsx']) { for (const file of ['src/pages/login.tsx', 'src/pages/projects/project-topbar.tsx', 'src/pages/projects/project-rail.tsx']) {
const source = readFileSync(file, 'utf8') const source = readFileSync(file, 'utf8')
if (!source.includes('/senlinai-icon.svg')) failures.push(`${file} must use the brand icon`) if (!source.includes('/senlinai-icon.svg')) failures.push(`${file} must use the brand icon`)

View File

@@ -33,6 +33,10 @@ const thirdInboxItemId = '019b0000-0000-7000-8000-000000000013'
const thirdInboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000014' const thirdInboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000014'
const aiSessionId = '019b0000-0000-7000-8000-000000000015' const aiSessionId = '019b0000-0000-7000-8000-000000000015'
const createdAISessionId = '019b0000-0000-7000-8000-000000000016' const createdAISessionId = '019b0000-0000-7000-8000-000000000016'
const capturedInboxItemId = '019b0000-0000-7000-8000-000000000017'
const capturedInboxSuggestionId = '019b0000-0000-7000-8000-000000000018'
const conflictInboxItemId = '019b0000-0000-7000-8000-000000000020'
const conflictInboxSuggestionId = '019b0000-0000-7000-8000-000000000021'
const unknownProjectId = '019b0000-0000-7000-8000-000000000099' const unknownProjectId = '019b0000-0000-7000-8000-000000000099'
const externalProjectId = '019b0000-0000-7000-8000-000000000088' const externalProjectId = '019b0000-0000-7000-8000-000000000088'
const externalTaskId = '019b0000-0000-7000-8000-000000000089' const externalTaskId = '019b0000-0000-7000-8000-000000000089'
@@ -43,6 +47,7 @@ const projectPatchRequests = []
const inboxAnalyzeRequests = [] const inboxAnalyzeRequests = []
const inboxConfirmRequests = [] const inboxConfirmRequests = []
const aiSessionRequests = [] const aiSessionRequests = []
const inboxCaptureRequests = []
let delayedAIListsRemaining = 0 let delayedAIListsRemaining = 0
let delayNextAICreate = false let delayNextAICreate = false
let delayedAICreateCompleted = false let delayedAICreateCompleted = false
@@ -140,11 +145,12 @@ const visualCheckWorkspace = {
}, },
channels: [ channels: [
{ id: 'overview', projectId, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 }, { id: 'overview', projectId, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 },
{ id: 'inbox', projectId, type: 'inbox', title: 'Inbox 消息流', icon: 'mail', count: 3, url: '', sortOrder: 1 }, { id: 'inbox', projectId, type: 'inbox', title: 'Inbox 消息流', icon: 'mail', count: 4, url: '', sortOrder: 1 },
{ id: 'tasks', projectId, type: 'tasks', title: '工作计划', icon: 'list', count: 1, url: '', sortOrder: 2 }, { id: 'tasks', projectId, type: 'tasks', title: '工作计划', icon: 'list', count: 1, url: '', sortOrder: 2 },
{ id: 'ai', projectId, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', count: 0, url: '', sortOrder: 3 }, { id: 'ai', projectId, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', count: 0, url: '', sortOrder: 3 },
{ id: 'notes', projectId, type: 'notes_sources', title: '笔记资料', icon: 'file', count: 0, url: '', sortOrder: 4 }, { id: 'notes', projectId, type: 'notes_sources', title: '笔记资料', icon: 'file', count: 0, url: '', sortOrder: 4 },
{ id: 'cron', projectId, type: 'cron', title: '计划任务', icon: 'clock', count: 0, url: '', sortOrder: 5 }, { id: 'cron', projectId, type: 'cron', title: '计划任务', icon: 'clock', count: 0, url: '', sortOrder: 5 },
{ id: 'custom-link', projectId, type: 'custom_link', title: '外部集成', icon: 'link', count: 0, url: 'https://example.invalid', sortOrder: 6 },
], ],
tags: [{ id: '019b0000-0000-7000-8000-000000000002', name: '产品' }], tags: [{ id: '019b0000-0000-7000-8000-000000000002', name: '产品' }],
recentSessions: [ recentSessions: [
@@ -181,11 +187,32 @@ const visualCheckWorkspace = {
tag: '待处理', tag: '待处理',
time: '2026-07-21T02:10:00Z', time: '2026-07-21T02:10:00Z',
}, },
{
id: conflictInboxItemId,
projectId,
source: '手动收集',
title: '冲突确认测试',
summary: '使用独立条目验证 409 冲突后的原项目刷新。',
status: 'open',
tag: '待处理',
time: '2026-07-21T02:00:00Z',
},
], ],
tasks: [], tasks: [],
aiSessions: [], aiSessions: [],
notesSources: [], notesSources: [],
cronPlans: [], cronPlans: [
{
id: '019b0000-0000-7000-8000-000000000019',
projectId,
title: '每日整理提醒',
schedule: '0 9 * * *',
nextRun: '2026-07-23T01:00:00Z',
enabled: true,
lastResult: '',
owner: 'demo',
},
],
} }
const secondVisualCheckWorkspace = { const secondVisualCheckWorkspace = {
@@ -201,7 +228,8 @@ const secondVisualCheckWorkspace = {
}, },
channels: [ channels: [
{ id: 'second-overview', projectId: secondProjectId, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 }, { id: 'second-overview', projectId: secondProjectId, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 },
{ id: 'second-ai', projectId: secondProjectId, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', count: 0, url: '', sortOrder: 1 }, { id: 'second-inbox', projectId: secondProjectId, type: 'inbox', title: 'Inbox 消息流', icon: 'mail', count: 0, url: '', sortOrder: 1 },
{ id: 'second-ai', projectId: secondProjectId, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', count: 0, url: '', sortOrder: 2 },
], ],
tags: [], tags: [],
recentSessions: [], recentSessions: [],
@@ -212,6 +240,34 @@ const secondVisualCheckWorkspace = {
cronPlans: [], cronPlans: [],
} }
const longProjectFixtures = Array.from({ length: 11 }, (_, index) => {
const sequence = String(index + 20).padStart(12, '0')
return {
id: `019b0000-0000-7000-8000-${sequence}`,
name: `长列表项目 ${index + 1}`,
identifier: `long-${index + 1}`,
icon: `L${index + 1}`,
background: '#14C9C9',
description: '用于检查长项目栏滚动区域。',
initials: `L${index + 1}`,
unreadCount: 0,
}
})
const longProjectWorkspaces = longProjectFixtures.map((project, index) => ({
project,
channels: [
{ id: `long-overview-${index}`, projectId: project.id, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 },
],
tags: [],
recentSessions: [],
inbox: [],
tasks: [],
aiSessions: [],
notesSources: [],
cronPlans: [],
}))
await page.route('http://localhost:9150/api/v1/**', async (route) => { await page.route('http://localhost:9150/api/v1/**', async (route) => {
const url = new URL(route.request().url()) const url = new URL(route.request().url())
const method = route.request().method() const method = route.request().method()
@@ -224,7 +280,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
return return
} }
if (url.pathname === '/api/v1/projects') { if (url.pathname === '/api/v1/projects') {
await route.fulfill({ json: [visualCheckWorkspace.project, secondVisualCheckWorkspace.project] }) await route.fulfill({ json: [visualCheckWorkspace.project, secondVisualCheckWorkspace.project, ...longProjectFixtures] })
return return
} }
if (url.pathname === `/api/v1/projects/${projectId}/workspace`) { if (url.pathname === `/api/v1/projects/${projectId}/workspace`) {
@@ -245,6 +301,34 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
await route.fulfill({ json: secondVisualCheckWorkspace }) await route.fulfill({ json: secondVisualCheckWorkspace })
return return
} }
const longProjectWorkspace = longProjectWorkspaces.find((workspace) => url.pathname === `/api/v1/projects/${workspace.project.id}/workspace`)
if (longProjectWorkspace) {
workspaceRequests.push(url.pathname)
await route.fulfill({ json: longProjectWorkspace })
return
}
if (url.pathname === `/api/v1/projects/${secondProjectId}/inbox` && method === 'POST') {
const input = route.request().postDataJSON()
inboxCaptureRequests.push(input)
const capturedItem = {
id: capturedInboxItemId,
projectId: secondProjectId,
source: input.sourceType,
sourceType: input.sourceType,
title: input.title,
body: input.body,
summary: input.body,
status: 'open',
tag: '待处理',
time: '2026-07-22T03:00:00Z',
createdAt: '2026-07-22T03:00:00Z',
updatedAt: '2026-07-22T03:00:00Z',
}
secondVisualCheckWorkspace.inbox = [capturedItem]
secondVisualCheckWorkspace.channels.find((channel) => channel.type === 'inbox').count = 1
await route.fulfill({ status: 201, json: capturedItem })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions` && method === 'GET') { if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions` && method === 'GET') {
const responseSnapshot = structuredClone(controlledAISessions) const responseSnapshot = structuredClone(controlledAISessions)
const delay = delayedAIListsRemaining > 0 ? 700 : 120 const delay = delayedAIListsRemaining > 0 ? 700 : 120
@@ -323,7 +407,29 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
}) })
return return
} }
if ([`/api/v1/inbox/${inboxItemId}/confirm`, `/api/v1/inbox/${secondInboxItemId}/confirm`, `/api/v1/inbox/${thirdInboxItemId}/confirm`].includes(url.pathname) && method === 'POST') { if (url.pathname === `/api/v1/inbox/${capturedInboxItemId}/analyze` && method === 'POST') {
inboxAnalyzeRequests.push(capturedInboxItemId)
await route.fulfill({
json: {
suggestions: [
{ id: capturedInboxSuggestionId, kind: 'task', title: '收集后的待办', body: '由真实收集条目生成的候选任务。' },
],
},
})
return
}
if (url.pathname === `/api/v1/inbox/${conflictInboxItemId}/analyze` && method === 'POST') {
inboxAnalyzeRequests.push(conflictInboxItemId)
await route.fulfill({
json: {
suggestions: [
{ id: conflictInboxSuggestionId, kind: 'task', title: '冲突候选任务', body: '只用于 409 冲突流程。' },
],
},
})
return
}
if ([`/api/v1/inbox/${inboxItemId}/confirm`, `/api/v1/inbox/${secondInboxItemId}/confirm`, `/api/v1/inbox/${thirdInboxItemId}/confirm`, `/api/v1/inbox/${capturedInboxItemId}/confirm`, `/api/v1/inbox/${conflictInboxItemId}/confirm`].includes(url.pathname) && method === 'POST') {
inboxConfirmRequests.push(route.request().postDataJSON()) inboxConfirmRequests.push(route.request().postDataJSON())
await new Promise((resolve) => setTimeout(resolve, 250)) await new Promise((resolve) => setTimeout(resolve, 250))
if (failNextInboxConfirm) { if (failNextInboxConfirm) {
@@ -333,6 +439,9 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
} }
if (conflictNextInboxConfirm) { if (conflictNextInboxConfirm) {
conflictNextInboxConfirm = false conflictNextInboxConfirm = false
const conflictedItemId = url.pathname.split('/').at(-2)
const conflictedItem = visualCheckWorkspace.inbox.find((item) => item.id === conflictedItemId)
if (conflictedItem) conflictedItem.status = 'processed'
await route.fulfill({ await route.fulfill({
status: 409, status: 409,
json: { error: { code: 'conflict', message: '确认状态发生冲突' } }, json: { error: { code: 'conflict', message: '确认状态发生冲突' } },
@@ -357,8 +466,9 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
return return
} }
const confirmedItemId = url.pathname.split('/').at(-2) const confirmedItemId = url.pathname.split('/').at(-2)
visualCheckWorkspace.inbox.find((item) => item.id === confirmedItemId).status = 'processed' const confirmedWorkspace = confirmedItemId === capturedInboxItemId ? secondVisualCheckWorkspace : visualCheckWorkspace
visualCheckWorkspace.channels.find((channel) => channel.type === 'inbox').count -= 1 confirmedWorkspace.inbox.find((item) => item.id === confirmedItemId).status = 'processed'
confirmedWorkspace.channels.find((channel) => channel.type === 'inbox').count -= 1
if (confirmedItemId === secondInboxItemId) failNextInboxWorkspaceRefresh = true if (confirmedItemId === secondInboxItemId) failNextInboxWorkspaceRefresh = true
await route.fulfill({ json: { createdCount: inboxConfirmRequests.at(-1).suggestionIds.length } }) await route.fulfill({ json: { createdCount: inboxConfirmRequests.at(-1).suggestionIds.length } })
return return
@@ -425,6 +535,16 @@ const waitForDrawerOpen = async (selector) => page.waitForFunction((drawerSelect
return drawer?.classList.contains('open') && Math.abs(drawer.getBoundingClientRect().left) < 1 return drawer?.classList.contains('open') && Math.abs(drawer.getBoundingClientRect().left) < 1
}, selector) }, selector)
const closeItemPreview = async (expectedTitle) => {
const preview = page.getByRole('dialog', { name: '只读预览' })
await preview.waitFor({ state: 'visible' })
if (!await preview.getByText(expectedTitle, { exact: true }).isVisible()) {
failures.push(`read-only item preview missing ${expectedTitle}`)
}
await page.keyboard.press('Escape')
await preview.waitFor({ state: 'hidden' })
}
const collectMetrics = async () => page.evaluate(() => { const collectMetrics = async () => page.evaluate(() => {
const statusbar = document.querySelector('.statusbar') const statusbar = document.querySelector('.statusbar')
const statusUser = document.querySelector('.status-user') const statusUser = document.querySelector('.status-user')
@@ -436,6 +556,7 @@ const collectMetrics = async () => page.evaluate(() => {
const projectOverview = document.querySelector('.overview-page:not(.workspace-page)') const projectOverview = document.querySelector('.overview-page:not(.workspace-page)')
const rail = document.querySelector('.project-rail') const rail = document.querySelector('.project-rail')
const railChildren = rail?.querySelector('.arco-layout-sider-children') const railChildren = rail?.querySelector('.arco-layout-sider-children')
const projectStackScroll = rail?.querySelector('.project-stack-scroll')
const sidebar = document.querySelector('.channel-sidebar') const sidebar = document.querySelector('.channel-sidebar')
const channelList = document.querySelector('.channel-list') const channelList = document.querySelector('.channel-list')
const stage = document.querySelector('.stage') const stage = document.querySelector('.stage')
@@ -447,13 +568,13 @@ const collectMetrics = async () => page.evaluate(() => {
const project = document.querySelector('.project-button') const project = document.querySelector('.project-button')
const create = document.querySelector('.create-project') const create = document.querySelector('.create-project')
const firstBadge = document.querySelector('.project-badge .arco-badge-number') const firstBadge = document.querySelector('.project-badge .arco-badge-number')
const railItems = [...document.querySelectorAll('.dashboard-button, .project-button, .create-project')] const projectItems = [...document.querySelectorAll('.project-button')]
const rect = (node) => { const rect = (node) => {
const box = node?.getBoundingClientRect() const box = node?.getBoundingClientRect()
return box ? { left: box.left, right: box.right, top: box.top, width: box.width, height: box.height } : null return box ? { left: box.left, right: box.right, top: box.top, width: box.width, height: box.height } : null
} }
const railBox = rail?.getBoundingClientRect() const railBox = rail?.getBoundingClientRect()
const itemRects = railItems.map(rect).filter(Boolean) const itemRects = projectItems.map(rect).filter(Boolean)
const horizontalInsets = railBox && project const horizontalInsets = railBox && project
? { ? {
left: project.getBoundingClientRect().left - railBox.left, left: project.getBoundingClientRect().left - railBox.left,
@@ -501,6 +622,8 @@ const collectMetrics = async () => page.evaluate(() => {
verticalGaps, verticalGaps,
projectRailOverflow: overflowState(rail), projectRailOverflow: overflowState(rail),
projectRailChildrenOverflow: overflowState(railChildren), projectRailChildrenOverflow: overflowState(railChildren),
projectStackOverflow: overflowState(projectStackScroll),
projectCount: projectItems.length,
channelSidebarOverflow: overflowState(sidebar), channelSidebarOverflow: overflowState(sidebar),
channelListOverflow: overflowState(channelList), channelListOverflow: overflowState(channelList),
stageOverflow: overflowState(stage), stageOverflow: overflowState(stage),
@@ -539,9 +662,16 @@ if (process.env.VISUAL_CHECK_SCOPE === 'login') {
} }
await page.setViewportSize({ width: 1440, height: 1024 }) await page.setViewportSize({ width: 1440, height: 1024 })
await page.getByText('记住服务器地址', { exact: true }).click()
if (!await page.getByRole('checkbox', { name: '记住服务器地址', exact: true }).isChecked()) {
failures.push('remember server checkbox must be interactive')
}
await page.locator('.login-form-panel .arco-btn-primary').click() await page.locator('.login-form-panel .arco-btn-primary').click()
await page.waitForSelector('.workbench-shell') await page.waitForSelector('.workbench-shell')
await page.waitForTimeout(700) await page.waitForFunction(() => document.querySelectorAll('.project-button').length >= 13)
if (await page.evaluate(() => window.localStorage.getItem('senlinai.server')) !== 'http://localhost:9150') {
failures.push('remember server must persist the selected server in localStorage after login')
}
await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true }) await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true })
const workspaceMetrics = await collectMetrics() const workspaceMetrics = await collectMetrics()
@@ -549,6 +679,19 @@ await page.setViewportSize({ width: 390, height: 844 })
if (await page.getByRole('button', { name: '打开频道导航', exact: true }).count() !== 0) { if (await page.getByRole('button', { name: '打开频道导航', exact: true }).count() !== 0) {
failures.push('workspace mobile view must not offer channel navigation without a channel aside') failures.push('workspace mobile view must not offer channel navigation without a channel aside')
} }
const mobileSearchButton = page.getByRole('button', { name: '打开全局搜索', exact: true })
if (await mobileSearchButton.count() !== 1) {
failures.push('390px workspace must provide one global search entry')
} else {
await mobileSearchButton.click()
await page.getByPlaceholder('搜索项目、任务和笔记').waitFor({ state: 'visible' })
await page.screenshot({ path: 'test-results/workbench-mobile-search.png' })
await page.getByRole('button', { name: '关闭全局搜索', exact: true }).click()
await page.waitForFunction(() => {
const search = document.querySelector('.global-search')
return search && getComputedStyle(search).display === 'none'
})
}
await page.setViewportSize({ width: 1440, height: 1024 }) await page.setViewportSize({ width: 1440, height: 1024 })
await page.locator('.dashboard-button[title="探索"]').click() await page.locator('.dashboard-button[title="探索"]').click()
@@ -565,6 +708,25 @@ await page.waitForTimeout(500)
await page.screenshot({ path: 'test-results/project-react-acro-light.png', fullPage: true }) await page.screenshot({ path: 'test-results/project-react-acro-light.png', fullPage: true })
const projectMetrics = await collectMetrics() const projectMetrics = await collectMetrics()
await page.setViewportSize({ width: 1024, height: 768 })
await page.waitForFunction(() => {
const sidebar = document.querySelector('aside.channel-sidebar')
return sidebar && getComputedStyle(sidebar).visibility === 'hidden'
})
const tabletChannelNavButton = page.getByRole('button', { name: '打开频道导航', exact: true })
if (await tabletChannelNavButton.count() !== 1) {
failures.push('1024px project view must provide one channel drawer entry')
} else {
await tabletChannelNavButton.click()
await page.waitForFunction(() => {
const sidebar = document.querySelector('aside.channel-sidebar')
return sidebar?.classList.contains('open') && Math.abs(sidebar.getBoundingClientRect().left - 96) <= 1
})
await page.screenshot({ path: 'test-results/channel-navigation-tablet-1024.png' })
await page.getByRole('button', { name: '关闭导航', exact: true }).click({ position: { x: 900, y: 20 } })
await page.waitForFunction(() => !document.querySelector('aside.channel-sidebar')?.classList.contains('open'))
}
await page.setViewportSize({ width: 390, height: 844 }) await page.setViewportSize({ width: 390, height: 844 })
await page.waitForFunction(() => [...document.querySelectorAll('.project-rail, .channel-sidebar')] await page.waitForFunction(() => [...document.querySelectorAll('.project-rail, .channel-sidebar')]
.every((node) => getComputedStyle(node).visibility === 'hidden')) .every((node) => getComputedStyle(node).visibility === 'hidden'))
@@ -572,7 +734,7 @@ const mobileProjectMetrics = await collectMetrics()
const mobileProjectOverviewLayout = await page.evaluate(() => { const mobileProjectOverviewLayout = await page.evaluate(() => {
const hero = document.querySelector('.project-overview-hero') const hero = document.querySelector('.project-overview-hero')
const heroTitle = document.querySelector('.project-overview-identity h3') const heroTitle = document.querySelector('.project-overview-identity h3')
const shareButton = document.querySelector('.project-overview-share .arco-btn') const projectIdentifier = document.querySelector('.project-overview-share .arco-tag')
const metricColumns = [...document.querySelectorAll('.project-overview-page > .metric-row > .arco-col')] const metricColumns = [...document.querySelectorAll('.project-overview-page > .metric-row > .arco-col')]
const metricLabels = [...document.querySelectorAll('.project-overview-page .metric-card .arco-typography-secondary')] const metricLabels = [...document.querySelectorAll('.project-overview-page .metric-card .arco-typography-secondary')]
const lineCount = (node) => { const lineCount = (node) => {
@@ -585,7 +747,10 @@ const mobileProjectOverviewLayout = await page.evaluate(() => {
viewportWidth: document.documentElement.clientWidth, viewportWidth: document.documentElement.clientWidth,
hero: hero?.getBoundingClientRect().toJSON() ?? null, hero: hero?.getBoundingClientRect().toJSON() ?? null,
heroTitle: heroTitle ? { ...heroTitle.getBoundingClientRect().toJSON(), lines: lineCount(heroTitle) } : null, heroTitle: heroTitle ? { ...heroTitle.getBoundingClientRect().toJSON(), lines: lineCount(heroTitle) } : null,
shareButton: shareButton?.getBoundingClientRect().toJSON() ?? null, projectIdentifier: projectIdentifier ? {
...projectIdentifier.getBoundingClientRect().toJSON(),
text: projectIdentifier.textContent?.trim() ?? '',
} : null,
metricColumnLefts: metricColumns.map((column) => Math.round(column.getBoundingClientRect().left)), metricColumnLefts: metricColumns.map((column) => Math.round(column.getBoundingClientRect().left)),
metricLabels: metricLabels.map((label) => ({ metricLabels: metricLabels.map((label) => ({
text: label.textContent?.trim() ?? '', text: label.textContent?.trim() ?? '',
@@ -597,8 +762,10 @@ const mobileProjectOverviewLayout = await page.evaluate(() => {
if (!mobileProjectOverviewLayout.heroTitle || mobileProjectOverviewLayout.heroTitle.lines > 2) { if (!mobileProjectOverviewLayout.heroTitle || mobileProjectOverviewLayout.heroTitle.lines > 2) {
failures.push(`mobile project title must fit in at most two readable lines, got ${JSON.stringify(mobileProjectOverviewLayout.heroTitle)}`) failures.push(`mobile project title must fit in at most two readable lines, got ${JSON.stringify(mobileProjectOverviewLayout.heroTitle)}`)
} }
if (!mobileProjectOverviewLayout.shareButton || mobileProjectOverviewLayout.shareButton.right > mobileProjectOverviewLayout.viewportWidth) { if (!mobileProjectOverviewLayout.projectIdentifier || mobileProjectOverviewLayout.projectIdentifier.right > mobileProjectOverviewLayout.viewportWidth) {
failures.push(`mobile project URL must stay inside the viewport, got ${JSON.stringify(mobileProjectOverviewLayout.shareButton)}`) failures.push(`mobile project identifier must stay inside the viewport, got ${JSON.stringify(mobileProjectOverviewLayout.projectIdentifier)}`)
} else if (mobileProjectOverviewLayout.projectIdentifier.text !== 'forest') {
failures.push(`project overview must display the persisted identifier, got ${JSON.stringify(mobileProjectOverviewLayout.projectIdentifier.text)}`)
} }
if (new Set(mobileProjectOverviewLayout.metricColumnLefts).size !== 2) { if (new Set(mobileProjectOverviewLayout.metricColumnLefts).size !== 2) {
failures.push(`mobile project metrics must use two columns, got left edges ${JSON.stringify(mobileProjectOverviewLayout.metricColumnLefts)}`) failures.push(`mobile project metrics must use two columns, got left edges ${JSON.stringify(mobileProjectOverviewLayout.metricColumnLefts)}`)
@@ -616,6 +783,12 @@ if (await projectNavButton.count() === 0) {
} else { } else {
await projectNavButton.click() await projectNavButton.click()
await waitForDrawerOpen('aside.project-rail') await waitForDrawerOpen('aside.project-rail')
const lastLongProject = page.locator('.project-button[title="长列表项目 11"]')
await lastLongProject.scrollIntoViewIfNeeded()
if (!await lastLongProject.isVisible()) failures.push('12+ project fixture must allow scrolling to the final project')
if (!await page.getByRole('button', { name: '新建项目', exact: true }).isVisible()) {
failures.push('create-project must remain reachable while the long project list scrolls')
}
await page.screenshot({ path: 'test-results/project-navigation-mobile.png', fullPage: true }) await page.screenshot({ path: 'test-results/project-navigation-mobile.png', fullPage: true })
if (!await page.locator('aside.project-rail.open').isVisible()) { if (!await page.locator('aside.project-rail.open').isVisible()) {
failures.push('打开项目导航 must reveal the project rail aside') failures.push('打开项目导航 must reveal the project rail aside')
@@ -658,6 +831,12 @@ if (await channelNavButton.count() === 0) {
failures.push('selecting a recent session must close the channel drawer') failures.push('selecting a recent session must close the channel drawer')
await page.getByRole('button', { name: '关闭导航', exact: true }).click({ position: { x: 380, y: 20 } }) await page.getByRole('button', { name: '关闭导航', exact: true }).click({ position: { x: 380, y: 20 } })
} }
const itemPreview = page.getByRole('dialog', { name: '只读预览' })
if (!await itemPreview.isVisible() || !await itemPreview.getByText('移动导航会话', { exact: true }).isVisible()) {
failures.push('selecting a recent session must consume selectedItem in a read-only preview')
}
await page.keyboard.press('Escape')
await itemPreview.waitFor({ state: 'hidden' })
} }
await page.setViewportSize({ width: 1440, height: 1024 }) await page.setViewportSize({ width: 1440, height: 1024 })
@@ -792,6 +971,10 @@ if (await settingsButton.count() === 0) {
if (!await settingsModal.getByText('项目设置保存失败,请稍后重试', { exact: true }).isVisible()) { if (!await settingsModal.getByText('项目设置保存失败,请稍后重试', { exact: true }).isVisible()) {
failures.push('failed project settings update must render the API Chinese error inside the modal') failures.push('failed project settings update must render the API Chinese error inside the modal')
} }
if (await settingsModal.getByLabel('简介').inputValue() !== '触发失败') {
failures.push('failed project settings update must retain the edited draft')
}
await page.screenshot({ path: 'test-results/project-mutation-failure-draft-retained.png' })
expectingProjectPatchError = false expectingProjectPatchError = false
if (expectedProjectPatchConsoleErrorCount !== 1) { if (expectedProjectPatchConsoleErrorCount !== 1) {
failures.push(`expected one simulated PATCH console error, got ${expectedProjectPatchConsoleErrorCount}`) failures.push(`expected one simulated PATCH console error, got ${expectedProjectPatchConsoleErrorCount}`)
@@ -806,6 +989,32 @@ const stageHoverMetrics = await collectMetrics()
await page.locator('.channel-list').hover() await page.locator('.channel-list').hover()
const channelListHoverMetrics = await collectMetrics() const channelListHoverMetrics = await collectMetrics()
await page.locator('.project-button[title="并行项目"]').click()
await page.locator('.channel-button', { hasText: 'Inbox 消息流' }).click()
const emptyInboxPage = page.locator('.project-inbox-page')
await emptyInboxPage.getByText('当前没有待处理的 Inbox 内容', { exact: true }).waitFor({ state: 'visible' })
await emptyInboxPage.getByLabel('Inbox 标题').fill('视觉收集条目')
await emptyInboxPage.getByLabel('Inbox 收集内容').fill('从空 Inbox 真实收集,再进入分析流程。')
await emptyInboxPage.getByRole('button', { name: '收集到 Inbox', exact: true }).click()
await emptyInboxPage.locator('.mail-item', { hasText: '视觉收集条目' }).waitFor({ state: 'visible' })
const captureRequest = inboxCaptureRequests.at(-1)
if (
captureRequest?.sourceType !== 'manual'
|| captureRequest?.title !== '视觉收集条目'
|| captureRequest?.body !== '从空 Inbox 真实收集,再进入分析流程。'
) {
failures.push(`empty Inbox capture must POST the entered draft, got ${JSON.stringify(captureRequest)}`)
}
await emptyInboxPage.getByRole('button', { name: '分析内容', exact: true }).click()
await emptyInboxPage.getByText('收集后的待办', { exact: true }).waitFor({ state: 'visible' })
if (!inboxAnalyzeRequests.includes(capturedInboxItemId)) {
failures.push('a newly captured Inbox item must continue into the real analyze flow')
}
await page.screenshot({ path: 'test-results/project-inbox-empty-capture-analysis.png', fullPage: true })
await page.locator('.project-button[title="森林项目已更新"]').click()
const analyzeRequestsBeforeExistingInboxFlow = inboxAnalyzeRequests.length
const confirmRequestsBeforeExistingInboxFlow = inboxConfirmRequests.length
const inboxChannelButton = page.locator('.channel-button', { hasText: 'Inbox 消息流' }) const inboxChannelButton = page.locator('.channel-button', { hasText: 'Inbox 消息流' })
if (await inboxChannelButton.count() === 0) { if (await inboxChannelButton.count() === 0) {
failures.push('project sidebar must expose the Inbox channel from the workspace API') failures.push('project sidebar must expose the Inbox channel from the workspace API')
@@ -832,7 +1041,10 @@ if (await inboxChannelButton.count() === 0) {
await firstInboxRow.click() await firstInboxRow.click()
await inboxPage.getByRole('button', { name: '分析内容', exact: true }).click() await inboxPage.getByRole('button', { name: '分析内容', exact: true }).click()
await inboxPage.getByRole('checkbox').first().waitFor({ state: 'attached', timeout: 2000 }).catch(() => {}) await inboxPage.getByRole('checkbox').first().waitFor({ state: 'attached', timeout: 2000 }).catch(() => {})
if (inboxAnalyzeRequests.length !== 2 || inboxConfirmRequests.length !== 0) { if (
inboxAnalyzeRequests.length !== analyzeRequestsBeforeExistingInboxFlow + 2
|| inboxConfirmRequests.length !== confirmRequestsBeforeExistingInboxFlow
) {
failures.push('analysis must load drafts without invoking confirmation writes') failures.push('analysis must load drafts without invoking confirmation writes')
} }
if (await inboxPage.getByRole('checkbox').count() !== 3) { if (await inboxPage.getByRole('checkbox').count() !== 3) {
@@ -1011,7 +1223,7 @@ if (await inboxChannelButton.count() === 0) {
await page.locator('.channel-button', { hasText: 'Inbox 消息流' }).click() await page.locator('.channel-button', { hasText: 'Inbox 消息流' }).click()
const conflictInboxPage = page.locator('.project-inbox-page') const conflictInboxPage = page.locator('.project-inbox-page')
await conflictInboxPage.locator('.mail-item', { hasText: '延迟确认测试' }).click() await conflictInboxPage.locator('.mail-item', { hasText: '冲突确认测试' }).click()
await conflictInboxPage.getByRole('button', { name: '分析内容', exact: true }).click() await conflictInboxPage.getByRole('button', { name: '分析内容', exact: true }).click()
await conflictInboxPage.getByRole('checkbox').first().waitFor({ state: 'attached', timeout: 2000 }).catch(() => {}) await conflictInboxPage.getByRole('checkbox').first().waitFor({ state: 'attached', timeout: 2000 }).catch(() => {})
conflictNextInboxConfirm = true conflictNextInboxConfirm = true
@@ -1019,7 +1231,7 @@ if (await inboxChannelButton.count() === 0) {
const workspaceRequestsBeforeConflict = workspaceRequests.length const workspaceRequestsBeforeConflict = workspaceRequests.length
const conflictConfirmButton = conflictInboxPage.getByRole('button', { name: '确认创建', exact: true }) const conflictConfirmButton = conflictInboxPage.getByRole('button', { name: '确认创建', exact: true })
await conflictConfirmButton.click() await conflictConfirmButton.click()
await conflictInboxPage.getByText('确认状态发生冲突,工作区已刷新,请重新核对', { exact: true }).waitFor({ state: 'visible', timeout: 2000 }).catch(() => {}) await conflictInboxPage.getByText('确认状态发生冲突,原项目已在后台刷新,请重新核对', { exact: true }).waitFor({ state: 'visible', timeout: 2000 }).catch(() => {})
if (workspaceRequests.length <= workspaceRequestsBeforeConflict) { if (workspaceRequests.length <= workspaceRequestsBeforeConflict) {
failures.push('a non-idempotency 409 must refresh the current workspace before reporting the conflict') failures.push('a non-idempotency 409 must refresh the current workspace before reporting the conflict')
} }
@@ -1056,8 +1268,18 @@ for (const channel of [
await aiPage.locator('textarea').fill('只创建受控会话入口') await aiPage.locator('textarea').fill('只创建受控会话入口')
await aiPage.getByRole('button', { name: '创建会话', exact: true }).click() await aiPage.getByRole('button', { name: '创建会话', exact: true }).click()
await aiPage.getByText('新会话视觉检查', { exact: true }).waitFor({ state: 'visible', timeout: 2000 }) await aiPage.getByText('新会话视觉检查', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
await closeItemPreview('新会话视觉检查')
await page.screenshot({ path: 'test-results/project-ai-controlled.png', fullPage: true }) await page.screenshot({ path: 'test-results/project-ai-controlled.png', fullPage: true })
} }
if (channel.label === '计划任务') {
const cronPage = page.locator('.project-cron-page')
if (await cronPage.locator('.arco-switch').count() !== 0) {
failures.push('cron metadata page must not expose a non-persistent switch')
}
for (const text of ['每日整理提醒', '暂无执行记录', '仅展示服务端保存的计划元数据']) {
if (!await cronPage.getByText(text, { exact: false }).isVisible()) failures.push(`cron metadata page missing ${text}`)
}
}
channelPageChecks.push(await page.evaluate((expected) => { channelPageChecks.push(await page.evaluate((expected) => {
const pageNode = document.querySelector(`.${expected.pageClass}`) const pageNode = document.querySelector(`.${expected.pageClass}`)
const heading = pageNode?.querySelector('h2, h3, h4, h5')?.textContent ?? '' const heading = pageNode?.querySelector('h2, h3, h4, h5')?.textContent ?? ''
@@ -1079,6 +1301,7 @@ for (const channel of [
await sameProjectRacePage.locator('textarea').fill('POST 完成后,旧 GET 不得覆盖结果') await sameProjectRacePage.locator('textarea').fill('POST 完成后,旧 GET 不得覆盖结果')
await sameProjectRacePage.getByRole('button', { name: '创建会话', exact: true }).click() await sameProjectRacePage.getByRole('button', { name: '创建会话', exact: true }).click()
await sameProjectRacePage.getByText('乱序请求保留的新会话', { exact: true }).waitFor({ state: 'visible', timeout: 2000 }) await sameProjectRacePage.getByText('乱序请求保留的新会话', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
await closeItemPreview('乱序请求保留的新会话')
await page.waitForTimeout(850) await page.waitForTimeout(850)
if (await sameProjectRacePage.getByText('乱序请求保留的新会话', { exact: true }).count() !== 1) { if (await sameProjectRacePage.getByText('乱序请求保留的新会话', { exact: true }).count() !== 1) {
failures.push('a stale AI session GET must not overwrite a newer POST result in the same project') failures.push('a stale AI session GET must not overwrite a newer POST result in the same project')
@@ -1122,6 +1345,16 @@ for (const channel of [
} }
} }
const locationBeforeCustomChannel = page.url()
await page.locator('.channel-button', { hasText: '外部集成' }).click()
const customChannelPage = page.locator('.project-unavailable-page')
await customChannelPage.getByText('自定义链接频道暂未开放', { exact: true }).waitFor({ state: 'visible' })
if (page.url() !== locationBeforeCustomChannel || await customChannelPage.locator('a').count() !== 0) {
failures.push('custom_link channel must remain a local read-only unavailable state')
}
await page.screenshot({ path: 'test-results/project-custom-link-unavailable.png', fullPage: true })
await page.locator('.channel-button', { hasText: '新建频道' }).click()
const unsupportedControlMetrics = await page.evaluate(() => ({ const unsupportedControlMetrics = await page.evaluate(() => ({
topbarLabels: [...document.querySelectorAll('.topbar-actions button')].map((button) => button.getAttribute('aria-label') ?? button.textContent?.trim()), topbarLabels: [...document.querySelectorAll('.topbar-actions button')].map((button) => button.getAttribute('aria-label') ?? button.textContent?.trim()),
statusbarText: document.querySelector('.statusbar')?.textContent ?? '', statusbarText: document.querySelector('.statusbar')?.textContent ?? '',
@@ -1299,6 +1532,22 @@ for (const check of channelPageChecks) {
failures.push(`expected ${check.label} sidebar button to stay active, got ${JSON.stringify(check.activeChannel)}`) failures.push(`expected ${check.label} sidebar button to stay active, got ${JSON.stringify(check.activeChannel)}`)
} }
} }
if (metrics.projectCount < 13) failures.push(`long project fixture must render at least 13 projects, got ${metrics.projectCount}`)
if (
!metrics.projectStackOverflow
|| metrics.projectStackOverflow.overflowY !== 'auto'
|| metrics.projectStackOverflow.scrollHeight <= metrics.projectStackOverflow.clientHeight
) {
failures.push(`long project list must use its dedicated scroll region, got ${JSON.stringify(metrics.projectStackOverflow)}`)
}
if (
!metrics.createProjectButton
|| !metrics.projectRail
|| metrics.createProjectButton.top < metrics.projectRail.top
|| metrics.createProjectButton.top + metrics.createProjectButton.height > metrics.projectRail.top + metrics.projectRail.height
) {
failures.push(`create-project must remain pinned inside the rail, got rail=${JSON.stringify(metrics.projectRail)} create=${JSON.stringify(metrics.createProjectButton)}`)
}
if (aiSessionRequests.length !== 3) failures.push(`expected three controlled AI session requests, got ${aiSessionRequests.length}`) if (aiSessionRequests.length !== 3) failures.push(`expected three controlled AI session requests, got ${aiSessionRequests.length}`)
for (const request of aiSessionRequests) { for (const request of aiSessionRequests) {

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createWorkspaceRefreshGate } from '../src/app/workspace-refresh-gate.ts'
test('a newer full workspace refresh aborts and invalidates the previous generation', () => {
const gate = createWorkspaceRefreshGate()
const first = gate.begin()
const second = gate.begin()
assert.equal(first.signal.aborted, true)
assert.equal(first.isCurrent(), false)
assert.equal(second.signal.aborted, false)
assert.equal(second.isCurrent(), true)
})
test('a targeted origin-project refresh also supersedes an older full refresh', () => {
const gate = createWorkspaceRefreshGate()
const fullRefresh = gate.begin()
const originRefresh = gate.begin()
assert.equal(fullRefresh.signal.aborted, true)
assert.equal(fullRefresh.isCurrent(), false)
assert.equal(originRefresh.isCurrent(), true)
})
test('invalidating on session cleanup prevents stale workspace commits', () => {
const gate = createWorkspaceRefreshGate()
const request = gate.begin()
gate.invalidate()
assert.equal(request.signal.aborted, true)
assert.equal(request.isCurrent(), false)
})

View File

@@ -281,6 +281,10 @@
display: none; display: none;
} }
.mobile-search-button {
display: none;
}
.nav-backdrop { .nav-backdrop {
display: none; display: none;
} }
@@ -346,6 +350,21 @@
width: 48px; width: 48px;
} }
.project-stack-scroll {
width: 64px;
min-height: 0;
flex: 1 1 auto;
overflow-x: hidden;
overflow-y: auto;
display: flex;
justify-content: center;
scrollbar-width: thin;
}
.project-stack-scroll .project-stack {
flex: 0 0 auto;
}
.rail-brand { .rail-brand {
width: 48px; width: 48px;
height: 48px; height: 48px;
@@ -357,6 +376,8 @@
border-radius: var(--radius-control); border-radius: var(--radius-control);
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
flex: 0 0 48px;
min-height: 48px;
} }
.rail-brand .brand-icon { .rail-brand .brand-icon {
@@ -370,6 +391,8 @@
width: 48px !important; width: 48px !important;
height: 48px !important; height: 48px !important;
min-width: 48px; min-width: 48px;
min-height: 48px;
flex: 0 0 48px;
padding: 0 !important; padding: 0 !important;
border-radius: var(--radius-control); border-radius: var(--radius-control);
} }
@@ -1835,7 +1858,7 @@
} }
.cron-row { .cron-row {
grid-template-columns: minmax(180px, 1fr) 64px 92px 92px 92px 48px 40px; grid-template-columns: minmax(180px, 1fr) 64px 92px 110px 110px 56px;
} }
.cron-summary .arco-card-body { .cron-summary .arco-card-body {
@@ -2212,6 +2235,44 @@
} }
} }
@media (max-width: 1179px) {
.channel-nav-button {
display: inline-flex;
min-width: 40px;
min-height: 40px;
}
.channel-sidebar {
position: fixed !important;
top: 58px;
bottom: 32px;
left: 96px;
z-index: 20;
height: auto !important;
max-width: calc(100vw - 96px);
visibility: hidden;
pointer-events: none;
transform: translateX(-110%);
transition: transform 160ms ease, visibility 160ms ease;
}
.channel-sidebar.open {
visibility: visible;
pointer-events: auto;
transform: translateX(0);
}
.nav-backdrop {
display: block;
position: fixed;
inset: 58px 0 32px 96px;
z-index: 19;
border: 0;
background: rgba(29, 33, 41, 0.38);
padding: 0;
}
}
@media (max-width: 767px) { @media (max-width: 767px) {
.topbar { .topbar {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -2223,6 +2284,17 @@
display: none; display: none;
} }
.topbar .global-search.mobile-open {
display: grid;
position: fixed;
top: 58px;
right: var(--space-3);
left: var(--space-3);
z-index: 30;
background: var(--color-panel);
box-shadow: 0 12px 28px rgba(29, 33, 41, 0.22);
}
.topbar-actions { .topbar-actions {
gap: var(--space-1); gap: var(--space-1);
} }
@@ -2231,7 +2303,8 @@
display: none; display: none;
} }
.mobile-nav-button { .mobile-nav-button,
.mobile-search-button {
display: inline-flex; display: inline-flex;
min-width: 40px; min-width: 40px;
min-height: 40px; min-height: 40px;

View File

@@ -62,7 +62,8 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
body: requestBody as BodyInit | undefined, body: requestBody as BodyInit | undefined,
signal: options.signal, signal: options.signal,
}) })
} catch { } catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') throw error
throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试') throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试')
} }

View File

@@ -124,7 +124,7 @@ export type SourceDTO = {
projectId: string projectId: string
kind: string kind: string
title: string title: string
filePath: string storageKey: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
@@ -184,12 +184,12 @@ export type CreateProjectTagInput = {
name: string name: string
} }
export async function fetchProjects(session: ApiSession) { export async function fetchProjects(session: ApiSession, signal?: AbortSignal) {
return apiRequest<ProjectDTO[]>('/api/v1/projects', { token: session.token }) return apiRequest<ProjectDTO[]>('/api/v1/projects', { token: session.token, signal })
} }
export async function fetchProjectWorkspace(session: ApiSession, projectId: string) { export async function fetchProjectWorkspace(session: ApiSession, projectId: string, signal?: AbortSignal) {
return apiRequest<ProjectWorkspaceDTO>(`/api/v1/projects/${projectId}/workspace`, { token: session.token }) return apiRequest<ProjectWorkspaceDTO>(`/api/v1/projects/${projectId}/workspace`, { token: session.token, signal })
} }
export async function fetchProjectTags(session: ApiSession, projectId: string) { export async function fetchProjectTags(session: ApiSession, projectId: string) {

View File

@@ -1,10 +1,10 @@
import { useCallback, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { ConfigProvider, Message, Spin } from '@arco-design/web-react' import { ConfigProvider, Message, Spin } from '@arco-design/web-react'
import '@arco-design/web-react/dist/css/arco.css' import '@arco-design/web-react/dist/css/arco.css'
import '../App.css' import '../App.css'
import { ApiError, login, setApiBaseUrl, type ApiSession } from '../api/client' import { ApiError, login, setApiBaseUrl, type ApiSession } from '../api/client'
import { createAISession, listAISessions, type CreateAISessionInput } from '../api/ai' import { createAISession, listAISessions, type CreateAISessionInput } from '../api/ai'
import { analyzeInboxItem, confirmInboxItem } from '../api/inbox' import { analyzeInboxItem, captureProjectInbox, confirmInboxItem, type CaptureInboxInput } from '../api/inbox'
import { mapWorkspace } from '../api/mappers' import { mapWorkspace } from '../api/mappers'
import { import {
createCronPlan, createCronPlan,
@@ -21,11 +21,13 @@ import type { SearchResultDTO } from '../api/search'
import { LoginPage } from '../pages/login' import { LoginPage } from '../pages/login'
import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals' import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals'
import { SearchResultPreview } from '../pages/projects/search-result-preview' import { SearchResultPreview } from '../pages/projects/search-result-preview'
import { WorkbenchItemPreview } from '../pages/projects/workbench-item-preview'
import type { ProjectSettingsUpdate } from '../pages/projects/project-sidebar' import type { ProjectSettingsUpdate } from '../pages/projects/project-sidebar'
import { ProjectPage } from '../pages/workspace-home' import { ProjectPage } from '../pages/workspace-home'
import type { WorkspaceTaskUpdate } from '../pages/workspace-body' import type { WorkspaceTaskUpdate } from '../pages/workspace-body'
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types' import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
import { useWorkbenchSearch } from './use-workbench-search' import { useWorkbenchSearch } from './use-workbench-search'
import { createWorkspaceRefreshGate, type WorkspaceRefreshGate } from './workspace-refresh-gate'
function App() { function App() {
const [screen, setScreen] = useState<Screen>('login') const [screen, setScreen] = useState<Screen>('login')
@@ -37,12 +39,17 @@ function App() {
const activeProjectIDRef = useRef('') const activeProjectIDRef = useRef('')
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview') const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
const [activeTaskID, setActiveTaskID] = useState<string | null>(null) const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
const [, setSelectedItem] = useState('探索采集') const [selectedItem, setSelectedItem] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false) const [actionLoading, setActionLoading] = useState(false)
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null) const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null) const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null)
const workspaceSearch = useWorkbenchSearch(session) const workspaceSearch = useWorkbenchSearch(session)
const workspaceRefreshGate = useRef<WorkspaceRefreshGate | null>(null)
const actionInFlight = useRef(false)
if (!workspaceRefreshGate.current) workspaceRefreshGate.current = createWorkspaceRefreshGate()
useEffect(() => () => workspaceRefreshGate.current?.invalidate(), [])
const handleListAISessions = useCallback((projectId: string, signal?: AbortSignal) => { const handleListAISessions = useCallback((projectId: string, signal?: AbortSignal) => {
if (!session) return Promise.reject(new Error('未登录')) if (!session) return Promise.reject(new Error('未登录'))
@@ -64,11 +71,13 @@ function App() {
} }
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID?: string) { async function loadWorkspaces(nextSession: ApiSession, preferredProjectID?: string) {
const request = workspaceRefreshGate.current!.begin()
const projectIDWhenStarted = activeProjectIDRef.current const projectIDWhenStarted = activeProjectIDRef.current
const backendProjects = await fetchProjects(nextSession) const backendProjects = await fetchProjects(nextSession, request.signal)
const backendWorkspaces = await Promise.all( const backendWorkspaces = await Promise.all(
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id).then((workspace) => mapWorkspace(workspace, index))), backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id, request.signal).then((workspace) => mapWorkspace(workspace, index))),
) )
if (!request.isCurrent()) throw staleWorkspaceRefreshError()
setWorkspaces(backendWorkspaces) setWorkspaces(backendWorkspaces)
const latestProjectID = activeProjectIDRef.current const latestProjectID = activeProjectIDRef.current
const requestedProjectID = latestProjectID !== projectIDWhenStarted const requestedProjectID = latestProjectID !== projectIDWhenStarted
@@ -79,6 +88,20 @@ function App() {
return backendWorkspaces return backendWorkspaces
} }
async function refreshProjectWorkspace(nextSession: ApiSession, projectID: string) {
const request = workspaceRefreshGate.current!.begin()
const workspaceIndex = workspaces.findIndex((workspace) => workspace.project.id === projectID)
const payload = await fetchProjectWorkspace(nextSession, projectID, request.signal)
if (!request.isCurrent()) throw staleWorkspaceRefreshError()
const refreshed = mapWorkspace(payload, workspaceIndex < 0 ? 0 : workspaceIndex)
setWorkspaces((current) => {
const existingIndex = current.findIndex((workspace) => workspace.project.id === projectID)
if (existingIndex < 0) return [...current, refreshed]
return current.map((workspace, index) => index === existingIndex ? refreshed : workspace)
})
return refreshed
}
async function handleLogin(input: { server: string; email: string; password: string }) { async function handleLogin(input: { server: string; email: string; password: string }) {
setLoading(true) setLoading(true)
try { try {
@@ -103,6 +126,8 @@ function App() {
} }
async function runAction(action: () => Promise<void>, success: string) { async function runAction(action: () => Promise<void>, success: string) {
if (actionInFlight.current) throw new Error('操作正在进行,请勿重复提交')
actionInFlight.current = true
setActionLoading(true) setActionLoading(true)
try { try {
await action() await action()
@@ -110,7 +135,9 @@ function App() {
Message.success(success) Message.success(success)
} catch (error) { } catch (error) {
Message.error(error instanceof Error ? error.message : '操作失败') Message.error(error instanceof Error ? error.message : '操作失败')
throw error
} finally { } finally {
actionInFlight.current = false
setActionLoading(false) setActionLoading(false)
} }
} }
@@ -130,12 +157,12 @@ function App() {
return trimmed === '' ? undefined : trimmed return trimmed === '' ? undefined : trimmed
} }
function handleCreateProject(draft: ProjectDraft) { async function handleCreateProject(draft: ProjectDraft) {
if (!draft.name.trim()) { if (!draft.name.trim()) {
Message.warning('请输入项目名称') Message.warning('请输入项目名称')
return return
} }
void runAction(async () => { return runAction(async () => {
const created = await createProject(requireSession(), { const created = await createProject(requireSession(), {
name: draft.name.trim(), name: draft.name.trim(),
identifier: draft.identifier.trim(), identifier: draft.identifier.trim(),
@@ -153,12 +180,12 @@ function App() {
}, '项目已创建') }, '项目已创建')
} }
function handleCreateTask(draft: TaskDraft) { async function handleCreateTask(draft: TaskDraft) {
if (!draft.title.trim()) { if (!draft.title.trim()) {
Message.warning('请输入任务标题') Message.warning('请输入任务标题')
return return
} }
void runAction(async () => { return runAction(async () => {
await createTask(requireSession(), requireActiveProject(), { await createTask(requireSession(), requireActiveProject(), {
title: draft.title.trim(), title: draft.title.trim(),
description: draft.description.trim(), description: draft.description.trim(),
@@ -170,13 +197,13 @@ function App() {
}, '任务已创建') }, '任务已创建')
} }
function handleUploadSource(draft: SourceDraft) { async function handleUploadSource(draft: SourceDraft) {
if (!draft.file) { if (!draft.file) {
Message.warning('请选择文件') Message.warning('请选择文件')
return return
} }
const file = draft.file const file = draft.file
void runAction(async () => { return runAction(async () => {
await uploadSource(requireSession(), requireActiveProject(), { await uploadSource(requireSession(), requireActiveProject(), {
title: draft.title.trim(), title: draft.title.trim(),
file, file,
@@ -186,7 +213,7 @@ function App() {
}, '文件已上传') }, '文件已上传')
} }
function handleCreateCronPlan(draft: CronDraft) { async function handleCreateCronPlan(draft: CronDraft) {
if (!draft.title.trim()) { if (!draft.title.trim()) {
Message.warning('请输入计划名称') Message.warning('请输入计划名称')
return return
@@ -195,7 +222,7 @@ function App() {
Message.warning('请输入 Cron 表达式') Message.warning('请输入 Cron 表达式')
return return
} }
void runAction(async () => { return runAction(async () => {
await createCronPlan(requireSession(), requireActiveProject(), { await createCronPlan(requireSession(), requireActiveProject(), {
title: draft.title.trim(), title: draft.title.trim(),
schedule: draft.schedule.trim(), schedule: draft.schedule.trim(),
@@ -207,13 +234,13 @@ function App() {
}, '计划任务已创建') }, '计划任务已创建')
} }
function handleCreateProjectTag(name: string) { async function handleCreateProjectTag(name: string) {
const trimmedName = name.trim() const trimmedName = name.trim()
if (!trimmedName) { if (!trimmedName) {
Message.warning('请输入标签名称') Message.warning('请输入标签名称')
return return
} }
void runAction(async () => { return runAction(async () => {
await createProjectTag(requireSession(), requireActiveProject(), { name: trimmedName }) await createProjectTag(requireSession(), requireActiveProject(), { name: trimmedName })
await refreshAfterAction() await refreshAfterAction()
}, '标签已创建') }, '标签已创建')
@@ -227,7 +254,7 @@ function App() {
} }
function handleUpdateWorkspaceTask(update: WorkspaceTaskUpdate) { function handleUpdateWorkspaceTask(update: WorkspaceTaskUpdate) {
void runAction(async () => { return runAction(async () => {
await updateTask(requireSession(), update.originalProjectId, update.taskId, { await updateTask(requireSession(), update.originalProjectId, update.taskId, {
title: update.title, title: update.title,
description: update.summary, description: update.summary,
@@ -257,6 +284,13 @@ function App() {
return response.suggestions return response.suggestions
} }
async function handleCaptureInbox(projectId: string, input: CaptureInboxInput) {
const currentSession = requireSession()
const item = await captureProjectInbox(currentSession, projectId, input)
await refreshProjectWorkspace(currentSession, projectId)
return item.id
}
async function handleConfirmInbox(inboxId: string, suggestionIds: string[]) { async function handleConfirmInbox(inboxId: string, suggestionIds: string[]) {
const confirmationProjectID = activeProjectIDRef.current const confirmationProjectID = activeProjectIDRef.current
let response let response
@@ -264,21 +298,15 @@ function App() {
response = await confirmInboxItem(requireSession(), inboxId, suggestionIds) response = await confirmInboxItem(requireSession(), inboxId, suggestionIds)
} catch (error) { } catch (error) {
if (!(error instanceof ApiError) || error.status !== 409) throw error if (!(error instanceof ApiError) || error.status !== 409) throw error
if (activeProjectIDRef.current !== confirmationProjectID) {
throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新核对当前工作区')
}
try { try {
await refreshAfterAction() await refreshProjectWorkspace(requireSession(), confirmationProjectID)
} catch { } catch {
throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新进入项目核对') throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新进入项目核对')
} }
throw new ApiError(409, 'conflict', '确认状态发生冲突,工作区已刷新,请重新核对') throw new ApiError(409, 'conflict', '确认状态发生冲突,原项目已在后台刷新,请重新核对')
}
if (activeProjectIDRef.current !== confirmationProjectID) {
return { createdCount: response.createdCount }
} }
try { try {
await refreshAfterAction() await refreshProjectWorkspace(requireSession(), confirmationProjectID)
return { createdCount: response.createdCount } return { createdCount: response.createdCount }
} catch { } catch {
return { return {
@@ -356,6 +384,7 @@ function App() {
onSearch={() => void workspaceSearch.onSearch()} onSearch={() => void workspaceSearch.onSearch()}
onSelectSearchResult={handleSelectSearchResult} onSelectSearchResult={handleSelectSearchResult}
onAnalyzeInbox={handleAnalyzeInbox} onAnalyzeInbox={handleAnalyzeInbox}
onCaptureInbox={handleCaptureInbox}
onConfirmInbox={handleConfirmInbox} onConfirmInbox={handleConfirmInbox}
onListAISessions={handleListAISessions} onListAISessions={handleListAISessions}
onCreateAISession={handleCreateAISession} onCreateAISession={handleCreateAISession}
@@ -374,6 +403,7 @@ function App() {
tagOptions={activeTagOptions} tagOptions={activeTagOptions}
/> />
<SearchResultPreview result={searchResultPreview} onClose={() => setSearchResultPreview(null)} /> <SearchResultPreview result={searchResultPreview} onClose={() => setSearchResultPreview(null)} />
<WorkbenchItemPreview title={selectedItem} onClose={() => setSelectedItem(null)} />
</main> </main>
</ConfigProvider> </ConfigProvider>
) )
@@ -386,6 +416,10 @@ function searchResultTarget(type: string): { channel: ChannelKey; openTask: bool
return null return null
} }
function staleWorkspaceRefreshError() {
return new DOMException('workspace refresh superseded', 'AbortError')
}
function isAuthorizedExternalPreview(result: SearchResultDTO) { function isAuthorizedExternalPreview(result: SearchResultDTO) {
return ( return (
(result.type === 'task' || result.type === 'note') (result.type === 'task' || result.type === 'note')

View File

@@ -0,0 +1,29 @@
export type WorkspaceRefreshTicket = {
signal: AbortSignal
isCurrent: () => boolean
}
export function createWorkspaceRefreshGate() {
let generation = 0
let controller: AbortController | null = null
return {
begin(): WorkspaceRefreshTicket {
generation += 1
controller?.abort()
controller = new AbortController()
const requestGeneration = generation
return {
signal: controller.signal,
isCurrent: () => requestGeneration === generation,
}
},
invalidate() {
generation += 1
controller?.abort()
controller = null
},
}
}
export type WorkspaceRefreshGate = ReturnType<typeof createWorkspaceRefreshGate>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Button, Card, Form, Input, Space, Typography } from '@arco-design/web-react' import { Button, Card, Checkbox, Form, Input, Space, Typography } from '@arco-design/web-react'
import { import {
IconCheckCircleFill, IconCheckCircleFill,
IconCloseCircleFill, IconCloseCircleFill,
@@ -11,9 +11,14 @@ import { normalizeBaseUrl } from '../api/client'
const { Title, Text } = Typography const { Title, Text } = Typography
type ConnectionStatus = 'checking' | 'online' | 'offline' type ConnectionStatus = 'checking' | 'online' | 'offline'
const REMEMBERED_SERVER_KEY = 'senlinai.server'
export function LoginPage({ onLogin }: { onLogin: (input: { server: string; email: string; password: string }) => void }) { export function LoginPage({ onLogin }: { onLogin: (input: { server: string; email: string; password: string }) => void }) {
const [server, setServer] = useState('http://localhost:9150') const [server, setServer] = useState(() => {
if (typeof window === 'undefined') return 'http://localhost:9150'
return window.localStorage.getItem(REMEMBERED_SERVER_KEY) || 'http://localhost:9150'
})
const [rememberServer, setRememberServer] = useState(() => typeof window !== 'undefined' && window.localStorage.getItem(REMEMBERED_SERVER_KEY) !== null)
const [email, setEmail] = useState('demo@senlin.ai') const [email, setEmail] = useState('demo@senlin.ai')
const [password, setPassword] = useState('password123') const [password, setPassword] = useState('password123')
const [status, setStatus] = useState<ConnectionStatus>('checking') const [status, setStatus] = useState<ConnectionStatus>('checking')
@@ -42,8 +47,6 @@ export function LoginPage({ onLogin }: { onLogin: (input: { server: string; emai
<Text type="secondary"> · · AI </Text> <Text type="secondary"> · · AI </Text>
<Space className="login-footer-links" size={24}> <Space className="login-footer-links" size={24}>
<Text type="secondary">v1.0.0</Text> <Text type="secondary">v1.0.0</Text>
<Text type="secondary"></Text>
<Text type="secondary"></Text>
</Space> </Space>
</div> </div>
@@ -63,13 +66,13 @@ export function LoginPage({ onLogin }: { onLogin: (input: { server: string; emai
<Input.Password value={password} onChange={setPassword} placeholder="请输入密码" /> <Input.Password value={password} onChange={setPassword} placeholder="请输入密码" />
</Form.Item> </Form.Item>
<div className="login-row"> <div className="login-row">
<Space size={8}> <Checkbox checked={rememberServer} onChange={setRememberServer}></Checkbox>
<span className="check-dot" />
<Text></Text>
</Space>
<Button type="text" size="mini">?</Button>
</div> </div>
<Button type="primary" long size="large" onClick={() => onLogin({ server, email, password })}> <Button type="primary" long size="large" onClick={() => {
if (rememberServer) window.localStorage.setItem(REMEMBERED_SERVER_KEY, server)
else window.localStorage.removeItem(REMEMBERED_SERVER_KEY)
onLogin({ server, email, password })
}}>
</Button> </Button>
</Form> </Form>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react' import { Alert, Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
const { Text } = Typography const { Text } = Typography
const { TextArea } = Input const { TextArea } = Input
@@ -45,19 +45,30 @@ export function ProjectActionModals({
activeModal: ProjectActionModal activeModal: ProjectActionModal
loading: boolean loading: boolean
onClose: () => void onClose: () => void
onCreateProject: (draft: ProjectDraft) => void onCreateProject: (draft: ProjectDraft) => Promise<void>
onCreateTask: (draft: TaskDraft) => void onCreateTask: (draft: TaskDraft) => Promise<void>
onUploadSource: (draft: SourceDraft) => void onUploadSource: (draft: SourceDraft) => Promise<void>
onCreateCronPlan: (draft: CronDraft) => void onCreateCronPlan: (draft: CronDraft) => Promise<void>
tagOptions: string[] tagOptions: string[]
}) { }) {
const [project, setProject] = useState<ProjectDraft>({ name: '', identifier: '', icon: '', background: '', description: '' }) const [project, setProject] = useState<ProjectDraft>({ name: '', identifier: '', icon: '', background: '', description: '' })
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', tag: '' }) const [task, setTask] = useState<TaskDraft>({ title: '', description: '', tag: '' })
const [source, setSource] = useState<SourceDraft>({ title: '', file: null }) const [source, setSource] = useState<SourceDraft>({ title: '', file: null })
const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' }) const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
const [submitError, setSubmitError] = useState('')
async function submit(action: () => Promise<void>) {
setSubmitError('')
try {
await action()
} catch (error) {
setSubmitError(error instanceof Error ? error.message : '操作失败,请稍后重试')
}
}
useEffect(() => { useEffect(() => {
if (activeModal === null) { if (activeModal === null) {
setSubmitError('')
setProject({ name: '', identifier: '', icon: '', background: '', description: '' }) setProject({ name: '', identifier: '', icon: '', background: '', description: '' })
setTask({ title: '', description: '', tag: '' }) setTask({ title: '', description: '', tag: '' })
setSource({ title: '', file: null }) setSource({ title: '', file: null })
@@ -72,10 +83,13 @@ export function ProjectActionModals({
title="新建项目" title="新建项目"
visible={activeModal === 'project'} visible={activeModal === 'project'}
confirmLoading={loading} confirmLoading={loading}
onCancel={onClose} maskClosable={!loading}
onOk={() => onCreateProject(project)} cancelButtonProps={{ disabled: loading }}
onCancel={() => { if (!loading) onClose() }}
onOk={() => void submit(() => onCreateProject(project))}
> >
<Space direction="vertical" size={12} className="action-form"> <Space direction="vertical" size={12} className="action-form">
{submitError && <Alert type="error" content={submitError} />}
<label> <label>
<Text></Text> <Text></Text>
<Input placeholder="例如:项目 A3" value={project.name} onChange={(name) => setProject((draft) => ({ ...draft, name }))} /> <Input placeholder="例如:项目 A3" value={project.name} onChange={(name) => setProject((draft) => ({ ...draft, name }))} />
@@ -104,10 +118,13 @@ export function ProjectActionModals({
title="新建任务" title="新建任务"
visible={activeModal === 'task'} visible={activeModal === 'task'}
confirmLoading={loading} confirmLoading={loading}
onCancel={onClose} maskClosable={!loading}
onOk={() => onCreateTask(task)} cancelButtonProps={{ disabled: loading }}
onCancel={() => { if (!loading) onClose() }}
onOk={() => void submit(() => onCreateTask(task))}
> >
<Space direction="vertical" size={12} className="action-form"> <Space direction="vertical" size={12} className="action-form">
{submitError && <Alert type="error" content={submitError} />}
<label> <label>
<Text></Text> <Text></Text>
<Input placeholder="要完成什么?" value={task.title} onChange={(title) => setTask((draft) => ({ ...draft, title }))} /> <Input placeholder="要完成什么?" value={task.title} onChange={(title) => setTask((draft) => ({ ...draft, title }))} />
@@ -137,10 +154,13 @@ export function ProjectActionModals({
title="上传文件" title="上传文件"
visible={activeModal === 'source'} visible={activeModal === 'source'}
confirmLoading={loading} confirmLoading={loading}
onCancel={onClose} maskClosable={!loading}
onOk={() => onUploadSource(source)} cancelButtonProps={{ disabled: loading }}
onCancel={() => { if (!loading) onClose() }}
onOk={() => void submit(() => onUploadSource(source))}
> >
<Space direction="vertical" size={12} className="action-form"> <Space direction="vertical" size={12} className="action-form">
{submitError && <Alert type="error" content={submitError} />}
<label> <label>
<Text></Text> <Text></Text>
<Input placeholder="默认使用文件名" value={source.title} onChange={(title) => setSource((draft) => ({ ...draft, title }))} /> <Input placeholder="默认使用文件名" value={source.title} onChange={(title) => setSource((draft) => ({ ...draft, title }))} />
@@ -161,10 +181,13 @@ export function ProjectActionModals({
title="新建计划任务" title="新建计划任务"
visible={activeModal === 'cron'} visible={activeModal === 'cron'}
confirmLoading={loading} confirmLoading={loading}
onCancel={onClose} maskClosable={!loading}
onOk={() => onCreateCronPlan(cron)} cancelButtonProps={{ disabled: loading }}
onCancel={() => { if (!loading) onClose() }}
onOk={() => void submit(() => onCreateCronPlan(cron))}
> >
<Space direction="vertical" size={12} className="action-form"> <Space direction="vertical" size={12} className="action-form">
{submitError && <Alert type="error" content={submitError} />}
<label> <label>
<Text></Text> <Text></Text>
<Input placeholder="例如:每日 Inbox 整理" value={cron.title} onChange={(title) => setCron((draft) => ({ ...draft, title }))} /> <Input placeholder="例如:每日 Inbox 整理" value={cron.title} onChange={(title) => setCron((draft) => ({ ...draft, title }))} />

View File

@@ -7,8 +7,11 @@ import { ProjectOverview } from './project-overview'
import type { ProjectTaskUpdate } from './project-task-edit-modal' import type { ProjectTaskUpdate } from './project-task-edit-modal'
import { ProjectTasks } from './project-tasks' import { ProjectTasks } from './project-tasks'
import type { ChannelKey, InboxConfirmationOutcome, ProjectWorkspace } from './project-types' import type { ChannelKey, InboxConfirmationOutcome, ProjectWorkspace } from './project-types'
import type { InboxSuggestionDTO } from '../../api/inbox' import type { CaptureInboxInput, InboxSuggestionDTO } from '../../api/inbox'
import type { AISessionDTO, CreateAISessionInput } from '../../api/ai' import type { AISessionDTO, CreateAISessionInput } from '../../api/ai'
import { Card, Empty, Typography } from '@arco-design/web-react'
const { Text } = Typography
export function ProjectChannelPage({ export function ProjectChannelPage({
activeChannel, activeChannel,
@@ -23,6 +26,7 @@ export function ProjectChannelPage({
onCreateProjectTag, onCreateProjectTag,
onUpdateTask, onUpdateTask,
onAnalyzeInbox, onAnalyzeInbox,
onCaptureInbox,
onConfirmInbox, onConfirmInbox,
onListAISessions, onListAISessions,
onCreateAISession, onCreateAISession,
@@ -36,16 +40,28 @@ export function ProjectChannelPage({
onCreateTask: () => void onCreateTask: () => void
onUploadSource: () => void onUploadSource: () => void
onCreateCronPlan: () => void onCreateCronPlan: () => void
onCreateProjectTag: (name: string) => void onCreateProjectTag: (name: string) => Promise<void>
onUpdateTask: (update: ProjectTaskUpdate) => void onUpdateTask: (update: ProjectTaskUpdate) => Promise<void>
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]> onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
onCaptureInbox: (projectId: string, input: CaptureInboxInput) => Promise<string>
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome> onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]> onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO> onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
}) { }) {
if (activeChannel.startsWith('custom:')) {
return (
<div className="project-channel-page project-unavailable-page overview-page">
<Card className="queue-section" bordered>
<Empty description="自定义链接频道暂未开放" />
<Text type="secondary">MVP </Text>
</Card>
</div>
)
}
switch (activeChannel) { switch (activeChannel) {
case 'inbox': case 'inbox':
return <ProjectInbox key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onAnalyze={onAnalyzeInbox} onConfirm={onConfirmInbox} /> return <ProjectInbox key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onCapture={onCaptureInbox} onAnalyze={onAnalyzeInbox} onConfirm={onConfirmInbox} />
case 'tasks': case 'tasks':
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} /> return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} />
case 'ai': case 'ai':

View File

@@ -1,5 +1,5 @@
import { Button, Card, Space, Switch, Tag, Typography } from '@arco-design/web-react' import { Button, Card, Empty, Space, Tag, Typography } from '@arco-design/web-react'
import { IconClockCircle, IconPlus, IconThunderbolt } from '@arco-design/web-react/icon' import { IconClockCircle, IconPlus } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './project-types' import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography const { Title, Text } = Typography
@@ -41,18 +41,18 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
<span className="row-title"><IconClockCircle /> {job.name}</span> <span className="row-title"><IconClockCircle /> {job.name}</span>
<Tag color={job.enabled ? 'green' : 'gray'}>{job.status}</Tag> <Tag color={job.enabled ? 'green' : 'gray'}>{job.status}</Tag>
<span>{job.expr}</span> <span>{job.expr}</span>
<span>{job.lastRun}</span> <span>{job.lastRun || '暂无执行记录'}</span>
<span>{job.nextRun}</span> <span>{job.nextRun}</span>
<span>{job.owner}</span> <span>{job.owner}</span>
<Switch size="small" checked={job.enabled} />
</div> </div>
))} ))}
{cronJobs.length === 0 && <Empty description="暂无计划任务" />}
</Card> </Card>
<Card className="queue-section cron-summary" bordered> <Card className="queue-section cron-summary" bordered>
<Space> <Space>
<Tag color="arcoblue"><IconThunderbolt /></Tag> <Tag color="arcoblue"></Tag>
<Text> 12:00</Text> <Text>MVP </Text>
</Space> </Space>
</Card> </Card>
</div> </div>

View File

@@ -1,14 +1,16 @@
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import { Alert, Button, Card, Checkbox, Empty, Space, Tag, Typography } from '@arco-design/web-react' import { Alert, Button, Card, Checkbox, Empty, Input, Space, Tag, Typography } from '@arco-design/web-react'
import { IconCheckCircle, IconRobot } from '@arco-design/web-react/icon' import { IconCheckCircle, IconPlus, IconRobot } from '@arco-design/web-react/icon'
import { ApiError } from '../../api/client' import { ApiError } from '../../api/client'
import type { InboxSuggestionDTO } from '../../api/inbox' import type { CaptureInboxInput, InboxSuggestionDTO } from '../../api/inbox'
import type { InboxConfirmationOutcome, InboxItem, ProjectWorkspace } from './project-types' import type { InboxConfirmationOutcome, InboxItem, ProjectWorkspace } from './project-types'
const { Title, Text, Paragraph } = Typography const { Title, Text, Paragraph } = Typography
const { TextArea } = Input
type ProjectInboxProps = { type ProjectInboxProps = {
activeWorkspace: ProjectWorkspace activeWorkspace: ProjectWorkspace
onCapture: (projectId: string, input: CaptureInboxInput) => Promise<string>
onAnalyze: (inboxId: string) => Promise<InboxSuggestionDTO[]> onAnalyze: (inboxId: string) => Promise<InboxSuggestionDTO[]>
onConfirm: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome> onConfirm: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
} }
@@ -18,7 +20,7 @@ type UncertainConfirmation = {
suggestionIds: string[] suggestionIds: string[]
} }
export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectInboxProps) { export function ProjectInbox({ activeWorkspace, onCapture, onAnalyze, onConfirm }: ProjectInboxProps) {
const [selectedItemId, setSelectedItemId] = useState(activeWorkspace.inbox[0]?.id ?? '') const [selectedItemId, setSelectedItemId] = useState(activeWorkspace.inbox[0]?.id ?? '')
const [draftSuggestions, setDraftSuggestions] = useState<InboxSuggestionDTO[]>([]) const [draftSuggestions, setDraftSuggestions] = useState<InboxSuggestionDTO[]>([])
const [selectedSuggestionIds, setSelectedSuggestionIds] = useState<string[]>([]) const [selectedSuggestionIds, setSelectedSuggestionIds] = useState<string[]>([])
@@ -29,12 +31,42 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
const [refreshWarning, setRefreshWarning] = useState('') const [refreshWarning, setRefreshWarning] = useState('')
const [locallyConfirmedItemIds, setLocallyConfirmedItemIds] = useState<string[]>([]) const [locallyConfirmedItemIds, setLocallyConfirmedItemIds] = useState<string[]>([])
const [confirmationUncertain, setConfirmationUncertain] = useState<UncertainConfirmation | null>(null) const [confirmationUncertain, setConfirmationUncertain] = useState<UncertainConfirmation | null>(null)
const [captureDraft, setCaptureDraft] = useState({ title: '', body: '' })
const [capturing, setCapturing] = useState(false)
const [captureError, setCaptureError] = useState('')
const [captureSuccess, setCaptureSuccess] = useState('')
const analysisGeneration = useRef(0) const analysisGeneration = useRef(0)
const selectedItem = useMemo( const selectedItem = useMemo(
() => activeWorkspace.inbox.find((item) => item.id === selectedItemId) ?? activeWorkspace.inbox[0], () => activeWorkspace.inbox.find((item) => item.id === selectedItemId) ?? activeWorkspace.inbox[0],
[activeWorkspace.inbox, selectedItemId], [activeWorkspace.inbox, selectedItemId],
) )
async function captureItem() {
const title = captureDraft.title.trim()
const body = captureDraft.body.trim()
if (!title && !body) {
setCaptureError('请输入标题或收集内容')
return
}
setCapturing(true)
setCaptureError('')
setCaptureSuccess('')
try {
const itemId = await onCapture(activeWorkspace.project.id, {
sourceType: 'manual',
title: title || body.slice(0, 40),
body: body || title,
})
setSelectedItemId(itemId)
setCaptureDraft({ title: '', body: '' })
setCaptureSuccess('已收集到 Inbox可继续分析并确认创建')
} catch (reason) {
setCaptureError(reason instanceof Error ? reason.message : '收集失败,请稍后重试')
} finally {
setCapturing(false)
}
}
function selectItem(item: InboxItem) { function selectItem(item: InboxItem) {
if (confirmationUncertain) return if (confirmationUncertain) return
analysisGeneration.current += 1 analysisGeneration.current += 1
@@ -112,6 +144,35 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
</div> </div>
</div> </div>
<Card className="queue-section inbox-capture-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Text type="secondary"> Inbox</Text>
</div>
<Space direction="vertical" size={10} className="inbox-capture-form">
<Input
aria-label="Inbox 标题"
placeholder="标题(可选)"
value={captureDraft.title}
disabled={capturing}
onChange={(title) => setCaptureDraft((draft) => ({ ...draft, title }))}
/>
<TextArea
aria-label="Inbox 收集内容"
placeholder="粘贴想法、待办或资料内容"
rows={3}
value={captureDraft.body}
disabled={capturing}
onChange={(body) => setCaptureDraft((draft) => ({ ...draft, body }))}
/>
{captureError && <Alert type="error" content={captureError} />}
{captureSuccess && <Alert type="success" content={captureSuccess} />}
<Button type="primary" icon={<IconPlus />} loading={capturing} disabled={capturing} onClick={() => void captureItem()}>
Inbox
</Button>
</Space>
</Card>
{activeWorkspace.inbox.length === 0 ? ( {activeWorkspace.inbox.length === 0 ? (
<Card className="queue-section" bordered> <Card className="queue-section" bordered>
<Empty description="当前没有待处理的 Inbox 内容" /> <Empty description="当前没有待处理的 Inbox 内容" />

View File

@@ -1,5 +1,5 @@
import { Button, Card, Space, Typography } from '@arco-design/web-react' import { Button, Card, Space, Typography } from '@arco-design/web-react'
import { IconPlus, IconSearch } from '@arco-design/web-react/icon' import { IconPlus } from '@arco-design/web-react/icon'
import { ProjectFileGrid } from './project-file-grid' import { ProjectFileGrid } from './project-file-grid'
import type { ProjectWorkspace } from './project-types' import type { ProjectWorkspace } from './project-types'
@@ -16,7 +16,6 @@ export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }:
<Text type="secondary">{project.name} 线</Text> <Text type="secondary">{project.name} 线</Text>
</div> </div>
<Space> <Space>
<Button icon={<IconSearch />}></Button>
<Button type="primary" icon={<IconPlus />} onClick={onUploadSource}>/</Button> <Button type="primary" icon={<IconPlus />} onClick={onUploadSource}>/</Button>
</Space> </Space>
</div> </div>
@@ -24,7 +23,6 @@ export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }:
<Card className="queue-section" bordered> <Card className="queue-section" bordered>
<div className="section-header"> <div className="section-header">
<Title heading={6}></Title> <Title heading={6}></Title>
<Button type="text" size="mini"></Button>
</div> </div>
<ProjectFileGrid items={notes} onSelectItem={onSelectItem} /> <ProjectFileGrid items={notes} onSelectItem={onSelectItem} />
</Card> </Card>

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react' import { Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { IconCheckCircleFill, IconClockCircle, IconFile, IconLink } from '@arco-design/web-react/icon' import { IconCheckCircleFill, IconClockCircle, IconFile } from '@arco-design/web-react/icon'
import { ProjectFileGrid } from './project-file-grid' import { ProjectFileGrid } from './project-file-grid'
import { ProjectTaskEditModal, type ProjectTaskUpdate } from './project-task-edit-modal' import { ProjectTaskEditModal, type ProjectTaskUpdate } from './project-task-edit-modal'
import type { ProjectWorkspace, TaskItem } from './project-types' import type { ProjectWorkspace, TaskItem } from './project-types'
@@ -15,13 +15,12 @@ export function ProjectOverview({
}: { }: {
activeWorkspace: ProjectWorkspace activeWorkspace: ProjectWorkspace
onSelectItem: (title: string) => void onSelectItem: (title: string) => void
onUpdateTask: (update: ProjectTaskUpdate) => void onUpdateTask: (update: ProjectTaskUpdate) => Promise<void>
}) { }) {
const { project, tasks, notes, cronJobs } = activeWorkspace const { project, tasks, notes, cronJobs } = activeWorkspace
const runningTasks = useMemo(() => tasks.filter((task) => !task.completed), [tasks]) const runningTasks = useMemo(() => tasks.filter((task) => !task.completed), [tasks])
const completedTasks = useMemo(() => tasks.filter((task) => task.completed), [tasks]) const completedTasks = useMemo(() => tasks.filter((task) => task.completed), [tasks])
const [editingTask, setEditingTask] = useState<TaskItem | null>(null) const [editingTask, setEditingTask] = useState<TaskItem | null>(null)
const shareURL = useMemo(() => projectShareURL(project.identifier || project.id), [project.id, project.identifier])
const metrics = useMemo(() => [ const metrics = useMemo(() => [
{ title: '进行中的任务', value: runningTasks.length, icon: <IconCheckCircleFill />, color: 'green' }, { title: '进行中的任务', value: runningTasks.length, icon: <IconCheckCircleFill />, color: 'green' },
{ title: '完成的任务', value: completedTasks.length, icon: <IconCheckCircleFill />, color: 'arcoblue' }, { title: '完成的任务', value: completedTasks.length, icon: <IconCheckCircleFill />, color: 'arcoblue' },
@@ -40,8 +39,8 @@ export function ProjectOverview({
</div> </div>
</div> </div>
<div className="project-overview-share"> <div className="project-overview-share">
<Text> URL</Text> <Text></Text>
<Button icon={<IconLink />} onClick={() => onSelectItem(shareURL)}>{shareURL}</Button> <Tag color="arcoblue">{project.identifier || '未设置'}</Tag>
</div> </div>
</section> </section>
@@ -69,10 +68,7 @@ export function ProjectOverview({
task={editingTask} task={editingTask}
workspace={activeWorkspace} workspace={activeWorkspace}
onClose={() => setEditingTask(null)} onClose={() => setEditingTask(null)}
onSubmit={(update) => { onSubmit={onUpdateTask}
onUpdateTask(update)
setEditingTask(null)
}}
/> />
</div> </div>
) )
@@ -95,7 +91,6 @@ function TaskCardSection({
<Card className="queue-section overview-task-section" bordered> <Card className="queue-section overview-task-section" bordered>
<div className="section-header"> <div className="section-header">
<Title heading={6}>{title}</Title> <Title heading={6}>{title}</Title>
<Button type="text" size="mini"></Button>
</div> </div>
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<Text type="secondary">{emptyText}</Text> <Text type="secondary">{emptyText}</Text>
@@ -126,19 +121,12 @@ function NoteSection({ notes, onSelectItem }: { notes: ProjectWorkspace['notes']
<Card className="queue-section" bordered> <Card className="queue-section" bordered>
<div className="section-header"> <div className="section-header">
<Title heading={6}>{notes.length}</Title> <Title heading={6}>{notes.length}</Title>
<Button type="text" size="mini"></Button>
</div> </div>
<ProjectFileGrid items={notes.slice(0, 8)} onSelectItem={onSelectItem} compact /> <ProjectFileGrid items={notes.slice(0, 8)} onSelectItem={onSelectItem} compact />
</Card> </Card>
) )
} }
function projectShareURL(identifier: string) {
const slug = encodeURIComponent(identifier.trim() || 'project')
const origin = typeof window === 'undefined' ? 'https://senlinai.local' : window.location.origin
return `${origin}/share/projects/${slug}`
}
function projectHeroBackground(background: string, fallback: string) { function projectHeroBackground(background: string, fallback: string) {
const value = background.trim() || fallback const value = background.trim() || fallback
if (value.startsWith('http')) { if (value.startsWith('http')) {

View File

@@ -50,20 +50,22 @@ export function ProjectRail({
aria-label="探索" aria-label="探索"
title="探索" title="探索"
/> />
<Space className="project-stack" direction="vertical" size={12}> <div className="project-stack-scroll">
{projects.map((project) => ( <Space className="project-stack" direction="vertical" size={12}>
<Badge key={project.id} count={project.badge} dot={false} className={project.urgent ? 'project-badge urgent' : 'project-badge'}> {projects.map((project) => (
<button <Badge key={project.id} count={project.badge} dot={false} className={project.urgent ? 'project-badge urgent' : 'project-badge'}>
className={activeView === 'project' && activeProject.id === project.id ? 'project-button active' : 'project-button'} <button
onClick={() => selectAndClose(() => onSelectProject(project))} className={activeView === 'project' && activeProject.id === project.id ? 'project-button active' : 'project-button'}
style={{ '--project-color': project.color } as CSSProperties} onClick={() => selectAndClose(() => onSelectProject(project))}
title={project.name} style={{ '--project-color': project.color } as CSSProperties}
> title={project.name}
{project.short} >
</button> {project.short}
</Badge> </button>
))} </Badge>
</Space> ))}
</Space>
</div>
<Button className="create-project" aria-label="新建项目" icon={<IconPlus />} title="新建项目" onClick={() => selectAndClose(onCreateProject)} /> <Button className="create-project" aria-label="新建项目" icon={<IconPlus />} title="新建项目" onClick={() => selectAndClose(onCreateProject)} />
</Sider> </Sider>
) )

View File

@@ -1,4 +1,4 @@
import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react' import { Alert, Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import type { ProjectWorkspace, TaskItem } from './project-types' import type { ProjectWorkspace, TaskItem } from './project-types'
@@ -29,13 +29,16 @@ export function ProjectTaskEditModal({
task: TaskItem | null task: TaskItem | null
workspace: ProjectWorkspace workspace: ProjectWorkspace
onClose: () => void onClose: () => void
onSubmit: (update: ProjectTaskUpdate) => void onSubmit: (update: ProjectTaskUpdate) => Promise<void>
}) { }) {
const tagOptions = useMemo(() => workspace.tags.filter((tag) => tag !== 'all' && tag !== '全部'), [workspace.tags]) const tagOptions = useMemo(() => workspace.tags.filter((tag) => tag !== 'all' && tag !== '全部'), [workspace.tags])
const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', tag: '', completed: false }) const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', tag: '', completed: false })
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState('')
useEffect(() => { useEffect(() => {
if (!task) return if (!task) return
setSaveError('')
setDraft({ setDraft({
title: task.title, title: task.title,
summary: task.summary, summary: task.summary,
@@ -44,24 +47,39 @@ export function ProjectTaskEditModal({
}) })
}, [task]) }, [task])
async function saveTask() {
if (!task || saving) return
setSaving(true)
setSaveError('')
try {
await onSubmit({
taskId: task.id,
title: draft.title.trim() || task.title,
summary: draft.summary.trim(),
tag: draft.tag.trim(),
completed: draft.completed,
})
onClose()
} catch (error) {
setSaveError(error instanceof Error ? error.message : '任务保存失败,请稍后重试')
} finally {
setSaving(false)
}
}
return ( return (
<Modal <Modal
className="action-modal" className="action-modal"
title="编辑计划" title="编辑计划"
visible={Boolean(task)} visible={Boolean(task)}
onCancel={onClose} confirmLoading={saving}
onOk={() => { maskClosable={!saving}
if (!task) return cancelButtonProps={{ disabled: saving }}
onSubmit({ onCancel={() => { if (!saving) onClose() }}
taskId: task.id, onOk={() => void saveTask()}
title: draft.title.trim() || task.title,
summary: draft.summary.trim(),
tag: draft.tag.trim(),
completed: draft.completed,
})
}}
> >
<Space direction="vertical" size={12} className="action-form"> <Space direction="vertical" size={12} className="action-form">
{saveError && <Alert type="error" content={saveError} />}
<label> <label>
<Text></Text> <Text></Text>
<Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} /> <Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} />

View File

@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Button, Card, Descriptions, Input, Modal, Progress, Space, Tag, Typography } from '@arco-design/web-react' import { Alert, Button, Card, Descriptions, Input, Modal, Progress, Space, Tag, Typography } from '@arco-design/web-react'
import { IconArrowLeft, IconCalendar, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconPlus } from '@arco-design/web-react/icon' import { IconArrowLeft, IconCalendar, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconPlus } from '@arco-design/web-react/icon'
import { ProjectTaskEditModal, type ProjectTaskUpdate } from './project-task-edit-modal' import { ProjectTaskEditModal, type ProjectTaskUpdate } from './project-task-edit-modal'
import type { ProjectWorkspace, TaskItem } from './project-types' import type { ProjectWorkspace, TaskItem } from './project-types'
@@ -21,8 +21,8 @@ export function ProjectTasks({
onCloseTask: () => void onCloseTask: () => void
onSelectItem: (title: string) => void onSelectItem: (title: string) => void
onCreateTask: () => void onCreateTask: () => void
onCreateProjectTag: (name: string) => void onCreateProjectTag: (name: string) => Promise<void>
onUpdateTask: (update: ProjectTaskUpdate) => void onUpdateTask: (update: ProjectTaskUpdate) => Promise<void>
}) { }) {
const { tasks, project, tags } = activeWorkspace const { tasks, project, tags } = activeWorkspace
const activeTask = tasks.find((task) => task.id === activeTaskID) const activeTask = tasks.find((task) => task.id === activeTaskID)
@@ -32,17 +32,27 @@ export function ProjectTasks({
const [tagModalOpen, setTagModalOpen] = useState(false) const [tagModalOpen, setTagModalOpen] = useState(false)
const [tagName, setTagName] = useState('') const [tagName, setTagName] = useState('')
const [editingTask, setEditingTask] = useState<TaskItem | null>(null) const [editingTask, setEditingTask] = useState<TaskItem | null>(null)
const [tagSaving, setTagSaving] = useState(false)
const [tagError, setTagError] = useState('')
if (activeTask) { if (activeTask) {
return <TaskDetail activeWorkspace={activeWorkspace} task={activeTask} onBack={onCloseTask} /> return <TaskDetail activeWorkspace={activeWorkspace} task={activeTask} onBack={onCloseTask} />
} }
function submitTag() { async function submitTag() {
const nextTag = tagName.trim() const nextTag = tagName.trim()
if (!nextTag) return if (!nextTag || tagSaving) return
onCreateProjectTag(nextTag) setTagSaving(true)
setTagName('') setTagError('')
setTagModalOpen(false) try {
await onCreateProjectTag(nextTag)
setTagName('')
setTagModalOpen(false)
} catch (error) {
setTagError(error instanceof Error ? error.message : '标签创建失败,请稍后重试')
} finally {
setTagSaving(false)
}
} }
return ( return (
@@ -111,16 +121,22 @@ export function ProjectTasks({
className="action-modal" className="action-modal"
title="新建标签" title="新建标签"
visible={tagModalOpen} visible={tagModalOpen}
confirmLoading={tagSaving}
maskClosable={!tagSaving}
cancelButtonProps={{ disabled: tagSaving }}
onCancel={() => { onCancel={() => {
if (tagSaving) return
setTagModalOpen(false) setTagModalOpen(false)
setTagName('') setTagName('')
setTagError('')
}} }}
onOk={submitTag} onOk={() => void submitTag()}
> >
<Space direction="vertical" size={12} className="action-form"> <Space direction="vertical" size={12} className="action-form">
{tagError && <Alert type="error" content={tagError} />}
<label> <label>
<Text></Text> <Text></Text>
<Input autoFocus placeholder="例如:设计、客户、重要" value={tagName} onChange={setTagName} onPressEnter={submitTag} /> <Input autoFocus placeholder="例如:设计、客户、重要" value={tagName} onChange={setTagName} onPressEnter={() => void submitTag()} />
</label> </label>
</Space> </Space>
</Modal> </Modal>
@@ -128,10 +144,7 @@ export function ProjectTasks({
task={editingTask} task={editingTask}
workspace={activeWorkspace} workspace={activeWorkspace}
onClose={() => setEditingTask(null)} onClose={() => setEditingTask(null)}
onSubmit={(update) => { onSubmit={onUpdateTask}
onUpdateTask(update)
setEditingTask(null)
}}
/> />
</div> </div>
) )

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Button, Dropdown, Input, Layout, List, Space, Typography } from '@arco-design/web-react' import { Button, Dropdown, Input, Layout, List, Space, Typography } from '@arco-design/web-react'
import { IconApps, IconMenuUnfold, IconMoon, IconSearch, IconSun } from '@arco-design/web-react/icon' import { IconApps, IconClose, IconMenuUnfold, IconMoon, IconSearch, IconSun } from '@arco-design/web-react/icon'
import type { SearchResultDTO } from '../../api/search' import type { SearchResultDTO } from '../../api/search'
import type { Theme } from './project-types' import type { Theme } from './project-types'
@@ -35,6 +35,7 @@ export function ProjectTopbar({
onSelectSearchResult: (result: SearchResultDTO) => void onSelectSearchResult: (result: SearchResultDTO) => void
}) { }) {
const [popupVisible, setPopupVisible] = useState(false) const [popupVisible, setPopupVisible] = useState(false)
const [mobileSearchOpen, setMobileSearchOpen] = useState(false)
useEffect(() => { useEffect(() => {
if (!loading && searched) setPopupVisible(true) if (!loading && searched) setPopupVisible(true)
@@ -42,6 +43,7 @@ export function ProjectTopbar({
const submitSearch = () => { const submitSearch = () => {
setPopupVisible(false) setPopupVisible(false)
setMobileSearchOpen(false)
onSearch() onSearch()
} }
@@ -59,6 +61,7 @@ export function ProjectTopbar({
className="search-result-item" className="search-result-item"
onClick={() => { onClick={() => {
setPopupVisible(false) setPopupVisible(false)
setMobileSearchOpen(false)
onSelectSearchResult(result) onSelectSearchResult(result)
}} }}
> >
@@ -87,7 +90,7 @@ export function ProjectTopbar({
position="bl" position="bl"
onVisibleChange={(visible) => setPopupVisible(visible && searched)} onVisibleChange={(visible) => setPopupVisible(visible && searched)}
> >
<div className="global-search" role="search"> <div className={mobileSearchOpen ? 'global-search mobile-open' : 'global-search'} role="search">
<Input <Input
size="large" size="large"
prefix={<IconSearch />} prefix={<IconSearch />}
@@ -103,9 +106,15 @@ export function ProjectTopbar({
</div> </div>
</Dropdown> </Dropdown>
<Space className="topbar-actions"> <Space className="topbar-actions">
<Button className="mobile-nav-button" aria-label="打开项目导航" icon={<IconApps />} onClick={onOpenProjects} /> <Button
className="mobile-search-button"
aria-label={mobileSearchOpen ? '关闭全局搜索' : '打开全局搜索'}
icon={mobileSearchOpen ? <IconClose /> : <IconSearch />}
onClick={() => setMobileSearchOpen((open) => !open)}
/>
<Button className="mobile-nav-button project-nav-button" aria-label="打开项目导航" icon={<IconApps />} onClick={onOpenProjects} />
{showChannels && ( {showChannels && (
<Button className="mobile-nav-button" aria-label="打开频道导航" icon={<IconMenuUnfold />} onClick={onOpenChannels} /> <Button className="mobile-nav-button channel-nav-button" aria-label="打开频道导航" icon={<IconMenuUnfold />} onClick={onOpenChannels} />
)} )}
<Button aria-label={theme === 'dark' ? '切换浅色模式' : '切换深色模式'} icon={theme === 'dark' ? <IconSun /> : <IconMoon />} onClick={onToggleTheme} /> <Button aria-label={theme === 'dark' ? '切换浅色模式' : '切换深色模式'} icon={theme === 'dark' ? <IconSun /> : <IconMoon />} onClick={onToggleTheme} />
</Space> </Space>

View File

@@ -0,0 +1,20 @@
import { Modal, Space, Typography } from '@arco-design/web-react'
const { Text, Title } = Typography
export function WorkbenchItemPreview({ title, onClose }: { title: string | null; onClose: () => void }) {
return (
<Modal
className="item-preview-modal"
title="只读预览"
visible={Boolean(title)}
footer={null}
onCancel={onClose}
>
<Space direction="vertical" size={12}>
<Title heading={5}>{title}</Title>
<Text type="secondary"></Text>
</Space>
</Modal>
)
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Card, Empty, Grid, Input, Modal, Select, Space, Switch, Tag, Typography } from '@arco-design/web-react' import { Alert, Card, Empty, Grid, Input, Modal, Select, Space, Switch, Tag, Typography } from '@arco-design/web-react'
import { import {
IconCalendar, IconCalendar,
IconCheckCircleFill, IconCheckCircleFill,
@@ -41,7 +41,7 @@ export function WorkspacePage({
}: { }: {
workspaces: ProjectWorkspace[] workspaces: ProjectWorkspace[]
onOpenTask: (project: Project, taskID: string) => void onOpenTask: (project: Project, taskID: string) => void
onUpdateTask: (update: WorkspaceTaskUpdate) => void onUpdateTask: (update: WorkspaceTaskUpdate) => Promise<void>
}) { }) {
const projects = workspaces.map((workspace) => workspace.project) const projects = workspaces.map((workspace) => workspace.project)
const projectOptions = useMemo(() => workspaces.map((workspace) => workspace.project), [workspaces]) const projectOptions = useMemo(() => workspaces.map((workspace) => workspace.project), [workspaces])
@@ -55,6 +55,8 @@ export function WorkspacePage({
) )
const [editingTask, setEditingTask] = useState<WorkspaceTask | null>(null) const [editingTask, setEditingTask] = useState<WorkspaceTask | null>(null)
const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', projectId: '', tag: '', completed: false }) const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', projectId: '', tag: '', completed: false })
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState('')
const tagOptions = useMemo(() => { const tagOptions = useMemo(() => {
const selectedWorkspace = workspaces.find((workspace) => workspace.project.id === draft.projectId) const selectedWorkspace = workspaces.find((workspace) => workspace.project.id === draft.projectId)
return selectedWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? [] return selectedWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
@@ -62,6 +64,7 @@ export function WorkspacePage({
useEffect(() => { useEffect(() => {
if (!editingTask) return if (!editingTask) return
setSaveError('')
setDraft({ setDraft({
title: editingTask.title, title: editingTask.title,
summary: editingTask.summary, summary: editingTask.summary,
@@ -70,6 +73,28 @@ export function WorkspacePage({
completed: editingTask.completed, completed: editingTask.completed,
}) })
}, [editingTask]) }, [editingTask])
async function saveTask() {
if (!editingTask || saving) return
setSaving(true)
setSaveError('')
try {
await 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)
} catch (error) {
setSaveError(error instanceof Error ? error.message : '任务保存失败,请稍后重试')
} finally {
setSaving(false)
}
}
useEffect(() => { useEffect(() => {
if (!draft.tag || tagOptions.includes(draft.tag)) return if (!draft.tag || tagOptions.includes(draft.tag)) return
setDraft((value) => ({ ...value, tag: '' })) setDraft((value) => ({ ...value, tag: '' }))
@@ -145,22 +170,14 @@ export function WorkspacePage({
className="action-modal" className="action-modal"
title="编辑任务" title="编辑任务"
visible={Boolean(editingTask)} visible={Boolean(editingTask)}
onCancel={() => setEditingTask(null)} confirmLoading={saving}
onOk={() => { maskClosable={!saving}
if (!editingTask) return cancelButtonProps={{ disabled: saving }}
onUpdateTask({ onCancel={() => { if (!saving) setEditingTask(null) }}
originalProjectId: editingTask.project.id, onOk={() => void saveTask()}
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"> <Space direction="vertical" size={12} className="action-form">
{saveError && <Alert type="error" content={saveError} />}
<label> <label>
<Text></Text> <Text></Text>
<Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} /> <Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} />

View File

@@ -9,7 +9,7 @@ import { ProjectSidebar, type ProjectSettingsUpdate } from './projects/project-s
import { ProjectStatusbar } from './projects/project-statusbar' import { ProjectStatusbar } from './projects/project-statusbar'
import { ProjectTopbar } from './projects/project-topbar' import { ProjectTopbar } from './projects/project-topbar'
import type { SearchResultDTO } from '../api/search' import type { SearchResultDTO } from '../api/search'
import type { InboxSuggestionDTO } from '../api/inbox' import type { CaptureInboxInput, InboxSuggestionDTO } from '../api/inbox'
import type { AISessionDTO, CreateAISessionInput } from '../api/ai' import type { AISessionDTO, CreateAISessionInput } from '../api/ai'
import type { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types' import type { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
@@ -45,6 +45,7 @@ export function ProjectPage({
onSearch, onSearch,
onSelectSearchResult, onSelectSearchResult,
onAnalyzeInbox, onAnalyzeInbox,
onCaptureInbox,
onConfirmInbox, onConfirmInbox,
onListAISessions, onListAISessions,
onCreateAISession, onCreateAISession,
@@ -67,9 +68,9 @@ export function ProjectPage({
onCreateTask: () => void onCreateTask: () => void
onUploadSource: () => void onUploadSource: () => void
onCreateCronPlan: () => void onCreateCronPlan: () => void
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => void onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => Promise<void>
onUpdateProject: (update: ProjectSettingsUpdate) => Promise<void> onUpdateProject: (update: ProjectSettingsUpdate) => Promise<void>
onCreateProjectTag: (name: string) => void onCreateProjectTag: (name: string) => Promise<void>
searchQuery: string searchQuery: string
searchLoading: boolean searchLoading: boolean
searchSearched: boolean searchSearched: boolean
@@ -78,6 +79,7 @@ export function ProjectPage({
onSearch: () => void onSearch: () => void
onSelectSearchResult: (result: SearchResultDTO) => void onSelectSearchResult: (result: SearchResultDTO) => void
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]> onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
onCaptureInbox: (projectId: string, input: CaptureInboxInput) => Promise<string>
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome> onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]> onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO> onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
@@ -86,7 +88,7 @@ export function ProjectPage({
const [navOpen, setNavOpen] = useState<'projects' | 'channels' | null>(null) const [navOpen, setNavOpen] = useState<'projects' | 'channels' | null>(null)
const projects = workspaces.map((workspace) => workspace.project) const projects = workspaces.map((workspace) => workspace.project)
const updateActiveProjectTask = (update: ProjectTaskUpdate) => { const updateActiveProjectTask = (update: ProjectTaskUpdate) => {
onUpdateWorkspaceTask({ return onUpdateWorkspaceTask({
originalProjectId: activeWorkspace.project.id, originalProjectId: activeWorkspace.project.id,
nextProjectId: activeWorkspace.project.id, nextProjectId: activeWorkspace.project.id,
taskId: update.taskId, taskId: update.taskId,
@@ -163,6 +165,7 @@ export function ProjectPage({
onCreateProjectTag={onCreateProjectTag} onCreateProjectTag={onCreateProjectTag}
onUpdateTask={updateActiveProjectTask} onUpdateTask={updateActiveProjectTask}
onAnalyzeInbox={onAnalyzeInbox} onAnalyzeInbox={onAnalyzeInbox}
onCaptureInbox={onCaptureInbox}
onConfirmInbox={onConfirmInbox} onConfirmInbox={onConfirmInbox}
onListAISessions={onListAISessions} onListAISessions={onListAISessions}
onCreateAISession={onCreateAISession} onCreateAISession={onCreateAISession}