fix(web): close final workbench interaction gaps
This commit is contained in:
@@ -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 () => {
|
||||
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(textCalls, 0)
|
||||
})
|
||||
|
||||
test('explicit void response returns undefined for a non-204 response', async () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ const requiredFiles = [
|
||||
'src/pages/projects/project-sidebar.tsx',
|
||||
'src/pages/projects/project-topbar.tsx',
|
||||
'src/pages/projects/project-statusbar.tsx',
|
||||
'src/pages/projects/workbench-item-preview.tsx',
|
||||
'src/pages/projects/project-types.ts',
|
||||
'src/api/client.ts',
|
||||
'src/api/projects.ts',
|
||||
@@ -26,6 +27,7 @@ const requiredFiles = [
|
||||
'src/api/inbox.ts',
|
||||
'src/api/ai.ts',
|
||||
'scripts/api-client.test.mjs',
|
||||
'scripts/workspace-refresh-gate.test.mjs',
|
||||
]
|
||||
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
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')
|
||||
@@ -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*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') : ''
|
||||
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') : ''
|
||||
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') : ''
|
||||
for (const required of ['/api/v1/projects/', '/ai-sessions', 'listAISessions', 'createAISession']) {
|
||||
@@ -151,6 +164,23 @@ const unsupportedControls = [
|
||||
file: 'src/pages/projects/project-topbar.tsx',
|
||||
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) {
|
||||
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('.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('@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']) {
|
||||
const source = readFileSync(file, 'utf8')
|
||||
if (!source.includes('/senlinai-icon.svg')) failures.push(`${file} must use the brand icon`)
|
||||
|
||||
@@ -33,6 +33,10 @@ const thirdInboxItemId = '019b0000-0000-7000-8000-000000000013'
|
||||
const thirdInboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000014'
|
||||
const aiSessionId = '019b0000-0000-7000-8000-000000000015'
|
||||
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 externalProjectId = '019b0000-0000-7000-8000-000000000088'
|
||||
const externalTaskId = '019b0000-0000-7000-8000-000000000089'
|
||||
@@ -43,6 +47,7 @@ const projectPatchRequests = []
|
||||
const inboxAnalyzeRequests = []
|
||||
const inboxConfirmRequests = []
|
||||
const aiSessionRequests = []
|
||||
const inboxCaptureRequests = []
|
||||
let delayedAIListsRemaining = 0
|
||||
let delayNextAICreate = false
|
||||
let delayedAICreateCompleted = false
|
||||
@@ -140,11 +145,12 @@ const visualCheckWorkspace = {
|
||||
},
|
||||
channels: [
|
||||
{ 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: '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: '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: '产品' }],
|
||||
recentSessions: [
|
||||
@@ -181,11 +187,32 @@ const visualCheckWorkspace = {
|
||||
tag: '待处理',
|
||||
time: '2026-07-21T02:10:00Z',
|
||||
},
|
||||
{
|
||||
id: conflictInboxItemId,
|
||||
projectId,
|
||||
source: '手动收集',
|
||||
title: '冲突确认测试',
|
||||
summary: '使用独立条目验证 409 冲突后的原项目刷新。',
|
||||
status: 'open',
|
||||
tag: '待处理',
|
||||
time: '2026-07-21T02:00:00Z',
|
||||
},
|
||||
],
|
||||
tasks: [],
|
||||
aiSessions: [],
|
||||
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 = {
|
||||
@@ -201,7 +228,8 @@ const secondVisualCheckWorkspace = {
|
||||
},
|
||||
channels: [
|
||||
{ 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: [],
|
||||
recentSessions: [],
|
||||
@@ -212,6 +240,34 @@ const secondVisualCheckWorkspace = {
|
||||
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) => {
|
||||
const url = new URL(route.request().url())
|
||||
const method = route.request().method()
|
||||
@@ -224,7 +280,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/api/v1/projects') {
|
||||
await route.fulfill({ json: [visualCheckWorkspace.project, secondVisualCheckWorkspace.project] })
|
||||
await route.fulfill({ json: [visualCheckWorkspace.project, secondVisualCheckWorkspace.project, ...longProjectFixtures] })
|
||||
return
|
||||
}
|
||||
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 })
|
||||
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') {
|
||||
const responseSnapshot = structuredClone(controlledAISessions)
|
||||
const delay = delayedAIListsRemaining > 0 ? 700 : 120
|
||||
@@ -323,7 +407,29 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
|
||||
})
|
||||
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())
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
if (failNextInboxConfirm) {
|
||||
@@ -333,6 +439,9 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
|
||||
}
|
||||
if (conflictNextInboxConfirm) {
|
||||
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({
|
||||
status: 409,
|
||||
json: { error: { code: 'conflict', message: '确认状态发生冲突' } },
|
||||
@@ -357,8 +466,9 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
|
||||
return
|
||||
}
|
||||
const confirmedItemId = url.pathname.split('/').at(-2)
|
||||
visualCheckWorkspace.inbox.find((item) => item.id === confirmedItemId).status = 'processed'
|
||||
visualCheckWorkspace.channels.find((channel) => channel.type === 'inbox').count -= 1
|
||||
const confirmedWorkspace = confirmedItemId === capturedInboxItemId ? secondVisualCheckWorkspace : visualCheckWorkspace
|
||||
confirmedWorkspace.inbox.find((item) => item.id === confirmedItemId).status = 'processed'
|
||||
confirmedWorkspace.channels.find((channel) => channel.type === 'inbox').count -= 1
|
||||
if (confirmedItemId === secondInboxItemId) failNextInboxWorkspaceRefresh = true
|
||||
await route.fulfill({ json: { createdCount: inboxConfirmRequests.at(-1).suggestionIds.length } })
|
||||
return
|
||||
@@ -425,6 +535,16 @@ const waitForDrawerOpen = async (selector) => page.waitForFunction((drawerSelect
|
||||
return drawer?.classList.contains('open') && Math.abs(drawer.getBoundingClientRect().left) < 1
|
||||
}, 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 statusbar = document.querySelector('.statusbar')
|
||||
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 rail = document.querySelector('.project-rail')
|
||||
const railChildren = rail?.querySelector('.arco-layout-sider-children')
|
||||
const projectStackScroll = rail?.querySelector('.project-stack-scroll')
|
||||
const sidebar = document.querySelector('.channel-sidebar')
|
||||
const channelList = document.querySelector('.channel-list')
|
||||
const stage = document.querySelector('.stage')
|
||||
@@ -447,13 +568,13 @@ const collectMetrics = async () => page.evaluate(() => {
|
||||
const project = document.querySelector('.project-button')
|
||||
const create = document.querySelector('.create-project')
|
||||
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 box = node?.getBoundingClientRect()
|
||||
return box ? { left: box.left, right: box.right, top: box.top, width: box.width, height: box.height } : null
|
||||
}
|
||||
const railBox = rail?.getBoundingClientRect()
|
||||
const itemRects = railItems.map(rect).filter(Boolean)
|
||||
const itemRects = projectItems.map(rect).filter(Boolean)
|
||||
const horizontalInsets = railBox && project
|
||||
? {
|
||||
left: project.getBoundingClientRect().left - railBox.left,
|
||||
@@ -501,6 +622,8 @@ const collectMetrics = async () => page.evaluate(() => {
|
||||
verticalGaps,
|
||||
projectRailOverflow: overflowState(rail),
|
||||
projectRailChildrenOverflow: overflowState(railChildren),
|
||||
projectStackOverflow: overflowState(projectStackScroll),
|
||||
projectCount: projectItems.length,
|
||||
channelSidebarOverflow: overflowState(sidebar),
|
||||
channelListOverflow: overflowState(channelList),
|
||||
stageOverflow: overflowState(stage),
|
||||
@@ -539,9 +662,16 @@ if (process.env.VISUAL_CHECK_SCOPE === 'login') {
|
||||
}
|
||||
|
||||
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.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 })
|
||||
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) {
|
||||
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.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 })
|
||||
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.waitForFunction(() => [...document.querySelectorAll('.project-rail, .channel-sidebar')]
|
||||
.every((node) => getComputedStyle(node).visibility === 'hidden'))
|
||||
@@ -572,7 +734,7 @@ const mobileProjectMetrics = await collectMetrics()
|
||||
const mobileProjectOverviewLayout = await page.evaluate(() => {
|
||||
const hero = document.querySelector('.project-overview-hero')
|
||||
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 metricLabels = [...document.querySelectorAll('.project-overview-page .metric-card .arco-typography-secondary')]
|
||||
const lineCount = (node) => {
|
||||
@@ -585,7 +747,10 @@ const mobileProjectOverviewLayout = await page.evaluate(() => {
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
hero: hero?.getBoundingClientRect().toJSON() ?? 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)),
|
||||
metricLabels: metricLabels.map((label) => ({
|
||||
text: label.textContent?.trim() ?? '',
|
||||
@@ -597,8 +762,10 @@ const mobileProjectOverviewLayout = await page.evaluate(() => {
|
||||
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)}`)
|
||||
}
|
||||
if (!mobileProjectOverviewLayout.shareButton || mobileProjectOverviewLayout.shareButton.right > mobileProjectOverviewLayout.viewportWidth) {
|
||||
failures.push(`mobile project URL must stay inside the viewport, got ${JSON.stringify(mobileProjectOverviewLayout.shareButton)}`)
|
||||
if (!mobileProjectOverviewLayout.projectIdentifier || mobileProjectOverviewLayout.projectIdentifier.right > mobileProjectOverviewLayout.viewportWidth) {
|
||||
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) {
|
||||
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 {
|
||||
await projectNavButton.click()
|
||||
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 })
|
||||
if (!await page.locator('aside.project-rail.open').isVisible()) {
|
||||
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')
|
||||
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 })
|
||||
|
||||
@@ -792,6 +971,10 @@ if (await settingsButton.count() === 0) {
|
||||
if (!await settingsModal.getByText('项目设置保存失败,请稍后重试', { exact: true }).isVisible()) {
|
||||
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
|
||||
if (expectedProjectPatchConsoleErrorCount !== 1) {
|
||||
failures.push(`expected one simulated PATCH console error, got ${expectedProjectPatchConsoleErrorCount}`)
|
||||
@@ -806,6 +989,32 @@ const stageHoverMetrics = await collectMetrics()
|
||||
await page.locator('.channel-list').hover()
|
||||
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 消息流' })
|
||||
if (await inboxChannelButton.count() === 0) {
|
||||
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 inboxPage.getByRole('button', { name: '分析内容', exact: true }).click()
|
||||
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')
|
||||
}
|
||||
if (await inboxPage.getByRole('checkbox').count() !== 3) {
|
||||
@@ -1011,7 +1223,7 @@ if (await inboxChannelButton.count() === 0) {
|
||||
|
||||
await page.locator('.channel-button', { hasText: 'Inbox 消息流' }).click()
|
||||
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('checkbox').first().waitFor({ state: 'attached', timeout: 2000 }).catch(() => {})
|
||||
conflictNextInboxConfirm = true
|
||||
@@ -1019,7 +1231,7 @@ if (await inboxChannelButton.count() === 0) {
|
||||
const workspaceRequestsBeforeConflict = workspaceRequests.length
|
||||
const conflictConfirmButton = conflictInboxPage.getByRole('button', { name: '确认创建', exact: true })
|
||||
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) {
|
||||
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.getByRole('button', { name: '创建会话', exact: true }).click()
|
||||
await aiPage.getByText('新会话视觉检查', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
|
||||
await closeItemPreview('新会话视觉检查')
|
||||
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) => {
|
||||
const pageNode = document.querySelector(`.${expected.pageClass}`)
|
||||
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.getByRole('button', { name: '创建会话', exact: true }).click()
|
||||
await sameProjectRacePage.getByText('乱序请求保留的新会话', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
|
||||
await closeItemPreview('乱序请求保留的新会话')
|
||||
await page.waitForTimeout(850)
|
||||
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')
|
||||
@@ -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(() => ({
|
||||
topbarLabels: [...document.querySelectorAll('.topbar-actions button')].map((button) => button.getAttribute('aria-label') ?? button.textContent?.trim()),
|
||||
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)}`)
|
||||
}
|
||||
}
|
||||
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}`)
|
||||
for (const request of aiSessionRequests) {
|
||||
|
||||
34
apps/web_v1/scripts/workspace-refresh-gate.test.mjs
Normal file
34
apps/web_v1/scripts/workspace-refresh-gate.test.mjs
Normal 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)
|
||||
})
|
||||
@@ -281,6 +281,10 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-search-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-backdrop {
|
||||
display: none;
|
||||
}
|
||||
@@ -346,6 +350,21 @@
|
||||
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 {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@@ -357,6 +376,8 @@
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
flex: 0 0 48px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.rail-brand .brand-icon {
|
||||
@@ -370,6 +391,8 @@
|
||||
width: 48px !important;
|
||||
height: 48px !important;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
flex: 0 0 48px;
|
||||
padding: 0 !important;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
@@ -1835,7 +1858,7 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -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) {
|
||||
.topbar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -2223,6 +2284,17 @@
|
||||
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 {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
@@ -2231,7 +2303,8 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-nav-button {
|
||||
.mobile-nav-button,
|
||||
.mobile-search-button {
|
||||
display: inline-flex;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
|
||||
@@ -62,7 +62,8 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
||||
body: requestBody as BodyInit | undefined,
|
||||
signal: options.signal,
|
||||
})
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') throw error
|
||||
throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试')
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ export type SourceDTO = {
|
||||
projectId: string
|
||||
kind: string
|
||||
title: string
|
||||
filePath: string
|
||||
storageKey: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -184,12 +184,12 @@ export type CreateProjectTagInput = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export async function fetchProjects(session: ApiSession) {
|
||||
return apiRequest<ProjectDTO[]>('/api/v1/projects', { token: session.token })
|
||||
export async function fetchProjects(session: ApiSession, signal?: AbortSignal) {
|
||||
return apiRequest<ProjectDTO[]>('/api/v1/projects', { token: session.token, signal })
|
||||
}
|
||||
|
||||
export async function fetchProjectWorkspace(session: ApiSession, projectId: string) {
|
||||
return apiRequest<ProjectWorkspaceDTO>(`/api/v1/projects/${projectId}/workspace`, { token: session.token })
|
||||
export async function fetchProjectWorkspace(session: ApiSession, projectId: string, signal?: AbortSignal) {
|
||||
return apiRequest<ProjectWorkspaceDTO>(`/api/v1/projects/${projectId}/workspace`, { token: session.token, signal })
|
||||
}
|
||||
|
||||
export async function fetchProjectTags(session: ApiSession, projectId: string) {
|
||||
|
||||
@@ -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 '@arco-design/web-react/dist/css/arco.css'
|
||||
import '../App.css'
|
||||
import { ApiError, login, setApiBaseUrl, type ApiSession } from '../api/client'
|
||||
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 {
|
||||
createCronPlan,
|
||||
@@ -21,11 +21,13 @@ import type { SearchResultDTO } from '../api/search'
|
||||
import { LoginPage } from '../pages/login'
|
||||
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 { WorkbenchItemPreview } from '../pages/projects/workbench-item-preview'
|
||||
import type { ProjectSettingsUpdate } from '../pages/projects/project-sidebar'
|
||||
import { ProjectPage } from '../pages/workspace-home'
|
||||
import type { WorkspaceTaskUpdate } from '../pages/workspace-body'
|
||||
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
|
||||
import { useWorkbenchSearch } from './use-workbench-search'
|
||||
import { createWorkspaceRefreshGate, type WorkspaceRefreshGate } from './workspace-refresh-gate'
|
||||
|
||||
function App() {
|
||||
const [screen, setScreen] = useState<Screen>('login')
|
||||
@@ -37,12 +39,17 @@ function App() {
|
||||
const activeProjectIDRef = useRef('')
|
||||
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
|
||||
const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
|
||||
const [, setSelectedItem] = useState('探索采集')
|
||||
const [selectedItem, setSelectedItem] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
|
||||
const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null)
|
||||
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) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
@@ -64,11 +71,13 @@ function App() {
|
||||
}
|
||||
|
||||
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID?: string) {
|
||||
const request = workspaceRefreshGate.current!.begin()
|
||||
const projectIDWhenStarted = activeProjectIDRef.current
|
||||
const backendProjects = await fetchProjects(nextSession)
|
||||
const backendProjects = await fetchProjects(nextSession, request.signal)
|
||||
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)
|
||||
const latestProjectID = activeProjectIDRef.current
|
||||
const requestedProjectID = latestProjectID !== projectIDWhenStarted
|
||||
@@ -79,6 +88,20 @@ function App() {
|
||||
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 }) {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -103,6 +126,8 @@ function App() {
|
||||
}
|
||||
|
||||
async function runAction(action: () => Promise<void>, success: string) {
|
||||
if (actionInFlight.current) throw new Error('操作正在进行,请勿重复提交')
|
||||
actionInFlight.current = true
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await action()
|
||||
@@ -110,7 +135,9 @@ function App() {
|
||||
Message.success(success)
|
||||
} catch (error) {
|
||||
Message.error(error instanceof Error ? error.message : '操作失败')
|
||||
throw error
|
||||
} finally {
|
||||
actionInFlight.current = false
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -130,12 +157,12 @@ function App() {
|
||||
return trimmed === '' ? undefined : trimmed
|
||||
}
|
||||
|
||||
function handleCreateProject(draft: ProjectDraft) {
|
||||
async function handleCreateProject(draft: ProjectDraft) {
|
||||
if (!draft.name.trim()) {
|
||||
Message.warning('请输入项目名称')
|
||||
return
|
||||
}
|
||||
void runAction(async () => {
|
||||
return runAction(async () => {
|
||||
const created = await createProject(requireSession(), {
|
||||
name: draft.name.trim(),
|
||||
identifier: draft.identifier.trim(),
|
||||
@@ -153,12 +180,12 @@ function App() {
|
||||
}, '项目已创建')
|
||||
}
|
||||
|
||||
function handleCreateTask(draft: TaskDraft) {
|
||||
async function handleCreateTask(draft: TaskDraft) {
|
||||
if (!draft.title.trim()) {
|
||||
Message.warning('请输入任务标题')
|
||||
return
|
||||
}
|
||||
void runAction(async () => {
|
||||
return runAction(async () => {
|
||||
await createTask(requireSession(), requireActiveProject(), {
|
||||
title: draft.title.trim(),
|
||||
description: draft.description.trim(),
|
||||
@@ -170,13 +197,13 @@ function App() {
|
||||
}, '任务已创建')
|
||||
}
|
||||
|
||||
function handleUploadSource(draft: SourceDraft) {
|
||||
async function handleUploadSource(draft: SourceDraft) {
|
||||
if (!draft.file) {
|
||||
Message.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
const file = draft.file
|
||||
void runAction(async () => {
|
||||
return runAction(async () => {
|
||||
await uploadSource(requireSession(), requireActiveProject(), {
|
||||
title: draft.title.trim(),
|
||||
file,
|
||||
@@ -186,7 +213,7 @@ function App() {
|
||||
}, '文件已上传')
|
||||
}
|
||||
|
||||
function handleCreateCronPlan(draft: CronDraft) {
|
||||
async function handleCreateCronPlan(draft: CronDraft) {
|
||||
if (!draft.title.trim()) {
|
||||
Message.warning('请输入计划名称')
|
||||
return
|
||||
@@ -195,7 +222,7 @@ function App() {
|
||||
Message.warning('请输入 Cron 表达式')
|
||||
return
|
||||
}
|
||||
void runAction(async () => {
|
||||
return runAction(async () => {
|
||||
await createCronPlan(requireSession(), requireActiveProject(), {
|
||||
title: draft.title.trim(),
|
||||
schedule: draft.schedule.trim(),
|
||||
@@ -207,13 +234,13 @@ function App() {
|
||||
}, '计划任务已创建')
|
||||
}
|
||||
|
||||
function handleCreateProjectTag(name: string) {
|
||||
async function handleCreateProjectTag(name: string) {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
Message.warning('请输入标签名称')
|
||||
return
|
||||
}
|
||||
void runAction(async () => {
|
||||
return runAction(async () => {
|
||||
await createProjectTag(requireSession(), requireActiveProject(), { name: trimmedName })
|
||||
await refreshAfterAction()
|
||||
}, '标签已创建')
|
||||
@@ -227,7 +254,7 @@ function App() {
|
||||
}
|
||||
|
||||
function handleUpdateWorkspaceTask(update: WorkspaceTaskUpdate) {
|
||||
void runAction(async () => {
|
||||
return runAction(async () => {
|
||||
await updateTask(requireSession(), update.originalProjectId, update.taskId, {
|
||||
title: update.title,
|
||||
description: update.summary,
|
||||
@@ -257,6 +284,13 @@ function App() {
|
||||
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[]) {
|
||||
const confirmationProjectID = activeProjectIDRef.current
|
||||
let response
|
||||
@@ -264,21 +298,15 @@ function App() {
|
||||
response = await confirmInboxItem(requireSession(), inboxId, suggestionIds)
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError) || error.status !== 409) throw error
|
||||
if (activeProjectIDRef.current !== confirmationProjectID) {
|
||||
throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新核对当前工作区')
|
||||
}
|
||||
try {
|
||||
await refreshAfterAction()
|
||||
await refreshProjectWorkspace(requireSession(), confirmationProjectID)
|
||||
} catch {
|
||||
throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新进入项目核对')
|
||||
throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新进入原项目核对')
|
||||
}
|
||||
throw new ApiError(409, 'conflict', '确认状态发生冲突,工作区已刷新,请重新核对')
|
||||
}
|
||||
if (activeProjectIDRef.current !== confirmationProjectID) {
|
||||
return { createdCount: response.createdCount }
|
||||
throw new ApiError(409, 'conflict', '确认状态发生冲突,原项目已在后台刷新,请重新核对')
|
||||
}
|
||||
try {
|
||||
await refreshAfterAction()
|
||||
await refreshProjectWorkspace(requireSession(), confirmationProjectID)
|
||||
return { createdCount: response.createdCount }
|
||||
} catch {
|
||||
return {
|
||||
@@ -356,6 +384,7 @@ function App() {
|
||||
onSearch={() => void workspaceSearch.onSearch()}
|
||||
onSelectSearchResult={handleSelectSearchResult}
|
||||
onAnalyzeInbox={handleAnalyzeInbox}
|
||||
onCaptureInbox={handleCaptureInbox}
|
||||
onConfirmInbox={handleConfirmInbox}
|
||||
onListAISessions={handleListAISessions}
|
||||
onCreateAISession={handleCreateAISession}
|
||||
@@ -374,6 +403,7 @@ function App() {
|
||||
tagOptions={activeTagOptions}
|
||||
/>
|
||||
<SearchResultPreview result={searchResultPreview} onClose={() => setSearchResultPreview(null)} />
|
||||
<WorkbenchItemPreview title={selectedItem} onClose={() => setSelectedItem(null)} />
|
||||
</main>
|
||||
</ConfigProvider>
|
||||
)
|
||||
@@ -386,6 +416,10 @@ function searchResultTarget(type: string): { channel: ChannelKey; openTask: bool
|
||||
return null
|
||||
}
|
||||
|
||||
function staleWorkspaceRefreshError() {
|
||||
return new DOMException('workspace refresh superseded', 'AbortError')
|
||||
}
|
||||
|
||||
function isAuthorizedExternalPreview(result: SearchResultDTO) {
|
||||
return (
|
||||
(result.type === 'task' || result.type === 'note')
|
||||
|
||||
29
apps/web_v1/src/app/workspace-refresh-gate.ts
Normal file
29
apps/web_v1/src/app/workspace-refresh-gate.ts
Normal 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>
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
IconCheckCircleFill,
|
||||
IconCloseCircleFill,
|
||||
@@ -11,9 +11,14 @@ import { normalizeBaseUrl } from '../api/client'
|
||||
const { Title, Text } = Typography
|
||||
|
||||
type ConnectionStatus = 'checking' | 'online' | 'offline'
|
||||
const REMEMBERED_SERVER_KEY = 'senlinai.server'
|
||||
|
||||
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 [password, setPassword] = useState('password123')
|
||||
const [status, setStatus] = useState<ConnectionStatus>('checking')
|
||||
@@ -42,8 +47,6 @@ export function LoginPage({ onLogin }: { onLogin: (input: { server: string; emai
|
||||
<Text type="secondary">知识沉淀 · 团队协作 · AI 助手</Text>
|
||||
<Space className="login-footer-links" size={24}>
|
||||
<Text type="secondary">v1.0.0</Text>
|
||||
<Text type="secondary">隐私政策</Text>
|
||||
<Text type="secondary">服务协议</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
@@ -63,13 +66,13 @@ export function LoginPage({ onLogin }: { onLogin: (input: { server: string; emai
|
||||
<Input.Password value={password} onChange={setPassword} placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
<div className="login-row">
|
||||
<Space size={8}>
|
||||
<span className="check-dot" />
|
||||
<Text>记住服务器地址</Text>
|
||||
</Space>
|
||||
<Button type="text" size="mini">无法连接?</Button>
|
||||
<Checkbox checked={rememberServer} onChange={setRememberServer}>记住服务器地址</Checkbox>
|
||||
</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>
|
||||
</Form>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { TextArea } = Input
|
||||
@@ -45,19 +45,30 @@ export function ProjectActionModals({
|
||||
activeModal: ProjectActionModal
|
||||
loading: boolean
|
||||
onClose: () => void
|
||||
onCreateProject: (draft: ProjectDraft) => void
|
||||
onCreateTask: (draft: TaskDraft) => void
|
||||
onUploadSource: (draft: SourceDraft) => void
|
||||
onCreateCronPlan: (draft: CronDraft) => void
|
||||
onCreateProject: (draft: ProjectDraft) => Promise<void>
|
||||
onCreateTask: (draft: TaskDraft) => Promise<void>
|
||||
onUploadSource: (draft: SourceDraft) => Promise<void>
|
||||
onCreateCronPlan: (draft: CronDraft) => Promise<void>
|
||||
tagOptions: string[]
|
||||
}) {
|
||||
const [project, setProject] = useState<ProjectDraft>({ name: '', identifier: '', icon: '', background: '', description: '' })
|
||||
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', tag: '' })
|
||||
const [source, setSource] = useState<SourceDraft>({ title: '', file: null })
|
||||
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(() => {
|
||||
if (activeModal === null) {
|
||||
setSubmitError('')
|
||||
setProject({ name: '', identifier: '', icon: '', background: '', description: '' })
|
||||
setTask({ title: '', description: '', tag: '' })
|
||||
setSource({ title: '', file: null })
|
||||
@@ -72,10 +83,13 @@ export function ProjectActionModals({
|
||||
title="新建项目"
|
||||
visible={activeModal === 'project'}
|
||||
confirmLoading={loading}
|
||||
onCancel={onClose}
|
||||
onOk={() => onCreateProject(project)}
|
||||
maskClosable={!loading}
|
||||
cancelButtonProps={{ disabled: loading }}
|
||||
onCancel={() => { if (!loading) onClose() }}
|
||||
onOk={() => void submit(() => onCreateProject(project))}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
{submitError && <Alert type="error" content={submitError} />}
|
||||
<label>
|
||||
<Text>项目名称</Text>
|
||||
<Input placeholder="例如:项目 A3" value={project.name} onChange={(name) => setProject((draft) => ({ ...draft, name }))} />
|
||||
@@ -104,10 +118,13 @@ export function ProjectActionModals({
|
||||
title="新建任务"
|
||||
visible={activeModal === 'task'}
|
||||
confirmLoading={loading}
|
||||
onCancel={onClose}
|
||||
onOk={() => onCreateTask(task)}
|
||||
maskClosable={!loading}
|
||||
cancelButtonProps={{ disabled: loading }}
|
||||
onCancel={() => { if (!loading) onClose() }}
|
||||
onOk={() => void submit(() => onCreateTask(task))}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
{submitError && <Alert type="error" content={submitError} />}
|
||||
<label>
|
||||
<Text>任务标题</Text>
|
||||
<Input placeholder="要完成什么?" value={task.title} onChange={(title) => setTask((draft) => ({ ...draft, title }))} />
|
||||
@@ -137,10 +154,13 @@ export function ProjectActionModals({
|
||||
title="上传文件"
|
||||
visible={activeModal === 'source'}
|
||||
confirmLoading={loading}
|
||||
onCancel={onClose}
|
||||
onOk={() => onUploadSource(source)}
|
||||
maskClosable={!loading}
|
||||
cancelButtonProps={{ disabled: loading }}
|
||||
onCancel={() => { if (!loading) onClose() }}
|
||||
onOk={() => void submit(() => onUploadSource(source))}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
{submitError && <Alert type="error" content={submitError} />}
|
||||
<label>
|
||||
<Text>资料标题</Text>
|
||||
<Input placeholder="默认使用文件名" value={source.title} onChange={(title) => setSource((draft) => ({ ...draft, title }))} />
|
||||
@@ -161,10 +181,13 @@ export function ProjectActionModals({
|
||||
title="新建计划任务"
|
||||
visible={activeModal === 'cron'}
|
||||
confirmLoading={loading}
|
||||
onCancel={onClose}
|
||||
onOk={() => onCreateCronPlan(cron)}
|
||||
maskClosable={!loading}
|
||||
cancelButtonProps={{ disabled: loading }}
|
||||
onCancel={() => { if (!loading) onClose() }}
|
||||
onOk={() => void submit(() => onCreateCronPlan(cron))}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
{submitError && <Alert type="error" content={submitError} />}
|
||||
<label>
|
||||
<Text>计划名称</Text>
|
||||
<Input placeholder="例如:每日 Inbox 整理" value={cron.title} onChange={(title) => setCron((draft) => ({ ...draft, title }))} />
|
||||
|
||||
@@ -7,8 +7,11 @@ import { ProjectOverview } from './project-overview'
|
||||
import type { ProjectTaskUpdate } from './project-task-edit-modal'
|
||||
import { ProjectTasks } from './project-tasks'
|
||||
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 { Card, Empty, Typography } from '@arco-design/web-react'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export function ProjectChannelPage({
|
||||
activeChannel,
|
||||
@@ -23,6 +26,7 @@ export function ProjectChannelPage({
|
||||
onCreateProjectTag,
|
||||
onUpdateTask,
|
||||
onAnalyzeInbox,
|
||||
onCaptureInbox,
|
||||
onConfirmInbox,
|
||||
onListAISessions,
|
||||
onCreateAISession,
|
||||
@@ -36,16 +40,28 @@ export function ProjectChannelPage({
|
||||
onCreateTask: () => void
|
||||
onUploadSource: () => void
|
||||
onCreateCronPlan: () => void
|
||||
onCreateProjectTag: (name: string) => void
|
||||
onUpdateTask: (update: ProjectTaskUpdate) => void
|
||||
onCreateProjectTag: (name: string) => Promise<void>
|
||||
onUpdateTask: (update: ProjectTaskUpdate) => Promise<void>
|
||||
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
|
||||
onCaptureInbox: (projectId: string, input: CaptureInboxInput) => Promise<string>
|
||||
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
|
||||
onListAISessions: (projectId: string, 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) {
|
||||
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':
|
||||
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} />
|
||||
case 'ai':
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Card, Space, Switch, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconClockCircle, IconPlus, IconThunderbolt } from '@arco-design/web-react/icon'
|
||||
import { Button, Card, Empty, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconClockCircle, IconPlus } from '@arco-design/web-react/icon'
|
||||
import type { ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
@@ -41,18 +41,18 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
|
||||
<span className="row-title"><IconClockCircle /> {job.name}</span>
|
||||
<Tag color={job.enabled ? 'green' : 'gray'}>{job.status}</Tag>
|
||||
<span>{job.expr}</span>
|
||||
<span>{job.lastRun}</span>
|
||||
<span>{job.lastRun || '暂无执行记录'}</span>
|
||||
<span>{job.nextRun}</span>
|
||||
<span>{job.owner}</span>
|
||||
<Switch size="small" checked={job.enabled} />
|
||||
</div>
|
||||
))}
|
||||
{cronJobs.length === 0 && <Empty description="暂无计划任务" />}
|
||||
</Card>
|
||||
|
||||
<Card className="queue-section cron-summary" bordered>
|
||||
<Space>
|
||||
<Tag color="arcoblue"><IconThunderbolt /></Tag>
|
||||
<Text>最近一次自动任务完成于今天 12:00,资料索引刷新成功,无异常重试。</Text>
|
||||
<Tag color="arcoblue">只读</Tag>
|
||||
<Text>仅展示服务端保存的计划元数据;MVP 不在此页面执行或切换计划。</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { Alert, Button, Card, Checkbox, Empty, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconCheckCircle, IconRobot } from '@arco-design/web-react/icon'
|
||||
import { Alert, Button, Card, Checkbox, Empty, Input, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconCheckCircle, IconPlus, IconRobot } from '@arco-design/web-react/icon'
|
||||
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'
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
type ProjectInboxProps = {
|
||||
activeWorkspace: ProjectWorkspace
|
||||
onCapture: (projectId: string, input: CaptureInboxInput) => Promise<string>
|
||||
onAnalyze: (inboxId: string) => Promise<InboxSuggestionDTO[]>
|
||||
onConfirm: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
|
||||
}
|
||||
@@ -18,7 +20,7 @@ type UncertainConfirmation = {
|
||||
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 [draftSuggestions, setDraftSuggestions] = useState<InboxSuggestionDTO[]>([])
|
||||
const [selectedSuggestionIds, setSelectedSuggestionIds] = useState<string[]>([])
|
||||
@@ -29,12 +31,42 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
|
||||
const [refreshWarning, setRefreshWarning] = useState('')
|
||||
const [locallyConfirmedItemIds, setLocallyConfirmedItemIds] = useState<string[]>([])
|
||||
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 selectedItem = useMemo(
|
||||
() => activeWorkspace.inbox.find((item) => item.id === selectedItemId) ?? activeWorkspace.inbox[0],
|
||||
[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) {
|
||||
if (confirmationUncertain) return
|
||||
analysisGeneration.current += 1
|
||||
@@ -112,6 +144,35 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
|
||||
</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 ? (
|
||||
<Card className="queue-section" bordered>
|
||||
<Empty description="当前没有待处理的 Inbox 内容" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { ProjectWorkspace } from './project-types'
|
||||
|
||||
@@ -16,7 +16,6 @@ export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }:
|
||||
<Text type="secondary">{project.name} 的在线文件管理页,集中管理笔记、资料和附件。</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<IconSearch />}>搜索资料</Button>
|
||||
<Button type="primary" icon={<IconPlus />} onClick={onUploadSource}>上传/新建</Button>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -24,7 +23,6 @@ export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }:
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>最近更新</Title>
|
||||
<Button type="text" size="mini">按更新时间排序</Button>
|
||||
</div>
|
||||
<ProjectFileGrid items={notes} onSelectItem={onSelectItem} />
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconCheckCircleFill, IconClockCircle, IconFile, IconLink } from '@arco-design/web-react/icon'
|
||||
import { Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconCheckCircleFill, IconClockCircle, IconFile } from '@arco-design/web-react/icon'
|
||||
import { ProjectFileGrid } from './project-file-grid'
|
||||
import { ProjectTaskEditModal, type ProjectTaskUpdate } from './project-task-edit-modal'
|
||||
import type { ProjectWorkspace, TaskItem } from './project-types'
|
||||
@@ -15,13 +15,12 @@ export function ProjectOverview({
|
||||
}: {
|
||||
activeWorkspace: ProjectWorkspace
|
||||
onSelectItem: (title: string) => void
|
||||
onUpdateTask: (update: ProjectTaskUpdate) => void
|
||||
onUpdateTask: (update: ProjectTaskUpdate) => Promise<void>
|
||||
}) {
|
||||
const { project, tasks, notes, cronJobs } = activeWorkspace
|
||||
const runningTasks = useMemo(() => tasks.filter((task) => !task.completed), [tasks])
|
||||
const completedTasks = useMemo(() => tasks.filter((task) => task.completed), [tasks])
|
||||
const [editingTask, setEditingTask] = useState<TaskItem | null>(null)
|
||||
const shareURL = useMemo(() => projectShareURL(project.identifier || project.id), [project.id, project.identifier])
|
||||
const metrics = useMemo(() => [
|
||||
{ title: '进行中的任务', value: runningTasks.length, icon: <IconCheckCircleFill />, color: 'green' },
|
||||
{ title: '完成的任务', value: completedTasks.length, icon: <IconCheckCircleFill />, color: 'arcoblue' },
|
||||
@@ -40,8 +39,8 @@ export function ProjectOverview({
|
||||
</div>
|
||||
</div>
|
||||
<div className="project-overview-share">
|
||||
<Text>项目 URL</Text>
|
||||
<Button icon={<IconLink />} onClick={() => onSelectItem(shareURL)}>{shareURL}</Button>
|
||||
<Text>项目标识</Text>
|
||||
<Tag color="arcoblue">{project.identifier || '未设置'}</Tag>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -69,10 +68,7 @@ export function ProjectOverview({
|
||||
task={editingTask}
|
||||
workspace={activeWorkspace}
|
||||
onClose={() => setEditingTask(null)}
|
||||
onSubmit={(update) => {
|
||||
onUpdateTask(update)
|
||||
setEditingTask(null)
|
||||
}}
|
||||
onSubmit={onUpdateTask}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -95,7 +91,6 @@ function TaskCardSection({
|
||||
<Card className="queue-section overview-task-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>{title}</Title>
|
||||
<Button type="text" size="mini">查看全部</Button>
|
||||
</div>
|
||||
{tasks.length === 0 ? (
|
||||
<Text type="secondary">{emptyText}</Text>
|
||||
@@ -126,19 +121,12 @@ function NoteSection({ notes, onSelectItem }: { notes: ProjectWorkspace['notes']
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>最近笔记资料({notes.length})</Title>
|
||||
<Button type="text" size="mini">查看全部</Button>
|
||||
</div>
|
||||
<ProjectFileGrid items={notes.slice(0, 8)} onSelectItem={onSelectItem} compact />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function projectShareURL(identifier: string) {
|
||||
const slug = encodeURIComponent(identifier.trim() || 'project')
|
||||
const origin = typeof window === 'undefined' ? 'https://senlinai.local' : window.location.origin
|
||||
return `${origin}/share/projects/${slug}`
|
||||
}
|
||||
|
||||
function projectHeroBackground(background: string, fallback: string) {
|
||||
const value = background.trim() || fallback
|
||||
if (value.startsWith('http')) {
|
||||
|
||||
@@ -50,20 +50,22 @@ export function ProjectRail({
|
||||
aria-label="探索"
|
||||
title="探索"
|
||||
/>
|
||||
<Space className="project-stack" direction="vertical" size={12}>
|
||||
{projects.map((project) => (
|
||||
<Badge key={project.id} count={project.badge} dot={false} className={project.urgent ? 'project-badge urgent' : 'project-badge'}>
|
||||
<button
|
||||
className={activeView === 'project' && activeProject.id === project.id ? 'project-button active' : 'project-button'}
|
||||
onClick={() => selectAndClose(() => onSelectProject(project))}
|
||||
style={{ '--project-color': project.color } as CSSProperties}
|
||||
title={project.name}
|
||||
>
|
||||
{project.short}
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</Space>
|
||||
<div className="project-stack-scroll">
|
||||
<Space className="project-stack" direction="vertical" size={12}>
|
||||
{projects.map((project) => (
|
||||
<Badge key={project.id} count={project.badge} dot={false} className={project.urgent ? 'project-badge urgent' : 'project-badge'}>
|
||||
<button
|
||||
className={activeView === 'project' && activeProject.id === project.id ? 'project-button active' : 'project-button'}
|
||||
onClick={() => selectAndClose(() => onSelectProject(project))}
|
||||
style={{ '--project-color': project.color } as CSSProperties}
|
||||
title={project.name}
|
||||
>
|
||||
{project.short}
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
<Button className="create-project" aria-label="新建项目" icon={<IconPlus />} title="新建项目" onClick={() => selectAndClose(onCreateProject)} />
|
||||
</Sider>
|
||||
)
|
||||
|
||||
@@ -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 type { ProjectWorkspace, TaskItem } from './project-types'
|
||||
|
||||
@@ -29,13 +29,16 @@ export function ProjectTaskEditModal({
|
||||
task: TaskItem | null
|
||||
workspace: ProjectWorkspace
|
||||
onClose: () => void
|
||||
onSubmit: (update: ProjectTaskUpdate) => void
|
||||
onSubmit: (update: ProjectTaskUpdate) => Promise<void>
|
||||
}) {
|
||||
const tagOptions = useMemo(() => workspace.tags.filter((tag) => tag !== 'all' && tag !== '全部'), [workspace.tags])
|
||||
const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', tag: '', completed: false })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!task) return
|
||||
setSaveError('')
|
||||
setDraft({
|
||||
title: task.title,
|
||||
summary: task.summary,
|
||||
@@ -44,24 +47,39 @@ export function ProjectTaskEditModal({
|
||||
})
|
||||
}, [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 (
|
||||
<Modal
|
||||
className="action-modal"
|
||||
title="编辑计划"
|
||||
visible={Boolean(task)}
|
||||
onCancel={onClose}
|
||||
onOk={() => {
|
||||
if (!task) return
|
||||
onSubmit({
|
||||
taskId: task.id,
|
||||
title: draft.title.trim() || task.title,
|
||||
summary: draft.summary.trim(),
|
||||
tag: draft.tag.trim(),
|
||||
completed: draft.completed,
|
||||
})
|
||||
}}
|
||||
confirmLoading={saving}
|
||||
maskClosable={!saving}
|
||||
cancelButtonProps={{ disabled: saving }}
|
||||
onCancel={() => { if (!saving) onClose() }}
|
||||
onOk={() => void saveTask()}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
{saveError && <Alert type="error" content={saveError} />}
|
||||
<label>
|
||||
<Text>标题</Text>
|
||||
<Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { ProjectTaskEditModal, type ProjectTaskUpdate } from './project-task-edit-modal'
|
||||
import type { ProjectWorkspace, TaskItem } from './project-types'
|
||||
@@ -21,8 +21,8 @@ export function ProjectTasks({
|
||||
onCloseTask: () => void
|
||||
onSelectItem: (title: string) => void
|
||||
onCreateTask: () => void
|
||||
onCreateProjectTag: (name: string) => void
|
||||
onUpdateTask: (update: ProjectTaskUpdate) => void
|
||||
onCreateProjectTag: (name: string) => Promise<void>
|
||||
onUpdateTask: (update: ProjectTaskUpdate) => Promise<void>
|
||||
}) {
|
||||
const { tasks, project, tags } = activeWorkspace
|
||||
const activeTask = tasks.find((task) => task.id === activeTaskID)
|
||||
@@ -32,17 +32,27 @@ export function ProjectTasks({
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [tagName, setTagName] = useState('')
|
||||
const [editingTask, setEditingTask] = useState<TaskItem | null>(null)
|
||||
const [tagSaving, setTagSaving] = useState(false)
|
||||
const [tagError, setTagError] = useState('')
|
||||
|
||||
if (activeTask) {
|
||||
return <TaskDetail activeWorkspace={activeWorkspace} task={activeTask} onBack={onCloseTask} />
|
||||
}
|
||||
|
||||
function submitTag() {
|
||||
async function submitTag() {
|
||||
const nextTag = tagName.trim()
|
||||
if (!nextTag) return
|
||||
onCreateProjectTag(nextTag)
|
||||
setTagName('')
|
||||
setTagModalOpen(false)
|
||||
if (!nextTag || tagSaving) return
|
||||
setTagSaving(true)
|
||||
setTagError('')
|
||||
try {
|
||||
await onCreateProjectTag(nextTag)
|
||||
setTagName('')
|
||||
setTagModalOpen(false)
|
||||
} catch (error) {
|
||||
setTagError(error instanceof Error ? error.message : '标签创建失败,请稍后重试')
|
||||
} finally {
|
||||
setTagSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -111,16 +121,22 @@ export function ProjectTasks({
|
||||
className="action-modal"
|
||||
title="新建标签"
|
||||
visible={tagModalOpen}
|
||||
confirmLoading={tagSaving}
|
||||
maskClosable={!tagSaving}
|
||||
cancelButtonProps={{ disabled: tagSaving }}
|
||||
onCancel={() => {
|
||||
if (tagSaving) return
|
||||
setTagModalOpen(false)
|
||||
setTagName('')
|
||||
setTagError('')
|
||||
}}
|
||||
onOk={submitTag}
|
||||
onOk={() => void submitTag()}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
{tagError && <Alert type="error" content={tagError} />}
|
||||
<label>
|
||||
<Text>标签名称</Text>
|
||||
<Input autoFocus placeholder="例如:设计、客户、重要" value={tagName} onChange={setTagName} onPressEnter={submitTag} />
|
||||
<Input autoFocus placeholder="例如:设计、客户、重要" value={tagName} onChange={setTagName} onPressEnter={() => void submitTag()} />
|
||||
</label>
|
||||
</Space>
|
||||
</Modal>
|
||||
@@ -128,10 +144,7 @@ export function ProjectTasks({
|
||||
task={editingTask}
|
||||
workspace={activeWorkspace}
|
||||
onClose={() => setEditingTask(null)}
|
||||
onSubmit={(update) => {
|
||||
onUpdateTask(update)
|
||||
setEditingTask(null)
|
||||
}}
|
||||
onSubmit={onUpdateTask}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from '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 { Theme } from './project-types'
|
||||
|
||||
@@ -35,6 +35,7 @@ export function ProjectTopbar({
|
||||
onSelectSearchResult: (result: SearchResultDTO) => void
|
||||
}) {
|
||||
const [popupVisible, setPopupVisible] = useState(false)
|
||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && searched) setPopupVisible(true)
|
||||
@@ -42,6 +43,7 @@ export function ProjectTopbar({
|
||||
|
||||
const submitSearch = () => {
|
||||
setPopupVisible(false)
|
||||
setMobileSearchOpen(false)
|
||||
onSearch()
|
||||
}
|
||||
|
||||
@@ -59,6 +61,7 @@ export function ProjectTopbar({
|
||||
className="search-result-item"
|
||||
onClick={() => {
|
||||
setPopupVisible(false)
|
||||
setMobileSearchOpen(false)
|
||||
onSelectSearchResult(result)
|
||||
}}
|
||||
>
|
||||
@@ -87,7 +90,7 @@ export function ProjectTopbar({
|
||||
position="bl"
|
||||
onVisibleChange={(visible) => setPopupVisible(visible && searched)}
|
||||
>
|
||||
<div className="global-search" role="search">
|
||||
<div className={mobileSearchOpen ? 'global-search mobile-open' : 'global-search'} role="search">
|
||||
<Input
|
||||
size="large"
|
||||
prefix={<IconSearch />}
|
||||
@@ -103,9 +106,15 @@ export function ProjectTopbar({
|
||||
</div>
|
||||
</Dropdown>
|
||||
<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 && (
|
||||
<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} />
|
||||
</Space>
|
||||
|
||||
20
apps/web_v1/src/pages/projects/workbench-item-preview.tsx
Normal file
20
apps/web_v1/src/pages/projects/workbench-item-preview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
IconCalendar,
|
||||
IconCheckCircleFill,
|
||||
@@ -41,7 +41,7 @@ export function WorkspacePage({
|
||||
}: {
|
||||
workspaces: ProjectWorkspace[]
|
||||
onOpenTask: (project: Project, taskID: string) => void
|
||||
onUpdateTask: (update: WorkspaceTaskUpdate) => void
|
||||
onUpdateTask: (update: WorkspaceTaskUpdate) => Promise<void>
|
||||
}) {
|
||||
const projects = workspaces.map((workspace) => workspace.project)
|
||||
const projectOptions = useMemo(() => workspaces.map((workspace) => workspace.project), [workspaces])
|
||||
@@ -55,6 +55,8 @@ export function WorkspacePage({
|
||||
)
|
||||
const [editingTask, setEditingTask] = useState<WorkspaceTask | null>(null)
|
||||
const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', projectId: '', tag: '', completed: false })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState('')
|
||||
const tagOptions = useMemo(() => {
|
||||
const selectedWorkspace = workspaces.find((workspace) => workspace.project.id === draft.projectId)
|
||||
return selectedWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
|
||||
@@ -62,6 +64,7 @@ export function WorkspacePage({
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingTask) return
|
||||
setSaveError('')
|
||||
setDraft({
|
||||
title: editingTask.title,
|
||||
summary: editingTask.summary,
|
||||
@@ -70,6 +73,28 @@ export function WorkspacePage({
|
||||
completed: editingTask.completed,
|
||||
})
|
||||
}, [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(() => {
|
||||
if (!draft.tag || tagOptions.includes(draft.tag)) return
|
||||
setDraft((value) => ({ ...value, tag: '' }))
|
||||
@@ -145,22 +170,14 @@ export function WorkspacePage({
|
||||
className="action-modal"
|
||||
title="编辑任务"
|
||||
visible={Boolean(editingTask)}
|
||||
onCancel={() => setEditingTask(null)}
|
||||
onOk={() => {
|
||||
if (!editingTask) return
|
||||
onUpdateTask({
|
||||
originalProjectId: editingTask.project.id,
|
||||
nextProjectId: draft.projectId,
|
||||
taskId: editingTask.id,
|
||||
title: draft.title.trim() || editingTask.title,
|
||||
summary: draft.summary.trim(),
|
||||
tag: draft.tag.trim(),
|
||||
completed: draft.completed,
|
||||
})
|
||||
setEditingTask(null)
|
||||
}}
|
||||
confirmLoading={saving}
|
||||
maskClosable={!saving}
|
||||
cancelButtonProps={{ disabled: saving }}
|
||||
onCancel={() => { if (!saving) setEditingTask(null) }}
|
||||
onOk={() => void saveTask()}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
{saveError && <Alert type="error" content={saveError} />}
|
||||
<label>
|
||||
<Text>任务标题</Text>
|
||||
<Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} />
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ProjectSidebar, type ProjectSettingsUpdate } from './projects/project-s
|
||||
import { ProjectStatusbar } from './projects/project-statusbar'
|
||||
import { ProjectTopbar } from './projects/project-topbar'
|
||||
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 { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
|
||||
|
||||
@@ -45,6 +45,7 @@ export function ProjectPage({
|
||||
onSearch,
|
||||
onSelectSearchResult,
|
||||
onAnalyzeInbox,
|
||||
onCaptureInbox,
|
||||
onConfirmInbox,
|
||||
onListAISessions,
|
||||
onCreateAISession,
|
||||
@@ -67,9 +68,9 @@ export function ProjectPage({
|
||||
onCreateTask: () => void
|
||||
onUploadSource: () => void
|
||||
onCreateCronPlan: () => void
|
||||
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => void
|
||||
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => Promise<void>
|
||||
onUpdateProject: (update: ProjectSettingsUpdate) => Promise<void>
|
||||
onCreateProjectTag: (name: string) => void
|
||||
onCreateProjectTag: (name: string) => Promise<void>
|
||||
searchQuery: string
|
||||
searchLoading: boolean
|
||||
searchSearched: boolean
|
||||
@@ -78,6 +79,7 @@ export function ProjectPage({
|
||||
onSearch: () => void
|
||||
onSelectSearchResult: (result: SearchResultDTO) => void
|
||||
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
|
||||
onCaptureInbox: (projectId: string, input: CaptureInboxInput) => Promise<string>
|
||||
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
|
||||
onListAISessions: (projectId: string, 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 projects = workspaces.map((workspace) => workspace.project)
|
||||
const updateActiveProjectTask = (update: ProjectTaskUpdate) => {
|
||||
onUpdateWorkspaceTask({
|
||||
return onUpdateWorkspaceTask({
|
||||
originalProjectId: activeWorkspace.project.id,
|
||||
nextProjectId: activeWorkspace.project.id,
|
||||
taskId: update.taskId,
|
||||
@@ -163,6 +165,7 @@ export function ProjectPage({
|
||||
onCreateProjectTag={onCreateProjectTag}
|
||||
onUpdateTask={updateActiveProjectTask}
|
||||
onAnalyzeInbox={onAnalyzeInbox}
|
||||
onCaptureInbox={onCaptureInbox}
|
||||
onConfirmInbox={onConfirmInbox}
|
||||
onListAISessions={onListAISessions}
|
||||
onCreateAISession={onCreateAISession}
|
||||
|
||||
Reference in New Issue
Block a user