diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index 31e8513..a828cbe 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs @@ -117,18 +117,187 @@ const expectedRequests = { ] } -function extractRequests(source) { - const requests = [] - const requestPattern = /return\s+request\(\{\s*url:\s*([^,\r\n]+),\s*method:\s*['"]([a-z]+)['"]/g - let match +const expectedReportExports = { + 'src/api/report/palletInventory.js': ['listPalletInventory'], + 'src/api/report/filling.js': ['listFillingReport'], + 'src/api/report/shipment.js': ['listShipmentReport'], + 'src/api/report/consumption.js': ['listConsumptionReport'], + 'src/api/report/return.js': ['listReturnReport'], + 'src/api/report/palletTrace.js': ['listPalletTrace'] +} - while ((match = requestPattern.exec(source)) !== null) { - requests.push({ - url: match[1].replace(/\s+/g, ' ').trim(), - method: match[2] - }) +const delimiterPairs = { '(': ')', '[': ']', '{': '}' } + +function sanitizeSource(source) { + const code = source.split('') + const structure = source.split('') + + for (let index = 0; index < source.length;) { + const quote = source[index] + if (quote === '\'' || quote === '"' || quote === '`') { + let literalEnd = index + 1 + while (literalEnd < source.length && source[literalEnd] !== quote) { + if (source[literalEnd] === '\\') { + literalEnd += 1 + } else if (quote === '`' && source[literalEnd] === '$' && source[literalEnd + 1] === '{') { + throw new Error('契约解析器不支持包含插值的模板字符串') + } + literalEnd += 1 + } + if (literalEnd >= source.length) { + throw new Error('契约源码包含未闭合字符串') + } + structure.fill('\0', index, literalEnd + 1) + index = literalEnd + 1 + continue + } + + const lineComment = source[index] === '/' && source[index + 1] === '/' + const blockComment = source[index] === '/' && source[index + 1] === '*' + if (!lineComment && !blockComment) { + index += 1 + continue + } + + let commentEnd = lineComment ? source.indexOf('\n', index + 2) : source.indexOf('*/', index + 2) + if (commentEnd === -1 && blockComment) { + throw new Error('契约源码包含未闭合注释') + } + commentEnd = commentEnd === -1 ? source.length : commentEnd + (blockComment ? 2 : 0) + while (index < commentEnd) { + if (source[index] !== '\n' && source[index] !== '\r') { + code[index] = ' ' + structure[index] = ' ' + } + index += 1 + } } + return { code: code.join(''), structure: structure.join('') } +} + +function findClosingDelimiter(structure, startIndex) { + const open = structure[startIndex] + const close = delimiterPairs[open] + let depth = 0 + + for (let index = startIndex; index < structure.length; index += 1) { + if (structure[index] === open) { + depth += 1 + } else if (structure[index] === close && --depth === 0) { + return index + } + } + throw new Error(`契约源码包含未闭合的 ${open}${close}`) +} + +function splitTopLevelRanges(structure, separator) { + const ranges = [] + const stack = [] + let rangeStart = 0 + + for (let index = 0; index < structure.length; index += 1) { + const character = structure[index] + if (Object.hasOwn(delimiterPairs, character)) { + stack.push(character) + } else if (Object.values(delimiterPairs).includes(character)) { + const open = stack.pop() + if (delimiterPairs[open] !== character) { + throw new Error('request 对象属性括号不匹配') + } + } else if (character === separator && stack.length === 0) { + ranges.push([rangeStart, index]) + rangeStart = index + 1 + } + } + if (stack.length) { + throw new Error('request 对象属性括号不匹配') + } + ranges.push([rangeStart, structure.length]) + return ranges +} + +function normalizeUrlExpression(code, structure) { + let normalized = '' + for (let index = 0; index < code.length; index += 1) { + if (structure[index] === '\0') { + normalized += code[index] + } else if (/\s/.test(code[index])) { + continue + } else if (code[index] === '+') { + normalized = normalized.trimEnd() + ' + ' + } else { + normalized += code[index] + } + } + return normalized.trim() +} + +function parseRequestArgument(argument, callIndex) { + let objectStart = 0 + while (/\s/.test(argument.structure[objectStart])) { + objectStart += 1 + } + if (argument.structure[objectStart] !== '{') { + throw new Error(`第 ${callIndex} 个 request 调用必须使用对象字面量`) + } + + const objectEnd = findClosingDelimiter(argument.structure, objectStart) + if (argument.structure.slice(objectEnd + 1).trim()) { + throw new Error(`第 ${callIndex} 个 request 调用必须只传入一个对象字面量`) + } + + const bodyCode = argument.code.slice(objectStart + 1, objectEnd) + const bodyStructure = argument.structure.slice(objectStart + 1, objectEnd) + const values = { url: [], method: [] } + for (const [start, end] of splitTopLevelRanges(bodyStructure, ',')) { + const propertyCode = bodyCode.slice(start, end) + const propertyStructure = bodyStructure.slice(start, end) + if (!propertyCode.trim()) { + continue + } + + const propertyParts = splitTopLevelRanges(propertyStructure, ':') + const key = propertyCode.slice(...propertyParts[0]).trim() + if (propertyParts.length !== 2 || !/^[A-Za-z_$][\w$]*$/.test(key)) { + throw new Error(`第 ${callIndex} 个 request 对象只支持静态键值属性`) + } + if (key === 'url' || key === 'method') { + const [valueStart, valueEnd] = propertyParts[1] + values[key].push({ + code: propertyCode.slice(valueStart, valueEnd), + structure: propertyStructure.slice(valueStart, valueEnd) + }) + } + } + + if (values.url.length !== 1 || values.method.length !== 1) { + throw new Error(`第 ${callIndex} 个 request 对象必须包含唯一 url 和 method`) + } + const methodMatch = /^\s*(['"])([a-z]+)\1\s*$/.exec(values.method[0].code) + if (!methodMatch) { + throw new Error(`第 ${callIndex} 个 request method 必须是静态小写字符串`) + } + return { + url: normalizeUrlExpression(values.url[0].code, values.url[0].structure), + method: methodMatch[2] + } +} + +function extractRequests(source) { + const sanitized = sanitizeSource(source) + const requestPattern = /(? { + const source = ` + export function sample(query) { + return request( + { + method : 'get', + params: query, + url : + '/sample/' + query.id + } + ) + } + ` + + assert.deepEqual(extractRequests(source), [ + { url: "'/sample/' + query.id", method: 'get' } + ]) +}) + +test('契约解析器拒绝无法静态核对的 request 调用', () => { + assert.throws( + () => extractRequests('export function sample(config) { return request(config) }'), + /request 调用必须使用对象字面量/ + ) + assert.throws( + () => extractRequests("return request({ url: '/sample', url: '/other', method: 'get' })"), + /唯一 url 和 method/ + ) + assert.throws( + () => extractRequests("return request({ url: '/sample' })"), + /唯一 url 和 method/ + ) +}) + +test('契约解析器忽略注释和字符串中的伪 request 调用', () => { + const source = ` + // return request({ url: '/commented', method: 'delete' }) + const example = "request(config)" + /* + request({ method: 'post', url: '/also-commented' }) + */ + export function sample() { + return request({ + url: '/sample', + /* 属性间注释不应干扰解析 */ + method: 'get' + }) + } + ` + + assert.deepEqual(extractRequests(source), [ + { url: "'/sample'", method: 'get' } + ]) +}) + test('扫码值会去除首尾空白,空白二维码会被拒绝', async () => { const { normalizeScanValue } = await import('../src/utils/palletScan.js') @@ -172,9 +396,6 @@ test('17 个 API 文件逐一匹配硬编码请求契约', async (t) => { await t.test(relativePath, async () => { const source = await readFile(path.join(frontendRoot, relativePath), 'utf8') const actual = extractRequests(source) - const requestCallCount = source.match(/\brequest\s*\(\{/g)?.length ?? 0 - - assert.equal(actual.length, requestCallCount, '存在未被契约解析器覆盖的 request 调用') assert.deepEqual(actual, expected) }) } @@ -219,7 +440,7 @@ test('六个报表 API 只提供 GET /list,不提供导出接口', async () => const source = await readFile(path.join(frontendRoot, relativePath), 'utf8') const exportedFunctionNames = [...source.matchAll(/\bexport\s+function\s+(\w+)/g)].map((match) => match[1]) assert.doesNotMatch(expected[0].url, /export/i) - assert.equal(exportedFunctionNames.some((name) => /export/i.test(name)), false) + assert.deepEqual(exportedFunctionNames, expectedReportExports[relativePath]) } }) @@ -240,6 +461,12 @@ test('扫码组件声明交互契约并保持触控尺寸', async () => { assert.match(source, /emit\(['"]scan['"]/) assert.match(source, /nextTick\(/) assert.match(source, /inputRef\.value\?\.focus\(\)/) + assert.match(source, /if \(!props\.disabled && !props\.loading\)/) + assert.match(source, /focusInput\(true\)/) + assert.match(source, /inputRef\.value\?\.select\(\)/) + assert.match(source, /nextTick\([\s\S]*?focus\(\)[\s\S]*?select\(\)/) + assert.match(source, /watch\(\s*\(\) => \[props\.loading, props\.disabled\]/) + assert.match(source, /if \(!loading && !disabled\)/) assert.match(source, /min-height:\s*48px/) assert.match(source, /width:\s*100%/) assert.match(source, /is-loading/) diff --git a/shierfeng-fastapi-frontend/src/components/PalletScanInput/index.vue b/shierfeng-fastapi-frontend/src/components/PalletScanInput/index.vue index fe6d141..4508c81 100644 --- a/shierfeng-fastapi-frontend/src/components/PalletScanInput/index.vue +++ b/shierfeng-fastapi-frontend/src/components/PalletScanInput/index.vue @@ -64,10 +64,13 @@ const errorMessage = ref('') let lastScanValue = '' let lastScanAt = 0 -function focusInput() { +function focusInput(selectValue = false) { nextTick(() => { - if (!props.disabled) { + if (!props.disabled && !props.loading) { inputRef.value?.focus() + if (selectValue) { + inputRef.value?.select() + } } }) } @@ -90,7 +93,7 @@ function handleSubmit() { } catch (error) { errorMessage.value = error.message emit('invalid', error.message) - focusInput() + focusInput(true) return } @@ -118,11 +121,14 @@ function handleSubmit() { onMounted(focusInput) -watch(() => props.loading, (loading) => { - if (!loading) { - focusInput() +watch( + () => [props.loading, props.disabled], + ([loading, disabled]) => { + if (!loading && !disabled) { + focusInput() + } } -}) +)