提交 91303665 authored 作者: 张伊明's avatar 张伊明

feat 新增pdf预览和文档编辑

上级 aef3ad69
流水线 #623 已通过 于阶段
in 1 分 53 秒
......@@ -13,6 +13,16 @@
"@element-plus/icons-vue": "^2.3.1",
"@kangc/v-md-editor": "^2.3.18",
"@microsoft/fetch-event-source": "^2.0.1",
"@tiptap/extension-bubble-menu": "^3.22.4",
"@tiptap/extension-color": "^3.22.4",
"@tiptap/extension-highlight": "^3.22.4",
"@tiptap/extension-table": "^3.22.4",
"@tiptap/extension-table-cell": "^3.22.4",
"@tiptap/extension-table-header": "^3.22.4",
"@tiptap/extension-table-row": "^3.22.4",
"@tiptap/extension-text-style": "^3.22.4",
"@tiptap/starter-kit": "^3.22.4",
"@tiptap/vue-3": "^3.22.4",
"@traptitech/markdown-it-katex": "^3.6.0",
"axios": "^1.12.2",
"d3": "^7.9.0",
......@@ -2099,6 +2109,518 @@
"win32"
]
},
"node_modules/@tiptap/core": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/core/-/core-3.22.4.tgz",
"integrity": "sha512-vGIGm/HpqLg8EAAQXQ+koV+/S828OEpzocfWcPOwo1u2QUVf9dQG47Yy6JJ8zFFaJwfv4dBcOXli+7BrJwsxDQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-blockquote/-/extension-blockquote-3.22.4.tgz",
"integrity": "sha512-7/61kNPbGFhMgM//zMknD0pSb69rGdRIkpulXOWS1JBrFHkH6hjZDfrOETNzgKkO+NlmzVl9rXSTv0xauS3lzA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-bold/-/extension-bold-3.22.4.tgz",
"integrity": "sha512-jIaPKfNOQu2lhpbLDvtwlQqM+mjF+Kk+auHpzYjBnsuwUli1Cl5ZOau7RH+rru/SQvZe1DtpQlANujDywugZAA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.22.4.tgz",
"integrity": "sha512-v4pux5Ql3THAEjaLMY4ldtdy/Xy2qU7PJLBkq8ugLp8qicaKC+tpqxp6sGif4vLIjz7Ap5hurRbTNbXzszyyHA==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.4.tgz",
"integrity": "sha512-TB+d3fGcTixYjO7coKqTr1mGTJuqr8hjDCPUFgzuvKyJnBhqWITmBzQ/8CLq4rr6mihgGURbD3N+xkQuPAKFiw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.22.4"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-code/-/extension-code-3.22.4.tgz",
"integrity": "sha512-cnbxmVhAcc7X3G81QUYEmKP0ve2hRmvAiFXBuuv9RUtQlBiRnzmhHoJOMgkX0CsMR7+8kMRpTfeDUYq2xp5s5w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-code-block/-/extension-code-block-3.22.4.tgz",
"integrity": "sha512-MEurzNXfMET3rhjpoPJYUgMfxTdTqbzT9+ToFrqNGAHocdXVm6m1hhO2frVC7fEtHPnxXKsn0Z3NUbCRkRTLuA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-color": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-color/-/extension-color-3.22.4.tgz",
"integrity": "sha512-1vDuVsrOETshe4j4nZhWalbKYcWfNybRCe30h829ExX06XwFryUYLb/LgTIaGCr9beWZUldsK+vOkBWdDTGMTw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-text-style": "3.22.4"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-document/-/extension-document-3.22.4.tgz",
"integrity": "sha512-XQKla1+703FqQJC48tPDVgt9ucGiFbIEmQdOg5L5o07z9a6/NzuaZAc+1zJ7NxcUZzy+z6wBn1PrVMTiqiSXlw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.4.tgz",
"integrity": "sha512-N9/yMDC35jJp0V/naL0+6gi4gUDUIcPpWEzFdCDWUSYBA8mt41c1kI1ZU7UTKYIBzTClenhYHRc2XKZxxx0+LQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.22.4"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-floating-menu/-/extension-floating-menu-3.22.4.tgz",
"integrity": "sha512-DFuyYxgaZPgxum5z1yvJPbfYCvDdO8geXsdyqt0qYYdiat3aGE4ncJhiLRIFDhSHBhaZg5eCgu/YPYAN6jZnrA==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.4.tgz",
"integrity": "sha512-UYBEUj3SFpKINIE7AdzcyeS3xICK+ee+YLBbuqNXyHStYChjJOohzJehqiqhjR16A88KQQ+ZjgyDcItKGygSog==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.22.4"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-hard-break/-/extension-hard-break-3.22.4.tgz",
"integrity": "sha512-xq+a4dE7T6VwApCkh/yU3p30gn3F8g8Arb9CyEZm58/WIJUIGvHSTjDdHmvU16+kiWSBg+wOOsaFHhYjJjxcKA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-heading/-/extension-heading-3.22.4.tgz",
"integrity": "sha512-TUaj5f0Ir5qy9HKKt2ocnwfXKpZDYeHgbbP9gshKFzdq5PLe1RbIgkjfy6bnoI865cYjmPYWRjcT7XsKyIcb9Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-highlight": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-highlight/-/extension-highlight-3.22.4.tgz",
"integrity": "sha512-QItbgkWqGZ04rOdYfjrn1TaUTv9XLKQE9VVJm9f6bxCpacPREJpz4lzkdea6zISHBZjEXJzlkjRWZP0zQLXXFA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.4.tgz",
"integrity": "sha512-cCI1HekGQwhY/MbgaKQ0R/7HcH5ZM1oFAyI/J72QGLC0XnF403S/OXoHMuBWr1mCu8hNiQWCzeNRJUty0iytNw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-italic/-/extension-italic-3.22.4.tgz",
"integrity": "sha512-fVSDx5AYXgDI3v2zZIqb7V8EewthwM2NJ/ZCX+XaxRsqNEpnjVhgHs7UlvDqK1wj2OJ6zmUNjPtVlAFRxwT+HQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-link/-/extension-link-3.22.4.tgz",
"integrity": "sha512-uoP3yus02uwGPVzW2QaEPJWVIrUb/r5nKm6c8DiJv9fNSX1+gykZZMg42c6GwRFLZ/vyfWjVCbAE03VMUqafgA==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-list/-/extension-list-3.22.4.tgz",
"integrity": "sha512-Xe8UFvvHmyp/c/TJsFwlwU9CWACYbBirNsluJ3U1+H8BTu1wqdrT/AXR5uIXeyCl5kiWKgX5q71eHWbYFOrqrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-list-item/-/extension-list-item-3.22.4.tgz",
"integrity": "sha512-H659KXTvggSypIDWSOJBZ37jh9pKjQriDDvYPYvOZCdfij0D0hsDXN/wXoypArneUkoBdgruHfTtMkFOaQlgkw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.22.4"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.4.tgz",
"integrity": "sha512-t/zhker4oIS78AIGYDdFFfZC6zSBlszfD7z/zqFLGCg5PHNNgkZK5hKj6Vyix6D2SapRn/ajnx+8mhbKIUH5eA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.22.4"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.4.tgz",
"integrity": "sha512-w77hPVf7pcHt97vfrybg/l0t5CimCd4y75OJKuHuo3CfgM5xbUP/gaPNMDyLLe7MYole/UHi/XvG3XjgzqTzAw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.22.4"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-paragraph/-/extension-paragraph-3.22.4.tgz",
"integrity": "sha512-de6dFkIhigiENESY6rNJ3yTVS/337ybfP30dNPudTwGe9oAu9ZCS+04j6QCvXSjhlI3ULiv7wiSHqrP26Gd+Hw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-strike/-/extension-strike-3.22.4.tgz",
"integrity": "sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-table": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-table/-/extension-table-3.22.4.tgz",
"integrity": "sha512-kjvLv3Z4JI+1tLDqZKa+bKU8VcxY+ZOyMCKWQA7wYmy8nKWkLJ60W+xy8AcXXpHB2goCIgSFLhsTyswx0GXH4w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-table-cell": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-table-cell/-/extension-table-cell-3.22.4.tgz",
"integrity": "sha512-uvFegCc1UQYK2nfIV2sIHg+hzLIMroJJm00XomzBgC1w/eSO7Ui8APiDh/baBcTPpCSU3SLiQLTgx7AU7oE3pg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.22.4"
}
},
"node_modules/@tiptap/extension-table-header": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-table-header/-/extension-table-header-3.22.4.tgz",
"integrity": "sha512-V4kLLWeRdc/I+IXiXZZhLAjsaHHiJWuLXTuOtZRDrCxQUiFLi4AgNg1DPQ09JAANkEWDhXq3x6BoUXaFwumbEw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.22.4"
}
},
"node_modules/@tiptap/extension-table-row": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-table-row/-/extension-table-row-3.22.4.tgz",
"integrity": "sha512-9tdS6jgS6DqUu5TpEmNrRoo/DL5Xam0PyrQaUEXUC+ssci+bMRCJ8PAWMcunNsI9NKf/Tb3wYrv6hGFChaT9uA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.22.4"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-text/-/extension-text-3.22.4.tgz",
"integrity": "sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-text-style/-/extension-text-style-3.22.4.tgz",
"integrity": "sha512-24DVBdySNKq3ovY+v9ERVxAyHStDa6ftUlyoHuZv0YXQ2amjUNOmqQtGEHBIULpCbBb1jZ+atHhv9MBZ0Ia9Pw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extension-underline/-/extension-underline-3.22.4.tgz",
"integrity": "sha512-08kGdbhIrA6h10GWXqOkqIveaBj5tmxclK208/nUIAlonI9hPd739vu7fmVtpnmqCnSSNpoRtU4u6Gj5at0ZpA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/extensions/-/extensions-3.22.4.tgz",
"integrity": "sha512-fOe8VptJvLPs32bNdUYo8SRyljwqKNQVXWW056VoXIc5en/59OdJlJQVeHI0jRRciH3MtrqODi/gfJR0VHNZ8A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/pm": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/pm/-/pm-3.22.4.tgz",
"integrity": "sha512-hj8Qka6WcHRllHUdeSjDnq2XaisUo4KsoGJc1WcFpoa1Yd+OeD861zUMnV7DFVGdZRy45Obht0CUYJpXQ4yA4w==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/starter-kit/-/starter-kit-3.22.4.tgz",
"integrity": "sha512-qWjw+vfdin1rzMRpRU4cC5tLTwMJtUpXeQukv+6mOqqvhptuwuZBjUHImVEJaSPoHXS7+1ut+nTnrLyWyEuE5Q==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.22.4",
"@tiptap/extension-blockquote": "^3.22.4",
"@tiptap/extension-bold": "^3.22.4",
"@tiptap/extension-bullet-list": "^3.22.4",
"@tiptap/extension-code": "^3.22.4",
"@tiptap/extension-code-block": "^3.22.4",
"@tiptap/extension-document": "^3.22.4",
"@tiptap/extension-dropcursor": "^3.22.4",
"@tiptap/extension-gapcursor": "^3.22.4",
"@tiptap/extension-hard-break": "^3.22.4",
"@tiptap/extension-heading": "^3.22.4",
"@tiptap/extension-horizontal-rule": "^3.22.4",
"@tiptap/extension-italic": "^3.22.4",
"@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-list": "^3.22.4",
"@tiptap/extension-list-item": "^3.22.4",
"@tiptap/extension-list-keymap": "^3.22.4",
"@tiptap/extension-ordered-list": "^3.22.4",
"@tiptap/extension-paragraph": "^3.22.4",
"@tiptap/extension-strike": "^3.22.4",
"@tiptap/extension-text": "^3.22.4",
"@tiptap/extension-underline": "^3.22.4",
"@tiptap/extensions": "^3.22.4",
"@tiptap/pm": "^3.22.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/vue-3": {
"version": "3.22.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/@tiptap/vue-3/-/vue-3-3.22.4.tgz",
"integrity": "sha512-fcqUWt6LlA5PbcFaDXyV1apWwAs8j80m0kWwoL5+DgKdkGxsB5LgDZU1pTWle0zvR5zmGvJ7LmB6EGAYIBjdmQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.22.4",
"@tiptap/extension-floating-menu": "^3.22.4"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4",
"vue": "^3.0.0"
}
},
"node_modules/@traptitech/markdown-it-katex": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/@traptitech/markdown-it-katex/-/markdown-it-katex-3.6.0.tgz",
......@@ -5313,6 +5835,12 @@
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.2",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/linkifyjs/-/linkifyjs-4.3.2.tgz",
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
"node_modules/local-pkg": {
"version": "0.5.1",
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz",
......@@ -6377,6 +6905,12 @@
"wrappy": "1"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/pascalcase": {
"version": "0.1.1",
"resolved": "https://registry.npmmirror.com/pascalcase/-/pascalcase-0.1.1.tgz",
......@@ -6719,6 +7253,135 @@
"@probe.gl/stats": "3.6.0"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.8",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
......@@ -7003,6 +7666,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz",
......@@ -8224,6 +8893,12 @@
"vue": "^3.2.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://mirrors.huaweicloud.com/repository/npm/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/web-worker": {
"version": "1.5.0",
"resolved": "https://registry.npmmirror.com/web-worker/-/web-worker-1.5.0.tgz",
......
......@@ -22,6 +22,16 @@
"@element-plus/icons-vue": "^2.3.1",
"@kangc/v-md-editor": "^2.3.18",
"@microsoft/fetch-event-source": "^2.0.1",
"@tiptap/extension-bubble-menu": "^3.22.4",
"@tiptap/extension-color": "^3.22.4",
"@tiptap/extension-highlight": "^3.22.4",
"@tiptap/extension-table": "^3.22.4",
"@tiptap/extension-table-cell": "^3.22.4",
"@tiptap/extension-table-header": "^3.22.4",
"@tiptap/extension-table-row": "^3.22.4",
"@tiptap/extension-text-style": "^3.22.4",
"@tiptap/starter-kit": "^3.22.4",
"@tiptap/vue-3": "^3.22.4",
"@traptitech/markdown-it-katex": "^3.6.0",
"axios": "^1.12.2",
"d3": "^7.9.0",
......
<template>
<aside class="article-nav-tree">
<div class="tree-title">导航</div>
<div class="tree-scroll">
<el-tree
ref="treeRef"
:data="treeData"
node-key="id"
:props="treeProps"
:current-node-key="currentNodeKey"
highlight-current
default-expand-all
@node-click="onNodeClick"
/>
</div>
</aside>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
treeData: {
type: Array,
default: () => []
},
currentNodeKey: {
type: String,
default: ''
}
})
const emit = defineEmits(['node-click'])
const treeRef = ref(null)
const treeProps = { label: 'title', children: 'children' }
function onNodeClick(nodeData) {
emit('node-click', nodeData)
}
</script>
<style scoped lang="scss">
.article-nav-tree {
width: 280px;
flex-shrink: 0;
padding: 16px 19px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
border: 1px solid #e8ecf2;
border-radius: 8px;
background: #fff;
}
.tree-title {
font-family: 'Source Han Sans CN', sans-serif;
font-weight: 700;
font-size: 16px;
line-height: 24px;
color: var(--text-primary-80-color, #344054);
margin-bottom: 17px;
}
:deep(.article-nav-tree .el-tree) {
min-width: 0;
}
.tree-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
:deep(.article-nav-tree .el-tree-node__label) {
font-family: 'Source Han Sans CN', sans-serif;
font-size: 14px;
line-height: 22px;
color: var(--text-primary-80-color, #344054);
}
</style>
<template>
<div class="page-block-content">
<div class="page-block-content__head">{{ page.page_idx + 1 }}</div>
<article
v-for="node in sortedNodes"
:key="node.node_id"
class="page-row"
:class="{
'page-row--page-number': isPageNumber(node),
'page-row--selected': selectedNodeKey === node.node_id
}"
:data-node-id="node.node_id"
:data-semantic-type="node.semantic_type"
:data-node-page-idx="page.page_idx"
@click="onNodeClick(node)"
>
<div class="page-row__en" :class="{ 'page-row__en--page-number': isPageNumber(node) }">
<component :is="resolveComponent(node)" :node="node" :placeholder-src="placeholderSrc" />
</div>
<div class="page-row__zh" :class="{ 'page-row__zh--page-number': isPageNumber(node) }">
<p class="page-row__zh-text" :class="{ 'page-row__zh-text--page-number': isPageNumber(node) }">{{ getZhPreview(node) }}</p>
</div>
</article>
</div>
</template>
<script setup>
import { computed } from 'vue'
import HeadingNode from './nodes/HeadingNode.vue'
import TextNode from './nodes/TextNode.vue'
import ImageNode from './nodes/ImageNode.vue'
import TableNode from './nodes/TableNode.vue'
import RichHtmlNode from './nodes/RichHtmlNode.vue'
const props = defineProps({
page: { type: Object, default: () => ({}) },
nodes: { type: Array, default: () => [] },
placeholderSrc: { type: String, default: '' },
selectedNodeKey: { type: String, default: '' }
})
const emit = defineEmits(['node-select'])
const sortedNodes = computed(() => [...props.nodes].sort((left, right) => Number(left?.layout?.reading_order || 0) - Number(right?.layout?.reading_order || 0)))
function resolveComponent(node) {
const semanticType = String(node?.semantic_type || '')
const payload = node?.content_payload || {}
if (semanticType === 'heading' || payload.heading_level != null) return HeadingNode
if (semanticType === 'table' || payload.type === 'table') return TableNode
if (semanticType === 'image' || payload.type === 'image') return ImageNode
if (payload.type === 'html' && payload.html) return RichHtmlNode
return TextNode
}
function getZhPreview(node) {
const text = String(node?.content_payload?.text || '').trim()
if (text) return `待翻译:${text}`
if (node?.semantic_type === 'image') return '待翻译:图片说明'
if (node?.semantic_type === 'table') return '待翻译:表格内容'
return '待翻译'
}
function isPageNumber(node) {
return String(node?.semantic_type || '') === 'page_number'
}
function onNodeClick(node) {
if (!node?.node_id) return
emit('node-select', node)
}
</script>
<style scoped lang="scss">
.page-block-content {
border: 1px solid #e4e7ec;
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.page-block-content__head {
padding: 8px 12px;
font-size: 13px;
color: #667085;
border-bottom: 1px solid #eaecf0;
}
.page-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #f2f4f7;
cursor: pointer;
transition: background-color 0.16s ease, box-shadow 0.16s ease;
}
.page-row:last-child { border-bottom: none; }
.page-row:hover { background: #f8fafc; }
.page-row--selected { background: #eff6ff; box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.12); }
.page-row--selected .page-row__zh-text { color: #1d4ed8; font-weight: 600; }
.page-row--selected .page-row__en { border-right-color: rgba(37, 99, 235, 0.18); }
.page-row--page-number { cursor: default; }
.page-row--page-number:hover { background: inherit; }
.page-row__en { padding-right: 12px; border-right: 1px solid #eaecf0; }
.page-row__zh-text { margin: 0; white-space: pre-wrap; word-break: break-word; font-size: 14px; line-height: 1.8; color: #475467; }
.page-row__en--page-number, .page-row__zh--page-number { text-align: center; }
.page-row__zh-text--page-number { text-align: center; }
@media (max-width: 1360px) {
.page-row { grid-template-columns: 1fr; }
.page-row__en { padding-right: 0; border-right: none; border-bottom: 1px solid #eaecf0; padding-bottom: 10px; margin-bottom: 2px; }
}
</style>
<template>
<div ref="scrollRef" class="sync-compare-scroll" @scroll="onScroll">
<section
v-for="page in pages"
:key="page.page_idx"
:id="`page-${page.page_idx}`"
:ref="(element) => setPageRef(page.page_idx, element)"
class="page-container"
:class="{ 'page-container--active': currentPageIdx === page.page_idx }"
>
<PageBlock
v-if="pageDataMap[page.page_idx]"
:page="page"
:nodes="pageDataMap[page.page_idx].nodes"
:placeholder-src="placeholderSrc"
:selected-node-key="selectedNodeKey"
@node-select="onSelectNode"
/>
<PageShell v-else :page="page" />
</section>
</div>
</template>
<script setup>
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import PageBlock from './PageBlock.vue'
import PageShell from './PageShell.vue'
const props = defineProps({
pages: { type: Array, default: () => [] },
pageDataMap: { type: Object, default: () => ({}) },
currentPageIdx: { type: Number, default: 0 },
currentNodeKey: { type: String, default: '' },
selectedNodeKey: { type: String, default: '' },
placeholderSrc: { type: String, default: '' }
})
const emit = defineEmits(['page-visible', 'active-page-change', 'active-node-change', 'node-select'])
const scrollRef = ref(null)
const pageRefMap = new Map()
let observer = null
let rafId = null
let lastActiveNodeId = ''
let jumpTimerIds = []
function setPageRef(pageIdx, element) {
if (element) {
pageRefMap.set(pageIdx, element)
if (observer) observer.observe(element)
return
}
const oldElement = pageRefMap.get(pageIdx)
if (oldElement && observer) observer.unobserve(oldElement)
pageRefMap.delete(pageIdx)
}
function getVisibleHeadings() {
const scroller = scrollRef.value
if (!scroller) return []
const scrollerRect = scroller.getBoundingClientRect()
return Array.from(scroller.querySelectorAll('[data-semantic-type="heading"]'))
.map((element) => ({
element,
rect: element.getBoundingClientRect()
}))
.filter(({ rect }) => rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom)
}
function getActiveHeading() {
const visibleHeadings = getVisibleHeadings()
if (!visibleHeadings.length) return null
// 全局视窗内最靠上的标题优先;当它越过顶部后,自然切换到下一个可见标题。
const topVisible = visibleHeadings.reduce((candidate, current) => {
if (!candidate) return current
if (current.rect.top < candidate.rect.top) return current
return candidate
}, null)
return topVisible?.element || null
}
function emitActiveNode() {
const activeHeadingEl = getActiveHeading()
const nodeId = activeHeadingEl?.getAttribute('data-node-id') || ''
if (!nodeId || nodeId === lastActiveNodeId) return
lastActiveNodeId = nodeId
emit('active-node-change', nodeId)
}
function onSelectNode(node) {
emit('node-select', node)
}
function onScroll() {
if (rafId !== null) return
rafId = window.requestAnimationFrame(() => {
rafId = null
emitActiveNode()
})
}
function scrollToNode(nodeId, options = {}) {
const { behavior = 'auto' } = options
const scroller = scrollRef.value
if (!scroller || !nodeId) return false
const targetElement = scroller.querySelector(`[data-node-id="${CSS.escape(String(nodeId))}"]`)
if (!targetElement) return false
const targetTop = Math.max(0, targetElement.offsetTop - 12)
scroller.scrollTo({ top: targetTop, behavior })
jumpTimerIds.forEach((timerId) => window.clearTimeout(timerId))
jumpTimerIds = [
window.setTimeout(() => {
scroller.scrollTo({ top: Math.max(0, targetElement.offsetTop - 12), behavior: 'auto' })
onScroll()
}, 120),
window.setTimeout(() => {
scroller.scrollTo({ top: Math.max(0, targetElement.offsetTop - 12), behavior: 'auto' })
onScroll()
}, 280)
]
return true
}
function scrollToPage(pageIdx, options = {}) {
const { behavior = 'auto' } = options
const scroller = scrollRef.value
const targetElement = pageRefMap.get(pageIdx)
if (!targetElement || !scroller) return
const targetTop = Math.max(0, targetElement.offsetTop - 16)
scroller.scrollTo({ top: targetTop, behavior })
jumpTimerIds.forEach((timerId) => window.clearTimeout(timerId))
jumpTimerIds = [
window.setTimeout(() => {
scroller.scrollTo({ top: Math.max(0, targetElement.offsetTop - 16), behavior: 'auto' })
onScroll()
}, 120),
window.setTimeout(() => {
scroller.scrollTo({ top: Math.max(0, targetElement.offsetTop - 16), behavior: 'auto' })
onScroll()
}, 280)
]
}
defineExpose({ scrollToPage, scrollToNode })
onMounted(async () => {
await nextTick()
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return
const pageId = String(entry.target.id || '').replace('page-', '')
const pageIdx = Number(pageId)
if (!Number.isNaN(pageIdx)) emit('page-visible', pageIdx)
})
},
{ root: scrollRef.value, rootMargin: '600px 0px 600px 0px', threshold: 0.01 }
)
pageRefMap.forEach((element) => observer.observe(element))
onScroll()
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
observer = null
}
if (rafId !== null) {
window.cancelAnimationFrame(rafId)
rafId = null
}
jumpTimerIds.forEach((timerId) => window.clearTimeout(timerId))
jumpTimerIds = []
})
</script>
<style scoped lang="scss">
.sync-compare-scroll {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
border: 1px solid #e8ecf2;
border-radius: 8px;
background: #fff;
padding: 16px;
box-sizing: border-box;
}
.page-container { padding: 0 0 18px; }
.page-container--active { background: #f8fafc; }
</style>
<template>
<div class="page-shell">
<div class="page-shell__header">{{ page.page_idx + 1 }}</div>
<div class="page-shell__body">
<span class="page-shell__text">页面内容按需加载中</span>
</div>
</div>
</template>
<script setup>
defineProps({
page: {
type: Object,
default: () => ({})
}
})
</script>
<style scoped lang="scss">
.page-shell {
border: 1px solid #e4e7ec;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.page-shell__header {
padding: 8px 12px;
font-size: 13px;
color: #667085;
border-bottom: 1px solid #eaecf0;
}
.page-shell__body {
height: 420px;
display: flex;
align-items: center;
justify-content: center;
color: #98a2b3;
background: linear-gradient(180deg, #f8fafc 0%, #f2f4f7 100%);
}
.page-shell__text {
font-size: 14px;
}
</style>
<template>
<div class="pdf-preview">
<div v-if="isReady && pageUrls.length > 0" class="pdf-preview__toolbar">
<div class="pdf-preview__toolbar-group">
<button class="pdf-preview__toolbar-btn" type="button" :disabled="zoomLevel <= minZoom" @click="decreaseZoom">缩小</button>
<button class="pdf-preview__toolbar-btn" type="button" :disabled="zoomLevel >= maxZoom" @click="increaseZoom">放大</button>
<span class="pdf-preview__toolbar-zoom">{{ Math.round(zoomLevel * 100) }}%</span>
</div>
<div class="pdf-preview__toolbar-group pdf-preview__toolbar-group--jump">
<label class="pdf-preview__toolbar-label" for="pdf-page-jump-input">跳转到</label>
<input id="pdf-page-jump-input" v-model="pageJumpValue" class="pdf-preview__toolbar-input" type="number" :min="1" :max="pageUrls.length" @keydown.enter.prevent="confirmPageJump" />
<button class="pdf-preview__toolbar-btn" type="button" @click="confirmPageJump">跳转</button>
</div>
<div class="pdf-preview__toolbar-group">
<span class="pdf-preview__toolbar-page">当前页:{{ activePageNumber }} / {{ pageUrls.length }}</span>
</div>
</div>
<template v-if="isReady && pageUrls.length > 0">
<div ref="scrollContainerRef" class="pdf-preview__scroll">
<section v-for="page in pageUrls" :key="page.pageNumber" :data-page-number="page.pageNumber" class="pdf-preview__page-card" :class="{ 'is-active': page.pageNumber === activePageNumber }">
<div class="pdf-preview__page-title">{{ page.pageNumber }}</div>
<canvas :ref="(el) => setCanvasRef(el, page.pageNumber)" class="pdf-preview__canvas" />
</section>
</div>
</template>
<template v-else>
<div class="pdf-preview__fallback">
<img class="pdf-preview__fallback-image" :src="fallbackSrc" :alt="titleText" />
<div class="pdf-preview__fallback-title">{{ titleText }}</div>
<div class="pdf-preview__fallback-text">{{ fallbackMessage }}</div>
<div v-if="debugInfo" class="pdf-preview__fallback-debug">{{ debugInfo }}</div>
</div>
</template>
</div>
</template>
<script setup>
import { computed, markRaw, nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { GlobalWorkerOptions, getDocument } from 'pdfjs-dist'
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url'
GlobalWorkerOptions.workerSrc = pdfWorkerUrl
const props = defineProps({
pdfUrl: { type: String, default: '' },
fileName: { type: String, default: '' },
fallbackSrc: { type: String, default: '' },
activePage: { type: Number, default: 1 }
})
const emit = defineEmits(['page-change'])
const objectUrl = ref('')
const scrollContainerRef = ref(null)
const pdfDocumentRef = shallowRef(null)
const canvasRefs = shallowRef(new Map())
const pageUrls = ref([])
const renderedPages = shallowRef(new Set())
const renderQueue = shallowRef(new Set())
const observerRef = shallowRef(null)
const activePageNumber = ref(1)
const pageJumpValue = ref(1)
const zoomLevel = ref(1.0)
const minZoom = 0.6
const maxZoom = 2.2
const zoomStep = 0.2
const baseScale = ref(1.0)
const titleText = computed(() => props.fileName || 'PDF 原文预览')
const isReady = computed(() => Boolean(props.pdfUrl))
const resolvedUrl = computed(() => objectUrl.value || props.pdfUrl)
const loadError = ref('')
const fallbackMessage = computed(() => loadError.value || '当前未获取到可直接预览的 PDF 地址,后续可接入 pdf.js 或后端直链。')
const debugInfo = computed(() => {
if (!props.pdfUrl) return 'pdfUrl 为空'
if (loadError.value) return `加载失败:${loadError.value}`
return `pdfUrl=${props.pdfUrl}`
})
function setCanvasRef(el, pageNumber) {
if (!el) return
canvasRefs.value.set(pageNumber, el)
}
function getPageScale(page) {
const viewport = page.getViewport({ scale: 1 })
const containerWidth = scrollContainerRef.value?.clientWidth || viewport.width || 1
const preferredScale = (containerWidth - 48) / viewport.width
const clampedScale = Math.min(maxZoom, Math.max(minZoom, preferredScale || 1))
return Number((clampedScale * baseScale.value).toFixed(2))
}
async function renderPage(pageNumber) {
const pdf = pdfDocumentRef.value
const canvas = canvasRefs.value.get(pageNumber)
if (!pdf || !canvas || renderedPages.value.has(pageNumber) || renderQueue.value.has(pageNumber)) return
renderQueue.value.add(pageNumber)
try {
const page = await pdf.getPage(pageNumber)
const context = canvas.getContext('2d')
if (!context) return
const scale = getPageScale(page)
const viewport = page.getViewport({ scale })
const outputScale = window.devicePixelRatio || 1
canvas.width = Math.floor(viewport.width * outputScale)
canvas.height = Math.floor(viewport.height * outputScale)
canvas.style.width = `${Math.floor(viewport.width)}px`
canvas.style.height = `${Math.floor(viewport.height)}px`
context.setTransform(outputScale, 0, 0, outputScale, 0, 0)
context.clearRect(0, 0, canvas.width, canvas.height)
await page.render({ canvasContext: context, viewport }).promise
renderedPages.value.add(pageNumber)
} catch (error) {
loadError.value = error?.message || String(error)
throw error
} finally {
renderQueue.value.delete(pageNumber)
}
}
function observePages() {
if (observerRef.value) {
observerRef.value.disconnect()
observerRef.value = null
}
if (typeof IntersectionObserver === 'undefined' || !scrollContainerRef.value) return
observerRef.value = new IntersectionObserver(
(entries) => {
let visibleCandidate = activePageNumber.value
entries.forEach((entry) => {
if (!entry.isIntersecting) return
const pageNumber = Number(entry.target?.dataset?.pageNumber || 0)
if (pageNumber > 0) {
visibleCandidate = pageNumber
renderPage(pageNumber).catch(() => {})
}
})
if (visibleCandidate !== activePageNumber.value) {
activePageNumber.value = visibleCandidate
emit('page-change', visibleCandidate)
}
},
{
root: scrollContainerRef.value,
rootMargin: '240px 0px',
threshold: 0.08
}
)
scrollContainerRef.value.querySelectorAll('[data-page-number]').forEach((el) => {
observerRef.value?.observe(el)
})
}
async function renderPdf(url) {
if (!url) {
loadError.value = 'PDF 地址为空'
return
}
loadError.value = ''
const loadingTask = getDocument({ url, useWorkerFetch: false, isEvalSupported: false })
const pdf = await loadingTask.promise
pdfDocumentRef.value = pdf
pageUrls.value = Array.from({ length: pdf.numPages }, (_, index) => ({ pageNumber: index + 1 }))
renderedPages.value = new Set()
renderQueue.value = new Set()
zoomLevel.value = Math.min(Math.max(zoomLevel.value, minZoom), maxZoom)
activePageNumber.value = Math.min(Math.max(Number(props.activePage || 1), 1), pdf.numPages)
baseScale.value = zoomLevel.value
pageJumpValue.value = activePageNumber.value
emit('page-change', activePageNumber.value)
await nextTick()
observePages()
try {
await renderPage(activePageNumber.value)
if (activePageNumber.value > 1) {
await renderPage(activePageNumber.value - 1)
}
if (activePageNumber.value < pdf.numPages) {
await renderPage(activePageNumber.value + 1)
}
} catch (error) {
loadError.value = error?.message || String(error)
}
}
async function resetAndRender(nextUrl) {
pageUrls.value = []
canvasRefs.value = new Map()
renderedPages.value = new Set()
renderQueue.value = new Set()
if (observerRef.value) {
observerRef.value.disconnect()
observerRef.value = null
}
if (pdfDocumentRef.value) {
try {
await pdfDocumentRef.value.destroy()
} catch (error) {
// ignore destroy failures
}
pdfDocumentRef.value = null
}
activePageNumber.value = 1
if (!nextUrl) return
await renderPdf(nextUrl)
}
watch(
() => props.pdfUrl,
async (nextUrl, prevUrl) => {
if (prevUrl && prevUrl.startsWith('blob:') && prevUrl !== nextUrl && objectUrl.value === prevUrl) {
URL.revokeObjectURL(prevUrl)
objectUrl.value = ''
}
if (nextUrl && nextUrl.startsWith('blob:')) {
objectUrl.value = nextUrl
} else {
objectUrl.value = ''
}
await resetAndRender(objectUrl.value || nextUrl)
},
{ immediate: true }
)
watch(
() => props.activePage,
async (nextPage) => {
const targetPage = Number(nextPage || 1)
if (!targetPage || targetPage === activePageNumber.value) return
activePageNumber.value = targetPage
pageJumpValue.value = targetPage
emit('page-change', targetPage)
await renderPage(targetPage)
}
)
function confirmPageJump() {
const targetPage = Math.min(Math.max(Number(pageJumpValue.value || 1), 1), pageUrls.value.length || 1)
pageJumpValue.value = targetPage
activePageNumber.value = targetPage
emit('page-change', targetPage)
const targetElement = scrollContainerRef.value?.querySelector(`[data-page-number="${targetPage}"]`)
if (targetElement?.scrollIntoView) {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
onBeforeUnmount(() => {
if (observerRef.value) {
observerRef.value.disconnect()
observerRef.value = null
}
if (objectUrl.value && objectUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(objectUrl.value)
}
if (pdfDocumentRef.value) {
pdfDocumentRef.value.destroy().catch(() => {})
}
})
function increaseZoom() {
zoomLevel.value = Math.min(maxZoom, Number((zoomLevel.value + zoomStep).toFixed(1)))
baseScale.value = zoomLevel.value
if (resolvedUrl.value) {
resetAndRender(resolvedUrl.value)
}
}
function decreaseZoom() {
zoomLevel.value = Math.max(minZoom, Number((zoomLevel.value - zoomStep).toFixed(1)))
baseScale.value = zoomLevel.value
if (resolvedUrl.value) {
resetAndRender(resolvedUrl.value)
}
}
</script>
<style scoped lang="scss">
.pdf-preview {
flex: 1;
min-height: 0;
background: #f8fafc;
}
.pdf-preview__toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #e8ecf2;
background: #fff;
}
.pdf-preview__toolbar-group {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pdf-preview__toolbar-group--jump {
flex: 1;
justify-content: center;
}
.pdf-preview__toolbar-btn {
height: 32px;
padding: 0 12px;
border: 1px solid #d0d5dd;
border-radius: 8px;
background: #fff;
cursor: pointer;
}
.pdf-preview__toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pdf-preview__toolbar-zoom,
.pdf-preview__toolbar-page,
.pdf-preview__toolbar-label {
font-size: 13px;
color: #344054;
}
.pdf-preview__toolbar-input {
width: 84px;
height: 32px;
padding: 0 10px;
box-sizing: border-box;
border: 1px solid #d0d5dd;
border-radius: 8px;
outline: none;
}
.pdf-preview__scroll {
height: 100%;
overflow: auto;
padding: 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 16px;
}
.pdf-preview__page-card {
background: #fff;
border: 1px solid #e8ecf2;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
}
.pdf-preview__page-card.is-active {
border-color: #2563eb;
box-shadow: 0 8px 24px rgba(37, 99, 235, 0.12);
}
.pdf-preview__page-title {
padding: 10px 14px;
font-size: 13px;
color: #667085;
border-bottom: 1px solid #eef2f7;
}
.pdf-preview__canvas {
display: block;
width: 100%;
height: auto;
}
.pdf-preview__fallback {
height: 100%;
min-height: 520px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
text-align: center;
gap: 12px;
}
.pdf-preview__fallback-image {
max-width: min(100%, 480px);
max-height: 360px;
object-fit: contain;
display: block;
}
.pdf-preview__fallback-title {
font-size: 16px;
line-height: 24px;
font-weight: 700;
color: #101828;
}
.pdf-preview__fallback-text {
max-width: 520px;
font-size: 14px;
line-height: 22px;
color: #667085;
}
.pdf-preview__fallback-debug {
max-width: 520px;
padding: 8px 10px;
border-radius: 8px;
background: #fef3f2;
color: #b42318;
font-size: 12px;
line-height: 18px;
word-break: break-all;
text-align: left;
}
</style>
<template>
<div class="translation-editor">
<div class="translation-editor__toolbar">
<div class="translation-editor__toolbar-group">
<button type="button" class="translation-editor__btn" :class="{ 'is-active': editor?.isActive('heading', { level: 1 }) }" @click="setHeading(1)">H1</button>
<button type="button" class="translation-editor__btn" :class="{ 'is-active': editor?.isActive('heading', { level: 2 }) }" @click="setHeading(2)">H2</button>
<button type="button" class="translation-editor__btn" :class="{ 'is-active': editor?.isActive('heading', { level: 3 }) }" @click="setHeading(3)">H3</button>
<button type="button" class="translation-editor__btn" :class="{ 'is-active': editor?.isActive('bulletList') }" @click="toggleBulletList">列表</button>
<button type="button" class="translation-editor__btn" @click="insertParagraphAfter">新增段落</button>
<button type="button" class="translation-editor__btn" @click="deleteCurrentBlock">删除段落</button>
<button type="button" class="translation-editor__btn" @click="insertTable">表格</button>
</div>
<div class="translation-editor__toolbar-group">
<span class="translation-editor__hint">块数 {{ blockCount }}</span>
<span class="translation-editor__hint">只读块 {{ readonlyBlockCount }}</span>
</div>
</div>
<div class="translation-editor__content">
<div class="translation-editor__page-hint">当前联动页:第 {{ activePage }}</div>
<div v-for="block in readonlyBlocks" :key="block.id" class="translation-editor__readonly-block">
<div class="translation-editor__readonly-label">只读块 · {{ block.semanticType }}</div>
<div class="translation-editor__readonly-text">{{ block.displayText || block.text || '未识别内容' }}</div>
</div>
<EditorContent v-if="editor" :editor="editor" />
</div>
</div>
</template>
<script setup>
import { onBeforeUnmount, ref, watch } from 'vue'
import { EditorContent, useEditor } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { TextStyle } from '@tiptap/extension-text-style'
import { Highlight } from '@tiptap/extension-highlight'
import { Table } from '@tiptap/extension-table'
import { TableRow } from '@tiptap/extension-table-row'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableCell } from '@tiptap/extension-table-cell'
const props = defineProps({
doc: {
type: Object,
default: () => ({ type: 'doc', content: [] })
},
blockCount: {
type: Number,
default: 0
},
readonlyBlockCount: {
type: Number,
default: 0
},
readonlyBlocks: {
type: Array,
default: () => []
},
activePage: {
type: Number,
default: 1
}
})
const emit = defineEmits(['update:doc'])
const updateTimer = ref(null)
function normalizeDoc(doc) {
const safeContent = Array.isArray(doc?.content)
? doc.content.filter((node) => node && typeof node === 'object' && typeof node.type === 'string')
: []
return {
type: 'doc',
content: safeContent
}
}
const editor = useEditor({
content: normalizeDoc(props.doc),
editable: true,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] }
}),
TextStyle,
Highlight,
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell
],
onUpdate({ editor }) {
if (updateTimer.value) {
clearTimeout(updateTimer.value)
}
updateTimer.value = window.setTimeout(() => {
emit('update:doc', normalizeDoc(editor.getJSON()))
}, 120)
}
})
watch(
() => props.doc,
(nextDoc) => {
if (!editor.value) return
editor.value.commands.setContent(normalizeDoc(nextDoc), false)
},
{ deep: true }
)
function setHeading(level) {
if (!editor.value) return
editor.value.chain().focus().toggleHeading({ level }).run()
}
function toggleBulletList() {
if (!editor.value) return
editor.value.chain().focus().toggleBulletList().run()
}
function insertParagraphAfter() {
if (!editor.value) return
editor.value.chain().focus().insertContent({ type: 'paragraph' }).run()
}
function deleteCurrentBlock() {
if (!editor.value) return
if (!editor.value.state.selection.empty) {
editor.value.chain().focus().deleteSelection().run()
return
}
editor.value.chain().focus().deleteCurrentNode().run()
}
function insertTable() {
if (!editor.value) return
editor.value.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run()
}
onBeforeUnmount(() => {
if (updateTimer.value) {
clearTimeout(updateTimer.value)
updateTimer.value = null
}
editor.value?.destroy()
})
</script>
<style scoped lang="scss">
.translation-editor {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.translation-editor__toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #e8ecf2;
background: #fff;
}
.translation-editor__toolbar-group {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.translation-editor__btn {
height: 32px;
padding: 0 12px;
border: 1px solid #d0d5dd;
border-radius: 8px;
background: #fff;
cursor: pointer;
}
.translation-editor__btn.is-active {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
.translation-editor__hint {
font-size: 13px;
color: #667085;
}
.translation-editor__content {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
box-sizing: border-box;
background: #fcfcfd;
}
.translation-editor__page-hint {
margin-bottom: 12px;
padding: 8px 12px;
border-radius: 8px;
background: #eff6ff;
color: #1d4ed8;
font-size: 13px;
}
.translation-editor__readonly-block {
margin-bottom: 12px;
padding: 12px 14px;
border: 1px dashed #d0d5dd;
border-radius: 10px;
background: #f8fafc;
}
.translation-editor__readonly-label {
font-size: 12px;
line-height: 18px;
color: #667085;
margin-bottom: 6px;
}
.translation-editor__readonly-text {
font-size: 14px;
line-height: 22px;
color: #101828;
white-space: pre-wrap;
word-break: break-word;
}
</style>
<template>
<div
class="heading-node"
:class="`heading-node--h${safeLevel}`"
:data-node-id="node?.node_id || ''"
data-semantic-type="heading"
:data-heading-level="safeLevel"
>
{{ text }}
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
node: {
type: Object,
default: () => ({})
}
})
const text = computed(() => String(props.node?.content_payload?.text || '').trim() || '未命名标题')
const safeLevel = computed(() => {
const level = Number(props.node?.content_payload?.heading_level || 1)
if (level < 1) return 1
if (level > 6) return 6
return level
})
</script>
<style scoped lang="scss">
.heading-node {
color: #101828;
line-height: 1.7;
word-break: break-word;
}
.heading-node--h1 { font-size: 22px; font-weight: 700; }
.heading-node--h2 { font-size: 20px; font-weight: 700; }
.heading-node--h3 { font-size: 18px; font-weight: 700; }
.heading-node--h4 { font-size: 16px; font-weight: 600; }
.heading-node--h5 { font-size: 15px; font-weight: 600; }
.heading-node--h6 { font-size: 14px; font-weight: 600; }
</style>
<template>
<figure class="image-node">
<img class="image-node__img" :src="resolvedSrc" :alt="altText" loading="lazy" @error="onError" />
<figcaption v-if="captionText" class="image-node__caption">{{ captionText }}</figcaption>
</figure>
</template>
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
node: {
type: Object,
default: () => ({})
},
placeholderSrc: {
type: String,
default: ''
}
})
const hasError = ref(false)
const rawSrc = computed(() => {
const payload = props.node?.content_payload || {}
return String(payload.img_path || payload.preview_img_path || '').trim()
})
const altText = computed(() => String(props.node?.content_payload?.alt_text || '图片'))
const captionText = computed(() => String(props.node?.content_payload?.caption || '').trim())
const resolvedSrc = computed(() => {
if (hasError.value) return props.placeholderSrc
if (!rawSrc.value) return props.placeholderSrc
if (/^(https?:)?\/\//i.test(rawSrc.value) || /^(data|blob):/i.test(rawSrc.value)) return rawSrc.value
const normalizedPath = rawSrc.value.replaceAll('\\', '/')
const baseUrl = String(import.meta.env.VITE_INTELLIGENT_TRANSLATION_ASSET_BASE_URL || import.meta.env.VITE_BASE_FILE_URL || '').trim()
if (!baseUrl) return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`
return `${baseUrl.replace(/\/+$/, '')}/${normalizedPath.replace(/^\/+/, '')}`
})
function onError() {
hasError.value = true
}
</script>
<style scoped lang="scss">
.image-node {
margin: 0;
}
.image-node__img {
width: 100%;
min-height: 120px;
object-fit: contain;
border-radius: 6px;
border: 1px solid #e4e7ec;
background: #f8fafc;
}
.image-node__caption {
margin-top: 8px;
font-size: 12px;
color: #667085;
}
</style>
<template>
<div class="rich-html-node" v-html="html"></div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
node: {
type: Object,
default: () => ({})
}
})
const html = computed(() => String(props.node?.content_payload?.html || ''))
</script>
<style scoped lang="scss">
.rich-html-node {
font-size: 14px;
color: #344054;
line-height: 1.8;
word-break: break-word;
}
</style>
<template>
<div class="table-node">
<div class="table-node__content" v-html="safeHtml"></div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
node: {
type: Object,
default: () => ({})
}
})
const safeHtml = computed(() => String(props.node?.content_payload?.table_body_html || ''))
</script>
<style scoped lang="scss">
.table-node {
border: 1px solid #eaecf0;
border-radius: 6px;
overflow: hidden;
}
.table-node__content {
padding: 8px;
}
.table-node__content :deep(table) {
width: 100%;
border-collapse: collapse;
}
.table-node__content :deep(td),
.table-node__content :deep(th) {
border: 1px solid #d0d5dd;
padding: 6px;
vertical-align: top;
}
</style>
<template>
<p class="text-node">{{ text }}</p>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
node: {
type: Object,
default: () => ({})
}
})
const text = computed(() => String(props.node?.content_payload?.text || '').trim() || ' ')
</script>
<style scoped lang="scss">
.text-node {
margin: 0;
font-size: 14px;
color: #344054;
line-height: 1.85;
white-space: pre-wrap;
word-break: break-word;
}
</style>
<template>
<div class="document-compare-page" @click="onPageClick">
<div class="document-compare-page">
<intelligence-left-tab-bar />
<div class="compare-main">
<header class="page-header">
<div class="page-header-left">
<img class="header-file-icon" src="./icons/file-type-icon.png" alt="" />
<span class="page-header-file-name">{{ displayFileName }}</span>
</div>
<div v-if="isCompareLoading" class="page-header-status">
<span class="status-loading-dot" />
<span class="page-header-status-title">文档解析中</span>
<span class="page-header-status-text">已完成...{{ translationProgress }}%,已用时{{ elapsedSeconds }}</span>
</div>
<div v-else class="page-header-action-group" @click.stop>
<button
v-for="action in headerActions"
:key="action.key"
type="button"
class="header-action-btn"
:class="action.variant === 'primary' ? 'header-action-btn--primary' : ''"
@click="onHeaderAction(action.key)"
>
{{ action.label }}
</button>
</div>
</header>
<div class="compare-body">
<div v-if="isCompareLoading" class="compare-loading-placeholder">
<img class="compare-loading-placeholder-img" :src="comparePlaceholderUrl" alt="" />
<div class="compare-loading-placeholder-text">{{ compareLoadingText }}</div>
</div>
<template v-else>
<div class="compare-content">
<aside class="article-nav-tree">
<div class="tree-title">导航</div>
<el-tree
ref="articleTreeRef"
:data="articleTree"
node-key="id"
:props="treeProps"
:current-node-key="currentNodeKey"
highlight-current
default-expand-all
@node-click="onClickTreeNode"
/>
</aside>
<div ref="compareScrollRef" class="sync-compare-scroll" @scroll="onSyncScroll">
<section
v-for="page in pageBlocks"
:key="page.pageId"
:id="page.pageId"
class="page-block"
:class="{ 'page-block--active': currentPageId === page.pageId }"
>
<div class="page-block__body" :class="{ 'page-block__body--absolute': page.pageLayoutMode === 'absolute' }">
<template v-if="page.pageLayoutMode === 'absolute'">
<div class="page-column">
<div class="page-canvas-wrapper">
<div class="page-canvas">
<article
v-for="item in page.items"
:key="`${page.pageId}-${item.id}-en`"
class="page-node page-node--en"
:class="[getBlockVariantClass(item.blockType), shouldCenterBlock(item) ? 'page-node--center' : '', ENABLE_DEBUG_BOX ? 'page-node--debug' : '']"
:style="getBlockStyle(item)"
:data-section-id="item.id"
>
<div class="page-node__content">
<div v-if="item.blockType === 'table' && item.tableHtml" class="page-item__html" v-html="item.tableHtml"></div>
<img
v-else-if="item.blockType === 'image'"
class="page-item__image"
:src="getEnglishImageSrc(item)"
alt=""
@error="onEnglishImageError(item)"
/>
<div v-else class="page-item__content" v-html="renderBlockHtml(item.text, item.styleSpans)"></div>
<ArticleNavTree :tree-data="outlineTree" :current-node-key="activeOutlineNodeId || currentNodeKey" @node-click="onClickTreeNode" />
<div class="compare-panels">
<section class="panel panel--source">
<div class="panel__header">
<div>
<div class="panel__title">原文预览</div>
<div class="panel__subtitle">优先加载 PDF 原文,无法获取时展示占位提示</div>
</div>
<span v-if="ENABLE_DEBUG_BOX" class="page-node__debug-label">{{ getBlockTypeLabel(item.blockType) }}</span>
</article>
</div>
</div>
</div>
<div class="page-column">
<div class="page-canvas-wrapper">
<div class="page-canvas">
<slot name="zh-page-canvas" :page="page"></slot>
</div>
</div>
</div>
</template>
<template v-else>
<article
v-for="item in page.items"
:key="`${page.pageId}-${item.id}`"
class="page-item"
:data-section-id="item.id"
>
<div class="page-item__text page-item__text--en" :class="[getBlockVariantClass(item.blockType), shouldCenterBlock(item) ? 'page-item__text--center' : '']">
<div v-if="item.blockType === 'table' && item.tableHtml" class="page-item__html" v-html="item.tableHtml"></div>
<img
v-else-if="item.blockType === 'image'"
class="page-item__image"
:src="getEnglishImageSrc(item)"
alt=""
@error="onEnglishImageError(item)"
<PdfPreview
:pdf-url="pdfPreviewUrl"
:file-name="displayFileName"
:fallback-src="comparePlaceholderUrl"
:active-page="currentPageIdx + 1"
@page-change="onPreviewPageChange"
/>
<div v-else class="page-item__content" v-html="renderBlockHtml(item.text, item.styleSpans)"></div>
</div>
<div class="page-item__text page-item__text--zh" :class="[getBlockVariantClass(item.blockType), shouldCenterBlock(item) ? 'page-item__text--center' : '']">
<slot name="zh-flow-item" :item="item" :page="page"></slot>
</section>
<section class="panel panel--target">
<div class="panel__header">
<div>
<div class="panel__title">译文编辑器</div>
<div class="panel__subtitle">基于 block 模型初始化,可直接编辑与导出</div>
</div>
</article>
</template>
</div>
<TranslationEditor
:doc="currentEditorDoc"
:block-count="blockModel.blocks.length"
:readonly-block-count="blockModel.blocks.filter((block) => block.isReadOnly).length"
:readonly-blocks="blockModel.blocks.filter((block) => block.isReadOnly)"
:active-page="currentPageIdx + 1"
@update:doc="onEditorDocUpdate"
/>
</section>
</div>
</div>
......@@ -137,329 +67,192 @@ import { ElMessage } from 'element-plus'
import { useRoute } from 'vue-router'
import IntelligenceLeftTabBar from '@/components/intelligenceLeftTabBar/index.vue'
import comparePlaceholderUrl from './icons/container-1885-placeholder.png'
import mergedAns from './merged_ans.json'
import ArticleNavTree from './components/ArticleNavTree.vue'
import PdfPreview from './components/PdfPreview.vue'
import TranslationEditor from './components/TranslationEditor.vue'
import { fetchDocumentMeta, fetchDocumentPage, toBlockListFromEditorDoc } from './services/documentCompareService'
const route = useRoute()
const ENABLE_ABSOLUTE_LAYOUT = true
const ENABLE_DEBUG_BOX = false
const PAGE_BBOX_VALID_THRESHOLD = 0.7
const ESTIMATED_SECONDS = 65
const headerActions = [
{ key: 'submit-review', label: '提交审核' },
{ key: 'preview', label: '预览' },
{ key: 'export', label: '导出' },
{ key: 'write-report', label: '写报' },
{ key: 'save', label: '保存', variant: 'primary' }
]
const normalizedPages = ref([])
const normalizedBlocks = ref([])
const articleTree = ref([])
const articleTreeRef = ref(null)
const compareScrollRef = ref(null)
const summary = ref(null)
const pageIndexList = ref([])
const outlineTree = ref([])
const pageBlockMap = ref({})
const loadingPageSet = ref(new Set())
const currentPageIdx = ref(0)
const currentNodeKey = ref('')
const currentPageId = ref('')
const selectedNodeKey = ref('')
const activeOutlineNodeId = ref('')
const isCompareLoading = ref(true)
const isJumpingToTarget = ref(false)
const elapsedSeconds = ref(0)
const translationProgress = ref(0)
const timerId = ref(null)
const imageLoadErrorMap = ref({})
const treeProps = { label: 'title', children: 'children' }
const jumpTimerId = ref(null)
const blockModel = ref({ blocks: [], pageMap: {}, nodeMap: {}, pageNodeMap: {} })
const currentEditorDoc = ref({ type: 'doc', content: [] })
const displayFileName = computed(() => {
const fileName = route.query.fileName
return typeof fileName === 'string' && fileName ? fileName : '未命名文档(占位)'
if (typeof fileName === 'string' && fileName) return fileName
return summary.value?.source_file_name || '未命名文档(占位)'
})
const compareLoadingText = computed(() => `文档解析中,预计用时${ESTIMATED_SECONDS}秒,已用时${elapsedSeconds.value}秒...`)
const pdfPreviewUrl = computed(() => route.query.pdfUrl || summary.value?.source_file_url || summary.value?.preview_url || summary.value?.document_url || '')
const pageBlocks = computed(() => {
return normalizedPages.value.map((page) => {
const pageItems = normalizedBlocks.value
.filter((block) => block.pageIdx === page.pageIdx)
.sort((a, b) => a.readingOrder - b.readingOrder)
const absoluteCount = pageItems.filter((item) => item.layoutMode === 'absolute').length
const hitRate = pageItems.length ? absoluteCount / pageItems.length : 0
const pageLayoutMode = ENABLE_ABSOLUTE_LAYOUT && pageItems.length > 0 && hitRate >= PAGE_BBOX_VALID_THRESHOLD ? 'absolute' : 'flow'
return {
pageId: `page-${page.pageIdx}`,
pageLayoutMode,
items: pageItems
}
})
})
function shouldCenterBlock(block) {
if (!block) return false
if (block.blockType !== 'heading') return false
const text = String(block.text || '').trim()
if (!text) return false
const strongHeading = block.headingLevel != null
const chapterLike = /^(I|II|III|IV|V|VI|VII|VIII|IX|X|XI|XII)[\.|、\s]/i.test(text)
const alphaHeading = /^[A-Z][a-zA-Z\s/&-]{3,}$/.test(text) && !/^[A-Z]{1,3}\b/.test(text)
const chineseHeading = /^[一二三四五六七八九十]+[、.]/.test(text)
const notNumericList = !/^\d+[\.|、]/.test(text)
return strongHeading || (notNumericList && (chapterLike || alphaHeading || chineseHeading))
}
function mapSemanticTypeToNodeType(semanticType, contentPayload = {}) {
if (semanticType === 'heading' || contentPayload.heading_level != null) return 'heading'
if (semanticType === 'paragraph') return 'paragraph'
if (semanticType === 'table' || contentPayload.type === 'table') return 'table'
if (semanticType === 'page_number') return 'page_number'
if (semanticType === 'image' || contentPayload.preview_img_path) return 'image'
return 'unknown'
}
function getBlockTypeLabel(type) {
const labelMap = { heading: '标题', paragraph: '正文', table: '表格', image: '图片', page_number: '页码', unknown: '未知' }
return labelMap[type] || '未知'
function getFirstPageIdx() {
if (outlineTree.value.length > 0) return Number(outlineTree.value[0]?.page_idx || 0)
return Number(pageIndexList.value[0]?.page_idx || 0)
}
function getBlockVariantClass(type) {
return `document-block--${type || 'unknown'}`
}
function isValidBboxNorm(rawBboxNorm) {
if (!Array.isArray(rawBboxNorm) || rawBboxNorm.length !== 4) return false
const [l, t, r, b] = rawBboxNorm.map((value) => Number(value))
if (![l, t, r, b].every((value) => Number.isFinite(value))) return false
if (l < 0 || t < 0 || r > 1 || b > 1) return false
if (r <= l || b <= t) return false
return true
function getBlockSummary() {
return {
blockCount: Array.isArray(blockModel.value.blocks) ? blockModel.value.blocks.length : 0,
pageCount: Array.isArray(pageIndexList.value) ? pageIndexList.value.length : 0,
outlineCount: Array.isArray(outlineTree.value) ? outlineTree.value.length : 0
}
}
function normalizeBboxFromPixel(rawBbox, pageMeta) {
if (!Array.isArray(rawBbox) || rawBbox.length !== 4) return []
const pageWidth = Number(pageMeta?.width || 0)
const pageHeight = Number(pageMeta?.height || 0)
if (pageWidth <= 0 || pageHeight <= 0) return []
const [l, t, r, b] = rawBbox.map((value) => Number(value))
if (![l, t, r, b].every((value) => Number.isFinite(value))) return []
return [l / pageWidth, t / pageHeight, r / pageWidth, b / pageHeight]
async function ensurePageLoaded(pageIdx) {
const pageIndex = Number(pageIdx)
if (Number.isNaN(pageIndex)) return
if (pageBlockMap.value[pageIndex]) return
if (loadingPageSet.value.has(pageIndex)) return
loadingPageSet.value.add(pageIndex)
try {
const response = await fetchDocumentPage(pageIndex)
const pageData = response?.data
pageBlockMap.value = {
...pageBlockMap.value,
[pageIndex]: {
page: pageData?.page || { page_idx: pageIndex },
nodes: Array.isArray(pageData?.nodes) ? pageData.nodes : []
}
}
} catch (error) {
ElMessage.error(error?.message || `第 ${pageIndex + 1} 页加载失败`)
} finally {
loadingPageSet.value.delete(pageIndex)
}
}
function getBlockStyle(item) {
if (!isValidBboxNorm(item?.bboxNorm)) return {}
const [l, t, r, b] = item.bboxNorm
const baseHeightPercent = (b - t) * 100
return {
left: `${l * 100}%`,
top: `${t * 100}%`,
width: `${(r - l) * 100}%`,
height: `${Math.min(100 - t * 100, baseHeightPercent)}%`,
position: 'absolute',
overflow: 'hidden'
async function preloadNeighborPages(centerPageIdx) {
const targetIndexes = [centerPageIdx - 1, centerPageIdx, centerPageIdx + 1, centerPageIdx + 2]
.filter((index) => index >= 0 && index < pageIndexList.value.length)
for (const pageIdx of targetIndexes) {
await ensurePageLoaded(pageIdx)
}
}
function escapeHtml(text = '') {
return String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function renderBlockHtml(text = '', styleSpans = []) {
const safeText = escapeHtml(text)
if (!Array.isArray(styleSpans) || styleSpans.length === 0) return safeText.replaceAll('\n', '<br />')
const sorted = [...styleSpans].sort((a, b) => a.start - b.start)
let html = ''
let cursor = 0
sorted.forEach((span) => {
const start = Math.max(0, span.start || 0)
const end = Math.max(start, span.end || start)
if (start > cursor) html += escapeHtml(text.slice(cursor, start))
const spanText = escapeHtml(text.slice(start, end))
const style = span?.style || {}
const inlineStyle = [
style.bold ? 'font-weight:700' : '',
style.italic ? 'font-style:italic' : '',
style.underline ? 'text-decoration:underline' : '',
style.color_hex ? `color:${style.color_hex}` : ''
].filter(Boolean).join(';')
html += `<span style="${inlineStyle}">${spanText}</span>`
cursor = end
})
if (cursor < text.length) html += escapeHtml(text.slice(cursor))
return html.replaceAll('\n', '<br />')
}
function resolveImageUrl(rawPath) {
const path = String(rawPath || '').trim()
if (!path) return ''
if (/^(https?:)?\/\//i.test(path) || /^(data|blob):/i.test(path)) return path
const normalizedPath = path.replaceAll('\\', '/')
const baseUrl = String(
import.meta.env.VITE_INTELLIGENT_TRANSLATION_ASSET_BASE_URL || import.meta.env.VITE_BASE_FILE_URL || ''
).trim()
if (baseUrl) {
return `${baseUrl.replace(/\/+$/, '')}/${normalizedPath.replace(/^\/+/, '')}`
async function preloadPagesForJump(targetPageIdx) {
const safeTargetIdx = Math.max(0, Number(targetPageIdx || 0))
for (let pageIdx = 0; pageIdx <= safeTargetIdx; pageIdx += 1) {
await ensurePageLoaded(pageIdx)
}
return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`
}
function getEnglishImageSrc(item) {
if (!item?.id) return comparePlaceholderUrl
if (imageLoadErrorMap.value[item.id]) return comparePlaceholderUrl
const resolvedUrl = resolveImageUrl(item.imagePath)
return resolvedUrl || comparePlaceholderUrl
function onActiveNodeChange(nodeId) {
if (!nodeId) return
currentNodeKey.value = String(nodeId)
activeOutlineNodeId.value = String(nodeId)
}
function onEnglishImageError(item) {
if (!item?.id) return
imageLoadErrorMap.value = {
...imageLoadErrorMap.value,
[item.id]: true
function onPreviewPageChange(pageNumber) {
const nextPageIdx = Math.max(0, Number(pageNumber || 1) - 1)
if (!Number.isNaN(nextPageIdx)) {
currentPageIdx.value = nextPageIdx
syncOutlineActiveByPage(nextPageIdx)
}
}
function buildNormalizedBlocks(rawData) {
const pages = Array.isArray(rawData?.data?.pages) ? rawData.data.pages : []
const nodes = Array.isArray(rawData?.data?.merged_nodes) ? rawData.data.merged_nodes : []
const pageSizeMap = pages.reduce((acc, page) => {
acc[page.page_idx] = { width: page.width, height: page.height }
return acc
}, {})
normalizedPages.value = pages.map((page) => ({
pageIdx: page.page_idx,
width: page.width,
height: page.height,
rotation: page.rotation,
nodeIds: Array.isArray(page.node_ids) ? page.node_ids : []
}))
normalizedBlocks.value = nodes.map((node, index) => {
const contentPayload = node?.content_payload || {}
const blockType = mapSemanticTypeToNodeType(node.semantic_type, contentPayload)
const bbox = Array.isArray(node?.layout?.bbox) ? node.layout.bbox : []
const originBboxNorm = Array.isArray(node?.layout?.bbox_norm) ? node.layout.bbox_norm : []
const pageMeta = pageSizeMap[node.page_idx] || null
const convertedBboxNorm = normalizeBboxFromPixel(bbox, pageMeta)
const bboxNorm = isValidBboxNorm(originBboxNorm) ? originBboxNorm : (isValidBboxNorm(convertedBboxNorm) ? convertedBboxNorm : [])
const layoutMode = isValidBboxNorm(bboxNorm) ? 'absolute' : 'flow'
return {
id: node.node_id || `node_${index}`,
nodeId: node.node_id || '',
pageIdx: node.page_idx ?? null,
type: blockType,
blockType,
semanticType: node.semantic_type || 'unknown',
readingOrder: node?.layout?.reading_order ?? index,
title: contentPayload.text || node.node_id || `未命名块${index + 1}`,
text: contentPayload.text || '',
translatedText: '',
html: contentPayload.html || '',
headingLevel: contentPayload.heading_level ?? null,
tableHtml: contentPayload.table_body_html || '',
translatedTableHtml: contentPayload.table_body_html || '',
imagePath: contentPayload.img_path || contentPayload.preview_img_path || '',
previewImgPath: contentPayload.preview_img_path || '',
bbox,
bboxNorm,
layoutMode,
layout: {
bbox,
bboxNorm,
angle: node?.layout?.angle ?? 0,
link: node?.layout?.link ?? null,
zIndex: node?.layout?.z_index ?? null
},
styleSpans: Array.isArray(node.style_spans) ? node.style_spans : []
function onEditorDocUpdate(doc) {
const safeDoc = doc || { type: 'doc', content: [] }
blockModel.value = {
...blockModel.value,
lastEditedDoc: safeDoc,
editedBlocks: toBlockListFromEditorDoc(safeDoc)
}
})
}
function buildNavigationTree(rawData) {
const nodes = Array.isArray(rawData?.data?.merged_nodes) ? rawData.data.merged_nodes : []
articleTree.value = nodes
.map((node, index) => {
const contentPayload = node?.content_payload || {}
const nodeType = mapSemanticTypeToNodeType(node.semantic_type, contentPayload)
const text = contentPayload.text || node.node_id || '未命名块'
if (nodeType !== 'heading') return null
if (!shouldCenterBlock({ ...node, blockType: nodeType, headingLevel: contentPayload.heading_level, text })) return null
return {
id: node.node_id || `heading_${index}`,
title: text,
pageIdx: node.page_idx ?? 0,
nodeType,
sectionId: node.node_id || `heading_${index}`,
children: []
}
})
.filter(Boolean)
}
function findBlockElementById(blockId) {
return document.querySelector(`[data-section-id='${blockId}']`)
}
async function onClickTreeNode(nodeData) {
currentNodeKey.value = nodeData.id
const targetPage = pageBlocks.value.find((page) => page.items.some((item) => item.id === nodeData.id))
currentPageId.value = targetPage?.pageId || currentPageId.value
const pageIdx = Number(nodeData?.page_idx || 0)
const nodeId = String(nodeData?.id || '')
currentNodeKey.value = nodeId
activeOutlineNodeId.value = nodeId
selectedNodeKey.value = nodeId
currentPageIdx.value = pageIdx
isJumpingToTarget.value = true
if (jumpTimerId.value !== null) {
clearTimeout(jumpTimerId.value)
jumpTimerId.value = null
}
try {
await preloadPagesForJump(pageIdx)
await nextTick()
findBlockElementById(nodeData.id)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
function onSyncScroll() {
const scroller = compareScrollRef.value
if (!scroller) return
const pageEls = Array.from(scroller.querySelectorAll('.page-block'))
const viewportTop = scroller.getBoundingClientRect().top
let activePage = pageBlocks.value[0]?.pageId || ''
for (const pageEl of pageEls) {
const rect = pageEl.getBoundingClientRect()
if (rect.top - viewportTop <= 96) activePage = pageEl.id
pageListRef.value?.scrollToNode(nodeId, { behavior: 'auto' })
await nextTick()
} finally {
jumpTimerId.value = window.setTimeout(() => {
isJumpingToTarget.value = false
currentNodeKey.value = nodeId
jumpTimerId.value = null
}, 200)
}
}
currentPageId.value = activePage
const activePageBlocks = pageBlocks.value.find((page) => page.pageId === activePage)?.items || []
const activeHeading = articleTree.value.find((item) => activePageBlocks.some((block) => block.id === item.id))
if (activeHeading) currentNodeKey.value = activeHeading.id
async function onPageVisible(pageIdx) {
await ensurePageLoaded(pageIdx)
}
function onPageClick() {
currentNodeKey.value = currentPageId.value || currentNodeKey.value
function onNodeSelect(nodeData) {
const nodeId = String(nodeData?.node_id || '')
if (!nodeId) return
selectedNodeKey.value = nodeId
const block = blockModel.value.nodeMap?.[nodeId]
if (block) {
currentPageIdx.value = Number(block.pageIdx || 0)
currentNodeKey.value = nodeId
}
}
function onHeaderAction(actionKey) {
ElMessage.info(`顶部功能按钮占位:${actionKey}`)
function syncOutlineActiveByPage(pageIdx) {
const pageNumber = Number(pageIdx || 0)
if (Number.isNaN(pageNumber)) return
const matchedNode = outlineTree.value.find((item) => Number(item?.page_idx || 0) === pageNumber)
if (matchedNode?.id) {
activeOutlineNodeId.value = String(matchedNode.id)
currentNodeKey.value = String(matchedNode.id)
}
}
function getFirstNavigableNode(nodes) {
if (!Array.isArray(nodes)) return null
return nodes.find((node) => node?.id) || null
async function initPageData() {
const response = await fetchDocumentMeta()
const responseData = response?.data || {}
summary.value = responseData.document_meta || {}
pageIndexList.value = Array.isArray(responseData.pages) ? responseData.pages : []
outlineTree.value = Array.isArray(responseData.outline) ? responseData.outline : []
blockModel.value = responseData.block_model || { blocks: [], pageMap: {}, nodeMap: {}, pageNodeMap: {} }
currentEditorDoc.value = responseData.initial_tiptap_doc || { type: 'doc', content: [] }
translationProgress.value = pageIndexList.value.length > 0 ? 100 : 0
currentPageIdx.value = getFirstPageIdx()
currentNodeKey.value = String(outlineTree.value[0]?.id || '')
activeOutlineNodeId.value = String(outlineTree.value[0]?.id || '')
await preloadNeighborPages(currentPageIdx.value)
console.debug('[intelligentTranslation] block summary', getBlockSummary())
}
onMounted(() => {
onMounted(async () => {
const requestStartTime = Date.now()
const updateElapsedSeconds = () => {
elapsedSeconds.value = Math.floor((Date.now() - requestStartTime) / 1000)
}
updateElapsedSeconds()
timerId.value = window.setInterval(updateElapsedSeconds, 1000)
try {
buildNormalizedBlocks(mergedAns)
buildNavigationTree(mergedAns)
translationProgress.value = Number(mergedAns?.data?.pages?.length ? 100 : 0)
const firstNode = getFirstNavigableNode(articleTree.value)
if (firstNode) {
currentNodeKey.value = firstNode.id
currentPageId.value = `page-${firstNode.pageIdx ?? 0}`
}
await initPageData()
} catch (error) {
ElMessage.error(error?.message || '本地 JSON 加载失败')
ElMessage.error(error?.message || '初始化数据失败')
} finally {
isCompareLoading.value = false
}
......@@ -470,6 +263,10 @@ onUnmounted(() => {
clearInterval(timerId.value)
timerId.value = null
}
if (jumpTimerId.value !== null) {
clearTimeout(jumpTimerId.value)
jumpTimerId.value = null
}
})
</script>
......@@ -488,7 +285,6 @@ onUnmounted(() => {
flex-direction: column;
min-height: 0;
overflow: hidden;
position: relative;
}
.page-header {
......@@ -525,61 +321,143 @@ onUnmounted(() => {
.page-header-status-title, .page-header-status-text { font-family: 'Source Han Sans CN', sans-serif; font-size: 14px; line-height: 22px; color: var(--color-primary-100); }
.page-header-status-title { font-weight: 700; }
.page-header-status-text { font-weight: 400; }
.page-header-action-group { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.header-action-btn { box-sizing: border-box; padding: 6px 19px; border: 1px solid var(--bg-black-10); border-radius: 6px; background: #fff; font-family: 'Source Han Sans CN', sans-serif; font-weight: 400; font-size: 16px; line-height: 24px; color: var(--text-primary-80-color, #344054); cursor: pointer; }
.header-action-btn--primary { background: var(--color-primary-100); border: none; color: var(--bg-white-100); }
@keyframes compare-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.compare-loading-placeholder { flex: 1; min-height: 560px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 16px; box-sizing: border-box; background: var(--bg-white-100); border-radius: 8px; }
.compare-loading-placeholder-img { max-width: 100%; max-height: min(720px, calc(100vh - 160px)); width: auto; height: auto; object-fit: contain; display: block; }
.compare-loading-placeholder-text { font-family: 'Source Han Sans CN', sans-serif; font-weight: 400; font-size: 16px; line-height: 30px; color: #344054; }
.compare-content { flex: 1; min-height: 0; display: flex; gap: 12px; overflow: hidden; }
.article-nav-tree { width: 280px; flex-shrink: 0; padding: 16px 19px; display: flex; flex-direction: column; min-height: 0; overflow: hidden; border: 1px solid #e8ecf2; border-radius: 8px; background: #fff; }
.tree-title { font-family: 'Source Han Sans CN', sans-serif; font-weight: 700; font-size: 16px; line-height: 24px; letter-spacing: 1px; color: var(--text-primary-80-color); margin-bottom: 17px; }
:deep(.article-nav-tree .el-tree-node__label) { font-family: 'Source Han Sans CN', sans-serif; font-weight: 400; font-size: 14px; line-height: 22px; color: var(--text-primary-80-color); }
.sync-compare-scroll { flex: 1; min-width: 0; min-height: 0; overflow-y: auto; overflow-x: hidden; border: 1px solid #e8ecf2; border-radius: 8px; background: #fff; padding: 16px; box-sizing: border-box; }
.page-block { display: flex; flex-direction: column; gap: 12px; padding: 12px 0 20px; border-bottom: 1px solid #d9e2ec; }
.page-block--active { background: #f8fafc; }
.page-block__body { display: flex; flex-direction: column; gap: 12px; }
.page-block__body--absolute { flex-direction: row; align-items: flex-start; }
.page-column { width: calc(50% - 6px); min-width: 0; }
.page-canvas-wrapper { width: 100%; }
.page-canvas { position: relative; width: 100%; aspect-ratio: 21 / 29.7; border: 1px solid #e4e7ec; border-radius: 8px; overflow: hidden; background: #fff; }
.page-node { position: absolute; box-sizing: border-box; padding: 0; }
.page-node--en { border-right: 1px solid #eef2f7; }
.page-node__content { width: 100%; height: 100%; overflow: hidden; padding: 1px 2px 4px; box-sizing: border-box; }
.page-node--center .page-item__content { text-align: center; }
.page-node--debug { border: 1px dashed #1677ff; }
.page-node__debug-label { position: absolute; left: 0; top: 0; font-size: 10px; line-height: 14px; padding: 0 4px; color: #fff; background: rgba(22, 119, 255, 0.8); }
.page-item { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-items: stretch; }
.page-item__text { min-height: 72px; padding: 0; background: transparent; border: none; }
.page-item__text--en { border-right: 1px solid #eef2f7; padding-right: 12px; }
.page-item__text--zh { padding-left: 12px; }
.page-item__text--center .page-item__content { text-align: center; }
.page-item__content { font-size: 14px; line-height: 1.85; color: #344054; white-space: pre-wrap; word-break: break-word; }
.page-item__html :deep(table) { width: 100%; border-collapse: collapse; font-size: 13px; }
.page-item__html :deep(td), .page-item__html :deep(th) { border: 1px solid #d0d5dd; padding: 8px; vertical-align: top; }
.page-item__image { width: 100%; height: 100%; object-fit: contain; display: block; border-radius: 6px; }
.page-item--heading .page-item__content { font-size: 18px; font-weight: 700; color: #101828; }
.page-item--paragraph .page-item__content { font-size: 14px; }
.page-item--table .page-item__content { font-size: 13px; }
.page-item--image .page-item__content { font-size: 13px; color: #667085; }
.page-item--page_number .page-item__content { font-size: 12px; color: #98a2b3; }
.page-item--unknown .page-item__content { color: #667085; font-style: italic; }
.page-node--en.document-block--heading .page-item__content,
.page-node--en.document-block--paragraph .page-item__content {
line-height: 1.9;
padding-bottom: 3px;
}
.page-node--en.document-block--table .page-item__content,
.page-node--en.document-block--page_number .page-item__content {
line-height: 1.9;
padding-bottom: 3px;
}
@media (max-width: 1360px) {
.page-block__body { gap: 10px; }
.page-block__body--absolute { flex-direction: column; }
.page-column { width: 100%; }
.compare-loading-placeholder {
flex: 1;
min-height: 560px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px;
box-sizing: border-box;
background: var(--bg-white-100);
border-radius: 8px;
}
.compare-loading-placeholder-img {
max-width: 100%;
max-height: min(720px, calc(100vh - 160px));
width: auto;
height: auto;
object-fit: contain;
display: block;
}
.compare-loading-placeholder-text {
font-family: 'Source Han Sans CN', sans-serif;
font-size: 16px;
line-height: 30px;
color: #344054;
}
.compare-content {
flex: 1;
min-height: 0;
display: flex;
gap: 12px;
overflow: hidden;
}
.compare-panels {
flex: 1;
min-width: 0;
min-height: 0;
display: grid;
grid-template-columns: minmax(420px, 1fr) minmax(520px, 1.2fr);
gap: 12px;
}
.panel {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #e8ecf2;
border-radius: 12px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
}
.panel__header {
flex-shrink: 0;
padding: 16px 18px 12px;
border-bottom: 1px solid #eef2f7;
display: flex;
align-items: center;
justify-content: space-between;
}
.panel__title {
font-size: 16px;
font-weight: 700;
color: #101828;
line-height: 24px;
}
.panel__subtitle {
margin-top: 4px;
font-size: 13px;
line-height: 20px;
color: #667085;
}
.panel--source {
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.page-panel {
position: relative;
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
padding: 12px;
box-sizing: border-box;
}
.jump-loading-mask {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(2px);
pointer-events: none;
}
.jump-loading-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid var(--color-primary-35);
border-top-color: var(--color-primary-100, #2563eb);
animation: compare-spin 0.8s linear infinite;
}
.jump-loading-text {
font-family: 'Source Han Sans CN', sans-serif;
font-size: 14px;
line-height: 22px;
color: var(--color-primary-100, #2563eb);
font-weight: 600;
}
.jump-fade-enter-active,
.jump-fade-leave-active {
transition: opacity 0.18s ease;
}
.jump-fade-enter-from,
.jump-fade-leave-to {
opacity: 0;
}
</style>
......@@ -131,6 +131,7 @@ const onTranslate = async () => {
try {
const response = await submitTranslationTask(targetFile)
const taskId = response?.data?.task_id || response?.data?.task_ids?.[0]
const pdfUrl = URL.createObjectURL(targetFile)
if (!taskId) {
throw new Error('未获取到任务ID')
}
......@@ -139,7 +140,8 @@ const onTranslate = async () => {
name: 'intelligentTranslationDocument',
query: {
taskId,
fileName: targetFile.name || ''
fileName: targetFile.name || '',
pdfUrl
}
})
} catch (error) {
......
import { computed, ref } from 'vue'
const uploadedPdfUrl = ref('')
const historyPdfUrl = ref('')
const activePdfSource = ref('upload')
export function usePdfPreviewState() {
const pdfUrl = computed(() => {
if (activePdfSource.value === 'history' && historyPdfUrl.value) {
return historyPdfUrl.value
}
return uploadedPdfUrl.value || historyPdfUrl.value || ''
})
function setUploadedPdfUrl(url) {
uploadedPdfUrl.value = url || ''
if (url) activePdfSource.value = 'upload'
}
function setHistoryPdfUrl(url) {
historyPdfUrl.value = url || ''
if (url && !uploadedPdfUrl.value) activePdfSource.value = 'history'
}
function setActivePdfSource(source) {
if (source === 'upload' || source === 'history') {
activePdfSource.value = source
}
}
return {
pdfUrl,
activePdfSource,
setUploadedPdfUrl,
setHistoryPdfUrl,
setActivePdfSource
}
}
import { articleTreeMock, paragraphRowsMock } from '../mock'
import { fetchLayoutStyleResult, mapLayoutStyleResult } from './layoutStyleService'
/**
* 对比页接口占位 service
* 后续后端接入时,只需要替换这里的数据获取方式和字段映射逻辑。
*/
export async function fetchDocumentComparePageData(params = {}) {
const { fileName = '' } = params
const response = await fetchDocumentCompareRawData({ fileName })
return mapDocumentComparePageData(response)
}
/**
* 原始接口占位:模拟后端可能返回的结构
* 这里保留为后续分接口时的聚合入口。
*/
export async function fetchDocumentCompareRawData(_params = {}) {
const layoutStyleRaw = await fetchLayoutStyleResult()
return Promise.resolve({
file_info: {
file_name: '测试文档.pdf',
file_type: 'pdf'
import mergedAns from '../merged_ans.json'
const MOCK_DELAY = 80
function wait(delay = MOCK_DELAY) {
return new Promise((resolve) => {
window.setTimeout(resolve, delay)
})
}
function getRawData() {
return mergedAns?.data || {}
}
function buildOutlineNodes(nodes = []) {
return nodes
.filter((node) => node?.semantic_type === 'heading')
.sort((left, right) => {
const leftPage = Number(left?.page_idx || 0)
const rightPage = Number(right?.page_idx || 0)
if (leftPage !== rightPage) return leftPage - rightPage
const leftOrder = Number(left?.layout?.reading_order || 0)
const rightOrder = Number(right?.layout?.reading_order || 0)
return leftOrder - rightOrder
})
.map((node, index) => {
const contentPayload = node?.content_payload || {}
return {
id: node?.node_id || `outline_${index}`,
title: contentPayload?.text || `章节 ${index + 1}`,
page_idx: Number(node?.page_idx || 0),
level: Number(contentPayload?.heading_level || 1),
children: []
}
})
}
function normalizePage(page) {
return {
page_idx: Number(page?.page_idx || 0),
width: Number(page?.width || 0),
height: Number(page?.height || 0),
rotation: Number(page?.rotation || 0),
node_ids: Array.isArray(page?.node_ids) ? page.node_ids : [],
node_count: Number(page?.node_count || 0),
has_image: Boolean(page?.has_image),
has_table: Boolean(page?.has_table)
}
}
function normalizeNode(node, index = 0) {
const contentPayload = node?.content_payload || {}
const semanticType = node?.semantic_type || 'unknown'
const pageIdx = Number(node?.page_idx || 0)
const nodeId = node?.node_id || `node_${pageIdx}_${index}`
const rawText = String(contentPayload?.text || '').trim()
const blockType = semanticType === 'heading'
? 'heading'
: semanticType === 'table'
? 'table'
: semanticType === 'image'
? 'image'
: semanticType === 'list'
? 'list'
: rawText
? 'paragraph'
: 'empty'
return {
node_id: nodeId,
semantic_type: semanticType,
page_idx: pageIdx,
content_payload: contentPayload,
layout: node?.layout || {},
style_spans: Array.isArray(node?.style_spans) ? node.style_spans : [],
block: {
id: nodeId,
pageIdx,
type: blockType,
semanticType,
text: rawText,
level: Number(contentPayload?.heading_level || 0) || null,
style: contentPayload?.style || {},
sourceNodeId: nodeId,
translationNodeId: `${nodeId}__translation`,
parentId: contentPayload?.parent_id || null,
order: Number(node?.layout?.reading_order ?? index),
pageNodeOrder: Number(index),
isReadOnly: ['image', 'formula'].includes(semanticType),
children: []
}
}
}
function buildBlockModel(nodes = []) {
const normalizedNodes = nodes.map((node, index) => normalizeNode(node, index))
const blocks = normalizedNodes.map((node) => node.block)
const pageMap = normalizedNodes.reduce((acc, node) => {
const pageIdx = Number(node.page_idx || 0)
if (!acc[pageIdx]) acc[pageIdx] = []
acc[pageIdx].push(node.block)
return acc
}, {})
const nodeMap = normalizedNodes.reduce((acc, node) => {
if (node.node_id) acc[node.node_id] = node.block
return acc
}, {})
const pageNodeMap = normalizedNodes.reduce((acc, node) => {
const pageIdx = Number(node.page_idx || 0)
if (!acc[pageIdx]) acc[pageIdx] = []
acc[pageIdx].push({
nodeId: node.node_id,
blockId: node.block.id,
type: node.block.type,
semanticType: node.block.semanticType,
order: node.block.order
})
return acc
}, {})
return { blocks, pageMap, nodeMap, pageNodeMap }
}
function normalizeEditorNode(node, index) {
if (!node || typeof node !== 'object') return null
const type = typeof node.type === 'string' ? node.type : ''
if (!type) return null
const attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : {}
const content = Array.isArray(node.content)
? node.content.map((child, childIndex) => normalizeEditorNode(child, childIndex)).filter(Boolean)
: []
const safeNode = { type }
if (Object.keys(attrs).length > 0) safeNode.attrs = attrs
if (content.length > 0) safeNode.content = content
if (typeof node.text === 'string' && node.text) safeNode.text = node.text
if (typeof node.marks !== 'undefined' && Array.isArray(node.marks)) safeNode.marks = node.marks
return safeNode
}
function buildTiptapDoc(blocks = []) {
const content = blocks
.filter((block) => block && typeof block === 'object' && typeof block.type === 'string')
.map((block) => {
if (block.type === 'heading') {
return {
type: 'heading',
attrs: { level: block.level || 1, blockId: block.id, sourceNodeId: block.sourceNodeId },
content: block.text ? [{ type: 'text', text: block.text }] : []
}
}
if (block.type === 'table') {
return {
type: 'paragraph',
attrs: { blockId: block.id, sourceNodeId: block.sourceNodeId, semanticType: block.semanticType, isReadOnly: true },
content: [{ type: 'text', text: '[table]' }]
}
}
if (block.type === 'image' || block.type === 'formula') {
return {
type: 'paragraph',
attrs: { blockId: block.id, sourceNodeId: block.sourceNodeId, semanticType: block.semanticType, isReadOnly: true },
content: [{ type: 'text', text: `[${block.semanticType}]` }]
}
}
return {
type: 'paragraph',
attrs: { blockId: block.id, sourceNodeId: block.sourceNodeId, semanticType: block.semanticType },
content: block.text ? [{ type: 'text', text: block.text }] : []
}
})
.map((node, index) => normalizeEditorNode(node, index))
.filter(Boolean)
return { type: 'doc', content }
}
export async function fetchDocumentMeta() {
await wait()
const rawData = getRawData()
const pages = Array.isArray(rawData?.pages) ? rawData.pages.map(normalizePage) : []
const mergedNodes = Array.isArray(rawData?.merged_nodes) ? rawData.merged_nodes : []
const blockModel = buildBlockModel(mergedNodes)
const initialTiptapDoc = buildTiptapDoc(blockModel.blocks)
return {
code: 0,
message: 'success',
data: {
document_meta: rawData?.document_meta || {},
statistics: {
page_count: pages.length,
node_count: mergedNodes.length,
heading_count: mergedNodes.filter((node) => node?.semantic_type === 'heading').length,
image_count: mergedNodes.filter((node) => node?.semantic_type === 'image').length,
table_count: mergedNodes.filter((node) => node?.semantic_type === 'table').length
},
translation_progress: 12,
article_tree: articleTreeMock,
paragraph_rows: paragraphRowsMock,
layout_style_result: layoutStyleRaw
pages,
outline: buildOutlineNodes(mergedNodes),
block_model: blockModel,
initial_tiptap_doc: initialTiptapDoc
}
}
}
function buildBlocksFromTiptapDoc(doc = { type: 'doc', content: [] }) {
const content = Array.isArray(doc?.content) ? doc.content : []
return content
.filter((node) => node && typeof node === 'object' && typeof node.type === 'string')
.map((node, index) => {
const semanticType = node?.attrs?.semanticType || node?.type || 'paragraph'
const isReadOnly = Boolean(node?.attrs?.isReadOnly)
const text = Array.isArray(node?.content)
? node.content
.filter((child) => child && typeof child === 'object')
.map((child) => child?.text || '')
.join('')
: ''
return {
id: node?.attrs?.blockId || `editor_block_${index}`,
pageIdx: Number(node?.attrs?.pageIdx || 0),
type: node?.type || 'paragraph',
semanticType,
text,
level: Number(node?.attrs?.level || 0) || null,
style: {},
sourceNodeId: node?.attrs?.sourceNodeId || node?.attrs?.blockId || `editor_block_${index}`,
translationNodeId: `${node?.attrs?.blockId || `editor_block_${index}`}__translation`,
parentId: node?.attrs?.parentId || null,
order: index,
pageNodeOrder: index,
isReadOnly,
children: [],
displayText: isReadOnly ? text.replace(/^\[(image|formula)\]$/i, '').trim() || text : text
}
})
}
/**
* 将后端返回结构映射为页面可直接使用的数据结构。
*/
export function mapDocumentComparePageData(rawData) {
const articleTree = Array.isArray(rawData?.article_tree) ? rawData.article_tree : []
const paragraphRows = Array.isArray(rawData?.paragraph_rows) ? rawData.paragraph_rows : []
const layoutStyleResult = rawData?.layout_style_result
? mapLayoutStyleResult(rawData.layout_style_result)
: null
export async function fetchDocumentPage(pageIdx) {
await wait()
const targetPageIdx = Number(pageIdx || 0)
const rawData = getRawData()
const pages = Array.isArray(rawData?.pages) ? rawData.pages : []
const mergedNodes = Array.isArray(rawData?.merged_nodes) ? rawData.merged_nodes : []
const page = pages.find((item) => Number(item?.page_idx) === targetPageIdx)
const nodes = mergedNodes
.filter((node) => Number(node?.page_idx) === targetPageIdx)
.sort((left, right) => Number(left?.layout?.reading_order || 0) - Number(right?.layout?.reading_order || 0))
.map(normalizeNode)
return {
fileName: rawData?.file_info?.file_name || '',
fileType: rawData?.file_info?.file_type || 'pdf',
translationProgress: Number(rawData?.translation_progress) || 0,
articleTree,
paragraphRows,
layoutStyleResult
code: 0,
message: 'success',
data: {
page: normalizePage(page || { page_idx: targetPageIdx }),
nodes,
blocks: nodes.map((node) => node.block),
page_map: {
[targetPageIdx]: nodes.map((node) => node.block)
},
page_node_map: {
[targetPageIdx]: nodes.map((node, index) => ({
nodeId: node.node_id,
blockId: node.block.id,
type: node.block.type,
semanticType: node.block.semanticType,
order: Number(node.block.order ?? index)
}))
}
}
}
}
export function toBlockListFromEditorDoc(doc) {
return buildBlocksFromTiptapDoc(doc)
}
# 智能翻译文档对比页前端重构实施方案
## 方案进度概述
- 当前阶段:重构方案定稿
- 目标:将现有全量渲染的文档对比页重构为页级懒加载架构
- 面向对象:大模型执行与前端开发协作
- 约束:原有接口不能改,只能基于新增接口重构
## 1. 需求理解
当前页面的核心问题不是单纯“慢”,而是:
1. 整包数据过大
2. 页和块一次性全量挂载
3. 富文本、图片、表格混合渲染
4. 目录与滚动状态依赖大量 DOM
因此前端重构的目标不是“微调样式”,而是从渲染架构上切换到更适合大文档的方式。
## 2. 最终结论
> 以“页”为最小渲染单位,采用“页级懒加载 + 视口内挂载 + 页外轻量壳 + 页内二级懒加载”的混合方案。
这是当前场景下最稳、最容易落地、也最符合文档阅读习惯的方案。
## 3. 重构目标
### 3.1 功能目标
- 支持超长文档平稳展示
- 支持目录跳转
- 支持页码同步
- 支持图片、表格、标题、正文正常展示
- 支持当前页高亮与章节高亮
### 3.2 性能目标
- 首屏加载更快
- 滚动更稳定
- 大文档不掉帧
- 降低一次性 DOM 规模
### 3.3 可维护性目标
- 页、块、节点职责分离
- 渲染逻辑可分层扩展
- 后续方便接搜索、高亮、差异对比
## 4. 重构原则
1. 页是最小渲染单位
2. 目录只负责导航,不参与重渲染
3. 可见页才挂载真实内容
4. 页外只保留占位壳
5. 图片、表格、富文本做二级懒加载
6. 原始完整结果保留,但不直接驱动 UI 全量渲染
## 5. 页面结构设计
### 5.1 页面容器
文件建议:`src/views/intelligentTranslation/documentCompare.vue`
职责:
- 请求文档摘要、目录、页索引、页内容
- 管理当前页、当前章节、加载状态
- 维护滚动容器
- 协调页面跳转和状态同步
### 5.2 页列表组件
文件建议:`PageList.vue`
职责:
- 遍历页元信息
- 判断页是否进入视口附近
- 决定挂载真实内容还是仅显示页壳
- 控制页缓存策略
### 5.3 页内容组件
文件建议:`PageBlock.vue`
职责:
- 渲染单页内部节点
-`semantic_type` 分发渲染子组件
- 处理 absolute 与 flow 两种布局模式
### 5.4 节点子组件
建议拆分:
- `HeadingNode.vue`
- `TextNode.vue`
- `ImageNode.vue`
- `TableNode.vue`
- `RichHtmlNode.vue`
职责:
- 各类节点独立渲染
- 减少单组件复杂度
- 控制 `v-html` 使用范围
### 5.5 目录树组件
文件建议:`ArticleNavTree.vue`
职责:
- 渲染标题目录
- 点击跳页
- 当前章节高亮
## 6. 状态设计
建议状态如下:
```js
const rawResult = ref(null)
const summary = ref(null)
const pageIndexList = ref([])
const outlineTree = ref([])
const pageBlockMap = ref(new Map())
const visiblePageSet = ref(new Set())
const mountedPageCache = ref(new Map())
const currentPageId = ref('')
const currentNodeKey = ref('')
const compareScrollRef = ref(null)
```
### 状态含义
- `rawResult`:原始完整结果,兼容旧逻辑
- `summary`:文档摘要数据
- `pageIndexList`:页级索引列表
- `outlineTree`:目录树
- `pageBlockMap`:页到节点的映射
- `visiblePageSet`:当前需要挂载的页集合
- `mountedPageCache`:已挂载页缓存
- `currentPageId`:当前激活页
- `currentNodeKey`:当前目录高亮节点
- `compareScrollRef`:滚动容器引用
## 7. 数据获取顺序
### 7.1 初始化顺序
1. 请求 `document-meta`
2. 解析出摘要、页索引、目录树
3. 请求首屏页内容 `document-page`
4. 根据滚动位置继续按需请求其他页
### 7.2 页内按需顺序
当某页进入视口附近:
1. 请求该页完整内容
2. 挂载页真实 DOM
### 7.3 目录跳转顺序
点击目录节点时:
1. 根据 `page_idx` 找目标页
2. 若该页未挂载,先请求页内容
3. 滚动到目标页
4. 更新当前页和章节高亮
## 8. 渲染策略
### 8.1 首屏策略
首屏只挂载:
- 当前页
- 当前页前 1 页
- 当前页后 2 页
其他页只渲染页壳,不渲染真实内容。
### 8.2 视口内挂载策略
使用 `IntersectionObserver` 或滚动监听判断页面是否进入视口附近:
- 进入可视区:挂载真实内容
- 离开较远:保留占位或卸载
### 8.3 页内二级懒加载策略
页内对重资源进一步控制:
- 图片 `loading="lazy"`
- 表格默认只渲染摘要或首屏片段
- 富文本仅对必要节点使用 `v-html`
### 8.4 页外壳策略
未挂载页仍保留:
- 页码
- 页面尺寸占位
- 可选的轻量标题摘要
这样可以保证滚动长度和定位稳定。
## 9. 现有代码改造重点
### 9.1 不再全量 `v-for` 渲染所有页内容
当前逻辑需要从“所有页一次性渲染”改成“页壳 + 条件挂载”。
### 9.2 控制 `v-html` 范围
只在确实需要富文本还原的节点使用 `v-html`,且尽量局部化。
### 9.3 目录与页级状态绑定
当前页、章节高亮、跳转定位统一使用页级索引,不再依赖海量 block DOM 计算。
### 9.4 图片和表格独立组件化
图片和表格不要和普通文本混成一个大渲染函数,必须独立拆组件。
## 10. 推荐组件结构
```text
documentCompare.vue
├── ArticleNavTree.vue
├── PageList.vue
│ ├── PageShell.vue
│ └── PageBlock.vue
│ ├── HeadingNode.vue
│ ├── TextNode.vue
│ ├── ImageNode.vue
│ ├── TableNode.vue
│ └── RichHtmlNode.vue
└── ComparePanel.vue
```
## 11. 实施步骤
### 第 1 步:接入新接口,但不改 UI
目标:先把数据链路切到新接口,保留现有页面结构。
验收:
- 能拿到摘要、页索引、目录
- 能按页拿到内容
### 第 2 步:改成页壳 + 条件挂载
目标:只挂载视口附近页。
验收:
- 非可见页不再创建真实 DOM
- 首屏明显变快
### 第 3 步:拆分页内节点组件
目标:降低单文件复杂度。
验收:
- 标题、正文、图片、表格分组件渲染
### 第 4 步:改造目录跳转和滚动同步
目标:按页同步当前章节。
验收:
- 点击目录可准确跳转
- 滚动时高亮正确
### 第 5 步:增加页内二级懒加载
目标:处理大图、大表和富文本。
验收:
- 长文档滚动更流畅
- 图片/表格不会拖慢主线程
## 12. 风险与注意事项
- 不能直接把整包 JSON 当唯一 UI 数据源长期使用
- 需要保证 `page_idx``node_id` 稳定唯一
- `bbox_norm` 优先使用,缺失时再由 `bbox` 换算
- `reading_order` 必须稳定,否则页内顺序会错乱
- `style_spans` 的字符区间必须与文本严格对齐
## 13. 验收标准
### 功能验收
- 文档正常渲染
- 目录可跳转
- 当前页可同步
- 图片、表格、标题、正文正常展示
### 性能验收
- 首屏快于原方案
- 长文档滚动不卡顿
- 不再出现明显“截断感”
### 结构验收
- 组件职责清晰
- 渲染链路分层明确
- 后续可继续拓展搜索和对比能力
## 14. 结论
前端最终采用:
> 页级懒加载渲染架构。
即:
- 页作为最小渲染单位
- 可见页才挂载真实内容
- 页外只保留轻量壳
- 目录和滚动按页同步
- 图片、表格、富文本做二级懒加载
这是当前场景下最稳妥、最可落地、最利于后续扩展的方案。
# 智能翻译文档对比页后端接口说明书
## 方案进度概述
- 当前阶段:正式接口说明书整理
- 目标:在不修改原有接口的前提下,新增少量、低冗余、可直接落地的后端接口,支撑前端页级懒加载渲染
- 原则:只保留渲染必须数据,合并可合并接口,去除重复字段和中间态字段
- 适用对象:后端开发、联调开发、接口测试
## 1. 需求背景
当前 `src/views/intelligentTranslation/merged_ans.json` 是完整合并结果,内部同时包含文档摘要、页信息、节点信息、布局信息、样式信息与富文本内容。若前端一次性加载整包数据,会造成:
- 首屏加载慢
- 响应体积大
- 页内 DOM 节点过多
- 富文本、图片、表格渲染成本高
- 用户感知“内容像被截断”
因此需要在**不改原有接口**的前提下,新增更适合页面渲染的接口。
## 2. 设计原则
1. 原有接口保持不变,继续兼容旧逻辑
2. 新接口尽量少,优先合并相近能力
3. 返回结果只保留前端渲染必须字段
4. 大字段、重复字段、可计算字段尽量不返回
5. 支持页级加载
## 3. 数据来源与拆分范围
### 3.1 主要数据来源
新接口的数据来源为 `merged_ans.json` 中的三类信息:
- `document_meta`:文档级信息
- `pages`:页级索引信息
- `merged_nodes`:页内节点信息
### 3.2 拆分目标
后端只需要输出前端实际渲染所需的数据:
- 文档摘要
- 页级索引
- 目录树
- 页内容
## 4. 字段说明
下面仅说明新增接口会使用到的关键字段。
### 4.1 文档级字段
| 字段 | 含义 | 是否必需 | 说明 |
|---|---|---:|
| `source_file_name` | 原始文件名 | 是 | 用于页面标题和调试 |
| `source_file_type` | 文件类型 | 是 | 如 pdf |
| `page_count` | 总页数 | 是 | 用于总览和分页 |
| `created_at` | 结果生成时间 | 否 | 用于记录与排查 |
### 4.2 页级字段
| 字段 | 含义 | 是否必需 | 说明 |
|---|---|---:|
| `page_idx` | 页码,从 0 开始 | 是 | 页唯一标识 |
| `width` | 页面宽度 | 是 | 用于坐标换算 |
| `height` | 页面高度 | 是 | 用于坐标换算 |
| `rotation` | 页面旋转角度 | 否 | 默认 0 |
| `node_ids` | 本页节点 id 列表 | 是 | 用于快速定位与页级懒加载 |
| `node_count` | 节点数量 | 否 | 统计信息 |
| `has_image` | 是否包含图片 | 否 | 用于渲染优化 |
| `has_table` | 是否包含表格 | 否 | 用于渲染优化 |
### 4.3 节点级字段
| 字段 | 含义 | 是否必需 | 说明 |
|---|---|---:|---|
| `node_id` | 节点唯一 id | 是 | 前端定位主键 |
| `page_idx` | 所属页码 | 是 | 用于归属与跳转 |
| `semantic_type` | 语义类型 | 是 | 决定节点渲染方式 |
| `reading_order` | 阅读顺序 | 是 | 页内排序依据 |
| `content_payload` | 内容载荷 | 是 | 仅保留必须字段 |
| `layout` | 布局信息 | 是 | 页内版式还原 |
| `style_spans` | 局部样式片段 | 否 | 富文本节点需要时再返回 |
### 4.4 `semantic_type` 建议枚举
| 值 | 含义 | 用途 |
|---|---|---|
| `heading` | 标题 | 目录生成、章节跳转 |
| `paragraph` | 正文段落 | 普通文本展示 |
| `list_item` | 列表项 | 列表渲染 |
| `table` | 表格 | 表格组件渲染 |
| `image` | 图片 | 图片组件渲染 |
| `caption` | 图注/表注 | 辅助说明 |
| `header` | 页眉 | 版式还原 |
| `footer` | 页脚 | 版式还原 |
| `unknown` | 未知 | 兜底 |
### 4.5 `content_payload` 建议字段
| 字段 | 含义 | 是否必需 | 说明 |
|---|---|---:|---|
| `type` | 内容类型 | 是 | text/html/table/image |
| `text` | 纯文本内容 | 是 | 标题、正文最常用 |
| `html` | 富文本 HTML | 否 | 仅必要节点返回 |
| `heading_level` | 标题级别 | 否 | 仅标题需要 |
| `table_body_html` | 表格 HTML | 否 | 仅表格需要 |
| `img_path` | 原图路径 | 否 | 仅图片需要 |
| `preview_img_path` | 预览图路径 | 否 | 图片懒加载使用 |
| `alt_text` | 图片替代文本 | 否 | 无障碍或兜底 |
| `caption` | 图注/表注 | 否 | 辅助说明 |
### 4.6 `layout` 建议字段
| 字段 | 含义 | 是否必需 | 说明 |
|---|---|---:|---|
| `bbox_norm` | 归一化坐标框 | 是 | 优先用于渲染 |
| `bbox` | 像素坐标框 | 否 | bbox_norm 缺失时兜底 |
| `angle` | 旋转角度 | 否 | 默认 0 |
| `reading_order` | 阅读顺序 | 是 | 页内排序 |
| `page_idx` | 页码 | 是 | 与节点归属一致 |
### 4.7 `style_spans` 建议字段
| 字段 | 含义 | 是否必需 | 说明 |
|---|---|---:|---|
| `start` | 起始字符下标 | 是 | 样式区间开始 |
| `end` | 结束字符下标 | 是 | 样式区间结束 |
| `style.bold` | 是否加粗 | 否 | 富文本样式 |
| `style.italic` | 是否斜体 | 否 | 富文本样式 |
| `style.underline` | 是否下划线 | 否 | 富文本样式 |
| `style.color_hex` | 字体颜色 | 否 | 富文本样式 |
## 5. 接口设计总览
> 说明:以下均为新增接口。旧接口保持不变。
考虑到“能合并尽量合并”的要求,后端最终只保留 **2 个新增接口**
1. 文档摘要接口:返回文档基本信息、统计信息、页索引和目录树
2. 页内容接口:返回某一页可渲染的完整节点数据
---
## 6. 接口 1:文档摘要接口
### 6.1 接口名称
`GET /intelligent-translation/document-meta`
### 6.2 用途
一次性返回文档基础信息、统计信息、页索引摘要和目录树。前端初始化时优先调用该接口。
### 6.3 请求参数
无需额外参数。
### 6.4 返回参数
| 字段 | 类型 | 含义 | 是否必需 |
|---|---|---|---:|
| `document_meta` | object | 文档基础信息 | 是 |
| `statistics` | object | 统计信息 | 是 |
| `pages` | array | 页索引列表 | 是 |
| `outline` | array | 目录树 | 是 |
### 6.5 返回示例
```json
{
"code": 0,
"message": "success",
"data": {
"document_meta": {
"source_file_name": "xxx.pdf",
"source_file_type": "pdf",
"page_count": 148,
"created_at": "2026-04-21T11:00:39.246595Z"
},
"statistics": {
"page_count": 148,
"node_count": 1320,
"heading_count": 86,
"image_count": 34,
"table_count": 12
},
"pages": [
{
"page_idx": 0,
"width": 612,
"height": 792,
"rotation": 0,
"node_ids": ["page0_node0", "page0_node1"],
"node_count": 11,
"has_image": true,
"has_table": false
}
],
"outline": [
{
"id": "page0_node0",
"title": "Office of Science ...",
"page_idx": 0,
"level": 1,
"children": []
}
]
}
}
```
### 6.6 返回字段说明
- `document_meta`:文档基本属性
- `statistics`:文档整体统计
- `pages`:页级轻量索引,用于页壳渲染和跳页
- `outline`:标题目录树,用于导航
---
## 7. 接口 2:页内容接口
### 7.1 接口名称
`GET /intelligent-translation/document-page?page_idx=12`
### 7.2 用途
返回指定页的完整可渲染内容。前端在视口内挂载该页时调用。
### 7.3 请求参数
| 参数 | 类型 | 是否必需 | 说明 |
|---|---|---:|---|
| `page_idx` | number | 是 | 目标页码,从 0 开始 |
### 7.4 返回参数
| 字段 | 类型 | 含义 | 是否必需 |
|---|---|---|---:|
| `page` | object | 页基本信息 | 是 |
| `nodes` | array | 该页节点列表 | 是 |
### 7.5 `page` 字段说明
| 字段 | 含义 | 是否必需 |
|---|---|---:|
| `page_idx` | 页码 | 是 |
| `width` | 页面宽度 | 是 |
| `height` | 页面高度 | 是 |
| `rotation` | 页面旋转角度 | 否 |
### 7.6 `nodes` 字段说明
| 字段 | 含义 | 是否必需 |
|---|---|---:|
| `node_id` | 节点唯一 id | 是 |
| `semantic_type` | 语义类型 | 是 |
| `page_idx` | 所属页码 | 是 |
| `content_payload` | 内容载荷 | 是 |
| `layout` | 布局信息 | 是 |
| `style_spans` | 样式片段 | 否 |
### 7.7 `content_payload` 允许返回的最小字段
| 字段 | 用途 |
|---|---|
| `type` | 内容类型分发 |
| `text` | 纯文本渲染 |
| `html` | 富文本渲染 |
| `heading_level` | 目录/标题层级 |
| `table_body_html` | 表格渲染 |
| `img_path` | 图片渲染 |
| `preview_img_path` | 图片预览 |
| `alt_text` | 图片替代文本 |
| `caption` | 图注/表注 |
### 7.8 返回示例
```json
{
"code": 0,
"message": "success",
"data": {
"page": {
"page_idx": 12,
"width": 612,
"height": 792,
"rotation": 0
},
"nodes": [
{
"node_id": "page12_node0",
"semantic_type": "heading",
"page_idx": 12,
"content_payload": {
"type": "text",
"text": "Section Title",
"html": "<b>Section Title</b>",
"heading_level": 2
},
"layout": {
"bbox": [119, 204, 880, 338],
"bbox_norm": [0.12, 0.205, 0.881, 0.339],
"angle": 0,
"reading_order": 0,
"page_idx": 12
},
"style_spans": []
}
]
}
}
```
### 7.9 使用场景
- 页面滚动进入某页时加载
- 目录跳转到某页时加载
- 页壳替换为真实内容时加载
## 8. 后端拆分规则
### 8.1 文档摘要接口返回什么
保留:
- 文档基本信息
- 统计信息
- 页索引
- 目录树
去除:
- 节点完整正文
- 大 HTML
- 重复的布局信息
### 8.2 页内容接口返回什么
保留:
- 页信息
- 页内节点完整可渲染数据
去除:
- 与当前页无关的其他页内容
- 文档级统计信息
- 目录树
## 9. 开发实现建议
1. 直接从现有 `merged_ans.json` 构造两个接口返回体
2. 先实现 `document-meta`,让前端可初始化
3. 再实现 `document-page`,支撑页级加载
4. 保持旧接口不变,避免历史页面受影响
## 10. 验收标准
### 10.1 接口正确性
- 各接口返回字段稳定
- 页索引与页内容一致
- 节点 id 可唯一定位
### 10.2 性能要求
- `document-meta` 返回体轻量
- `document-page` 只返回单页数据
- 不再出现整包大响应作为前端主入口
### 10.3 联调要求
- 前端可先拿摘要和目录
- 前端可按页加载内容
## 11. 结论
后端新增接口最终收敛为 2 个:
- `document-meta`
- `document-page`
其中 `document-meta` 已合并页索引和目录,避免重复接口。整体目标是:**只保留渲染必须数据,降低响应体积,支撑前端页级懒加载渲染。**
# intelligentTranslation 重构方案(阶段任务清单版)
## 进度概述
- [ ] 阶段 1:静态数据适配层
- [ ] 阶段 2:左侧 PDF 原文预览
- [ ] 阶段 3:右侧 Tiptap 译文编辑器
- [ ] 阶段 4:页对应 / 段级高亮 / 导航跳转
- [ ] 阶段 5:Word 导出
执行规则:每完成一个阶段,必须先经用户验收确认,再更新本文档进度,然后才能进入下一阶段。
---
## 1. 目标
- 左侧使用 PDF 直接预览原文,不再用 JSON 反推版式。
- 右侧使用 Tiptap 编辑译文,按段块编辑。
- 原文与译文保持块级一一对应,支持段级高亮联动。
- 文本型 PDF 优先段级对应;扫描件退化为页级对应。
- 译文支持纯文本、表格可编辑、段落可新增/删除。
- 最终导出 Word。
---
## 2. 已确认边界
- 译文按段块编辑。
- 允许新增段落、删除段落。
- 不允许合并段落、拆分段落。
- 新增段落插在当前段后面,并自动继承上一段样式。
- 译文只允许纯文本。
- 表格仅改单元格文本,允许增删行列。
- 图片 / 公式先原样保留,不做编辑。
- 左原右译,右侧自然流式,但尽量与左侧页对齐。
- 滚动时左右页尽量同步。
- 导航粒度到章节标题。
---
## 3. 现有代码现状
- 页面入口:`src/views/intelligentTranslation/documentCompare.vue`
- 服务层:`src/views/intelligentTranslation/services/documentCompareService.js`
- 静态数据:`src/views/intelligentTranslation/merged_ans.json`
- 已有能力:
- 页面数据按页获取
- 章节大纲生成
- 页面滚动容器
- 节点选择 / 页面跳转基础逻辑
---
## 4. 推荐技术路线
- 原文:直接 PDF 预览。
- 译文:Tiptap。
- 数据层:把 `merged_ans.json` 适配成统一 block 模型。
- 导出:从 block 模型生成 Word。
- Markdown:仅作为辅助,不作为主模型。
---
## 5. 统一数据模型
### 必须保留的字段
- `id`
- `pageIdx`
- `type`
- `text`
- `level`
- `style`
- `sourceNodeId`
- `translationNodeId`
- `children`
### 基本映射
- `heading` → 标题块
- `paragraph` → 段落块
- `table` → 表格块
- `list` → 列表块
- `image` / `formula` → 只读占位块
### 页对应规则
- 有真实文本的 PDF:优先按 `pageIdx + 顺序` 对齐。
- 扫描件:只做页级对应。
---
## 6. 分阶段任务清单
### 阶段 1:静态数据适配层
目标:把 `merged_ans.json` 转成前端可消费的 block 数据。
任务:
- [ ] 提取页面、节点、章节大纲。
- [ ] 为每个节点保留稳定 ID。
- [ ] 生成统一 block schema。
- [ ] 生成页映射和章节映射。
- [ ] 生成右侧 Tiptap 初始文档数据。
验收:
- 能稳定拿到页、标题、段落、表格等块。
- 每个块都有唯一 ID。
- 页面和章节能正常定位。
---
### 阶段 2:左侧 PDF 原文预览
目标:左侧直接预览原始 PDF。
任务:
- [ ] 接入 PDF 预览组件或 PDF.js。
- [ ] 固定左侧页面渲染上下文。
- [ ] 保持缩放、滚动、页定位能力。
- [ ] 不再依赖 JSON 重建原文版式。
验收:
- 原文页面视觉接近 PDF 原图。
- 不再出现 JSON 重建导致的文字重叠。
---
### 阶段 3:右侧 Tiptap 译文编辑器
目标:实现按段块编辑的右侧译文区。
任务:
- [ ] 初始化 Tiptap。
- [ ] 支持正文、一级标题、二级标题等预设样式。
- [ ] 支持纯文本段落编辑。
- [ ] 支持段落新增。
- [ ] 支持段落删除。
- [ ] 支持表格单元格文本编辑。
- [ ] 支持增删表格行列。
验收:
- 右侧可编辑纯文本块。
- 样式仅通过预设切换。
- 表格能按要求编辑。
---
### 阶段 4:页对应 / 段级高亮 / 导航跳转
目标:建立原文与译文的联动。
任务:
- [ ]`pageIdx` 建立页锚点。
- [ ] 建立原文块与译文块的一一映射。
- [ ] 点击目录跳转到章节。
- [ ] 点击块时高亮对应块。
- [ ] 滚动时同步更新当前页。
- [ ] 扫描件场景降级为页级对应。
验收:
- 章节跳转可用。
- 左右块能联动高亮。
- 滚动时页状态正确更新。
---
### 阶段 5:Word 导出
目标:将编辑结果导出为 docx。
任务:
- [ ] 从 block 模型生成 Word 结构。
- [ ] 保留标题层级。
- [ ] 保留表格结构。
- [ ] 图片 / 公式保持原样占位或原样保留。
- [ ] 生成下载文件。
验收:
- Word 内容正确。
- 标题层级正确。
- 表格尽量保留。
- 能正常导出下载。
---
## 7. 当前风险
- `page_idx` 可能不是绝对真实页码,需要验证。
- 右侧流式编辑与左侧页同步天然存在偏差。
- 扫描件无法做真正段级对齐。
- 复杂表格导出可能与原文存在差异。
---
## 8. 当前待确认项
- `page_idx` 是否为真实 PDF 页码。
- 表格单元格是否只允许纯文本。
- 图片 / 公式导出时是否保留原对象。
- Word 是否要求尽量分页一致。
---
## 9. 执行规则
1. 只做当前阶段,不提前做后续阶段。
2. 当前阶段完成后,先由用户验收。
3. 验收通过后,更新本文件进度,再进入下一阶段。
4. 所有后续改动必须围绕统一 block 模型展开。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论