diff --git a/backend/app/api/copilot_context.py b/backend/app/api/copilot_context.py
index 5b065047..3e963b5d 100644
--- a/backend/app/api/copilot_context.py
+++ b/backend/app/api/copilot_context.py
@@ -214,6 +214,11 @@ class SourcePlan:
"""给模型看的目录:去掉驳回项。"""
return {t: catalog.visible_notes(n) for t, n in self.raw_notes.items()}
+ @property
+ def catalog_tables(self) -> int:
+ """有数据目录的表数:去掉驳回项之后还有内容的。整份被驳回的不算——挑表、SQL 检查都用不上它。"""
+ return sum(1 for n in self.raw_notes.values() if catalog.visible_notes(n))
+
def _modes(sources: list[Any]) -> list[Literal["all", "pick"]]:
"""每个源给详细摘要还是先挑表。
@@ -565,16 +570,20 @@ class DatasourceContext:
def op(self) -> dict[str, Any] | None:
"""告诉前端这一轮参考了哪些表:{"op": "context", "sources": [...], "elapsed_ms"?}。没有可查的表时为 None。
- 每个源一项:source 源名,tables 给了全字段的表(全名),selected_by,total 这个源一共几张表(表结构缓存里
- 记的真实总数);探查截断了的再带 explored(探查到几张),界面写「共 N 张,只探查了 200 张」;挑表失败的
- 带上 reason。只列了名字的表不进 tables:界面上说的「参考了 N 张表」是模型看得到字段的那些。
+ 每个源一项:source 源名,source_id 源的 id(界面据此给去数据目录页的入口),tables 给了全字段的表(全名),
+ selected_by,total 这个源一共几张表(表结构缓存里记的真实总数),catalog_tables 有数据目录的表数(没有目录的
+ 大库,界面提示先去起草目录:挑表和 SQL 检查都靠它);探查截断了的再带 explored(探查到几张),界面写「共 N 张,
+ 只探查了 200 张」;挑表失败的带上 reason。只列了名字的表不进 tables:界面上说的「参考了 N 张表」是模型看得到
+ 字段的那些。
"""
rows = []
for s in self.sources:
if not s.explored:
continue
- row: dict[str, Any] = {"source": s.source.name, "tables": [s.qualified(t) for t in s.tables],
- "selected_by": s.selected_by, "total": s.total}
+ row: dict[str, Any] = {"source": s.source.name, "source_id": getattr(s.source, "id", None),
+ "tables": [s.qualified(t) for t in s.tables],
+ "selected_by": s.selected_by, "total": s.total,
+ "catalog_tables": s.plan.catalog_tables}
if s.plan.unexplored:
row["explored"] = s.explored
if s.selected_by == "fallback" and self.reason:
diff --git a/backend/tests/test_copilot_table_pick.py b/backend/tests/test_copilot_table_pick.py
index 7e249404..53361e70 100644
--- a/backend/tests/test_copilot_table_pick.py
+++ b/backend/tests/test_copilot_table_pick.py
@@ -322,8 +322,9 @@ async def test_small_source_keeps_detail_and_carries_the_catalog(small_source):
assert f"中文名:订单{INFERRED_MARK}" in text and "单位:元" in text
assert f"orders.customer_id = customers.id(多对一){INFERRED_MARK}" in text
op = ctx.op()
- assert op["sources"] == [{"source": small_source.name, "tables": ["customers", "orders"], "selected_by": "all",
- "total": 2}]
+ assert op["sources"] == [{"source": small_source.name, "source_id": small_source.id,
+ "tables": ["customers", "orders"], "selected_by": "all", "total": 2,
+ "catalog_tables": 1}]
# ---------------------------------------------------------------- 回退:绝不阻断生成
@@ -346,8 +347,9 @@ async def test_pick_failures_fall_back_to_names_only(client, monkeypatch, scenic
"instruction": NEED, "intent": "answer", "datasource_ids": [scenic_source.id]})
op = _context_op(events)
- assert op["sources"] == [{"source": scenic_source.name, "tables": [], "selected_by": "fallback",
- "total": scenic.TABLE_COUNT + len(scenic.VIEWS), "reason": op["sources"][0]["reason"]}]
+ assert op["sources"] == [{"source": scenic_source.name, "source_id": scenic_source.id, "tables": [],
+ "selected_by": "fallback", "total": scenic.TABLE_COUNT + len(scenic.VIEWS),
+ "catalog_tables": 0, "reason": op["sources"][0]["reason"]}]
assert op["sources"][0]["reason"]
system = model.systems[0]
# 和以前一样只列表名、提醒先查字段
@@ -371,8 +373,9 @@ async def test_stream_reports_the_context_and_prompt_carries_selected_tables(cli
"instruction": NEED, "intent": "answer", "datasource_ids": [scenic_source.id]})
op = _context_op(events)
- assert op["sources"] == [{"source": scenic_source.name, "tables": ["visits", "channels", "channel_visits"],
- "selected_by": "model", "total": scenic.TABLE_COUNT + len(scenic.VIEWS)}]
+ assert op["sources"] == [{"source": scenic_source.name, "source_id": scenic_source.id,
+ "tables": ["visits", "channels", "channel_visits"], "selected_by": "model",
+ "total": scenic.TABLE_COUNT + len(scenic.VIEWS), "catalog_tables": 0}]
assert isinstance(op["elapsed_ms"], int) and op["elapsed_ms"] >= 0
# context 在模型首帧之后、第一条图操作之前
kinds = [e["op"] for e in events]
@@ -423,6 +426,28 @@ async def test_self_check_repair_reuses_the_same_context(client, monkeypatch, sc
assert len(picker.calls) == 1
+async def test_context_op_says_how_much_of_each_source_has_a_catalog(scenic_source):
+ """context 操作里每个源带上有数据目录的表数(catalog_tables)和源的 id:没有目录的大库,界面提示先去起草目录。
+
+ 真实运行里数据源一张目录都没有,挑表和 SQL 检查都用不上目录,用户也不知道该先去起草。只算有可见项的表:
+ 整份被驳回的不算有目录。"""
+ from app.api import copilot_context
+
+ async def op() -> dict:
+ plan = await copilot_context.plan_context([scenic_source])
+ return (await plan.resolve(Picker(PICKED), need=NEED)).op()["sources"][0]
+
+ first = await op()
+ assert first["catalog_tables"] == 0 and first["source_id"] == scenic_source.id
+ async with SessionLocal() as session:
+ await catalog.write_entry(session, scenic_source.id, "visits", {
+ "label": make_item("入园记录", "llm")}, if_version=0, actor="王敏")
+ rejected = make_item("门店", "llm")
+ rejected["status"] = "rejected"
+ await catalog.write_entry(session, scenic_source.id, "stores", {"label": rejected}, if_version=0, actor="王敏")
+ assert (await op())["catalog_tables"] == 1
+
+
async def test_small_sources_do_not_call_the_picker(client, monkeypatch, small_source):
picker = Picker(error=AssertionError("小库不该挑表"))
_use_picker(monkeypatch, picker)
diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts
index 37388a16..6b017e2f 100644
--- a/frontend/src/lib/terms.ts
+++ b/frontend/src/lib/terms.ts
@@ -2657,4 +2657,7 @@ export const COPILOT_CONTEXT_TEXT = {
withFields: (n: number) => `参考了 ${formatNumber(n)} 张表的字段`,
namesOnly: (n: number) => `另有 ${formatNumber(n)} 张表只提供了表名`,
notPicked: '未能按需求挑选',
+ /** 没有数据目录的大库:只有一个数据源时说「这个数据源」,几个时点名 */
+ noCatalog: (who: string | null) => `${who ?? '这个数据源'}还没有数据目录,起草后挑表和 SQL 检查会更准`,
+ toCatalog: '去数据目录起草',
}
diff --git a/frontend/src/run/AssistantStream.tsx b/frontend/src/run/AssistantStream.tsx
index 9ecf00fb..ecdc7eb0 100644
--- a/frontend/src/run/AssistantStream.tsx
+++ b/frontend/src/run/AssistantStream.tsx
@@ -15,7 +15,9 @@ import { humanizeError } from '../lib/errors'
import { explainRunError } from '../lib/explain'
import { formatClock, formatDuration, formatNumber, NONE, shortId } from '../lib/format'
import { statusLabel } from '../lib/status'
-import { EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, evidenceTally, issuanceLabel, nodeTypeLabel } from '../lib/terms'
+import {
+ COPILOT_CONTEXT_TEXT, EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, evidenceTally, issuanceLabel, nodeTypeLabel,
+} from '../lib/terms'
import { SqlCheckList } from './SqlChecks'
import {
ISSUANCE_HINT, childrenByExec, compactSteps, parseQueryResult, progressOf, spread, teamVerdictOf,
@@ -901,6 +903,12 @@ function FixAction({ fix, nodeId, label, to, first, field }: {
if (fix === 'settings') {
return 去模型接入
}
+ if (fix === 'catalog' && to) {
+ // 助手参考的大库还没有数据目录:直达那个数据源的目录页去起草
+ return (
+ {COPILOT_CONTEXT_TEXT.toCatalog}
+ )
+ }
if (fix === 'tools') {
// 数据源工具(db_query__ 这类)不在工具库里接入,在数据页
const data = !!to?.startsWith('/data')
@@ -1170,7 +1178,7 @@ function StepLine({ step, depth, turnMs }: { step: Step; depth: number; turnMs?:
{/* 要改的是另一个节点(指标的问题出在来源查询的 SQL 上):入口落到那个节点的那一栏 */}
+ label={step.fixNode ? step.fixLabel : undefined} to={step.fixTo} />
)}
diff --git a/frontend/src/run/__tests__/synthetic.ts b/frontend/src/run/__tests__/synthetic.ts
index 3177ed1f..46fea4ed 100644
--- a/frontend/src/run/__tests__/synthetic.ts
+++ b/frontend/src/run/__tests__/synthetic.ts
@@ -503,8 +503,10 @@ export const COPILOT_CONTEXT = [
{ op: 'model', model: 'claude-sonnet-4' },
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 3000 },
{ op: 'context', elapsed_ms: 3420, sources: [
- { source: 'scenic', tables: ['visits', 'channels', 'channel_visits'], selected_by: 'model', total: 51 },
- { source: 'shop', tables: ['orders', 'customers'], selected_by: 'all', total: 2 },
+ { source: 'scenic', source_id: 'src-scenic', tables: ['visits', 'channels', 'channel_visits'], selected_by: 'model', total: 51,
+ catalog_tables: 12 },
+ // 小库全部表都带字段:没有数据目录也不提示
+ { source: 'shop', source_id: 'src-shop', tables: ['orders', 'customers'], selected_by: 'all', total: 2, catalog_tables: 0 },
] },
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 6000 },
{ op: 'thinking', delta: '入园记录不带渠道,要经 channel_visits 连到渠道表,再按渠道汇总上月的入园人次。' },
@@ -539,7 +541,9 @@ export const COPILOT_CONTEXT_FALLBACK = [
{ op: 'model', model: 'claude-sonnet-4' },
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 18000 },
{ op: 'context', elapsed_ms: 20004, sources: [
- { source: 'scenic', tables: [], selected_by: 'fallback', total: 51, reason: '挑选数据表超过 20 秒未完成' },
+ // 大库一张目录都没有:过程里提示先去起草目录
+ { source: 'scenic', source_id: 'src-scenic', tables: [], selected_by: 'fallback', total: 51, catalog_tables: 0,
+ reason: '挑选数据表超过 20 秒未完成' },
] },
{ op: 'plan', summary: '先查表结构,再取数' },
{ op: 'add_node', node: { id: 's', type: 'tool', label: '查看表结构' } },
diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts
index cd668956..e352daf3 100644
--- a/frontend/src/run/decode.ts
+++ b/frontend/src/run/decode.ts
@@ -48,7 +48,8 @@ export const teamVerdictOf = (team: TeamRun | undefined | null): TeamVerdict | u
(team as TeamRunEx | undefined | null)?.verdict
/** 下一步该去哪儿改。界面据此给一个直达的入口,而不只是一句话 */
-export type FixKind = 'canvas' | 'settings' | 'tools'
+/** catalog:去数据目录页(fixTo 指到哪个数据源),助手参考的大库还没有数据目录时给 */
+export type FixKind = 'canvas' | 'settings' | 'tools' | 'catalog'
/**
* 事件 → 人能看懂的步骤。
@@ -147,6 +148,8 @@ export interface Step {
next?: string
/** 下一步该去的地方 */
fix?: FixKind
+ /** fix 要去的页面地址(catalog:/data/catalog/:sourceId) */
+ fixTo?: string
/** 工具的时限(秒,tool.start.timeout_s)。进行中的行超过它就要说破 */
limitS?: number
/** 出具那一行的档位(formal / degraded / withheld) */
@@ -2649,6 +2652,10 @@ export interface CopilotContextSource {
explored?: number
/** 没能挑出来的原因 */
reason?: string
+ /** 数据源 id:去数据目录页的入口用(老服务端不给) */
+ sourceId?: string
+ /** 有数据目录的表数(老服务端不给:不知道就不提示) */
+ catalogTables?: number
}
/** 挑表失败时的说法:模型这一轮只看到表名,字段要它自己去查 */
@@ -2668,10 +2675,24 @@ function contextSources(op: CopilotOp): CopilotContextSource[] {
...(total != null ? { total } : {}),
...(total != null && explored != null && explored < total ? { explored } : {}),
...(typeof x.reason === 'string' && x.reason ? { reason: x.reason } : {}),
+ ...(typeof x.source_id === 'string' && x.source_id ? { sourceId: x.source_id } : {}),
+ ...(num(x.catalog_tables) != null ? { catalogTables: num(x.catalog_tables) } : {}),
}
})
}
+/**
+ * 没有数据目录的大库(要按需求挑表的那种):挑表只能照表名猜,SQL 检查也没有目录可对照。真实运行里数据源一张目录
+ * 都没有,用户也不知道该先去起草。只认服务端明说了 0 张的;小库全部表都带字段,不提示
+ */
+function noCatalog(groups: CopilotContextSource[]): { next: string; fixTo?: string } | null {
+ const bare = groups.filter((g) => g.catalogTables === 0 && g.selectedBy !== 'all')
+ if (!bare.length) return null
+ const who = groups.length === 1 ? null : `「${bare.map((g) => g.source).join('」「')}」`
+ const to = bare.find((g) => g.sourceId)?.sourceId
+ return { next: COPILOT_CONTEXT_TEXT.noCatalog(who), ...(to ? { fixTo: `/data/catalog/${encodeURIComponent(to)}` } : {}) }
+}
+
/**
* 一个库在展开区里的那一行:怎么来的 + 表名。探查结构截断了的库写明「共 205 张,只探查了 200 张」:
* 模型只看得到探查到的那些,用户说的表不在里面时,先得知道是没探查到,不是库里没有
@@ -2702,7 +2723,8 @@ function contextLine(g: CopilotContextSource): string {
* 先得知道模型当时看到的是哪几张。标题只说几张(「参考了 8 张表」),展开按数据源分组列表名;挑表失败时
* 标题直说只给了表名,原因放在展开区。认不出的形状返回 null:不出这一行,也不报错。
*/
-export function copilotContext(op: CopilotOp): { title: string; sub?: string; detail: string; ms?: number } | null {
+export function copilotContext(op: CopilotOp):
+ { title: string; sub?: string; detail: string; ms?: number; next?: string; fixTo?: string } | null {
// 展开区里按需求挑过表的库排在前面:请求里点名的那个库往往就是它,排在一串小库后面得在内层滚动框里翻才看得到
const order: Record = { model: 0, all: 1, fallback: 2 }
const groups = contextSources(op).map((g, i) => ({ g, i }))
@@ -2721,11 +2743,13 @@ export function copilotContext(op: CopilotOp): { title: string; sub?: string; de
const reasons = [...new Set(failed.map((g) => g.reason).filter(Boolean))]
// 挑过表才有 elapsed_ms:挑表是生成之前多出来的一次模型调用,没挑成(比如超时)也要让人看到等了多久
const ms = num(op.elapsed_ms)
+ const hint = noCatalog(groups)
return {
title: count ? (namesOnly ? COPILOT_CONTEXT_TEXT.withFields(count) : `参考了 ${count} 张表`) : CONTEXT_FALLBACK_TEXT,
...(sub ? { sub } : {}),
detail: [...groups.map(contextLine), ...reasons.map((r) => `原因:${r}`)].join('\n'),
...(ms != null && ms >= 10 ? { ms } : {}),
+ ...(hint ?? {}),
}
}
@@ -2848,7 +2872,9 @@ export function decodeCopilot(ops: CopilotOp[], opts?: {
add({ id: `cx-${i}`, seq: i, kind: 'schema', status: 'done', code: 'copilot_context',
title: ctx.title, detail: ctx.detail,
...(ctx.sub ? { sub: ctx.sub } : {}),
- ...(ctx.ms != null ? { ms: ctx.ms, meta: formatDuration(ctx.ms) } : {}) })
+ ...(ctx.ms != null ? { ms: ctx.ms, meta: formatDuration(ctx.ms) } : {}),
+ // 没有数据目录的大库:下一步去起草目录,入口直达那个数据源的目录页
+ ...(ctx.next ? { next: ctx.next, ...(ctx.fixTo ? { fix: 'catalog' as const, fixTo: ctx.fixTo } : {}) } : {}) })
break
}
case 'catalog_patch': {
diff --git a/scripts/check-chat.mjs b/scripts/check-chat.mjs
index 69b7bd90..d1f65737 100644
--- a/scripts/check-chat.mjs
+++ b/scripts/check-chat.mjs
@@ -487,8 +487,10 @@ async function fakeApi(route) {
const ops = [
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 800 },
// 服务端按需求挑了表(copilot_context):过程里多一行「参考了 N 张表」
+ // 这个库还没有数据目录(catalog_tables: 0):那一行下面提示先去起草目录
{ op: 'context', elapsed_ms: 1200, sources: [
- { source: 'shop', tables: ['orders', 'order_items'], selected_by: 'model', total: 48 }] },
+ { source: 'shop', source_id: 'src-shop', tables: ['orders', 'order_items'], selected_by: 'model', total: 48,
+ catalog_tables: 0 }] },
{ op: 'plan', summary: '查一下再回答' },
...GRAPH.nodes.map((n) => ({ op: 'add_node', node: n })),
{ op: 'done', explanation: '一张三步的图' },
@@ -1069,6 +1071,13 @@ for (const theme of THEMES) {
await ctxRow.locator('button').first().click()
check('再点开看到按数据源分组的表名', await ctxRow.getByText('「shop」按需求从 48 张表中挑出 2 张:orders、order_items')
.waitFor({ timeout: 3000 }).then(() => true, () => false))
+ // 真实运行里数据源一张目录都没有,挑表和 SQL 检查都用不上目录:这一行下面提示起草,并给去目录页的入口
+ const noCatalog = ctxRow.locator('[data-step-next]')
+ check('没有数据目录的大库:提示先起草目录', await noCatalog.count() === 1
+ && (await noCatalog.innerText()).includes('这个数据源还没有数据目录,起草后挑表和 SQL 检查会更准'),
+ await noCatalog.count() ? await noCatalog.innerText() : '没有这一句')
+ check('……入口直达这个数据源的目录页', await ctxRow.locator('a[data-fix="catalog"]').getAttribute('href').catch(() => null)
+ === '/data/catalog/src-shop')
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
})
diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs
index c3ef2f87..b021e395 100644
--- a/scripts/check-decode.mjs
+++ b/scripts/check-decode.mjs
@@ -682,6 +682,28 @@ await section('Copilot:这一轮参考了哪些表(context 操作)', async
check('挑表失败:原因和库的规模放在展开区', !!fb?.detail?.includes('原因:挑选数据表超过 20 秒未完成')
&& fb.detail.includes('「scenic」共 51 张表'), fb?.detail)
check('挑表失败也说等了多久', fb?.ms === 20004, String(fb?.ms))
+ // 真实运行里数据源一张目录都没有,挑表、SQL 检查都用不上目录,用户也不知道该先去起草
+ check('没有数据目录的大库:下一步提示起草目录,入口直达那个源的目录页',
+ fb?.next === '这个数据源还没有数据目录,起草后挑表和 SQL 检查会更准' && fb?.fix === 'catalog'
+ && fb?.fixTo === '/data/catalog/src-scenic', JSON.stringify({ next: fb?.next, fix: fb?.fix, fixTo: fb?.fixTo }))
+ check('有目录的大库、没有目录的小库:不提示', !row?.next && !row?.fix && !row?.fixTo, JSON.stringify({ next: row?.next }))
+ const bare = mod.copilotContext({ op: 'context', sources: [
+ { source: 'a', source_id: 'id-a', tables: ['x'], selected_by: 'model', total: 60, catalog_tables: 0 },
+ { source: 'b', source_id: 'id-b', tables: ['y'], selected_by: 'all', total: 3, catalog_tables: 0 },
+ { source: 'c', source_id: 'id c', tables: [], selected_by: 'fallback', total: 90, catalog_tables: 0, reason: 'r' }] })
+ check('几个数据源:点名没有目录的大库,入口去第一个', bare?.next === '「a」「c」还没有数据目录,起草后挑表和 SQL 检查会更准'
+ && bare?.fixTo === '/data/catalog/id-a', JSON.stringify({ next: bare?.next, fixTo: bare?.fixTo }))
+ const partly = mod.copilotContext({ op: 'context', sources: [
+ { source: 'a', source_id: 'id-a', tables: ['x'], selected_by: 'model', total: 60, catalog_tables: 3 }] })
+ check('起草过一部分目录:不再提示', !partly?.next, partly?.next)
+ const old = mod.copilotContext({ op: 'context', sources: [{ source: 'a', tables: ['x'], selected_by: 'model', total: 60 }] })
+ check('老服务端不给目录表数:不猜,不提示', !old?.next && !old?.fixTo, old?.next)
+ const noId = mod.copilotContext({ op: 'context', sources: [
+ { source: 'a', tables: [], selected_by: 'fallback', total: 60, catalog_tables: 0 }] })
+ check('没给数据源 id:照样提示,只是不给入口', !!noId?.next && !noId?.fixTo, JSON.stringify(noId))
+ const step = mod.decodeCopilot([{ op: 'context', sources: [
+ { source: 'a', tables: [], selected_by: 'fallback', total: 60, catalog_tables: 0 }] }]).find((s) => s.code === 'copilot_context')
+ check('没有入口时不带 fix:不画一个点了没处去的按钮', !!step?.next && !step?.fix, JSON.stringify(step))
const seeded = mod.copilotContext({ op: 'context', sources: [
{ source: 'scenic', tables: ['stores'], selected_by: 'fallback', total: 51, reason: '模型未挑出任何表' }] })
diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs
index f4660f41..db54b27e 100644
--- a/scripts/check-stream.mjs
+++ b/scripts/check-stream.mjs
@@ -774,12 +774,24 @@ await section('参考了哪些表:画布右栏和问数据页(context 操作
&& expanded.includes('channel_visits') && expanded.includes('「shop」全部 2 张表'), expanded.slice(0, 160))
const overflow = await c.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
check('展开后不撑破 380px 窄栏', overflow <= 0, `${overflow}px`)
+ check('有数据目录的大库:不提示起草目录', await row.locator('[data-step-next]').count() === 0
+ && await row.locator('[data-fix="catalog"]').count() === 0)
check('没有运行时报错', c.errors.length === 0, c.errors.join(' | '))
await c.page.close()
const f = await open('syn=copilot-fallback&dense=1', { w: 380, h: 800, name: 'copilot-fallback' })
const fb = f.page.locator('[data-step-code="copilot_context"]')
check('挑表失败:写明只给了表名', await fb.count() === 1 && (await fb.innerText()).includes('未能按需求挑选,已提供全部表名'))
+ // 真实运行里数据源一张目录都没有:这一行下面常显下一步,入口去那个源的目录页
+ const hint = fb.locator('[data-step-next]')
+ check('没有数据目录的大库:常显「还没有数据目录」,不收进展开区', await hint.count() === 1
+ && (await hint.innerText()).includes('这个数据源还没有数据目录,起草后挑表和 SQL 检查会更准'),
+ await hint.count() ? await hint.innerText() : '没有这一句')
+ const go = fb.locator('a[data-fix="catalog"]')
+ check('……附去数据目录页的入口', await go.count() === 1 && await go.getAttribute('href') === '/data/catalog/src-scenic'
+ && (await go.innerText()).includes('去数据目录起草'), await go.count() ? await go.getAttribute('href') : '没有入口')
+ const narrow = await f.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
+ check('……不撑破 380px 窄栏', narrow <= 0, `${narrow}px`)
await fb.locator('button').first().click()
await f.page.waitForTimeout(150)
check('挑表失败:展开看到原因', (await fb.innerText()).includes('原因:挑选数据表超过 20 秒未完成'))