feat: enhance workspace index progress tracking and UI feedback

This commit is contained in:
hy committed 2026-09-20 17:24:44 +08:00
1 parent 7d2af03f2a
commit 39fc231d2a
7 files changed
+128 -21

No files matched your search

+4 -4
View File
@@ -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` 向量模型未安装时,“更新索引”按钮禁用并展示“为什么不能更新索引”说明;安装模型后按钮恢复可用。
+3 -3
View File
@@ -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`
@@ -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<WorkspaceIndexJobProgressProps> = ({ 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<WorkspaceIndexJobProgressProps> = ({ j
>
<div className="flex items-center justify-between gap-3 text-[12px]">
<span className="font-medium text-[#37352F]">{title}</span>
{hasDeterminateProgress ? (
<span className="shrink-0 text-[#9B9A97]">
{percent}% · {done}/{total}
</span>
{progress.hasDeterminateProgress ? (
<span className="shrink-0 text-[#9B9A97]">{formatIndexJobProgressLabel(progress)}</span>
) : (
<Spin size="small" />
)}
</div>
<Progress
percent={hasDeterminateProgress ? percent : 0}
status={hasDeterminateProgress && percent > 0 ? 'active' : 'normal'}
percent={progress.hasDeterminateProgress ? progress.percent : 0}
status={progress.hasDeterminateProgress && progress.percent > 0 ? 'active' : 'normal'}
showInfo={false}
size="small"
strokeColor="#37352F"
+28
View File
@@ -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+/
@@ -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
}
)
@@ -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
}
)
@@ -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')
})
})