diff --git a/frontend/src/dev/fixtures/inquiry.json b/frontend/src/dev/fixtures/inquiry.json
index dcf1c971..d7d2a0d8 100644
--- a/frontend/src/dev/fixtures/inquiry.json
+++ b/frontend/src/dev/fixtures/inquiry.json
@@ -1,5 +1,5 @@
{
- "//": "审批质询预览用例与 check-inquiry 共用的夹具(frontend/src/dev/fixtures/inquiry.tsx)。报告文档和回答文档由后端 evidence.build_catalog + compose_doc 真实组装(虚构的超市、片区、金额),查询快照照质询工件接口的样子给:遮罩列的值写「已遮罩」,另带 masked。生成脚本不在仓库里:改了文档的形状,照这个思路按后端的输出重新生成",
+ "//": "审批质询预览用例与 check-inquiry 共用的夹具(frontend/src/dev/fixtures/inquiry.tsx)。报告文档和回答文档由后端 evidence.build_catalog + compose_doc 真实组装(虚构的超市、片区、金额),查询快照照质询工件接口的样子给:遮罩列的值写「已遮罩」,另带 masked。生成脚本不在仓库里:改了文档的形状,照这个思路按后端的输出重新生成。run_artifacts 是探索运行的工件列表(照 GET /api/runs/{id}/artifacts 的样子手写,质询回答的 meta 同 engine/inquiry.py 写的 {report, unit, question: 问题的前 40 字}),给运行详情「工件」页签的用例",
"runs": {
"approve": "run-inq-approve",
"formal": "run-inq-formal",
@@ -4278,5 +4278,58 @@
"masked": []
}
},
+ "run_artifacts": [
+ {
+ "id": "8cb677489bbaafcafd3b0f0df1939c1ca57cccaa3c065e14c4efe2991a3dd9a9",
+ "kind": "report_doc",
+ "node_id": "write",
+ "size": 18234,
+ "meta": {},
+ "created_at": "2026-10-05T06:30:00"
+ },
+ {
+ "id": "02ca8aeabca59d785d982b321d22ff889cb2da5a037cd4a080407e9044356434",
+ "kind": "query_snapshot",
+ "node_id": null,
+ "size": 1460,
+ "meta": {},
+ "created_at": "2026-10-05T06:44:30"
+ },
+ {
+ "id": "911780ed990217a2a2e7e3424a9408d23b53a524ba5d1812ec677b7d6b5b80aa",
+ "kind": "inquiry_answer",
+ "node_id": null,
+ "size": 2140,
+ "meta": {
+ "report": "write",
+ "unit": "u2",
+ "question": "退款率怎么算的?"
+ },
+ "created_at": "2026-10-05T06:45:00"
+ },
+ {
+ "id": "4ed3d62d2af13bbea40195283f3caf63462ca76bb5d4f7ddf1216d36dd5e0463",
+ "kind": "inquiry_answer",
+ "node_id": null,
+ "size": 2510,
+ "meta": {
+ "report": "write",
+ "unit": null,
+ "question": "枫岭区的销售额是否包含线上订单?如果包含,线上订单按下单门店还是按收货地址归到片"
+ },
+ "created_at": "2026-10-05T06:52:00"
+ },
+ {
+ "id": "1c66d590eac155dec35e2215e20c6e7497e96d0b2d73894eff1dea3fb641bb51",
+ "kind": "inquiry_answer",
+ "node_id": null,
+ "size": 1980,
+ "meta": {
+ "report": "write",
+ "unit": "u2"
+ },
+ "created_at": "2026-10-05T06:58:00"
+ }
+ ],
"card_artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e"
}
diff --git a/frontend/src/dev/fixtures/inquiry.tsx b/frontend/src/dev/fixtures/inquiry.tsx
index 88e69b3d..608dd590 100644
--- a/frontend/src/dev/fixtures/inquiry.tsx
+++ b/frontend/src/dev/fixtures/inquiry.tsx
@@ -1,7 +1,9 @@
-import { useState, type JSX } from 'react'
+import { useMemo, useState, type JSX } from 'react'
import { createPortal } from 'react-dom'
+import { ArtifactsPane, useRunArtifacts } from '../../pages/runs/ArtifactsPane'
import { EvidencePane } from '../../pages/runs/EvidencePane'
import { ApprovalCard } from '../../run/RunPanel'
+import { emptyTrace } from '../../run/trace'
import type { Approval, ApprovalReport, Run } from '../../types'
import fx from './inquiry.json'
@@ -20,12 +22,17 @@ import fx from './inquiry.json'
* React 树里排在前面)和右栏;「收起浮层」按钮开关浮层
* - ?syn=inquiry-approval-steps:同一份报告先后两张审批(主管审批 → 总监审批),「处理掉这张」换下一张;卡按审批 id
* 重新挂(同右栏)。?syn=inquiry-approval-steps-same:同一个卡片实例换审批(同画布的审批浮层,不按 id 重新挂)
+ * - ?syn=inquiry-approval-two:一张卡审两份报告(写月报、写周报),两份报告的问答串各自挂着、共用这次运行的审批列表
*
* 运行详情的「证据」页签(pages/runs/EvidencePane,是否可问由它按运行状态判断):
* - ?syn=inquiry-run-explore:封存了的探索运行,报告上给入口,问答追加在封存之后
* - ?syn=inquiry-run-formal:封存了的正式运行,不给入口,审批时问过的照列
+ * - ?syn=inquiry-run-waiting:停在审批上的运行,同运行详情直接打开「证据」页签:时间线里的待审批卡和证据页签同时挂着
+ * 同一份报告的问答串(卡上这张审批里问的直接认,证据页签哪张审批都要对一下)
+ * - ?syn=inquiry-run-artifacts:运行详情的「工件」页签(pages/runs/ArtifactsPane),工件列表里有质询的回答
*
- * 列表、提问、质询工件、报告文档、证据图、审批列表、设置都由检查脚本用 page.route 伪造;直接打开时问答串照实说无法读取。
+ * 列表、提问、质询工件、报告文档、证据图、审批列表、工件列表、设置都由检查脚本用 page.route 伪造;直接打开时问答串照实说
+ * 无法读取。
*/
const RUNS = fx.runs
@@ -115,6 +122,36 @@ function RunEvidence({ run, name }: { run: Run; name: string }) {
)
}
+/**
+ * 停在审批上的运行,同运行详情直接打开「证据」页签:时间线(一直挂着)里的待审批卡在上,证据页签在下,两处挂着同一份
+ * 报告的问答串。卡上的问答串认得这张审批,证据页签不认任何审批(cardId 为空),两处要为不同的几张审批取审批列表
+ */
+function Waiting({ approval, run }: { approval: Approval; run: Run }) {
+ return (
+
+
+
+
undefined} showWorkflow={false} />
+
+
+
+ (id ? LABELS[id] : undefined)} refreshKey="0" />
+
+
+ )
+}
+
+/** 运行详情的「工件」页签:工件列表由检查脚本按 inquiry.json 的 run_artifacts 答 */
+function RunArtifacts({ run }: { run: Run }) {
+ const list = useRunArtifacts(run.id, '0')
+ const trace = useMemo(() => emptyTrace(), [])
+ return (
+
+
(id ? LABELS[id] : undefined)} />
+
+ )
+}
+
export const cases: Record JSX.Element> = {
'inquiry-approval': () => (
@@ -138,6 +175,18 @@ export const cases: Record JSX.Element> = {
),
'inquiry-approval-steps': () => ,
'inquiry-approval-steps-same': () => ,
+ 'inquiry-approval-two': () => (
+
+ ),
'inquiry-run-explore': () => ,
'inquiry-run-formal': () => ,
+ 'inquiry-run-waiting': () => (
+
+ ),
+ 'inquiry-run-artifacts': () => ,
}
diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts
index 76b9a95e..f9e6ff1b 100644
--- a/frontend/src/lib/terms.ts
+++ b/frontend/src/lib/terms.ts
@@ -3500,6 +3500,19 @@ export const INQUIRY_TEXT = {
snapshotLoading: '正在读取查询结果…',
snapshotFailed: '无法读取这次查询的结果',
masked: (columns: string) => `${columns} 在数据源中设置了遮罩,不显示原值`,
+
+ // ---- 运行详情「工件」页签:回答文档按工件种类 inquiry_answer 记在运行名下(pages/runs/model.ts 的工件名和说明) ----
+ artifactKind: '质询回答',
+ /**
+ * 服务端在工件的 meta 里只记问题的前 40 字(engine/inquiry.py 的 QUESTION_BRIEF):满 40 字的多半截断过,末尾补省略号,
+ * 免得半句话读成整个问题
+ */
+ artifactAbout: (question: string) => {
+ const chars = [...question.trim()]
+ return `质询「${chars.join('')}${chars.length >= 40 ? '…' : ''}」的回答`
+ },
+ /** 没记问题摘要的(这一版之前存的):照报告文档的说法写装的是什么 */
+ artifactAboutNone: '审批质询的回答:逐段的片段和每个数字的出处',
}
/**
diff --git a/frontend/src/pages/runs/model.ts b/frontend/src/pages/runs/model.ts
index 0c8ed4c7..2380b144 100644
--- a/frontend/src/pages/runs/model.ts
+++ b/frontend/src/pages/runs/model.ts
@@ -10,6 +10,7 @@ import {
RUN_STATUS_ORDER, STATUS, resolveStatus, serverStatusOf, type StatusCode,
} from '../../lib/status'
import { formatSpan, parseServerTime } from '../../lib/format'
+import { INQUIRY_TEXT } from '../../lib/terms'
import { hasPendingApproval } from '../../store/catalog'
// -------------------------------------------------------------------------
@@ -334,6 +335,7 @@ export const ARTIFACT_KIND_LABEL: Record = {
schema_snapshot: '表结构快照',
dual_check: '双盲复算对照',
challenge: '反方审稿记录',
+ inquiry_answer: INQUIRY_TEXT.artifactKind,
}
export const artifactKindLabel = (kind: string): string => ARTIFACT_KIND_LABEL[kind] ?? kind
@@ -356,6 +358,9 @@ export function artifactDescription(kind: string, meta?: Record | n
? `双盲复算:${meta.metrics} 个指标两边的 SQL、取值与差异说明` : '双盲复算:两边的 SQL、取值与差异说明'
// 反方审稿的完整记录(工件行的 meta 里有 {report: 审的报告节点})
case 'challenge': return '反方审稿:每句结论的判断、理由和反方执行的查询'
+ // 审批质询的回答文档(工件行的 meta 里有 {report, unit, question: 问题的前 40 字}):不挂在节点上,靠问题认出是哪一条
+ case 'inquiry_answer': return typeof meta?.question === 'string' && meta.question.trim()
+ ? INQUIRY_TEXT.artifactAbout(meta.question) : INQUIRY_TEXT.artifactAboutNone
default: return '工件'
}
}
diff --git a/frontend/src/run/Inquiry.tsx b/frontend/src/run/Inquiry.tsx
index 90f4ff41..58cba9c6 100644
--- a/frontend/src/run/Inquiry.tsx
+++ b/frontend/src/run/Inquiry.tsx
@@ -308,7 +308,17 @@ export const useInquiry = create((set, get) => ({
},
loadApprovalDocs: async (runId, ids) => {
- if (get().approvalDocs[runId]?.status === 'loading') return
+ const inflight = get().approvalDocs[runId]
+ if (inflight?.status === 'loading') {
+ // 别处已经在取(同一张卡审两份报告;运行详情的待审批卡和证据页签同时挂着,两处要对的审批不同):不再另取,但这一处
+ // 要对的也记成取过——列表按运行取、不按审批,在路上的那一份本来就带着全部审批。只是提前返回的话,这一处的 unknown
+ // 在请求回来之后不变、effect 不再执行,settled 一直为假:头上写着「· N 次」,别的审批里问的那几条一直不显示
+ const asked = [...new Set([...inflight.asked, ...ids])]
+ if (asked.length > inflight.asked.length) {
+ set((s) => ({ approvalDocs: { ...s.approvalDocs, [runId]: { ...s.approvalDocs[runId]!, asked } } }))
+ }
+ return
+ }
set((s) => {
const cur = s.approvalDocs[runId]
return { approvalDocs: { ...s.approvalDocs, [runId]: {
diff --git a/scripts/check-inquiry.mjs b/scripts/check-inquiry.mjs
index 94f3ba91..b4c017ca 100644
--- a/scripts/check-inquiry.mjs
+++ b/scripts/check-inquiry.mjs
@@ -1,6 +1,6 @@
// 审批质询的回归检查:报告上的入口(run/EvidenceDoc.tsx 的句末「质询」、划选原文浮出的「质询」)、问答串和提问框
// (run/Inquiry.tsx)、审批卡上的挂法(run/ApprovalReport.tsx、run/RunPanel.tsx)、运行详情「证据」页签的挂法
-// (pages/runs/EvidencePane.tsx)。
+// (pages/runs/EvidencePane.tsx),以及运行详情「工件」页签里质询回答的名字(pages/runs/model.ts)。
//
// 守的是这些「看着没坏、其实用不了」的地方:悬停、键盘聚焦一句都不出「质询」(或者出了 Tab 却到不了、整份报告多出一串
// Tab 位)、没有可点内容的句子键盘走不到、划选原文不浮出入口(或者带上的原文混进了徽标和「质询」本身)、提交的请求体
@@ -8,11 +8,14 @@
// 进入审批卡时问答串没有恢复、问答串写不出问的是哪一句、回答里的数字点了不去取质询工件(或者每点一次取一次、标不出
// 那一格)、引用报告已有出处的数字点了没反应、查询的 SQL / 行数 / 截断 / 遮罩不显示、触顶和没答成不说、recorded 为假
// 不醒目、409 / 422 不照服务端的中文说明显示(或者问题文字丢了)、Esc 关不掉提问框(或者连带收起了别的、焦点掉到页面上)、
-// 已处理的审批和封存了的正式运行还给入口、封存了的探索运行反而没有;窄屏(390px)被撑出横向滚动、浅色主题下看不清。
+// 已处理的审批和封存了的正式运行还给入口、封存了的探索运行反而没有;同一次运行的几处问答串要对的审批不同、审批列表
+// 取不到时后一处一直不列别的审批里问的(头上写着次数,下面一条没有);工件页签里质询的回答只写英文的种类名;
+// 窄屏(390px)被撑出横向滚动、浅色主题下看不清。
//
// 跑在预览页上:/preview.html?syn=inquiry-*(用例见 frontend/src/dev/fixtures/inquiry.tsx)。夹具在
// frontend/src/dev/fixtures/inquiry.json,报告文档和回答文档是后端 build_catalog + compose_doc 真实组装的(虚构的数据)。
-// 质询的三个接口、工件、证据图、片段接口都在这里伪造;别的写请求一律拦掉并记下来,别的 GET 答 404,不碰真实服务。
+// 质询的三个接口、工件、证据图、片段接口、审批列表、运行的工件列表都在这里伪造;别的写请求一律拦掉并记下来,别的 GET
+// 答 404,不碰真实服务。
//
// node scripts/check-inquiry.mjs
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-inquiry.mjs
@@ -66,8 +69,9 @@ const answerFor = (body, extra = {}) => ({
/**
* 开一个预览用例。列表按运行答夹具里的问答(state.lists 可改),提问按 state.reply 答({status, json} 或按请求体算的函数;
* state.gate 是个没兑现的 Promise 时先挂着,测「回答期间」;state.listGate 同样挂着列表)。审批列表按 state.approvals
- * (运行 → 审批数组)答,设置按 state.settings 答;state.docs、state.snaps 盖过夹具里的文档、快照。setup 在打开页面之前
- * 改 state(进入时就要取的列表、设置得先备好);ws 接管运行的事件流。记下列表、提问、取快照、取工件、取审批列表的请求和拦下的写请求
+ * (运行 → 审批数组)答,没有的答 404,state.approvalsGate 同样先挂着;设置按 state.settings 答;state.docs、state.snaps
+ * 盖过夹具里的文档、快照;运行的工件列表答夹具里的 run_artifacts(探索运行)。setup 在打开页面之前改 state(进入时就要
+ * 取的列表、设置得先备好);ws 接管运行的事件流。记下列表、提问、取快照、取工件、取审批列表的请求和拦下的写请求
*/
async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws, waitUntil = 'networkidle' } = {}) {
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
@@ -79,6 +83,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws
reply: (body) => ({ status: 200, json: answerFor(body) }),
gate: null,
listGate: null,
+ approvalsGate: null,
approvals: {},
settings: {},
docs: {},
@@ -114,6 +119,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws
if (path === '/api/approvals') {
const run = url.searchParams.get('run_id')
hits.approvals.push({ run, status: url.searchParams.get('status') })
+ if (state.approvalsGate) await state.approvalsGate
const list = state.approvals[run]
return list ? route.fulfill({ json: list }) : route.fulfill({ status: 404, json: { detail: '检查脚本没有伪造这个接口' } })
}
@@ -141,6 +147,8 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws
hits.segments.push(decodeURIComponent(seg[2]))
return route.fulfill({ json: {} })
}
+ const arts = path.match(/^\/api\/runs\/([^/]+)\/artifacts$/)
+ if (arts) return route.fulfill({ json: decodeURIComponent(arts[1]) === RUNS.explore ? fx.run_artifacts : [] })
const graph = path.match(/^\/api\/runs\/([^/]+)\/evidence$/)
if (graph) {
const run = decodeURIComponent(graph[1])
@@ -822,6 +830,37 @@ await section('运行详情「证据」页签:封存了的探索运行有入
}
})
+await section('运行详情「工件」页签:质询的回答有名字和说明,不出现英文的工件种类名', async () => {
+ const { ctx, page, errors } = await open('inquiry-run-artifacts')
+ await page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor()
+ // 每一行:种类(行首的标签)、说明(同一种类有几件时末尾带「#n」,去掉再比)、悬停
+ const rows = await page.locator('[data-artifact-id]').evaluateAll((els) => els.map((el) => {
+ const open = el.querySelector('[data-action="artifact-open"]')
+ const spans = open ? [...open.children] : []
+ return { id: el.getAttribute('data-artifact-id'), kind: spans[0]?.textContent?.trim() ?? '',
+ about: (spans[1]?.textContent ?? '').replace(/\s*#\d+$/, '').trim(), title: open?.getAttribute('title') ?? '' }
+ }))
+ const answers = fx.run_artifacts.filter((a) => a.kind === 'inquiry_answer').map((a) => rows.find((r) => r.id === a.id))
+ check('质询的回答:种类写「质询回答」', answers.length === 3 && answers.every((r) => r?.kind === '质询回答'), JSON.stringify(answers))
+ check('有问题摘要的:说明写「质询「问题」的回答」', answers[0]?.about === '质询「退款率怎么算的?」的回答', answers[0]?.about)
+ check('问题摘要满 40 字(服务端只记前 40 字):末尾补省略号',
+ answers[1]?.about === '质询「枫岭区的销售额是否包含线上订单?如果包含,线上订单按下单门店还是按收货地址归到片…」的回答', answers[1]?.about)
+ check('没有问题摘要的:也有一句说明', answers[2]?.about === '审批质询的回答:逐段的片段和每个数字的出处', answers[2]?.about)
+ check('悬停写「打开质询回答」', answers.every((r) => r?.title.startsWith('打开质询回答')), answers.map((r) => r?.title).join(' / '))
+ const pane = await text(page.locator('[data-run-artifacts]'))
+ check('工件页签里不出现英文的工件种类名', !/inquiry_answer|report_doc|query_snapshot/.test(pane) && rows.every((r) => !/[a-z_]{4,}/.test(r.kind)),
+ rows.map((r) => r.kind).join('、'))
+ check('不挂在节点上的写「不属于某个节点」', (await text(page.locator('[data-artifact-group=""] h3'))).startsWith('不属于某个节点'))
+ // 打开一件:弹窗标题也是中文的种类名
+ await page.locator(`[data-artifact-id="${answers[0]?.id}"] [data-action="artifact-open"]`).click()
+ const dialog = page.getByRole('dialog')
+ await dialog.waitFor()
+ const head = await text(dialog.locator('h2').first())
+ check('打开的弹窗标题写「质询回答」,不写英文', head.includes('质询回答') && !head.includes('inquiry_answer'), head)
+ check('页面没有报错', errors.length === 0, errors.join(';'))
+ await ctx.close()
+})
+
await section('有表格的报告:悬停句子、表格的格子都不挪动排版;表格的格子不挂「质询」', async () => {
// 报告文档换成带表格的那份(run/__tests__/evidence-doc.json):表里带出处的格子也是结论句
const ev = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-doc.json`, 'utf8'))
@@ -1054,6 +1093,73 @@ await section('之前几轮审批里问的:报告改写过的不按句子编
}
})
+/** 一处问答串:抬头写的次数、实际列出的条数 */
+const tally = async (page, scope) => ({
+ head: (await page.locator(`${scope} [data-inquiry-count]`).getAttribute('data-inquiry-count').catch(() => null)) ?? '',
+ shown: await page.locator(`${scope} [data-inquiry-entry]`).count(),
+})
+
+await section('两处要对的审批不同、审批列表取不到:两处的问答都列出来,次数和条数一致', async () => {
+ // 一张卡审两份报告:写月报上有 ap-x 里问的,写周报上有 ap-y 里问的,两份报告的问答串共用这次运行的审批列表。先发的那一处
+ // 请求还在路上时,后一处要对的审批也得记上:不然请求回来(这里是 404)之后后一处一直等着,头上写「· 1 次」,下面一条没有
+ const x = { ...clone(fx.items[0]), seq: 41, approval_id: 'ap-x', question: '写月报上一轮问的:退款率怎么算?', at: '2026-10-05T05:30:00Z' }
+ const y = { ...clone(fx.items[1]), seq: 42, report: 'write2', approval_id: 'ap-y', question: '写周报上一轮问的:新客怎么界定?',
+ at: '2026-10-05T05:35:00Z' }
+ let release
+ // 审批列表压着,页面等不到网络空闲:打开到 load 就往下走
+ const { ctx, page, hits, errors } = await open('inquiry-approval-two', { waitUntil: 'load', setup: (s) => {
+ s.lists[RUNS.approve] = [x, y]
+ s.approvalsGate = new Promise((r) => { release = r })
+ } })
+ // 两份报告的问答串都取回来了(头上写着次数),审批列表的请求还压着
+ await page.locator('[data-inquiry="write"] [data-inquiry-count]').waitFor()
+ await page.locator('[data-inquiry="write2"] [data-inquiry-count]').waitFor()
+ await until(() => hits.approvals.length >= 1)
+ await page.waitForTimeout(200)
+ release()
+ const both = await until(async () => (await tally(page, '[data-inquiry="write"]')).shown === 1
+ && (await tally(page, '[data-inquiry="write2"]')).shown === 1)
+ const [gw, gw2] = [await groupsOf(page, '[data-inquiry="write"]'), await groupsOf(page, '[data-inquiry="write2"]')]
+ check('写月报:查不到那张审批,问答单独一组', JSON.stringify(gw.map((g) => [g.unit, g.entries])) === JSON.stringify([['other', ['41']]]),
+ JSON.stringify(gw.map((g) => [g.unit, g.entries])))
+ check('写周报:请求是另一处先发的,回来之后照样列出、单独一组', both
+ && JSON.stringify(gw2.map((g) => [g.unit, g.entries])) === JSON.stringify([['other', ['42']]]), JSON.stringify(gw2.map((g) => [g.unit, g.entries])))
+ const [tw, tw2] = [await tally(page, '[data-inquiry="write"]'), await tally(page, '[data-inquiry="write2"]')]
+ check('两份报告头上的次数都和列出的条数一致', tw.head === '1' && tw.shown === 1 && tw2.head === '1' && tw2.shown === 1,
+ JSON.stringify({ tw, tw2 }))
+ check('按运行只取一次审批列表(一份列表里各张审批都有)', hits.approvals.length === 1, JSON.stringify(hits.approvals))
+ check('页面没有报错', errors.length === 0, errors.join(';'))
+ await ctx.close()
+ {
+ // 运行详情直接打开「证据」页签、运行停在审批上:时间线里的待审批卡只为更早那一轮(ap-inq-0)取审批列表,证据页签不认
+ // 任何审批,ap-inq-0 和这张卡的 ap-inq-1 都要对。卡那一处先发了请求,证据页签那一处的条目在请求回来之后也得列出来
+ const early = { ...clone(fx.items[0]), seq: 12, approval_id: 'ap-inq-0', question: '上一轮:退款率 3.5% 是怎么算的?', at: '2026-10-05T05:40:00Z' }
+ const { ctx, page, hits, errors } = await open('inquiry-run-waiting', { waitUntil: 'load', setup: (s) => {
+ s.lists[RUNS.approve] = [early, clone(fx.items[1])]
+ s.approvalsGate = new Promise((r) => { release = r })
+ } })
+ const card = '[data-fixture-card] [data-inquiry="write"]'
+ const pane = '[data-fixture-evidence] [data-inquiry="write"]'
+ await page.locator(`${card} [data-inquiry-count]`).waitFor()
+ await page.locator(`${pane} [data-inquiry-count]`).waitFor()
+ await until(() => hits.approvals.length >= 1)
+ await page.waitForTimeout(200)
+ release()
+ const listed = await until(async () => (await tally(page, card)).shown === 2 && (await tally(page, pane)).shown === 2)
+ const [gc, gp] = [await groupsOf(page, card), await groupsOf(page, pane)]
+ check('待审批卡:这张审批里问的挂句子下面,查不到的那一轮单独一组',
+ JSON.stringify(gc.map((g) => [g.unit, g.entries])) === JSON.stringify([['u3', ['34']], ['other', ['12']]]),
+ JSON.stringify(gc.map((g) => [g.unit, g.entries])))
+ check('证据页签:两条都列出来,查不到审批的单独一组(不再一直等着)', listed
+ && JSON.stringify(gp.map((g) => [g.unit, g.entries])) === JSON.stringify([['other', ['12', '34']]]), JSON.stringify(gp.map((g) => [g.unit, g.entries])))
+ const [tc, tp] = [await tally(page, card), await tally(page, pane)]
+ check('两处头上的次数都和列出的条数一致', tc.head === '2' && tc.shown === 2 && tp.head === '2' && tp.shown === 2, JSON.stringify({ tc, tp }))
+ check('运行详情:按运行只取一次审批列表', hits.approvals.length === 1, JSON.stringify(hits.approvals))
+ check('运行详情:页面没有报错', errors.length === 0, errors.join(';'))
+ await ctx.close()
+ }
+})
+
await section('回答期间另开了提问框,正在回答的那一条没问成:新写的不被覆盖', async () => {
const { ctx, page, state, errors } = await open('inquiry-approval')
await expand(page)
@@ -1366,6 +1472,21 @@ await section('窄屏与深浅色', async () => {
check('390px 运行详情:页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
}
+ for (const theme of ['dark', 'light']) {
+ // 运行详情「工件」页签:质询回答的说明可能很长(问题的前 40 字),窄屏上截断、不撑宽;种类和说明深浅色都看得清
+ const { ctx, page, errors } = await open('inquiry-run-artifacts', { width: 390, height: 844, theme })
+ const long = fx.run_artifacts.find((a) => a.kind === 'inquiry_answer' && [...(a.meta.question ?? '')].length >= 40)
+ const row = `[data-artifact-id="${long.id}"] [data-action="artifact-open"]`
+ await page.locator(row).waitFor()
+ const o = await overflow(page, '[data-run-artifacts]')
+ const ratios = { kind: await ratioOf(page, `${row} > span:nth-child(1)`), about: await ratioOf(page, `${row} > span:nth-child(2)`) }
+ check(`390px 工件页签(${theme}):质询回答的长说明截断、不横向溢出,种类和说明对比度 ≥ 4.5`,
+ o.page <= 0 && o.box <= 0 && ratios.kind >= 4.5 && ratios.about >= 4.5,
+ JSON.stringify({ o, kind: Number(ratios.kind.toFixed(2)), about: Number(ratios.about.toFixed(2)) }))
+ await shot(page, `run-artifacts-narrow-${theme}`)
+ check(`390px 工件页签(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
+ await ctx.close()
+ }
})
await browser.close()