diff --git a/frontend-v2/e2e/workflow-forms.spec.ts b/frontend-v2/e2e/workflow-forms.spec.ts index 154233d5..8c12ef11 100644 --- a/frontend-v2/e2e/workflow-forms.spec.ts +++ b/frontend-v2/e2e/workflow-forms.spec.ts @@ -28,8 +28,10 @@ test('inspection form persists a draft, creates a real task once and survives re await page.goto(`/chat?run=${run}`) const form = page.getByRole('region', { name: '报告与问题确认' }) await expect(form.getByText('验收磁盘异常', { exact: true })).toBeVisible() - await form.getByLabel('是否为问题').selectOption('confirmed') + // One click on the visible control, not a select that hides the two real answers. + await form.getByRole('group', { name: '是否为问题' }).getByRole('button', { name: '是问题', exact: true }).click() await form.getByLabel('指派给', { exact: true }).selectOption({ label: ownerName }) + await form.getByRole('button', { name: '添加备注' }).click() await form.getByLabel('备注', { exact: true }).fill('请清理并核对增长来源') await form.getByRole('button', { name: '保存草稿' }).click() await expect(form.getByText('草稿已保存。')).toBeVisible() diff --git a/frontend-v2/e2e/workflow-review.spec.ts b/frontend-v2/e2e/workflow-review.spec.ts new file mode 100644 index 00000000..45c2bdb4 --- /dev/null +++ b/frontend-v2/e2e/workflow-review.spec.ts @@ -0,0 +1,405 @@ +// The review node end to end: what a reviewer sees and can do at the form, what the task node +// records, and how the same page behaves for a waiting schedule, a second reviewer, a member who +// may only read, a cancelled run, a broken binding and a long list. +import { expect, test } from '@playwright/test' +import { signIn } from './login.ts' + +const findings = (suffix: string) => [ + { key: `src:token-${suffix}`, title: '源码中存在硬编码令牌', description: '令牌应与代码分离。', evidence: 'src/sync.mjs:1', severity: '高', module: 'src/sync.mjs' }, + { key: `src:eval-${suffix}`, title: '使用 eval 执行动态代码', description: '改为模板函数。', evidence: 'src/report.mjs:3', severity: '中', module: 'src/report.mjs' }, + { key: `test:coverage-${suffix}`, title: '模块缺少测试覆盖', description: '补上 node --test 用例。', evidence: 'src/export.mjs', severity: '中', module: 'test/' }, +] + +const graph = (rows: unknown[], name: string) => JSON.stringify({ + nodes: [ + { id: 'start', data: { kind: 'start', title: `开始:${name}`, inputVariables: [{ name: 'findings', valueType: 'array[object]', value: rows }] } }, + { id: 'review', data: { kind: 'form', title: '确认扫描结果', formConfig: { title: '版本质量扫描报告与问题确认', submitLabel: '确认并生成任务', reportSelector: ['start', 'input'], itemsSelector: ['start', 'findings'], fields: [{ name: 'notes', label: '备注', type: 'textarea', required: false }] } } }, + { id: 'tasks', data: { kind: 'createTasks', title: '创建或关联协作任务', createTasksConfig: { formNodeId: 'review' } } }, + { id: 'output', data: { kind: 'output', title: '输出处置结果', outputs: [{ name: 'tasks', variableSelector: ['tasks', 'structured_output', 'tasks'] }] } }, + ].map((node, index) => ({ ...node, type: 'workflow', position: { x: index * 340, y: 100 }, data: { ...node.data, description: '' } })), + edges: [['start', 'review'], ['review', 'tasks'], ['tasks', 'output']].map(([source, target]) => ({ id: `${source}-${target}`, source, target })), +}) + +async function publishRun(page: import('@playwright/test').Page, name: string, rows: unknown[]) { + const stamp = Date.now().toString(36) + const tenant = ((await (await page.request.get('/api/v1/me/tenants')).json()) as { items: { id: string }[] }).items[0]!.id + const projects = (await (await page.request.get(`/api/v2/tenants/${tenant}/projects`)).json()) as { items: { id: string; name: string }[] } + const project = projects.items.find(project => project.name === 'Demo') ?? projects.items[0]! + const created = await page.request.post(`/api/v2/tenants/${tenant}/workflows`, { data: { name: `${name} ${stamp}`, graph: graph(rows, name) } }) + expect(created.status()).toBe(201) + const workflow = ((await created.json()) as { workflow: { id: string } }).workflow.id + expect((await page.request.post(`/api/v2/tenants/${tenant}/workflows/${workflow}/publish`, { data: { version: 'v1' } })).ok()).toBe(true) + const started = await page.request.post(`/api/v2/tenants/${tenant}/workflows/${workflow}/runs`, { data: { projectId: project.id, input: '# 版本质量扫描报告\n\n三处问题,等待确认。' } }) + expect(started.ok()).toBe(true) + const run = ((await started.json()) as { run: { id: string } }).run.id + expect((await page.request.post(`/api/v2/tenants/${tenant}/workflow-runs/${run}/start`, { data: {} })).ok()).toBe(true) + return { tenant, projectId: project.id, workflow, run } +} + +test('review → tasks → task page, with draft, bulk, filters and reuse on the second night', async ({ page }) => { + test.setTimeout(180_000) + await page.goto('/chat') + await signIn(page) + const suffix = Date.now().toString(36) + const { workflow, run } = await publishRun(page, '巡检端到端', findings(suffix)) + + await page.goto(`/chat?run=${run}`) + const form = page.getByRole('region', { name: '报告与问题确认' }) + await expect(form.getByRole('heading', { name: '源码中存在硬编码令牌' })).toBeVisible({ timeout: 30_000 }) + + // Structure: three findings, every count agrees, no owner control until a decision exists. + await expect(form.getByRole('button', { name: '全部 3' })).toBeVisible() + await expect(form.getByRole('button', { name: '待确认 3' })).toBeVisible() + await expect(form.getByLabel('指派给', { exact: true })).toHaveCount(0) + await expect(form.getByText('还有 3 项待确认')).toBeVisible() + + // Bulk marks everything and can be undone. + await form.getByRole('button', { name: '全部标为问题' }).click() + await expect(form.getByText('将新建 3 个任务')).toBeVisible() + await expect(form.getByLabel('指派给', { exact: true })).toHaveCount(3) + await form.getByRole('button', { name: '撤销' }).click() + await expect(form.getByText('还有 3 项待确认')).toBeVisible() + + // One click decides one row and gives it the confirmer as owner. + const groups = form.getByRole('group', { name: '是否为问题' }) + await groups.nth(0).getByRole('button', { name: '是问题', exact: true }).click() + await expect(form.getByLabel('指派给', { exact: true })).toHaveCount(1) + await expect(form.getByLabel('指派给', { exact: true })).toHaveValue(/^human:/) + await expect(form.getByText('还有 2 项待确认')).toBeVisible() + + // Filtering hides what it should and keeps the counts. + await form.getByRole('button', { name: '待确认 2' }).click() + await expect(form.getByRole('heading', { name: '源码中存在硬编码令牌' })).toHaveCount(0) + await form.getByRole('button', { name: '排除筛选的 2 项' }).click() + await expect(form.getByRole('button', { name: '是问题 1' })).toBeVisible() + await expect(form.getByRole('button', { name: '不是问题 2' })).toBeVisible() + await form.getByRole('button', { name: '撤销' }).click() + await form.getByRole('button', { name: '是问题 1' }).click() + await expect(form.getByRole('heading', { name: '源码中存在硬编码令牌' })).toBeVisible() + await form.getByRole('button', { name: '全部 3' }).click() + + // A rejection asks for nothing and the last problem gets its own picker. + await groups.nth(1).getByRole('button', { name: '不是问题', exact: true }).click() + await groups.nth(2).getByRole('button', { name: '是问题', exact: true }).click() + await expect(form.getByLabel('指派给', { exact: true })).toHaveCount(2) + + // Notes are hidden until asked for and persist through a draft round trip. + await form.getByRole('button', { name: '添加备注' }).first().click() + await form.getByLabel('备注', { exact: true }).first().fill('令牌已轮换,本周改为环境变量。') + await form.getByRole('button', { name: '保存草稿' }).click() + await expect(form.getByText('草稿已保存。')).toBeVisible() + await page.reload() + const reloaded = page.getByRole('region', { name: '报告与问题确认' }) + await expect(reloaded.getByLabel('备注', { exact: true }).first()).toHaveValue('令牌已轮换,本周改为环境变量。') + await expect(reloaded.getByRole('group', { name: '是否为问题' }).nth(1).getByRole('button', { name: '不是问题', exact: true })).toHaveAttribute('aria-pressed', 'true') + + // Submit turns the page into conclusions, not disabled inputs. + await reloaded.getByRole('button', { name: '确认并生成任务' }).click() + await expect(reloaded.getByText('已提交')).toBeVisible({ timeout: 30_000 }) + await expect(reloaded.getByRole('link', { name: '打开任务' })).toHaveCount(2) + await expect(reloaded.getByLabel('是否为问题')).toHaveCount(0) + await expect(reloaded.getByText('已确认无需处理,不会创建任务。')).toBeVisible() + await expect(reloaded.getByText('已由 协作空间 Owner 确认')).toBeVisible() + + // CreateTasks lists the same two problems, by title, with links. + await page.getByRole('button', { name: '创建或关联协作任务' }).first().click() + const tasks = page.getByRole('region', { name: '任务处理结果' }) + await expect(tasks.getByText('源码中存在硬编码令牌')).toBeVisible() + await expect(tasks.getByText('模块缺少测试覆盖')).toBeVisible() + await expect(tasks.getByText('使用 eval 执行动态代码')).toHaveCount(0) + await expect(tasks.getByText('新建 2')).toBeVisible() + + // The task page keeps the source of the inspection. + await tasks.getByRole('link', { name: '打开任务' }).first().click() + await expect(page.getByRole('heading', { name: '源码中存在硬编码令牌' })).toBeVisible() + await expect(page.getByText(`/chat?run=${run}`)).toBeVisible() + expect(page.url()).toContain('/collaboration/tasks/') + + // The same findings on a later night link to the open tasks instead of creating new ones. + const tenant = ((await (await page.request.get('/api/v1/me/tenants')).json()) as { items: { id: string }[] }).items[0]!.id + const projects = (await (await page.request.get(`/api/v2/tenants/${tenant}/projects`)).json()) as { items: { id: string; name: string }[] } + const projectId = projects.items.find(project => project.name === 'Demo')!.id + const second = await page.request.post(`/api/v2/tenants/${tenant}/workflows/${workflow}/runs`, { data: { projectId, input: '# 第二次巡检' } }) + const secondRun = ((await second.json()) as { run: { id: string } }).run.id + await page.request.post(`/api/v2/tenants/${tenant}/workflow-runs/${secondRun}/start`, { data: {} }) + await page.goto(`/chat?run=${secondRun}`) + const again = page.getByRole('region', { name: '报告与问题确认' }) + await expect(again.getByRole('heading', { name: '源码中存在硬编码令牌' })).toBeVisible({ timeout: 30_000 }) + await expect(again.getByText('关联到已有任务')).toHaveCount(2) + await again.getByRole('button', { name: '全部标为问题' }).click() + await expect(again.getByText('将新建 1 个任务、关联 2 个已有任务')).toBeVisible() + await expect(again.getByLabel('指派给', { exact: true })).toHaveCount(3) + await again.getByRole('button', { name: '确认并生成任务' }).click() + await expect(again.getByText('已提交')).toBeVisible({ timeout: 30_000 }) + await page.getByRole('button', { name: '创建或关联协作任务' }).first().click() + await expect(page.getByRole('region', { name: '任务处理结果' }).getByText('关联 2')).toBeVisible() + await expect(page.getByRole('region', { name: '任务处理结果' }).getByText('新建 1')).toBeVisible() +}) + +test('a run with no findings asks for one confirmation and creates nothing', async ({ page }) => { + test.setTimeout(90_000) + await page.goto('/chat') + await signIn(page) + const { run } = await publishRun(page, '巡检无问题', []) + await page.goto(`/chat?run=${run}`) + const form = page.getByRole('region', { name: '报告与问题确认' }) + await expect(form.getByText('本次未发现问题')).toBeVisible({ timeout: 30_000 }) + await expect(form.getByRole('button', { name: '全部标为问题' })).toHaveCount(0) + await form.getByRole('button', { name: '确认无问题并继续' }).click() + await expect(form.getByText('已提交')).toBeVisible({ timeout: 30_000 }) + await expect(form.getByRole('link', { name: '打开任务' })).toHaveCount(0) + await page.getByRole('button', { name: '创建或关联协作任务' }).first().click() + await expect(page.getByRole('region', { name: '任务处理结果' }).getByText('没有需要创建的任务')).toBeVisible() +}) + +test('a member reads the form but cannot submit it', async ({ page }) => { + test.setTimeout(120_000) + await page.goto('/chat') + await signIn(page) + const { run, tenant } = await publishRun(page, '巡检权限', findings(Date.now().toString(36))) + const email = `member-${Date.now().toString(36)}@demo.ora-space.dev` + const invited = await page.request.post(`/api/v2/tenants/${tenant}/members`, { data: { email, displayName: `受邀成员 ${Date.now()}` } }) + expect(invited.ok()).toBe(true) + + await page.getByRole('button', { name: '退出登录', exact: true }).click() + await signIn(page, email) + await page.goto(`/chat?run=${run}`) + const form = page.getByRole('region', { name: '报告与问题确认' }) + await expect(form.getByText(/只有确认人或工作区管理员可以提交/)).toBeVisible({ timeout: 30_000 }) + const deciding = form.getByRole('group', { name: '是否为问题' }).getByRole('button', { name: '是问题', exact: true }) + await expect(deciding).toHaveCount(3) + for (const button of await deciding.all()) await expect(button).toBeDisabled() + await expect(form.getByRole('button', { name: '保存草稿' })).toHaveCount(0) + await expect(form.getByText('负责人')).toHaveCount(0) +}) + +test('cancelling a waiting run closes the form', async ({ page }) => { + test.setTimeout(90_000) + await page.goto('/chat') + await signIn(page) + const { run } = await publishRun(page, '巡检取消', findings(Date.now().toString(36))) + await page.goto(`/chat?run=${run}`) + await expect(page.getByRole('region', { name: '报告与问题确认' }).getByText('还有 3 项待确认')).toBeVisible({ timeout: 30_000 }) + await page.getByRole('button', { name: '停止', exact: true }).click() + await expect(page.getByRole('region', { name: '报告与问题确认' }).getByText('运行已取消')).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('region', { name: '报告与问题确认' }).getByRole('button', { name: '确认并生成任务' })).toHaveCount(0) +}) + +test('a 390px reviewer can judge, see the footer and submit', async ({ page }) => { + test.setTimeout(120_000) + await page.goto('/chat') + await signIn(page) + const { run } = await publishRun(page, '巡检窄屏', findings(Date.now().toString(36))) + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto(`/chat?run=${run}`) + const form = page.getByRole('region', { name: '报告与问题确认' }) + await expect(form.getByRole('heading', { name: '源码中存在硬编码令牌' })).toBeVisible({ timeout: 30_000 }) + const width = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth })) + expect(width.scroll).toBe(width.client) + await form.getByRole('group', { name: '是否为问题' }).nth(0).getByRole('button', { name: '是问题', exact: true }).click() + await expect(form.getByLabel('指派给', { exact: true })).toBeVisible() + await form.getByRole('group', { name: '是否为问题' }).nth(1).getByRole('button', { name: '不是问题', exact: true }).click() + await form.getByRole('group', { name: '是否为问题' }).nth(2).getByRole('button', { name: '不是问题', exact: true }).click() + await expect(form.getByText('将新建 1 个任务')).toBeVisible() + await form.getByRole('button', { name: '确认并生成任务' }).click() + await expect(form.getByText('已提交')).toBeVisible({ timeout: 30_000 }) +}) + +test('every stage shows its own content instead of the review page', async ({ page }) => { + test.setTimeout(120_000) + await page.goto('/chat') + await signIn(page) + const { run } = await publishRun(page, '巡检节点视图', findings(Date.now().toString(36))) + await page.goto(`/chat?run=${run}`) + const form = page.getByRole('region', { name: '报告与问题确认' }) + await expect(form.getByText('还有 3 项待确认')).toBeVisible({ timeout: 30_000 }) + + // Start keeps the kickoff input, and returning to the review keeps the form. + await page.getByRole('button', { name: '开始:巡检节点视图' }).first().click() + await expect(page.getByRole('region', { name: '报告与问题确认' })).toHaveCount(0) + await expect(page.getByText('# 版本质量扫描报告').first()).toBeVisible() + await page.getByRole('button', { name: '确认扫描结果' }).first().click() + await expect(page.getByRole('region', { name: '报告与问题确认' })).toBeVisible() + + // Output only exists after the review; it lists what the task node produced. + await form.getByRole('button', { name: '全部标为问题' }).click() + await form.getByRole('button', { name: '确认并生成任务' }).click() + await expect(form.getByText('已提交')).toBeVisible({ timeout: 30_000 }) + await page.getByRole('button', { name: '输出处置结果' }).first().click() + await expect(page.getByText(/"taskId"/).first()).toBeVisible() +}) + +test('a form node that cannot read its findings explains itself', async ({ page }) => { + test.setTimeout(90_000) + await page.goto('/chat') + await signIn(page) + const suffix = Date.now().toString(36) + const rows = findings(suffix) + const broken = JSON.stringify({ + nodes: [ + { id: 'start', data: { kind: 'start', title: '开始:坏绑定', inputVariables: [{ name: 'findings', valueType: 'array[object]', value: rows }] } }, + { id: 'review', data: { kind: 'form', title: '确认扫描结果', formConfig: { title: '确认', submitLabel: '提交', reportSelector: ['start', 'input'], itemsSelector: ['start', 'input'], fields: [] } } }, + ].map((node, index) => ({ ...node, type: 'workflow', position: { x: index * 340, y: 100 }, data: { ...node.data, description: '' } })), + edges: [{ id: 'e1', source: 'start', target: 'review' }], + }) + const tenant = ((await (await page.request.get('/api/v1/me/tenants')).json()) as { items: { id: string }[] }).items[0]!.id + const projects = (await (await page.request.get(`/api/v2/tenants/${tenant}/projects`)).json()) as { items: { id: string; name: string }[] } + const workflow = ((await (await page.request.post(`/api/v2/tenants/${tenant}/workflows`, { data: { name: `坏绑定 ${suffix}`, graph: broken } })).json()) as { workflow: { id: string } }).workflow.id + await page.request.post(`/api/v2/tenants/${tenant}/workflows/${workflow}/publish`, { data: { version: 'v1' } }) + const run = ((await (await page.request.post(`/api/v2/tenants/${tenant}/workflows/${workflow}/runs`, { data: { projectId: projects.items[0]!.id, input: '# 报告' } })).json()) as { run: { id: string } }).run.id + await page.request.post(`/api/v2/tenants/${tenant}/workflow-runs/${run}/start`, { data: {} }) + await page.goto(`/chat?run=${run}`) + const failure = page.getByRole('region', { name: '确认表单未生成' }) + await expect(failure).toBeVisible({ timeout: 30_000 }) + await expect(failure.getByText('表单处理失败', { exact: false })).toBeVisible() + await expect(failure.getByRole('button', { name: '刷新' })).toHaveCount(0) +}) + +test('two reviewers editing the same form get a conflict they can resolve', async ({ page, browser }) => { + test.setTimeout(120_000) + await page.goto('/chat') + await signIn(page) + const { run } = await publishRun(page, '巡检并发', findings(Date.now().toString(36))) + + const other = await browser.newContext() + const second = await other.newPage() + await second.goto('/chat') + await signIn(second) + + await page.goto(`/chat?run=${run}`) + await second.goto(`/chat?run=${run}`) + const first = page.getByRole('region', { name: '报告与问题确认' }) + const peer = second.getByRole('region', { name: '报告与问题确认' }) + await expect(first.getByText('还有 3 项待确认')).toBeVisible({ timeout: 30_000 }) + await expect(peer.getByText('还有 3 项待确认')).toBeVisible({ timeout: 30_000 }) + + // The first reviewer saves what the second has not seen yet. + await peer.getByRole('button', { name: '添加备注' }).first().click() + await peer.getByLabel('备注', { exact: true }).first().fill('同伴已经保存的备注') + await peer.getByRole('button', { name: '保存草稿' }).click() + await expect(peer.getByText('草稿已保存。')).toBeVisible() + + // The second reviewer edits before the refresh lands, and is told to reload the draft. + await first.getByRole('group', { name: '是否为问题' }).nth(0).getByRole('button', { name: '是问题', exact: true }).click() + await expect(first.getByText('其他确认人已更新这份表单')).toBeVisible({ timeout: 15_000 }) + await first.getByRole('button', { name: '载入最新草稿' }).click() + await expect(first.getByLabel('备注', { exact: true }).first()).toHaveValue('同伴已经保存的备注') + await other.close() +}) + +test('thirty findings stay scannable and bulk-ready', async ({ page }) => { + test.setTimeout(120_000) + await page.goto('/chat') + await signIn(page) + const suffix = Date.now().toString(36) + const rows = Array.from({ length: 30 }, (_, index) => ({ + key: `bulk:${suffix}:${index}`, title: `批量问题 ${index + 1}`, description: '用于检查长列表。', evidence: `src/module-${index}.ts:1`, + severity: index % 3 === 0 ? '高' : '中', module: `src/module-${index}.ts`, + })) + const { run } = await publishRun(page, '巡检长列表', rows) + await page.goto(`/chat?run=${run}`) + const form = page.getByRole('region', { name: '报告与问题确认' }) + await expect(form.getByRole('heading', { name: '批量问题 1', exact: true })).toBeVisible({ timeout: 30_000 }) + await expect(form.getByRole('article')).toHaveCount(30) + await expect(form.getByText('还有 30 项待确认')).toBeVisible() + await form.getByRole('button', { name: '全部标为问题' }).click() + await expect(form.getByText('将新建 30 个任务')).toBeVisible() + await expect(form.getByLabel('指派给', { exact: true })).toHaveCount(30) + const width = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth })) + expect(width.scroll).toBe(width.client) + await form.getByRole('button', { name: '确认并生成任务' }).click() + await expect(form.getByText('已提交')).toBeVisible({ timeout: 60_000 }) + await expect(form.getByRole('link', { name: '打开任务' })).toHaveCount(30) +}) + +test('scrolling a decision into view never puts it behind the action footer', async ({ page }) => { + await page.goto('/chat') + await signIn(page) + const rows = findings(Date.now().toString(36)).map(row => ({ ...row, description: '长描述与证据需要滚动检查。'.repeat(40) })) + const { run } = await publishRun(page, '巡检滚动操作', rows) + await page.goto(`/chat?run=${run}`) + const form = page.getByRole('region', { name: '报告与问题确认' }) + const button = form.getByRole('group', { name: '是否为问题' }).nth(1).getByRole('button', { name: '是问题', exact: true }) + await expect(button).toBeVisible({ timeout: 30_000 }) + for (const viewport of [{ width: 1280, height: 800 }, { width: 390, height: 844 }]) { + await page.setViewportSize(viewport) + // Native focus/scrolling must see the usable viewport, not space covered by the footer. + await expect.poll(async () => button.evaluate(element => { + const footer = element.closest('section[aria-label="报告与问题确认"]')!.querySelector('footer')! + let scroller = element.parentElement + while (scroller && !/(auto|scroll)/.test(getComputedStyle(scroller).overflowY)) scroller = scroller.parentElement + return scroller !== null && scroller.getBoundingClientRect().bottom <= footer.getBoundingClientRect().top + })).toBe(true) + await button.evaluate(element => element.scrollIntoView({ block: 'end', behavior: 'instant' })) + await expect.poll(async () => button.evaluate(element => { + const rect = element.getBoundingClientRect() + const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2) + return hit !== null && element.contains(hit) + })).toBe(true) + await button.click({ trial: true }) + await expect(form.getByRole('button', { name: '保存草稿' })).toBeInViewport() + } +}) + +test('a completed run waits for delayed form receipts instead of claiming no tasks', async ({ page }) => { + await page.goto('/chat') + await signIn(page) + const { run } = await publishRun(page, '巡检结果同步', findings(Date.now().toString(36))) + let delayReceipts = true + // Only delay the read projection; submission and task creation still hit the real backend. + await page.route(`**/workflow-runs/${run}/form`, async route => { + if (route.request().method() !== 'GET') return route.continue() + const response = await route.fetch() + const form = await response.json() + await route.fulfill({ response, json: delayReceipts ? { ...form, results: [] } : form }) + }) + await page.goto(`/chat?run=${run}`) + const form = page.getByRole('region', { name: '报告与问题确认' }) + await expect(form.getByText('还有 3 项待确认')).toBeVisible({ timeout: 30_000 }) + await form.getByRole('button', { name: '全部标为问题' }).click() + await form.getByRole('button', { name: '确认并生成任务' }).click() + await expect(form.getByText(/运行已完成,正在同步任务结果/)).toBeVisible({ timeout: 30_000 }) + await expect(form.getByText(/无需处理,运行已完成/)).toHaveCount(0) + delayReceipts = false + await expect(form.getByText(/新建 3 个任务/)).toBeVisible({ timeout: 15_000 }) + await expect(form.getByRole('link', { name: '打开任务', exact: true })).toHaveCount(3) +}) + +test('a scheduled workflow parks at the form and its history links back to it', async ({ page }) => { + test.setTimeout(150_000) + await page.goto('/schedules') + await signIn(page) + const suffix = Date.now().toString(36) + const tenant = ((await (await page.request.get('/api/v1/me/tenants')).json()) as { items: { id: string }[] }).items[0]!.id + const name = `定时巡检 ${suffix}` + const created = await page.request.post(`/api/v2/tenants/${tenant}/workflows`, { data: { name, graph: graph(findings(suffix), '定时巡检') } }) + const workflow = ((await created.json()) as { workflow: { id: string } }).workflow.id + await page.request.post(`/api/v2/tenants/${tenant}/workflows/${workflow}/publish`, { data: { version: 'v1' } }) + await page.reload() + + const dialog = page.getByRole('dialog') + await page.locator('header').getByRole('button', { name: '新建任务', exact: true }).click() + await dialog.getByRole('textbox', { name: '任务名称' }).fill(`定时巡检-${suffix}`) + await dialog.getByRole('button', { name: /运行工作流/ }).click() + await dialog.getByRole('combobox', { name: '工作流', exact: true }).selectOption({ label: `${name}(v1)` }) + await dialog.getByRole('combobox', { name: '关联项目', exact: true }).selectOption({ label: 'Demo' }) + await dialog.getByRole('textbox', { name: '初始输入' }).fill('巡检演示服务') + await dialog.getByRole('button', { name: '创建并启用' }).click() + await expect(dialog.getByRole('heading', { name: `定时巡检-${suffix}` })).toBeVisible() + await dialog.getByRole('button', { name: '立即运行', exact: true }).click() + // A waiting review does not block the next trigger: the schedule can start another run. + await expect(dialog.getByRole('button', { name: '立即运行', exact: true })).toBeEnabled() + const waiting = dialog.getByRole('button', { name: /待确认/ }) + await expect(waiting).toHaveCount(1, { timeout: 60_000 }) + await dialog.getByRole('button', { name: '立即运行', exact: true }).click() + await expect(dialog.getByRole('button', { name: /待确认/ })).toHaveCount(2, { timeout: 60_000 }) + await waiting.first().click() + await dialog.getByRole('link', { name: '查看执行详情' }).click() + const form = page.getByRole('region', { name: '报告与问题确认' }) + await expect(form.getByText('还有 3 项待确认')).toBeVisible({ timeout: 30_000 }) + await form.getByRole('button', { name: '全部标为问题' }).click() + await form.getByRole('button', { name: '确认并生成任务' }).click() + await expect(form.getByText('已提交')).toBeVisible({ timeout: 30_000 }) + await page.goto('/schedules') + await page.getByRole('button', { name: new RegExp(`定时巡检-${suffix}`) }).first().click() +}) diff --git a/frontend-v2/src/components/workspace-panel/workspace-panel.tsx b/frontend-v2/src/components/workspace-panel/workspace-panel.tsx index 0df26398..891a0d28 100644 --- a/frontend-v2/src/components/workspace-panel/workspace-panel.tsx +++ b/frontend-v2/src/components/workspace-panel/workspace-panel.tsx @@ -131,9 +131,9 @@ export function WorkspacePanel({ /** The header button that shows or hides a page's workspace panel. */ export function WorkspaceToggle({ open, toggle }: { open: boolean; toggle: () => void }) { return ( - ) } diff --git a/frontend-v2/src/features/chat/workflow-run/dts-receipt.tsx b/frontend-v2/src/features/chat/workflow-run/dts-receipt.tsx index 1af8b178..83690441 100644 --- a/frontend-v2/src/features/chat/workflow-run/dts-receipt.tsx +++ b/frontend-v2/src/features/chat/workflow-run/dts-receipt.tsx @@ -1,7 +1,9 @@ import { useQuery } from '@tanstack/react-query' import { Link } from 'react-router' -import { Button } from '@/components/ui/button' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { ArrowUpRight, ExternalLink, ShieldAlert } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button, buttonVariants } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { simulateDtsSubmission } from '@/lib/demo/dts' import { demoMockEnabled } from '@/lib/demo/flags' @@ -11,11 +13,34 @@ export function DtsReceipt({ taskId, title, description, severity, module, runId if (!demoMockEnabled()) return null if (receipt.isError) return

DTS 模拟提交失败,Cloud 任务已保留。

if (!receipt.data) return

正在生成 DTS 模拟回执…

- return }>DTS 演示 · {receipt.data.number} - DTS 模拟问题单 -

仅供演示,尚未向 DTS 提交。

-
单号
{receipt.data.number}
标题
{title}
问题描述
{description}
严重程度
{severity || '未指定'}
所属模块
{module || '未指定'}
处理人
沿用关联 Cloud 任务的负责人
- 打开 Cloud 任务来源巡检报告 + return }>DTS 演示 · {receipt.data.number} + + +
+ +
+ DTS 模拟问题单 + 用真实的 Cloud 任务信息生成,尚未向外部 DTS 写入。 +
+
+
+
+
+ {receipt.data.number} + 仅演示 + {severity || '未定级'} +
+
+
标题
{title}
+
问题描述
{description}
+
所属模块
{module || '未指定'}
+
处理人
沿用关联 Cloud 任务的负责人
+
+
+ + 来源巡检报告 + 打开 Cloud 任务 +
} diff --git a/frontend-v2/src/features/chat/workflow-run/run-finding-card.tsx b/frontend-v2/src/features/chat/workflow-run/run-finding-card.tsx new file mode 100644 index 00000000..c3f9688d --- /dev/null +++ b/frontend-v2/src/features/chat/workflow-run/run-finding-card.tsx @@ -0,0 +1,240 @@ +import { useId, useState, type ReactNode } from 'react' +import { + ArrowUpRight, + Check, + CheckCircle2, + ChevronDown, + Link2, + Plus, + ShieldCheck, + TriangleAlert, + X, + XCircle, +} from 'lucide-react' +import { Link } from 'react-router' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' +import { Textarea } from '@/components/ui/textarea' +import { TaskAssigneePicker } from '@/components/task-assignee-picker' +import type { SquadSummary } from '@/lib/squads-api/generated' +import type { TaskAssigneeRef } from '@/features/collaboration/tasks-api/generated' +import type { + WorkflowFormExistingTask, + WorkflowFormField, + WorkflowFormFinding, + WorkflowFormTaskResult, +} from '@/lib/workflows/api/generated' +import { cn } from '@/lib/utils' + +/** One editable row: only the decision, its owner and the configured extra values. */ +export type FormAnswer = { + key: string + decision: string + assignee?: TaskAssigneeRef + values: Record +} + +type Roster = { userId: string; displayName: string } + +const text = (value: unknown) => (typeof value === 'string' ? value : '') + +/** Severity stays legible without colour: the label carries the level. */ +function severityTone(severity: string) { + if (severity.includes('高') || /critical|high/i.test(severity)) return 'border-destructive/30 bg-destructive/10 text-destructive' + if (severity.includes('中') || /medium/i.test(severity)) return 'border-warning/35 bg-warning/10 text-warning' + if (severity.includes('低') || /low/i.test(severity)) return 'border-info/30 bg-info/10 text-info' + return 'border-border bg-muted text-muted-foreground' +} + +const decisionLabel = (decision: string) => decision === 'confirmed' ? '是问题' : decision === 'rejected' ? '不是问题' : '待确认' + +type OwnerRef = { type?: string; id?: string; displayName?: string } + +const existingStatus = (status: string) => status === 'pending' ? '待处理' : status === 'running' ? '进行中' : status === 'review' ? '待验收' : status === 'done' ? '已完成' : status === 'cancelled' ? '已取消' : '' + +/** Names an existing task's owner from the frozen read first, then this workspace's roster. */ +function assigneeName(assignee: OwnerRef | null | undefined, roster: Roster[], squads: SquadSummary[]) { + if (!assignee) return '未指派' + if (assignee.displayName) return assignee.displayName + if (assignee.type === 'human') return roster.find(member => member.userId === assignee.id)?.displayName ?? '工作区成员' + if (assignee.type === 'squad') return squads.find(squad => squad.id === assignee.id)?.name ?? '小队' + return '空间 Agent' +} + +/** Two explicit buttons instead of a select: the primary decision is one click, always visible. */ +function DecisionToggle({ decision, disabled, onDecide }: { decision: string; disabled: boolean; onDecide: (decision: string) => void }) { + const option = (value: 'confirmed' | 'rejected', label: string, Icon: typeof Check) => { + const active = decision === value + return + } + return
+ {option('confirmed', '是问题', Check)} + {option('rejected', '不是问题', X)} +
+} + +function DecisionBadge({ decision }: { decision: string }) { + if (decision === 'confirmed') return 是问题 + if (decision === 'rejected') return 不是问题 + return {decisionLabel(decision)} +} + +function FieldControl({ id, field, value, invalid, disabled, onChange }: { + id: string + field: WorkflowFormField + value: string + invalid: boolean + disabled: boolean + onChange: (value: string) => void +}) { + if (field.type === 'select') return onChange(event.target.value)}> + 请选择 + {(Array.isArray(field.options) ? field.options : []).map(option => {String(option)})} + + if (field.type === 'textarea') return