mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:51:59 +00:00
合入数据目录阶段 3:合并查询节点——跨库按键合并,截断输入拒绝,类型不一致与行数放大告警,证据可下钻到两边原始查询
This commit is contained in:
@@ -112,6 +112,33 @@ await section('知识检索', async () => {
|
||||
check('未知事件的类型名不上标题和副标题', !unknown.some((x) => x.title.includes('brand.new') || x.sub?.includes('brand.new')))
|
||||
})
|
||||
|
||||
await section('合并查询', async () => {
|
||||
// merge.end 是合并查询节点做完的那一条:哪几个输入、合并 SQL、几行。和查询同一类(kind query),
|
||||
// 合并 SQL 进详情、预览进结果、工件可下钻;警告的原文由紧跟着的 log 各占一行,这一行只说有几条
|
||||
const steps = mod.decodeRun(synthetic.mergeRun(), { status: 'succeeded' })
|
||||
const all = flatten(steps)
|
||||
const m = all.find((s) => s.code === 'merge')
|
||||
check('合并查询是一行查询步骤,挂在合并节点下', !!m && m.kind === 'query' && m.nodeId === 'merge'
|
||||
&& steps.some((s) => s.kind === 'node' && s.nodeId === 'merge' && s.children?.includes(m)), m?.kind)
|
||||
check('标题说合并的是哪几个输入', m?.title === '合并 s、v 的查询结果', m?.title)
|
||||
check('合并 SQL 留在详情里', m?.detail === synthetic.MERGE_SQL)
|
||||
check('预览按查询结果的形状给出,能画成表格', !!mod.parseQueryResult(m?.result ?? '')?.columns?.includes('到店人数'))
|
||||
check('输入、行数、数据源都在', m?.merge?.inputs?.length === 2 && m.merge.inputs[1].source === 'members'
|
||||
&& m.merge.inputs[0].label === '门店销售' && m.merge.rows === 8)
|
||||
check('行数和耗时写在行尾', !!m?.meta?.includes('8 行'), m?.meta)
|
||||
check('工件可下钻到合并结果的快照', m?.artifact === 'mq-merge')
|
||||
check('有警告时这一行标成警告、说有几条', m?.level === 'warn' && !!m.sub?.includes('2 条警告'), m?.sub)
|
||||
const key = all.find((s) => s.code === 'merge_key_type')
|
||||
check('键类型不一致说成人话:标题点名两列', !!key?.title.includes('s.门店') && key.title.includes('v.门店'), key?.title)
|
||||
check('键类型不一致给出下一步', !!key?.next?.includes('CAST') && key.fix === 'canvas')
|
||||
const grew = all.find((s) => s.code === 'merge_rows_grew')
|
||||
check('行数放大说成人话,并带上行数和重复的键', !!grew?.title.includes('不唯一') && !!grew.sub?.includes('8 行')
|
||||
&& !!grew.sub?.includes('出现 2 次'), grew?.sub)
|
||||
check('警告原文各占一行,不在合并那一行里重复', all.filter((s) => s.level === 'warn' && s.nodeId === 'merge').length === 3)
|
||||
check('合并节点照常完成,两次源查询仍是普通查询步骤', all.filter((s) => s.kind === 'query' && s.code !== 'merge').length === 2
|
||||
&& steps.find((s) => s.nodeId === 'merge')?.status === 'done')
|
||||
})
|
||||
|
||||
await section('长期记忆', async () => {
|
||||
// 写记忆以前只发 info 日志,而 info 在这一层是被丢弃的——系统往长期记忆里
|
||||
// 存东西,界面上一点痕迹都没有。Copilot 会主动记之后,这条不可接受。
|
||||
|
||||
@@ -2989,7 +2989,96 @@ if (SHOTS) {
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
}
|
||||
await section('copy-r2', '按后端原文匹配的地方:后端现在的原文、整改前的原文都认得(文案整改第二轮)', async () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 合并查询:片段接口的查询步骤带 merge(表级来历、逐格来历),后面接着追到的输入的查询步骤(merged_into);
|
||||
// 推断来源的答复带 merge(经过的每一次合并)。答复按 backend/app/api/evidence.py 的 _merge_view、_query_steps 的形状改
|
||||
// ---------------------------------------------------------------------------
|
||||
const MERGE_SQL = 'SELECT o.order_id, o.region, o.amount, t.target FROM o JOIN t ON o.region = t.region ORDER BY o.order_id'
|
||||
function asMerge(body, traced, { withInput = true } = {}) {
|
||||
const q = body.chain.find((st) => st.step === 'query')
|
||||
const merged = {
|
||||
...q, tool: undefined, source: '合并查询', sql: MERGE_SQL,
|
||||
merge: {
|
||||
sql: MERGE_SQL,
|
||||
inputs: [
|
||||
{ alias: 'o', node_id: 'fetch', label: '订单明细', query: 'Q1', rows: 12, source: 'shop', artifact: 'a'.repeat(64), sealed: true },
|
||||
{ alias: 't', node_id: 'targets', label: '区域目标', query: null, rows: 4, source: 'plan', artifact: 'b'.repeat(64), sealed: true },
|
||||
],
|
||||
warnings: [{ code: 'rows_grew', message: '合并结果有 14 行,多于行数最多的输入「o」(12 行):合并键可能不唯一,同一行被重复匹配' }],
|
||||
traced,
|
||||
},
|
||||
}
|
||||
const input = { ...q, alias: 'Q1', merged_into: q.alias }
|
||||
return { ...body, chain: body.chain.flatMap((st) => (st.step === 'query' ? (withInput ? [merged, input] : [merged]) : [st])) }
|
||||
}
|
||||
|
||||
await section('merge', '合并查询:表级来历(输入、合并 SQL、警告)、逐格来历、输入步骤标明是哪次合并的输入', async () => {
|
||||
const traced = await probeQ((id, body) => (id === qseg('1,288')
|
||||
? asMerge(body, [{ cell: [5, 'amount'], input: 'o', query: 'Q1', row: 5, column: 'amount' }]) : body))
|
||||
await openQ(traced.page, '1,288')
|
||||
if (SHOTS) {
|
||||
for (const theme of ['dark', 'light']) {
|
||||
await traced.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
|
||||
await traced.page.waitForTimeout(250)
|
||||
await traced.page.screenshot({ path: `${SHOTS}/evidence-merge-${theme}.png`, fullPage: true })
|
||||
}
|
||||
await traced.page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
|
||||
}
|
||||
const p = traced.page.locator('[data-evidence-panel]')
|
||||
const parts = p.locator('[data-ev-query]')
|
||||
check('合并步骤和它追到的输入步骤各一块', await parts.count() === 2, String(await parts.count()))
|
||||
const head = await parts.first().locator('[data-ev-query-head]').innerText().catch(() => '')
|
||||
check('合并步骤的标题写「合并查询」,不写工具名', head.includes('Q3') && head.includes('合并查询') && !head.includes('db_query__'), head)
|
||||
const inputs = p.locator('[data-ev-merge]')
|
||||
check('表级来历:合并自 2 个输入', (await inputs.innerText().catch(() => '')).includes('合并自 2 个输入'))
|
||||
const o = await p.locator('[data-ev-merge-input="o"]').innerText().catch(() => '')
|
||||
check('……每个输入写别名、目录编号、节点名、行数、数据源', ['o', '查询 Q1', '订单明细', '12 行', '数据源 shop'].every((t) => o.includes(t)), o)
|
||||
const t = await p.locator('[data-ev-merge-input="t"]').innerText().catch(() => '')
|
||||
check('……不在本报告目录里的输入照实说', t.includes('不在本报告的证据目录中'), t)
|
||||
check('合并 SQL 有标签、带复制', (await parts.first().innerText()).includes('合并 SQL')
|
||||
&& await parts.first().locator('button', { hasText: '复制合并 SQL' }).count() === 1)
|
||||
const warn = await p.locator('[data-ev-merge-warnings]').innerText().catch(() => '')
|
||||
check('执行时的警告写出来', warn.includes('执行时的警告') && warn.includes('不唯一'), warn)
|
||||
const line = await p.locator('[data-ev-merge-trace="cell"]').innerText().catch(() => '')
|
||||
check('逐格来历:被引用的格追到输入的哪一格(行号从 1 数)', line.includes('Q3 第 6 行「amount」') && line.includes('Q1 第 6 行「amount」'), line)
|
||||
const into = await parts.nth(1).locator('[data-ev-merged-into]').innerText().catch(() => '')
|
||||
check('输入步骤标明是哪次合并的输入,高亮追到的那一格', into.includes('合并查询 Q3 的输入')
|
||||
&& await parts.nth(1).locator('td[data-highlight="cell"]').count() === 1, into)
|
||||
check('合并查询不是数据源:不说它「已不存在」', await p.locator('[data-ev-query-mask-note]').count() === 0)
|
||||
await traced.ctx.close()
|
||||
|
||||
const flat = await probeQ((id, body) => (id === qseg('1,288') ? asMerge(body, [{
|
||||
cell: [5, 'amount'], input: null, query: null, row: null, column: null, note: '没有逐格来历',
|
||||
}], { withInput: false }) : body))
|
||||
await openQ(flat.page, '1,288')
|
||||
const none = await flat.page.locator('[data-evidence-panel] [data-ev-merge-trace="none"]').innerText().catch(() => '')
|
||||
check('追不到的格:写明没有逐格来历、只有表级来历,不接输入步骤', none.includes('没有逐格来历')
|
||||
&& none.includes('表级来历') && await flat.page.locator('[data-evidence-panel] [data-ev-query]').count() === 1, none)
|
||||
await flat.ctx.close()
|
||||
|
||||
const wideId = pseg('wide')
|
||||
const hop = { alias: 'Q3', node_id: 'merge', input: 'f', query: 'Q1', row: 5, column: '全日客流' }
|
||||
const lost = { alias: 'Q3', node_id: 'merge', input: null, query: null, row: null, column: null }
|
||||
const noLineage = {
|
||||
...fxp.provenance[wideId], status: 'none', version: null, cell_source: null, checks: [], merge: [lost],
|
||||
reason: { code: 'merge_no_lineage', detail: '', text: '这一格来自合并查询,无法确定它对应哪个输入的哪一格' },
|
||||
}
|
||||
const hops = await open('/ui-harness.html?evidence=1', {
|
||||
prov: (run, sid, reply) => (sid === wideId ? { json: { ...reply.json, merge: [hop] } }
|
||||
: sid === pseg('long') ? { json: noLineage } : reply),
|
||||
})
|
||||
await openP(hops.page, 'wide')
|
||||
const said = await textOf(pv(hops.page, '[data-ev-prov-merge]'))
|
||||
check('推断的来源:先说经过的合并、追到的那一格,下面的原表格子照常', said === '经合并查询 Q3 追到 Q1 第 6 行「全日客流」'
|
||||
&& await pv(hops.page, '[data-ev-prov-cell]').count() === 1, said)
|
||||
await openP(hops.page, 'long')
|
||||
check('追不到逐格来历:写原因和没追到的那一跳,不画数据版本和格子', (await textOf(pv(hops.page, '[data-ev-prov-reason="merge_no_lineage"]')))
|
||||
.includes('合并查询') && (await textOf(pv(hops.page, '[data-ev-prov-merge-hop="none"]'))).includes('没有逐格来历')
|
||||
&& await pv(hops.page, '[data-ev-prov-version], [data-ev-prov-cell]').count() === 0)
|
||||
await hops.ctx.close()
|
||||
})
|
||||
|
||||
await section('copy-r2','按后端原文匹配的地方:后端现在的原文、整改前的原文都认得(文案整改第二轮)', async () => {
|
||||
// 画布问题落到哪一栏(canvas/issues.ts)、检查器的分支标识过滤(Inspector 用 issues.ts 的 isCaseKeyIssue)、
|
||||
// 证据面板的裁判触顶(lib/evidence.ts)都按后端原文判断。开发库里历史运行、旧版本存下的文字是整改前的,
|
||||
// 新的是整改后的:两种各喂一遍。「新」样本和 backend/app 现在的写法一致(先核对源码片段),
|
||||
|
||||
@@ -694,6 +694,43 @@ await section('长表名、Copilot 结局、问数据的分段', async () => {
|
||||
await q.page.close()
|
||||
})
|
||||
|
||||
await section('合并查询:输入、合并 SQL、结果预览,警告各占一行并给出下一步', async () => {
|
||||
const { page, errors } = await open('syn=merge', { w: 1100, h: 900, name: 'merge' })
|
||||
const row = page.locator('[data-step-code="merge"]').first()
|
||||
const line = (await row.innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||||
check('合并那一行说合并的是哪几个输入,行尾写行数', line.includes('合并 s、v 的查询结果') && line.includes('8 行'), line)
|
||||
check('有警告:这一行标成警告、说有几条、指到下方', line.includes('2 条警告,详见本节点下方'), line)
|
||||
await row.locator('button').first().click()
|
||||
await page.waitForTimeout(200)
|
||||
const merge = row.locator('[data-step-merge]')
|
||||
const inputs = (await merge.innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||||
check('展开:合并自 2 个输入,每个写别名、节点名、行数、数据源', inputs.includes('合并自 2 个输入')
|
||||
&& ['s', '门店销售', 'v', '到店人数', '4 行', '数据源 stores', '数据源 members'].every((t) => inputs.includes(t)), inputs)
|
||||
check('合并 SQL 有标签、带复制', (await row.innerText()).includes('合并 SQL')
|
||||
&& await row.getByRole('button', { name: '复制合并 SQL' }).count() === 1)
|
||||
check('结果预览画成表格(列是合并结果的列)', (await row.locator('table th').allInnerTexts()).some((t) => t.includes('到店人数')))
|
||||
check('输入写在合并 SQL 前面:先看合并的是什么,再看怎么合并', await row.evaluate((el) => {
|
||||
const m = el.querySelector('[data-step-merge]')
|
||||
const pre = el.querySelector('pre')
|
||||
return !!m && !!pre && !!(m.compareDocumentPosition(pre) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
}))
|
||||
const key = page.locator('[data-step-code="merge_key_type"]').first()
|
||||
check('键类型不一致:标题点名两列,下一步不用展开就看得到', (await key.innerText().catch(() => '')).includes('合并键类型不一致:s.门店 与 v.门店')
|
||||
&& (await key.locator('[data-step-next]').innerText().catch(() => '')).includes('CAST'))
|
||||
const grew = page.locator('[data-step-code="merge_rows_grew"]').first()
|
||||
check('行数放大:说合并键可能不唯一,下一步说怎么改', (await grew.innerText().catch(() => '')).includes('合并键可能不唯一')
|
||||
&& (await grew.locator('[data-step-next]').innerText().catch(() => '')).includes('聚合到相同粒度'))
|
||||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||||
await page.close()
|
||||
|
||||
const narrow = await open('syn=merge&dense=1', { w: 380, h: 900 })
|
||||
await narrow.page.locator('[data-step-code="merge"] button').first().click()
|
||||
await narrow.page.waitForTimeout(200)
|
||||
const overflow = await narrow.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
check('窄栏展开合并那一行:输入清单、合并 SQL、预览都不撑出横向滚动', overflow <= 0, `${overflow}px`)
|
||||
await narrow.page.close()
|
||||
})
|
||||
|
||||
await section('步骤行和画布联动', async () => {
|
||||
const { page } = await open('syn=mixed&link=1', { w: 1100, h: 800 })
|
||||
const row = page.locator('[data-node-id="agent"]').first()
|
||||
|
||||
@@ -114,6 +114,23 @@ const EV_GRAPH = {
|
||||
edges: [edge('start', 'fetch'), edge('start', 'manual'), edge('fetch', 'write'), edge('manual', 'write'), edge('write', 'done')],
|
||||
}
|
||||
|
||||
// 合并查询:门店库、会员库各查一次,按日期 + 门店合并。上游另有一个模型调用节点,检查器的输入下拉里不该有它
|
||||
const MERGE_GRAPH = {
|
||||
nodes: [
|
||||
node('start', 'input', 0, 200, '输入', { fields: [{ name: 'day', required: true }] }),
|
||||
node('q_sales', 'tool', 300, 80, '门店销售', { tool: 'db_query__shop', args: { sql: 'SELECT order_date AS 日期, store_id AS 门店, COUNT(*) AS 订单数 FROM orders GROUP BY 1, 2' } }),
|
||||
node('q_visits', 'tool', 300, 240, '到店人数', { tool: 'db_query__members', args: { sql: 'SELECT visit_date AS 日期, store_code AS 门店, COUNT(*) AS 到店人数 FROM visits GROUP BY 1, 2' } }),
|
||||
node('think', 'llm', 300, 400, '先想一想', { prompt: '{{ input.day }}' }),
|
||||
node('merge', 'merge', 640, 200, '按门店合并', {
|
||||
inputs: { s: 'q_sales', v: 'q_visits' },
|
||||
sql: 'SELECT s.日期, s.门店, s.订单数, v.到店人数 FROM s JOIN v ON s.日期 = v.日期 AND s.门店 = v.门店',
|
||||
}),
|
||||
node('done', 'output', 980, 200, '成果', { fields: [{ name: 'answer', value: '{{ nodes.merge.rows }}' }] }),
|
||||
],
|
||||
edges: [edge('start', 'q_sales'), edge('start', 'q_visits'), edge('start', 'think'), edge('q_sales', 'merge'),
|
||||
edge('q_visits', 'merge'), edge('think', 'merge'), edge('merge', 'done')],
|
||||
}
|
||||
|
||||
const wf = (id, extra) => ({
|
||||
id, name: extra.name, description: extra.description ?? '检查脚本伪造的工作流', graph: extra.graph ?? GRAPH,
|
||||
tags: extra.tags ?? [], version: extra.version ?? 3, is_template: !!extra.is_template,
|
||||
@@ -128,6 +145,7 @@ const FAKES = {
|
||||
'st-team': wf('st-team', { name: '__studio_check_team__', graph: TEAM_BOUND, version: 2, published_version: 2, status: 'published' }),
|
||||
'st-lint': wf('st-lint', { name: '__studio_check_lint__', graph: TEAM_GRAPH, version: 1 }),
|
||||
'st-ev': wf('st-ev', { name: '__studio_check_ev__', graph: EV_GRAPH, version: 2 }),
|
||||
'st-merge': wf('st-merge', { name: '__studio_check_merge__', graph: MERGE_GRAPH, version: 1 }),
|
||||
}
|
||||
const VERSIONS = [
|
||||
{ id: 'v3', version: 3, note: '', created_at: '2026-09-26T02:00:00Z' },
|
||||
@@ -2353,6 +2371,69 @@ await section('证据路径:点开报告片段时画布高亮、节点名能
|
||||
await race.ctx.close()
|
||||
})
|
||||
|
||||
// ================================================================ 合并查询
|
||||
await section('合并查询:节点库认得、卡片摘要、检查器从上游查询节点里选输入、别名重名先不写回、定位落到那一行', async () => {
|
||||
const { ctx, page, errors } = await open({ path: '/studio/st-merge' })
|
||||
const entry = page.locator('[aria-label="节点库"] button[data-node-type="merge"]')
|
||||
check('节点库「执行」一类里有「合并查询」', await entry.count() === 1 && (await entry.innerText()).includes('合并查询'))
|
||||
const card = page.locator('.react-flow__node[data-id="merge"]')
|
||||
check('卡片摘要写合并的是哪几张表(别名)', (await card.innerText().catch(() => '')).includes('合并 s、v'),
|
||||
(await card.innerText().catch(() => '')).replace(/\s+/g, ' ').slice(0, 80))
|
||||
await S(page, () => window.__studio.getState().select('merge'))
|
||||
await page.waitForSelector('[data-inspector-sheet] [data-field="inputs"]', { timeout: 4000 }).catch(() => {})
|
||||
// STUDIO_SHOTS=<目录>:亮暗两套各截一张合并查询的检查器,供人眼复核
|
||||
if (process.env.STUDIO_SHOTS) {
|
||||
for (const theme of ['dark', 'light']) {
|
||||
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
|
||||
await page.waitForTimeout(250)
|
||||
await page.screenshot({ path: `${process.env.STUDIO_SHOTS}/studio-merge-${theme}.png` })
|
||||
}
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
|
||||
}
|
||||
const box = page.locator('[data-inspector-sheet] [data-field="inputs"]')
|
||||
const aliases = await box.locator('input.mono').evaluateAll((els) => els.map((e) => e.value))
|
||||
check('每个输入一行:别名 s、v,各配一个查询节点', aliases.join(',') === 's,v', aliases.join(','))
|
||||
const options = await box.locator('select').first().locator('option:not([disabled])').evaluateAll((els) => els.map((e) => e.value))
|
||||
check('下拉只列上游的查询节点(调用数据库查询工具的节点),不列模型调用', options.join(',') === 'q_sales,q_visits'
|
||||
|| options.join(',') === 'q_visits,q_sales', options.join(','))
|
||||
check('选中的节点下面写着它调用的工具', (await box.innerText()).includes('db_query__shop'))
|
||||
check('合并 SQL 用代码编辑框', await page.locator('[data-inspector-sheet] [data-field="sql"] textarea, [data-inspector-sheet] [data-field="sql"] [contenteditable]').count() >= 1)
|
||||
const config = () => S(page, () => JSON.stringify(window.__studio.getState().nodes.find((n) => n.id === 'merge')?.data.config.inputs))
|
||||
|
||||
await box.locator('input.mono').nth(1).fill('S')
|
||||
await page.waitForTimeout(150)
|
||||
check('别名改成和另一行只差大小写:行内说明重复,先不写回(两行都还在)',
|
||||
(await box.innerText()).includes('重复') && await config() === JSON.stringify({ s: 'q_sales', v: 'q_visits' }), await config())
|
||||
await box.locator('input.mono').nth(1).fill('visits')
|
||||
await page.waitForTimeout(150)
|
||||
check('改成不重名的别名:写回,顺序不变', await config() === JSON.stringify({ s: 'q_sales', visits: 'q_visits' }), await config())
|
||||
await box.locator('input.mono').nth(1).fill('9v')
|
||||
await page.waitForTimeout(150)
|
||||
check('不合法的别名当场说明写法', (await box.innerText()).includes('只能用英文字母、数字和下划线'))
|
||||
await box.locator('input.mono').nth(1).fill('v')
|
||||
await box.getByRole('button', { name: '添加输入' }).click()
|
||||
await page.waitForTimeout(150)
|
||||
const added = JSON.parse(await config())
|
||||
check('添加输入:取一个上游查询节点,别名从节点 id 起、不重名', Object.keys(added).length === 3
|
||||
&& Object.keys(added)[2] === 'q_sales', JSON.stringify(added))
|
||||
await box.getByRole('button', { name: '删除输入 q_sales' }).click()
|
||||
await page.waitForTimeout(150)
|
||||
check('删除输入:那一行和配置里的那一项一起去掉', await config() === JSON.stringify({ s: 'q_sales', v: 'q_visits' }), await config())
|
||||
|
||||
await S(page, () => window.__studio.getState().select(null))
|
||||
await page.waitForTimeout(200)
|
||||
const r = await page.evaluate(async () => {
|
||||
const m = await window.__appImport('/src/canvas/InspectorSheet.tsx')
|
||||
m.revealField('merge', 'inputs.v', { focus: true })
|
||||
await new Promise((res) => setTimeout(res, 600))
|
||||
const a = document.activeElement
|
||||
return { inRow: !!a?.closest('[data-field="inputs"] [data-sub="v"]'), value: (a && 'value' in a) ? a.value : null }
|
||||
}).catch((e) => ({ error: e.message.split('\n')[0] }))
|
||||
check('问题落在 inputs.<别名> 上:定位到那一行,光标落进别名', r.inRow && r.value === 'v', JSON.stringify(r))
|
||||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
check('整个检查没有弹出原生 confirm / prompt', dialogs === 0, `${dialogs} 次`)
|
||||
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user