diff --git a/apps/web_v1/scripts/api-client.test.mjs b/apps/web_v1/scripts/api-client.test.mjs index 223fdde..3e82e75 100644 --- a/apps/web_v1/scripts/api-client.test.mjs +++ b/apps/web_v1/scripts/api-client.test.mjs @@ -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 () => { diff --git a/apps/web_v1/scripts/structure-check.mjs b/apps/web_v1/scripts/structure-check.mjs index d8d4f28..97dc555 100644 --- a/apps/web_v1/scripts/structure-check.mjs +++ b/apps/web_v1/scripts/structure-check.mjs @@ -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: [' { + 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) { diff --git a/apps/web_v1/scripts/workspace-refresh-gate.test.mjs b/apps/web_v1/scripts/workspace-refresh-gate.test.mjs new file mode 100644 index 0000000..4c21d37 --- /dev/null +++ b/apps/web_v1/scripts/workspace-refresh-gate.test.mjs @@ -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) +}) diff --git a/apps/web_v1/src/App.css b/apps/web_v1/src/App.css index b622f1d..47a79a3 100644 --- a/apps/web_v1/src/App.css +++ b/apps/web_v1/src/App.css @@ -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; diff --git a/apps/web_v1/src/api/client.ts b/apps/web_v1/src/api/client.ts index 1d8f15c..ae5d101 100644 --- a/apps/web_v1/src/api/client.ts +++ b/apps/web_v1/src/api/client.ts @@ -62,7 +62,8 @@ export async function apiRequest(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', '无法连接服务器,请检查地址和网络后重试') } diff --git a/apps/web_v1/src/api/projects.ts b/apps/web_v1/src/api/projects.ts index c77fa83..f60a3bb 100644 --- a/apps/web_v1/src/api/projects.ts +++ b/apps/web_v1/src/api/projects.ts @@ -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('/api/v1/projects', { token: session.token }) +export async function fetchProjects(session: ApiSession, signal?: AbortSignal) { + return apiRequest('/api/v1/projects', { token: session.token, signal }) } -export async function fetchProjectWorkspace(session: ApiSession, projectId: string) { - return apiRequest(`/api/v1/projects/${projectId}/workspace`, { token: session.token }) +export async function fetchProjectWorkspace(session: ApiSession, projectId: string, signal?: AbortSignal) { + return apiRequest(`/api/v1/projects/${projectId}/workspace`, { token: session.token, signal }) } export async function fetchProjectTags(session: ApiSession, projectId: string) { diff --git a/apps/web_v1/src/app/App.tsx b/apps/web_v1/src/app/App.tsx index ab065ad..339f397 100644 --- a/apps/web_v1/src/app/App.tsx +++ b/apps/web_v1/src/app/App.tsx @@ -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('login') @@ -37,12 +39,17 @@ function App() { const activeProjectIDRef = useRef('') const [activeChannel, setActiveChannel] = useState('overview') const [activeTaskID, setActiveTaskID] = useState(null) - const [, setSelectedItem] = useState('探索采集') + const [selectedItem, setSelectedItem] = useState(null) const [loading, setLoading] = useState(false) const [actionLoading, setActionLoading] = useState(false) const [activeModal, setActiveModal] = useState(null) const [searchResultPreview, setSearchResultPreview] = useState(null) const workspaceSearch = useWorkbenchSearch(session) + const workspaceRefreshGate = useRef(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, 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} /> setSearchResultPreview(null)} /> + setSelectedItem(null)} /> ) @@ -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') diff --git a/apps/web_v1/src/app/workspace-refresh-gate.ts b/apps/web_v1/src/app/workspace-refresh-gate.ts new file mode 100644 index 0000000..ca80d5e --- /dev/null +++ b/apps/web_v1/src/app/workspace-refresh-gate.ts @@ -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 diff --git a/apps/web_v1/src/pages/login.tsx b/apps/web_v1/src/pages/login.tsx index 56169c1..5c16fa7 100644 --- a/apps/web_v1/src/pages/login.tsx +++ b/apps/web_v1/src/pages/login.tsx @@ -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('checking') @@ -42,8 +47,6 @@ export function LoginPage({ onLogin }: { onLogin: (input: { server: string; emai 知识沉淀 · 团队协作 · AI 助手 v1.0.0 - 隐私政策 - 服务协议 @@ -63,13 +66,13 @@ export function LoginPage({ onLogin }: { onLogin: (input: { server: string; emai
- - - 记住服务器地址 - - + 记住服务器地址
- diff --git a/apps/web_v1/src/pages/projects/project-action-modals.tsx b/apps/web_v1/src/pages/projects/project-action-modals.tsx index c84702f..0e70ce1 100644 --- a/apps/web_v1/src/pages/projects/project-action-modals.tsx +++ b/apps/web_v1/src/pages/projects/project-action-modals.tsx @@ -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 + onCreateTask: (draft: TaskDraft) => Promise + onUploadSource: (draft: SourceDraft) => Promise + onCreateCronPlan: (draft: CronDraft) => Promise tagOptions: string[] }) { const [project, setProject] = useState({ name: '', identifier: '', icon: '', background: '', description: '' }) const [task, setTask] = useState({ title: '', description: '', tag: '' }) const [source, setSource] = useState({ title: '', file: null }) const [cron, setCron] = useState({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' }) + const [submitError, setSubmitError] = useState('') + + async function submit(action: () => Promise) { + 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))} > + {submitError && }