diff --git a/doc/prd/settings.md b/doc/prd/settings.md index fba68e41..2ea9a6aa 100644 --- a/doc/prd/settings.md +++ b/doc/prd/settings.md @@ -145,11 +145,11 @@ #### SET-WORKSPACE-004 更新文件索引 -- 系统行为:点击“更新索引”后启动持久化后台任务,先检查全部文件地址的目录变化,再处理新增、变更、可索引和失败文件。多个文件地址时按地址顺序执行,前一个地址进入终态后再启动下一个。执行中「检查更新」保持可用;「更新索引」不 disable、不加 loading,文案变为「取消」。页面按目标文件总数展示进度。点「取消」先弹出确认框(点击遮罩不关闭):确认停止后,尚未完成的将不会继续索引,已经完成的索引会保留。主按钮「停止」,次按钮「继续更新」。确认停止后,当前嵌入批次(每批最多 4 小段)跑完即停,不再继续该文件剩余批次;已写入的已索引与本次失败保留;未处理与当前未写完的文件回到处理前状态(`needs_index` 或原来的 `failed`),不因取消标失败;尚未启动的文件地址不再开始。若确认时任务已到成功或失败终态,停止为空操作,不把 job 改成 `cancelled`,不回退已索引。确认后按钮显示「停止中」直到任务终态;job 终态为 `cancelled`(任务已跑完则为原成功/失败)。向量模型未安装时“更新索引”禁用并提示先安装模型。界面只提供“检查更新”和“更新索引”。无文件地址时“检查更新”禁用;可索引数为 0 时“更新索引”禁用。不做暂停;用户可再次点击“更新索引”处理剩余可索引文件。 +- 系统行为:点击“更新索引”后启动持久化后台任务,先检查全部文件地址的目录变化,再处理新增、变更、可索引和失败文件。多个文件地址时按地址顺序执行,前一个地址进入终态后再启动下一个。执行中「检查更新」保持可用;「更新索引」不 disable、不加 loading,文案变为「取消」。进度条从点击后保持可见:目标总数尚未算出时显示活动进度条,不先闪文案再出条,也不在算出总数后继续停在准备中的空进度。算出总数后立即按百分比和 `已处理/总数` 展示真实进度,已处理为 0 时仍显示 `0% · 0/N`。点「取消」先弹出确认框(点击遮罩不关闭):确认停止后,尚未完成的将不会继续索引,已经完成的索引会保留。主按钮「停止」,次按钮「继续更新」。确认停止后,当前嵌入批次(每批最多 4 小段)跑完即停,不再继续该文件剩余批次;已写入的已索引与本次失败保留;未处理与当前未写完的文件回到处理前状态(`needs_index` 或原来的 `failed`),不因取消标失败;尚未启动的文件地址不再开始。若确认时任务已到成功或失败终态,停止为空操作,不把 job 改成 `cancelled`,不回退已索引。确认后按钮显示「停止中」直到任务终态;job 终态为 `cancelled`(任务已跑完则为原成功/失败)。向量模型未安装时“更新索引”禁用并提示先安装模型。界面只提供“检查更新”和“更新索引”。无文件地址时“检查更新”禁用;可索引数为 0 时“更新索引”禁用。不做暂停;用户可再次点击“更新索引”处理剩余可索引文件。 - 验收: - `SET-WORKSPACE-004-AC1` 更新索引时,实际处理范围会包含新增、变更、未索引和失败文件,而不是只停留在界面展示。 - `SET-WORKSPACE-004-AC2` 更新成功后,可索引摘要和文件节点状态同步刷新;失败文件继续参与下次更新索引。 - - `SET-WORKSPACE-004-AC3` 更新索引执行中,界面可见持久化总进度,「检查更新」保持可用,「更新索引」文案变为「取消」且可点。进度从任务开始即以进度条呈现;目标总数尚未算出时显示活动进度条,不先闪准备文案再换成进度条。 + - `SET-WORKSPACE-004-AC3` 更新索引执行中,界面可见持久化总进度,「检查更新」保持可用,「更新索引」文案变为「取消」且可点。进度从任务开始即以进度条呈现并保持可见;目标总数尚未算出时显示活动进度条,不先闪准备文案再换成进度条。总数算出后立即展示真实百分比和 `已处理/总数`,已处理为 0 时仍为 `0% · 0/N`,不得继续停在准备中的空进度。 - `SET-WORKSPACE-004-AC4` 更新索引执行中离开工作空间页再进入时,仍能恢复当前索引进度。 - `SET-WORKSPACE-004-AC5` 程序关闭导致索引中断时,下次启动后任务和索引中的文件都收敛为失败状态。 - `SET-WORKSPACE-004-AC6` 向量模型未安装时,“更新索引”按钮禁用并展示“为什么不能更新索引”说明;安装模型后按钮恢复可用。 @@ -225,12 +225,12 @@ #### SET-WEB-004 更新网页索引 -- 系统行为:点击“更新索引”时启动持久化后台任务,仅对全局列表中状态为 `可索引 / 爬取失败 / 索引失败` 的网页执行爬取和索引更新;有抓取动作时先展示“步骤 1/2 抓取网页”,再展示“步骤 2/2 建立索引”,无抓取动作时直接展示第二步。执行中「检查更新」保持可用;「更新索引」不 disable、不加 loading,文案变为「取消」。点「取消」先弹出确认框(点击遮罩不关闭):确认停止后,尚未完成的将不会继续索引,已经完成的索引会保留。主按钮「停止」,次按钮「继续更新」。确认框不展示已完成/剩余数量。确认停止后,当前嵌入批次跑完即停;已完成的网页保留;未完成的回到进入本轮前的 `可索引 / 爬取失败 / 索引失败`,不因取消标成新的失败。若确认时任务已到成功或失败终态,停止为空操作。job 终态为 `cancelled`(任务已跑完则为原成功/失败)。向量模型未安装时“更新索引”禁用并提示先安装模型。可索引数为 0 时“更新索引”禁用。不做暂停;用户可再次点击“更新索引”处理剩余可索引网页。 +- 系统行为:点击“更新索引”时启动持久化后台任务,仅对全局列表中状态为 `可索引 / 爬取失败 / 索引失败` 的网页执行爬取和索引更新;有抓取动作时先展示“步骤 1/2 抓取网页”,再展示“步骤 2/2 建立索引”,无抓取动作时直接展示第二步。执行中「检查更新」保持可用;「更新索引」不 disable、不加 loading,文案变为「取消」。进度条从点击后保持可见:目标总数尚未算出时显示活动进度条;算出后立即按百分比和 `已处理/总数` 展示真实进度,已处理为 0 时仍显示 `0% · 0/N`,不得继续停在准备中的空进度。点「取消」先弹出确认框(点击遮罩不关闭):确认停止后,尚未完成的将不会继续索引,已经完成的索引会保留。主按钮「停止」,次按钮「继续更新」。确认框不展示已完成/剩余数量。确认停止后,当前嵌入批次跑完即停;已完成的网页保留;未完成的回到进入本轮前的 `可索引 / 爬取失败 / 索引失败`,不因取消标成新的失败。若确认时任务已到成功或失败终态,停止为空操作。job 终态为 `cancelled`(任务已跑完则为原成功/失败)。向量模型未安装时“更新索引”禁用并提示先安装模型。可索引数为 0 时“更新索引”禁用。不做暂停;用户可再次点击“更新索引”处理剩余可索引网页。 - 验收: - `SET-WEB-004-AC1` 更新索引执行中,网页状态会进入 `爬取中` 或 `索引中`。 - `SET-WEB-004-AC2` 更新成功后,网页状态变为 `已索引`,抓取时间同步刷新。 - `SET-WEB-004-AC3` 更新失败时,会区分 `爬取失败` 与 `索引失败`。 - - `SET-WEB-004-AC4` 更新索引执行中,界面可见抓取/索引分阶段进度,「检查更新」保持可用,「更新索引」文案变为「取消」且可点。进度从任务开始即以进度条呈现;目标总数尚未算出时显示活动进度条,不先闪准备文案再换成进度条。 + - `SET-WEB-004-AC4` 更新索引执行中,界面可见抓取/索引分阶段进度,「检查更新」保持可用,「更新索引」文案变为「取消」且可点。进度从任务开始即以进度条呈现并保持可见;目标总数尚未算出时显示活动进度条,不先闪准备文案再换成进度条。总数算出后立即展示真实百分比和 `已处理/总数`,已处理为 0 时仍为 `0% · 0/N`,不得继续停在准备中的空进度。 - `SET-WEB-004-AC5` 更新索引执行中离开网页 tab 再进入时,仍能恢复当前阶段和进度。 - `SET-WEB-004-AC6` 程序关闭导致网页索引中断时,下次启动后任务收敛为失败,`爬取中` 网页变为 `爬取失败`,`索引中` 网页变为 `索引失败`。 - `SET-WEB-004-AC7` 向量模型未安装时,“更新索引”按钮禁用并展示“为什么不能更新索引”说明;安装模型后按钮恢复可用。 diff --git a/doc/test-plan/settings-test.md b/doc/test-plan/settings-test.md index 9ee6a2c5..5a102b5c 100644 --- a/doc/test-plan/settings-test.md +++ b/doc/test-plan/settings-test.md @@ -113,8 +113,8 @@ Settings 顶层 suite 对齐 PRD 模块: - 测试类型:`e2e` - What:点击“更新索引”后,文件状态会刷新为已索引并同步更新摘要。 - Why:“索引已经可用”的核心用户反馈链路。全仓唯一的向量 warmup,预热共享 fixture 的 split/vector 缓存。 -- How:复制共享 workspace 输入的隔离副本,通过 Settings UI 添加为文件地址并点击更新索引,读取刷新后的文件树和全部支持文件状态。共享 fixture 含 `.doc`、`docx`、`pdf`、`md`、`txt`、`xlsx`、`xlsm` 等全部受支持类型,其中 excel 只以元数据方式索引进库。不切换、不断言索引速度。 -- Oracle:更新索引过程中出现持久化总进度,「检查更新」可用,「更新索引」文案变为「取消」且可点,对齐 `SET-WORKSPACE-004-AC3`、`SET-WORKSPACE-004-AC12`;进度从开始即以进度条呈现,未知总数时不先闪准备文案;运行期间目录读取次数为 0,终态只刷新一次且不因逐文件完成而闪烁或折叠,对齐 `SET-WORKSPACE-004-AC7`;共享 fixture 中全部支持文件真实进入处理范围,对齐 `SET-WORKSPACE-004-AC1`;更新后所有支持文件收敛为 `indexed / failed`,摘要和节点状态同步刷新,对齐 `SET-WORKSPACE-004-AC2`。 +- How:复制共享 workspace 输入的隔离副本,通过 Settings UI 添加为文件地址并点击更新索引,读取刷新后的文件树和全部支持文件状态。共享 fixture 含 `.doc`、`docx`、`pdf`、`md`、`txt`、`xlsx`、`xlsm` 等全部受支持类型,其中 excel 只以元数据方式索引进库。不切换、不断言索引速度。点击后等待进度块出现,再等到目标总数算出后读取进度文案。 +- Oracle:更新索引过程中出现持久化总进度,「检查更新」可用,「更新索引」文案变为「取消」且可点,对齐 `SET-WORKSPACE-004-AC3`、`SET-WORKSPACE-004-AC12`;进度从开始即以进度条呈现并保持可见,未知总数时不先闪准备文案;总数算出后进度文案含百分比和 `已处理/总数`(可为 `0% · 0/N`),不再停在无数字的准备空进度,对齐 `SET-WORKSPACE-004-AC3`;运行期间目录读取次数为 0,终态只刷新一次且不因逐文件完成而闪烁或折叠,对齐 `SET-WORKSPACE-004-AC7`;共享 fixture 中全部支持文件真实进入处理范围,对齐 `SET-WORKSPACE-004-AC1`;更新后所有支持文件收敛为 `indexed / failed`,摘要和节点状态同步刷新,对齐 `SET-WORKSPACE-004-AC2`。 #### `workspace-index-speed` @@ -196,7 +196,7 @@ Settings 顶层 suite 对齐 PRD 模块: - What:网页“检查更新 → 更新索引”完整链路会先刷新可索引状态,再区分成功索引与爬取失败并刷新摘要。 - Why:网页索引是“爬取 + 建索引”两阶段链路,失败类型必须可区分。 - How:预置一条成功网页和一条带失败标记的网页,先执行“检查更新”,再执行“更新索引”。 -- Oracle:网页列表、摘要、失败数量和操作区初始化对齐 `SET-WEB-001-AC1`、`SET-WEB-001-AC2`;检查进度和完成状态对齐 `SET-WEB-003-AC1`、`SET-WEB-003-AC2`;更新时网页进入抓取/索引状态,对齐 `SET-WEB-004-AC1`;成功与失败分别收敛,对齐 `SET-WEB-004-AC2`、`SET-WEB-004-AC3`;分阶段进度可见,「检查更新」可用,「更新索引」文案变为「取消」且可点,对齐 `SET-WEB-004-AC4`、`SET-WEB-004-AC10`。 +- Oracle:网页列表、摘要、失败数量和操作区初始化对齐 `SET-WEB-001-AC1`、`SET-WEB-001-AC2`;检查进度和完成状态对齐 `SET-WEB-003-AC1`、`SET-WEB-003-AC2`;更新时网页进入抓取/索引状态,对齐 `SET-WEB-004-AC1`;成功与失败分别收敛,对齐 `SET-WEB-004-AC2`、`SET-WEB-004-AC3`;分阶段进度可见,「检查更新」可用,「更新索引」文案变为「取消」且可点;目标总数算出后进度文案含百分比和 `已处理/总数`,对齐 `SET-WEB-004-AC4`、`SET-WEB-004-AC10`。 #### `web-prepare-progress-restore` diff --git a/src/renderer/src/components/settings/WorkspaceIndexJobProgress.tsx b/src/renderer/src/components/settings/WorkspaceIndexJobProgress.tsx index 7de8bd66..8402698b 100644 --- a/src/renderer/src/components/settings/WorkspaceIndexJobProgress.tsx +++ b/src/renderer/src/components/settings/WorkspaceIndexJobProgress.tsx @@ -1,6 +1,10 @@ import React from 'react' import { Progress, Spin } from 'antd' import type { WorkspaceIndexJobRecord } from '../../../../shared/types' +import { + formatIndexJobProgressLabel, + getIndexJobProgressNumbers +} from '../../../../shared/indexJobProgress' type WorkspaceIndexJobProgressProps = { job?: WorkspaceIndexJobRecord @@ -23,10 +27,7 @@ const getJobTitle = (job: WorkspaceIndexJobRecord): string => { const WorkspaceIndexJobProgress: React.FC = ({ job, testId }) => { if (!job || job.status !== 'running') return null const title = getJobTitle(job) - const total = Math.max(0, job.total_count) - const done = Math.min(total, Math.max(0, job.processed_count + job.failed_count)) - const percent = total > 0 ? Math.round((done / total) * 100) : 0 - const hasDeterminateProgress = total > 0 + const progress = getIndexJobProgressNumbers(job) const message = job.message || '正在处理' const shouldHideMessage = message.startsWith(`${title}:`) || message === title @@ -37,17 +38,15 @@ const WorkspaceIndexJobProgress: React.FC = ({ j >
{title} - {hasDeterminateProgress ? ( - - {percent}% · {done}/{total} - + {progress.hasDeterminateProgress ? ( + {formatIndexJobProgressLabel(progress)} ) : ( )}
0 ? 'active' : 'normal'} + percent={progress.hasDeterminateProgress ? progress.percent : 0} + status={progress.hasDeterminateProgress && progress.percent > 0 ? 'active' : 'normal'} showInfo={false} size="small" strokeColor="#37352F" diff --git a/src/shared/indexJobProgress.ts b/src/shared/indexJobProgress.ts new file mode 100644 index 00000000..11bcda5e --- /dev/null +++ b/src/shared/indexJobProgress.ts @@ -0,0 +1,28 @@ +export const getIndexJobProgressNumbers = (job: { + total_count: number + processed_count: number + failed_count: number +}): { + total: number + done: number + percent: number + hasDeterminateProgress: boolean +} => { + const total = Math.max(0, job.total_count) + const done = Math.min(total, Math.max(0, job.processed_count + job.failed_count)) + const percent = total > 0 ? Math.round((done / total) * 100) : 0 + return { + total, + done, + percent, + hasDeterminateProgress: total > 0 + } +} + +export const formatIndexJobProgressLabel = (input: { + percent: number + done: number + total: number +}): string => `${input.percent}% · ${input.done}/${input.total}` + +export const INDEX_JOB_PROGRESS_LABEL_PATTERN = /\d+%\s*·\s*\d+\/\d+/ diff --git a/tests/case/settings/workspace/files/workspace-prepare-refresh.e2e.test.ts b/tests/case/settings/workspace/files/workspace-prepare-refresh.e2e.test.ts index 1cff8771..106da931 100644 --- a/tests/case/settings/workspace/files/workspace-prepare-refresh.e2e.test.ts +++ b/tests/case/settings/workspace/files/workspace-prepare-refresh.e2e.test.ts @@ -9,6 +9,7 @@ import { waitForFileIndexJobIdle } from '../../../../harness/e2e/settings' import { copySharedWorkspaceFixture, toSupportedFileStatuses } from './realWorkspaceHelpers' +import { INDEX_JOB_PROGRESS_LABEL_PATTERN } from '../../../../../src/shared/indexJobProgress' const LEGACY_DOC_NAME = '风险等级判断标准-图案设计【2026.2.10更新】.doc' @@ -35,6 +36,7 @@ describe('settings workspace e2e', () => { }) const workspaceId = workspace.workspace!.id let prepareProgressVisible = false + let prepareProgressText = '' let prepareButtonName = '' let buttonsDisabled = false let directoryCallBaseline = 0 @@ -65,6 +67,22 @@ describe('settings workspace e2e', () => { state: 'visible' }) prepareProgressVisible = true + await page.waitForFunction( + (testId) => { + const el = document.querySelector(`[data-testid="${testId}"]`) + return ( + el instanceof HTMLElement && /\d+%\s*·\s*\d+\/\d+/.test(el.innerText || '') + ) + }, + importSelectors.workspaceIndexJobProgress, + { timeout: SETTINGS_REAL_WORKSPACE_LONG_E2E_TIMEOUT_MS } + ) + prepareProgressText = ( + (await getSettingsLocator( + page, + importSelectors.workspaceIndexJobProgress + ).innerText()) || '' + ).trim() prepareButtonName = ( (await getSettingsLocator( page, @@ -106,6 +124,7 @@ describe('settings workspace e2e', () => { return { prepareProgressVisible, + prepareProgressText, prepareButtonName, buttonsDisabled, directoryCallsWhileRunning, @@ -118,11 +137,12 @@ describe('settings workspace e2e', () => { await runtime.assertThat( result.prepareProgressVisible && !result.buttonsDisabled && - result.prepareButtonName.includes('取消'), + result.prepareButtonName.includes('取消') && + INDEX_JOB_PROGRESS_LABEL_PATTERN.test(result.prepareProgressText), { name: 'prepare-progress-is-visible', message: - '更新索引执行中,界面应展示持久化总进度,「检查更新」可用,「更新索引」文案变为「取消」且可点,对齐 SET-WORKSPACE-004-AC3、SET-WORKSPACE-004-AC12。', + '更新索引执行中,界面应展示持久化总进度,「检查更新」可用,「更新索引」文案变为「取消」且可点;目标总数算出后进度文案含百分比和已处理/总数,不得停在无数字的准备空进度,对齐 SET-WORKSPACE-004-AC3、SET-WORKSPACE-004-AC12。', data: result } ) diff --git a/tests/case/settings/workspace/web/web-prepare-summary.e2e.test.ts b/tests/case/settings/workspace/web/web-prepare-summary.e2e.test.ts index fd251570..d935e2fc 100644 --- a/tests/case/settings/workspace/web/web-prepare-summary.e2e.test.ts +++ b/tests/case/settings/workspace/web/web-prepare-summary.e2e.test.ts @@ -1,5 +1,6 @@ import { describe, it } from 'vitest' import type { Page } from 'playwright' +import { INDEX_JOB_PROGRESS_LABEL_PATTERN } from '../../../../../src/shared/indexJobProgress' import { importSelectors } from '../../../../../src/renderer/src/testing/selectors' import { runE2ECase } from '../../../../harness/e2e/run-e2e-case' import { @@ -127,6 +128,16 @@ describe('settings workspace web e2e', () => { state: 'visible' }) crawlStepVisible = true + await page.waitForFunction( + (testId) => { + const el = document.querySelector(`[data-testid="${testId}"]`) + return ( + el instanceof HTMLElement && /\d+%\s*·\s*\d+\/\d+/.test(el.innerText || '') + ) + }, + importSelectors.webSourceIndexJobProgress, + { timeout: SETTINGS_WEB_E2E_TIMEOUT_MS } + ) progressText = (await progressBlock.innerText()) || '' progressVisible = true buttonsDisabled = @@ -177,11 +188,12 @@ describe('settings workspace web e2e', () => { result.progressVisible && !result.buttonsDisabled && result.prepareButtonName.includes('取消') && - result.crawlStepVisible, + result.crawlStepVisible && + INDEX_JOB_PROGRESS_LABEL_PATTERN.test(result.progressText), { name: 'prepare-web-progress-is-visible', message: - '网页索引执行中应展示抓取/索引进度,「检查更新」可用,「更新索引」文案变为「取消」且可点,对齐 SET-WEB-004-AC4、SET-WEB-004-AC10。', + '网页索引执行中应展示抓取/索引进度,「检查更新」可用,「更新索引」文案变为「取消」且可点;目标总数算出后进度文案含百分比和已处理/总数,对齐 SET-WEB-004-AC4、SET-WEB-004-AC10。', data: result } ) diff --git a/tests/case/unit/workspace/files/workspace-index-job-progress.test.ts b/tests/case/unit/workspace/files/workspace-index-job-progress.test.ts new file mode 100644 index 00000000..58043374 --- /dev/null +++ b/tests/case/unit/workspace/files/workspace-index-job-progress.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + formatIndexJobProgressLabel, + getIndexJobProgressNumbers, + INDEX_JOB_PROGRESS_LABEL_PATTERN +} from '../../../../../src/shared/indexJobProgress' + +describe('unit workspace files', () => { + it('workspace-index-job-progress', () => { + expect( + getIndexJobProgressNumbers({ total_count: 0, processed_count: 0, failed_count: 0 }) + ).toEqual({ + total: 0, + done: 0, + percent: 0, + hasDeterminateProgress: false + }) + + const oneFilePending = getIndexJobProgressNumbers({ + total_count: 1, + processed_count: 0, + failed_count: 0 + }) + expect(oneFilePending).toEqual({ + total: 1, + done: 0, + percent: 0, + hasDeterminateProgress: true + }) + expect(formatIndexJobProgressLabel(oneFilePending)).toBe('0% · 0/1') + expect(INDEX_JOB_PROGRESS_LABEL_PATTERN.test(formatIndexJobProgressLabel(oneFilePending))).toBe( + true + ) + + const halfDone = getIndexJobProgressNumbers({ + total_count: 4, + processed_count: 2, + failed_count: 0 + }) + expect(halfDone).toEqual({ + total: 4, + done: 2, + percent: 50, + hasDeterminateProgress: true + }) + expect(formatIndexJobProgressLabel(halfDone)).toBe('50% · 2/4') + }) +})