feat: 同步全部系统改动与文档

This commit is contained in:
2026-07-28 10:29:56 +08:00
parent 62f7632fc6
commit 1fa1ece06d
487 changed files with 267645 additions and 144 deletions

View File

@@ -0,0 +1,242 @@
const fs = require('fs')
const path = require('path')
const JSZip = require('jszip')
const { DOMParser } = require('@xmldom/xmldom')
const { PNG } = require('pngjs')
const root = path.resolve(__dirname, '..', '..')
const docxPath = process.env.MES_MANUAL_DOCX
? path.resolve(process.env.MES_MANUAL_DOCX)
: path.resolve(root, 'doc', '大连元利流体技术有限公司MES系统用户操作手册_V1.1_2026-07-23.docx')
const catalog = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'full-page-catalog.json'), 'utf8'))
const dialogAudit = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'manual-dialog-audit.json'), 'utf8'))
const screenshotsDir = path.resolve(__dirname, 'full-screenshots')
function localName(node) {
return node && (node.localName || String(node.nodeName || '').split(':').pop())
}
function elementChildren(node, name) {
if (!node || !node.childNodes) return []
return [...node.childNodes].filter(child => child.nodeType === 1 && (!name || localName(child) === name))
}
function descendants(node, name, output = []) {
for (const child of elementChildren(node)) {
if (!name || localName(child) === name) output.push(child)
descendants(child, name, output)
}
return output
}
function textOf(node) {
return descendants(node, 't').map(item => item.textContent || '').join('').trim()
}
function firstDescendant(node, name) {
return descendants(node, name, [])[0] || null
}
function fail(message) {
throw new Error(message)
}
function imagesMatch(actual, expected) {
if (actual.equals(expected)) return true
const actualPng = PNG.sync.read(actual)
const expectedPng = PNG.sync.read(expected)
if (actualPng.width === expectedPng.width && actualPng.height === expectedPng.height) {
return actualPng.data.equals(expectedPng.data)
}
const actualRatio = actualPng.width / actualPng.height
const expectedRatio = expectedPng.width / expectedPng.height
if (Math.abs(actualRatio - expectedRatio) > 0.002) return false
// WPS downsamples embedded PNGs to their displayed size when saving. Compare
// sampled RGB values against a bilinear resize of the captured source image.
let difference = 0
let samples = 0
const stride = 2
for (let y = 0; y < actualPng.height; y += stride) {
const sourceY = (y + 0.5) * expectedPng.height / actualPng.height - 0.5
const y0 = Math.max(0, Math.floor(sourceY))
const y1 = Math.min(expectedPng.height - 1, y0 + 1)
const fractionY = sourceY - y0
for (let x = 0; x < actualPng.width; x += stride) {
const sourceX = (x + 0.5) * expectedPng.width / actualPng.width - 0.5
const x0 = Math.max(0, Math.floor(sourceX))
const x1 = Math.min(expectedPng.width - 1, x0 + 1)
const fractionX = sourceX - x0
for (let channel = 0; channel < 3; channel++) {
const topLeft = expectedPng.data[(y0 * expectedPng.width + x0) * 4 + channel]
const topRight = expectedPng.data[(y0 * expectedPng.width + x1) * 4 + channel]
const bottomLeft = expectedPng.data[(y1 * expectedPng.width + x0) * 4 + channel]
const bottomRight = expectedPng.data[(y1 * expectedPng.width + x1) * 4 + channel]
const expectedValue = (topLeft * (1 - fractionX) + topRight * fractionX) * (1 - fractionY) +
(bottomLeft * (1 - fractionX) + bottomRight * fractionX) * fractionY
difference += Math.abs(actualPng.data[(y * actualPng.width + x) * 4 + channel] - expectedValue)
samples++
}
}
}
return difference / samples < 2
}
function main() {
const zip = new JSZip(fs.readFileSync(docxPath))
const parser = new DOMParser()
const document = parser.parseFromString(zip.file('word/document.xml').asText(), 'application/xml')
const relationships = parser.parseFromString(zip.file('word/_rels/document.xml.rels').asText(), 'application/xml')
const body = firstDescendant(document.documentElement, 'body')
const bodyChildren = elementChildren(body)
const fullText = textOf(document.documentElement)
const relationshipTargets = new Map()
for (const relationship of elementChildren(relationships.documentElement, 'Relationship')) {
relationshipTargets.set(relationship.getAttribute('Id'), relationship.getAttribute('Target').replace(/^\.\//, ''))
}
const chapterByModule = {
'设备管理': 7,
'研发管理': 8,
'工艺管理': 9,
'计划排产': 10,
'生产管理': 11,
'异常提醒': 12,
'质量管理': 13,
'销售订单管理': 14
}
let verifiedScreenshots = 0
let verifiedActionTables = 0
for (const page of catalog) {
const section = `${chapterByModule[page.module]}.${page.pageIndex}`
const pageStart = bodyChildren.findIndex(node => localName(node) === 'p' && textOf(node) === `${section} ${page.title}`)
if (pageStart < 0) fail(`页面标题缺失:${section} ${page.title}`)
let pageEnd = bodyChildren.findIndex((node, index) => index > pageStart && localName(node) === 'p' && /^\d+\.\d+ /.test(textOf(node)))
if (pageEnd < 0) pageEnd = bodyChildren.length
const imageParagraph = bodyChildren.slice(pageStart, pageEnd).find(node => localName(node) === 'p' && firstDescendant(node, 'blip'))
if (!imageParagraph) fail(`页面截图缺失:${section} ${page.title}`)
const blip = firstDescendant(imageParagraph, 'blip')
const relationshipId = blip.getAttribute('r:embed') || blip.getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'embed')
const target = relationshipTargets.get(relationshipId)
if (!target || !zip.file(`word/${target}`)) fail(`页面截图关系缺失:${section} ${page.title}`)
const embedded = zip.file(`word/${target}`).asNodeBuffer()
const expected = fs.readFileSync(path.resolve(screenshotsDir, page.screenshot))
if (!imagesMatch(embedded, expected)) fail(`页面截图未更新:${section} ${page.title}${target}`)
verifiedScreenshots++
const actionHeadingIndex = bodyChildren.findIndex((node, index) =>
index > pageStart && index < pageEnd && localName(node) === 'p' && textOf(node) === `${section}.3 功能按钮与操作`
)
const actionTable = actionHeadingIndex >= 0
? bodyChildren.slice(actionHeadingIndex + 1, pageEnd).find(node => localName(node) === 'tbl')
: null
const actionHeaders = actionTable ? elementChildren(elementChildren(actionTable, 'tr')[0], 'tc').map(textOf) : []
if (actionHeaders.join('|') !== '操作入口|执行结果|业务影响与注意事项') {
const subsectionHeadings = bodyChildren.slice(pageStart, pageEnd)
.filter(node => localName(node) === 'p' && textOf(node).startsWith(`${section}.`))
.map(textOf)
fail(`操作结果与影响表缺失:${section} ${page.title};子节:${subsectionHeadings.join(' | ')}`)
}
verifiedActionTables++
}
let verifiedDialogPages = 0
for (const item of dialogAudit.filter(page => page.dialogs.length)) {
const page = catalog.find(entry => entry.module === item.module && entry.title === item.title)
if (!page) fail(`弹窗审计页未纳入目录:${item.module}/${item.title}`)
const section = `${chapterByModule[item.module]}.${page.pageIndex}`
const pageStart = bodyChildren.findIndex(node => localName(node) === 'p' && textOf(node) === `${section} ${item.title}`)
let pageEnd = bodyChildren.findIndex((node, index) => index > pageStart && localName(node) === 'p' && /^\d+\.\d+ /.test(textOf(node)))
if (pageEnd < 0) pageEnd = bodyChildren.length
const pageText = bodyChildren.slice(pageStart, pageEnd).map(textOf).join('\n')
const hasDialogCoverage = pageText.includes('弹窗') || (section === '9.3' && pageText.includes('编辑生产工艺与保存时机'))
if (!hasDialogCoverage) fail(`弹窗说明缺失:${section} ${item.title}`)
verifiedDialogPages++
}
const requiredPhrases = [
'9.3.4 编辑生产工艺与保存时机',
'左侧工序列表',
'上移/下移',
'保存为标准工艺',
'替换该物料原有的标准工艺路线',
'不会自动生成生产任务',
'下发任务(单工序行)',
'操作入口',
'执行结果',
'业务影响与注意事项',
'10.3.4 可编辑字段与编辑弹窗',
'计划开始',
'计划完成',
'外协分组',
'修改后立即保存',
'编辑备注信息弹窗',
'上游业务数据',
'本页维护结果',
'下游业务影响'
]
for (const phrase of requiredPhrases) if (!fullText.includes(phrase)) fail(`关键内容缺失:${phrase}`)
const forbiddenPhrases = [
'页面路由',
'只读查询过程/接口',
'写入过程/接口',
'技术核对说明',
'本文档编制未实际执行',
'未产生数据',
'不产生数据',
'正式环境只读截图',
'后台过程',
'存储过程',
'核对内容后使用弹窗中的',
'保存提交'
]
const presentForbiddenPhrases = forbiddenPhrases.filter(phrase => fullText.includes(phrase))
if (presentForbiddenPhrases.length) fail(`仍含不应出现的措辞:${presentForbiddenPhrases.join('、')}`)
const implementationNames = [...new Set(catalog.flatMap(page => [
page.route,
...(page.readProcedures || []),
...(page.writeProcedures || [])
]).filter(Boolean))]
const leakedNames = implementationNames.filter(name => fullText.includes(name))
if (leakedNames.length) fail(`仍含内部路由或过程名称:${leakedNames.join('、')}`)
const processPopupCaptionIndex = bodyChildren.findIndex(node => localName(node) === 'p' && /^图 9\.3-2/.test(textOf(node)))
const processPopupImageParagraph = bodyChildren.slice(0, processPopupCaptionIndex).reverse()
.find(node => localName(node) === 'p' && firstDescendant(node, 'blip'))
if (processPopupCaptionIndex < 0 || !processPopupImageParagraph) fail('9.3 编辑生产工艺弹窗截图未嵌入文档')
const processPopupBlip = firstDescendant(processPopupImageParagraph, 'blip')
const processPopupRelationshipId = processPopupBlip.getAttribute('r:embed') || processPopupBlip.getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'embed')
const processPopupTarget = relationshipTargets.get(processPopupRelationshipId)
const processPopupEntry = processPopupTarget && zip.file(`word/${processPopupTarget}`)
const processPopupExpected = fs.readFileSync(path.resolve(screenshotsDir, '03-03-process-edit-dialog.png'))
if (!processPopupEntry || !imagesMatch(processPopupEntry.asNodeBuffer(), processPopupExpected)) fail('9.3 编辑生产工艺弹窗截图内容不正确')
const popupCaptionIndex = bodyChildren.findIndex(node => localName(node) === 'p' && /^图 10\.3-2/.test(textOf(node)))
const popupImageParagraph = bodyChildren.slice(0, popupCaptionIndex).reverse().find(node => localName(node) === 'p' && firstDescendant(node, 'blip'))
if (popupCaptionIndex < 0 || !popupImageParagraph) fail('10.3 编辑弹窗截图未嵌入文档')
const popupBlip = firstDescendant(popupImageParagraph, 'blip')
const popupRelationshipId = popupBlip.getAttribute('r:embed') || popupBlip.getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'embed')
const popupTarget = relationshipTargets.get(popupRelationshipId)
const popupEntry = popupTarget && zip.file(`word/${popupTarget}`)
const popupExpected = fs.readFileSync(path.resolve(screenshotsDir, '04-03-plan-edit-dialog.png'))
if (!popupEntry || !imagesMatch(popupEntry.asNodeBuffer(), popupExpected)) fail('10.3 编辑弹窗截图内容不正确')
console.log(`Verified screenshots: ${verifiedScreenshots}`)
console.log(`Verified action tables: ${verifiedActionTables}`)
console.log(`Verified dialog pages: ${verifiedDialogPages}`)
console.log('Forbidden wording: 0')
console.log('Internal routes/procedures: 0')
console.log('Section 9.3: complete')
console.log('Section 10.3: complete')
}
try {
main()
} catch (error) {
console.error(error.stack || error.message)
process.exitCode = 1
}