From 60c501e148f5a4c30acf6a7c760909db7e4a2950 Mon Sep 17 00:00:00 2001 From: "majun.jason" Date: Tue, 4 Aug 2026 19:26:18 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E5=90=88=E5=B9=B6=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + pnpm-lock.yaml | 317 +++++++++++++++- src/main/export-html-template.ts | 167 ++++++++- src/main/export-service.ts | 321 +++++++++++++--- src/renderer/src/App.tsx | 49 ++- .../components/export/ExportContactPanel.tsx | 30 +- .../components/export/ExportPreviewPanel.tsx | 25 +- .../components/export/ExportTaskCenter.tsx | 68 +++- .../src/components/export/ExportWorkspace.tsx | 353 ++++++++++-------- .../src/components/export/exportTypes.ts | 14 +- src/renderer/src/styles/export.scss | 101 +++++ src/shared/export.ts | 23 +- src/shared/types.ts | 3 + tests/component/export-workspace.test.tsx | 162 ++++++++ tests/e2e/app.spec.ts | 32 ++ tests/e2e/export-archive.spec.ts | 170 +++++++++ tests/integration/export-media-flow.test.ts | 302 +++++++++++++-- tests/unit/export-media.test.ts | 74 ++++ 18 files changed, 1926 insertions(+), 287 deletions(-) create mode 100644 tests/component/export-workspace.test.tsx create mode 100644 tests/e2e/export-archive.spec.ts diff --git a/package.json b/package.json index 67fe05f..e6419f4 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@electron-toolkit/utils": "^4.0.0", "@koromix/koffi-win32-x64": "3.1.0", "@tanstack/react-virtual": "^3.14.6", + "archiver": "^8.0.0", "cross-env": "^10.1.0", "electron-updater": "^6.6.2", "ffmpeg-static": "5.3.0", @@ -79,6 +80,7 @@ "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/archiver": "^8.0.0", "@types/fs-extra": "^11.0.4", "@types/node": "^22.19.1", "@types/react": "^19.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 414630e..a7bd9a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,12 +19,14 @@ specifiers: '@testing-library/jest-dom': ^7.0.0 '@testing-library/react': ^16.3.2 '@testing-library/user-event': ^14.6.1 + '@types/archiver': ^8.0.0 '@types/fs-extra': ^11.0.4 '@types/node': ^22.19.1 '@types/react': ^19.2.7 '@types/react-dom': ^19.2.3 '@vitejs/plugin-react': ^5.1.1 '@vitest/coverage-v8': ^4.1.10 + archiver: ^8.0.0 cross-env: ^10.1.0 electron: ^43.0.0 electron-builder: ^26.0.12 @@ -56,6 +58,7 @@ dependencies: '@electron-toolkit/utils': 4.0.0_electron@43.1.0 '@koromix/koffi-win32-x64': 3.1.0 '@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq + archiver: 8.0.0 cross-env: 10.1.0 electron-updater: 6.8.9 ffmpeg-static: 5.3.0 @@ -77,6 +80,7 @@ devDependencies: '@testing-library/jest-dom': 7.0.0_rwcxf7kajngkz26znmtz643644 '@testing-library/react': 16.3.2_ifbmwgqdqpqwyvhobr7vc7fmyu '@testing-library/user-event': 14.6.1_rwcxf7kajngkz26znmtz643644 + '@types/archiver': 8.0.0 '@types/fs-extra': 11.0.4 '@types/node': 22.19.2 '@types/react': 19.2.7 @@ -1513,6 +1517,13 @@ packages: engines: {node: '>= 10'} dev: true + /@types/archiver/8.0.0: + resolution: {integrity: sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A==} + dependencies: + '@types/node': 22.19.2 + '@types/readdir-glob': 1.1.5 + dev: true + /@types/aria-query/5.0.4: resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} dev: true @@ -1650,6 +1661,12 @@ packages: csstype: 3.2.3 dev: true + /@types/readdir-glob/1.1.5: + resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + dependencies: + '@types/node': 22.19.2 + dev: true + /@types/responselike/1.0.3: resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} dependencies: @@ -1911,6 +1928,13 @@ packages: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} dev: true + /abort-controller/3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + dependencies: + event-target-shim: 5.0.1 + dev: false + /acorn-jsx/5.3.2_acorn@8.15.0: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2047,6 +2071,25 @@ packages: - supports-color dev: true + /archiver/8.0.0: + resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==} + engines: {node: '>=18'} + dependencies: + async: 3.2.6 + buffer-crc32: 1.0.0 + is-stream: 4.0.1 + lazystream: 1.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + readdir-glob: 3.0.0 + tar-stream: 3.2.0 + zip-stream: 7.0.5 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + dev: false + /argparse/2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2176,7 +2219,6 @@ packages: /async/3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - dev: true /asynckit/0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2194,13 +2236,86 @@ packages: possible-typed-array-names: 1.1.0 dev: true + /b4a/1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + dev: false + /balanced-match/1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: true + /balanced-match/4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + dev: false + + /bare-events/2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + dev: false + + /bare-fs/4.8.0: + resolution: {integrity: sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==} + engines: {bare: '>=1.28.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + dependencies: + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.13.3_bare-events@2.9.1 + bare-url: 2.4.7 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + dev: false + + /bare-path/3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + dev: false + + /bare-stream/2.13.3_bare-events@2.9.1: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + dependencies: + b4a: 1.8.1 + bare-events: 2.9.1 + streamx: 2.28.0 + teex: 1.0.1 + transitivePeerDependencies: + - react-native-b4a + dev: false + + /bare-url/2.4.7: + resolution: {integrity: sha512-o8CRCiJtib+ycO3mE4A5UChtGX4dDP2XxsWVu9P+Zc3H8tcmKwNVEDoDTXmwN+uuMhfKeT7/i7Y26xS8W7ohoA==} + dependencies: + bare-path: 3.1.1 + dev: false + /base64-js/1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - dev: true /baseline-browser-mapping/2.9.5: resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==} @@ -2234,6 +2349,13 @@ packages: balanced-match: 1.0.2 dev: true + /brace-expansion/5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + dependencies: + balanced-match: 4.0.4 + dev: false + /browserslist/4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -2246,6 +2368,11 @@ packages: update-browserslist-db: 1.2.2_browserslist@4.28.1 dev: true + /buffer-crc32/1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + dev: false + /buffer-from/1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2256,6 +2383,13 @@ packages: ieee754: 1.2.1 dev: true + /buffer/6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: false + /builder-util-runtime/9.3.1: resolution: {integrity: sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==} engines: {node: '>=12.0.0'} @@ -2496,6 +2630,17 @@ packages: engines: {node: '>=0.10.0'} dev: true + /compress-commons/7.0.1: + resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==} + engines: {node: '>=18'} + dependencies: + crc-32: 1.2.2 + crc32-stream: 7.0.1 + is-stream: 4.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + dev: false + /concat-map/0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true @@ -2523,8 +2668,12 @@ packages: /core-util-is/1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} - dev: true - optional: true + + /crc-32/1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + dev: false /crc/3.8.0: resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} @@ -2533,6 +2682,14 @@ packages: dev: true optional: true + /crc32-stream/7.0.1: + resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==} + engines: {node: '>=18'} + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + dev: false + /cross-env/10.1.0: resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} engines: {node: '>=20'} @@ -3247,6 +3404,24 @@ packages: engines: {node: '>=0.10.0'} dev: true + /event-target-shim/5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + dev: false + + /events-universal/1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + dev: false + + /events/3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + dev: false + /expect-type/1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -3270,6 +3445,10 @@ packages: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} dev: true + /fast-fifo/1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + dev: false + /fast-json-stable-stringify/2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} dev: true @@ -3538,7 +3717,7 @@ packages: /glob/8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -3745,7 +3924,6 @@ packages: /ieee754/1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - dev: true /ignore/5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} @@ -3967,6 +4145,11 @@ packages: call-bound: 1.0.4 dev: true + /is-stream/4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + dev: false + /is-string/1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -4016,6 +4199,10 @@ packages: get-intrinsic: 1.3.0 dev: true + /isarray/1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + dev: false + /isarray/2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} dev: true @@ -4207,6 +4394,13 @@ packages: /lazy-val/1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + /lazystream/1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + dependencies: + readable-stream: 2.3.8 + dev: false + /levn/0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -4391,6 +4585,13 @@ packages: '@isaacs/brace-expansion': 5.0.0 dev: true + /minimatch/10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + dependencies: + brace-expansion: 5.0.9 + dev: false + /minimatch/3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -4538,6 +4739,11 @@ packages: abbrev: 1.1.1 dev: true + /normalize-path/3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: false + /normalize-url/6.1.0: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} @@ -4834,6 +5040,15 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} dev: true + /process-nextick-args/2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + dev: false + + /process/0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + dev: false + /progress/2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -4914,6 +5129,18 @@ packages: - supports-color dev: true + /readable-stream/2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + dependencies: + core-util-is: 1.0.2 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + dev: false + /readable-stream/3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -4922,6 +5149,24 @@ packages: string_decoder: 1.3.0 util-deprecate: 1.0.2 + /readable-stream/4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + dev: false + + /readdir-glob/3.0.0: + resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==} + engines: {node: '>=18'} + dependencies: + minimatch: 10.2.6 + dev: false + /readdirp/5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -5066,6 +5311,10 @@ packages: isarray: 2.0.5 dev: true + /safe-buffer/5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + dev: false + /safe-buffer/5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -5321,6 +5570,17 @@ packages: internal-slot: 1.1.0 dev: true + /streamx/2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + dev: false + /string-width/4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -5397,6 +5657,12 @@ packages: es-object-atoms: 1.1.1 dev: true + /string_decoder/1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + dependencies: + safe-buffer: 5.1.2 + dev: false + /string_decoder/1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} dependencies: @@ -5459,6 +5725,19 @@ packages: '@pkgr/core': 0.2.9 dev: true + /tar-stream/3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + dependencies: + b4a: 1.8.1 + bare-fs: 4.8.0 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + dev: false + /tar/6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} @@ -5471,6 +5750,15 @@ packages: yallist: 4.0.0 dev: true + /teex/1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + dev: false + /temp-file/3.4.0: resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} dependencies: @@ -5478,6 +5766,14 @@ packages: fs-extra: 10.1.0 dev: true + /text-decoder/1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + dev: false + /tiny-async-pool/1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} dependencies: @@ -6035,6 +6331,15 @@ packages: engines: {node: '>=10'} dev: true + /zip-stream/7.0.5: + resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} + engines: {node: '>=18'} + dependencies: + compress-commons: 7.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + dev: false + /zod-validation-error/4.0.2_zod@4.1.13: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index 38bb627..c753011 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -39,8 +39,16 @@ body { padding: 16px 20px; box-shadow: 0 8px 24px #29483b12; } +.archive-heading { + display: flex; + align-items: center; + gap: 8px 12px; + min-width: 0; + flex-wrap: wrap; +} .title { font-size: 18px; font-weight: 750; } .meta { color: var(--muted); margin-left: 12px; font-size: 13px; } +.archive-heading .meta { margin-left: 0; } .controls { display: flex; gap: 8px; align-items: center; justify-content: flex-end; } .controls input, .filter-button { border: 1px solid var(--border); @@ -63,13 +71,66 @@ body { .count { margin-left: auto; color: var(--muted); font-size: 13px; } .archive-layout { display: grid; - grid-template-columns: 150px minmax(0, 1fr); + grid-template-columns: 168px minmax(0, 1fr); gap: 18px; min-height: 0; flex: 1; margin-top: 16px; } +.archive-layout.single-conversation { grid-template-columns: 168px minmax(0, 1fr); } +.archive-navigation { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + min-height: 0; +} +.conversation-filter { + position: relative; + display: inline-flex; + flex: 0 1 320px; + min-width: 210px; + max-width: 100%; +} +.conversation-filter[hidden] { display: none; } +.conversation-filter::after { + content: ''; + position: absolute; + right: 15px; + top: 50%; + width: 8px; + height: 8px; + border-right: 2px solid var(--accent); + border-bottom: 2px solid var(--accent); + transform: translateY(-65%) rotate(45deg); + pointer-events: none; +} +.conversation-select { + appearance: none; + width: 100%; + min-width: 0; + height: 42px; + padding: 0 42px 0 14px; + border: 1px solid #b8cec5; + border-radius: 10px; + outline: 0; + background: #f7fbf9; + color: var(--text); + font: inherit; + font-size: 17px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 2px 8px #29483b12; + transition: border-color .15s ease, background .15s ease, box-shadow .15s ease; +} +.conversation-select:hover { border-color: #70a392; background: #fff; } +.conversation-select:focus { + border-color: var(--accent); + background: #fff; + box-shadow: 0 0 0 3px #176b571c; +} .timeline { + flex: 1; overflow: auto; background: #f7faf8; border: 1px solid var(--border); @@ -133,6 +194,14 @@ body { } .message.system .sender { display: none; } .time { color: var(--muted); font-size: 11px; margin: 0 12px; } +.conversation-source { + display: inline-block; + margin-left: 8px; + padding: 2px 6px; + border-radius: 5px; + background: var(--accent-soft); + color: var(--accent); +} .row { display: flex; gap: 12px; align-items: flex-end; max-width: 100%; } .sent .row { flex-direction: row-reverse; } .avatar { @@ -249,9 +318,19 @@ body { .controls input[type=search] { width: 100%; } .filters { grid-column: 1; } .count { width: 100%; margin-left: 0; } - .archive-layout { grid-template-columns: 1fr; margin-top: 10px; } - .timeline { display: flex; gap: 6px; overflow: auto; padding: 8px; } - .timeline-year { display: none; } + .archive-layout { grid-template-columns: 1fr; align-content: start; margin-top: 10px; } + .archive-layout.single-conversation { grid-template-columns: 1fr; } + .archive-navigation { align-self: start; gap: 8px; } + .timeline { align-self: start; display: flex; gap: 6px; overflow: auto; padding: 8px; } + .archive-heading { align-items: flex-start; } + .conversation-filter { flex-basis: 100%; width: 100%; } + .timeline-year { + flex: 0 0 auto; + align-self: center; + margin: 0 2px 0 0; + padding: 7px 5px; + white-space: nowrap; + } .timeline-month { flex: 0 0 auto; width: auto; @@ -281,8 +360,24 @@ const renderExportScript = (name: string): string => ` const PAGE_SIZE = ${EXPORT_PAGE_SIZE} const WINDOW_STEP = Math.floor(PAGE_SIZE / 2) const archive = window.__WECHAT_EXPORT__ || { name: ${JSON.stringify(name)}, messages: [] } - const allMessages = Array.isArray(archive.messages) ? archive.messages : [] + const legacyConversation = archive.sourceId + ? [{ id: archive.sourceId, name: archive.name || ${JSON.stringify(name)}, type: 'user', messageCount: 0 }] + : [] + const conversations = Array.isArray(archive.conversations) && archive.conversations.length + ? archive.conversations + : legacyConversation + const allMessages = (Array.isArray(archive.messages) ? archive.messages : []).map((message) => + message.exportConversationId || conversations.length !== 1 + ? message + : Object.assign({}, message, { + exportConversationId: conversations[0].id, + exportConversationName: conversations[0].name + }) + ) const list = document.querySelector('#messages') + const layout = document.querySelector('.archive-layout') + const conversationFilter = document.querySelector('#conversation-filter') + const conversationSelect = document.querySelector('#conversation-select') const timeline = document.querySelector('#timeline') const query = document.querySelector('#query') const count = document.querySelector('#count') @@ -293,6 +388,7 @@ const renderExportScript = (name: string): string => ` const preview = document.querySelector('#lightbox-image') const closeButton = document.querySelector('#lightbox-close') let activeKind = 'all' + let activeConversation = 'all' let filtered = [] let windowStart = 0 let windowEnd = 0 @@ -345,6 +441,7 @@ const renderExportScript = (name: string): string => ` return 'text' } const searchText = (message) => [ + message.exportConversationName, message.name, message.senderId, message.content, @@ -389,14 +486,42 @@ const renderExportScript = (name: string): string => ` : avatarFallback) + '' const text = message.content || (data.type === 'quote' ? data.title : '') const content = esc(text || (!media && !audio && !quote ? '[' + (message.type || '消息') + ']' : '')) + const source = conversations.length > 1 && activeConversation === 'all' + ? '' + esc(message.exportConversationName || '聊天') + '' + : '' return '
' + - '
' + esc(fullTime(message)) + '
' + + '
' + esc(fullTime(message)) + source + '
' + (isSystem ? '' : avatar) + '
' + (isSystem ? '' : esc(sender)) + '
' + media + audio + quote + '
' + content + '
' + mediaStatus + '
' } + const renderConversations = () => { + if (conversations.length <= 1) { + conversationFilter.hidden = true + title.hidden = false + layout.classList.add('single-conversation') + return + } + const counts = new Map() + for (const message of allMessages) { + const id = message.exportConversationId || '' + counts.set(id, (counts.get(id) || 0) + 1) + } + const option = (id, label, total) => + '' + title.hidden = true + conversationFilter.hidden = false + conversationSelect.innerHTML = option('all', '全部聊天', allMessages.length) + + conversations.map((conversation) => option( + conversation.id, + conversation.name, + counts.get(conversation.id) || 0 + )).join('') + conversationSelect.value = activeConversation + } + const renderTimeline = () => { if (filtered.length === 0) { timeline.innerHTML = '
没有可跳转的月份
' @@ -431,7 +556,11 @@ const renderExportScript = (name: string): string => ` } const updateCount = () => { const shown = Math.max(0, windowEnd - windowStart) - count.textContent = '已显示 ' + shown + ' / 筛选 ' + filtered.length + ' / 全部 ' + allMessages.length + const scopeTotal = activeConversation === 'all' + ? allMessages.length + : allMessages.filter((message) => message.exportConversationId === activeConversation).length + const scopeLabel = activeConversation === 'all' ? '全部' : '当前聊天' + count.textContent = '已显示 ' + shown + ' / 筛选 ' + filtered.length + ' / ' + scopeLabel + ' ' + scopeTotal } const setScrollTop = (value) => { scrollLoadSuppressed = true @@ -472,6 +601,7 @@ const renderExportScript = (name: string): string => ` const applyFilters = () => { const term = query.value.trim().toLowerCase() filtered = allMessages.filter((message) => + (activeConversation === 'all' || message.exportConversationId === activeConversation) && (activeKind === 'all' || kindOf(message) === activeKind) && (!term || searchText(message).includes(term)) ) @@ -540,6 +670,10 @@ const renderExportScript = (name: string): string => ` filters.querySelectorAll('[data-kind]').forEach((item) => item.classList.toggle('active', item === button)) applyFilters() }) + conversationSelect.addEventListener('change', () => { + activeConversation = conversationSelect.value + applyFilters() + }) timeline.addEventListener('click', (event) => { const button = event.target.closest('[data-month]') if (button) jumpToMonth(button.dataset.month) @@ -577,10 +711,12 @@ const renderExportScript = (name: string): string => ` }) title.textContent = archive.name || ${JSON.stringify(name)} - meta.textContent = allMessages.length.toLocaleString() + ' 条消息 · 更新于 ' + - (archive.exportedAt - ? new Date(archive.exportedAt).toLocaleString('zh-CN', { hour12: false }) - : '未知时间') + const updatedAt = archive.exportedAt + ? new Date(archive.exportedAt).toLocaleString('zh-CN', { hour12: false }) + : '未知时间' + meta.textContent = (conversations.length > 1 ? '' : allMessages.length.toLocaleString() + ' 条消息 · ') + + '更新于 ' + updatedAt + renderConversations() applyFilters() })() ` @@ -597,8 +733,11 @@ export function renderExportPage(name: string): string {
-
+
${safe(name)} + 正在读取消息…
@@ -616,7 +755,9 @@ export function renderExportPage(name: string): string {
- +
正在加载聊天档案…
diff --git a/src/main/export-service.ts b/src/main/export-service.ts index 461d85b..bfdf78c 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -1,14 +1,16 @@ import { app, BrowserWindow, shell } from 'electron' import { createHash } from 'crypto' -import { promises as fs } from 'fs' +import { createWriteStream, promises as fs } from 'fs' import { extname, join } from 'path' import { fileURLToPath } from 'url' +import { ZipArchive, type Archiver } from 'archiver' import * as chat from './services/chat-service' import type { ExportJobProgress, ExportMessageKind, ExportRequest, - ExportResult + ExportResult, + ExportTarget } from '../shared/export' import type { Message } from '../shared/types' import { VoiceService } from './voice-service' @@ -22,8 +24,18 @@ import { FileAssetService } from './file-asset-service' import { mergeCachedSelfInfo } from './services/bootstrap-cache' const jobs = new Set() +const activeArchives = new Map() const safeFilePart = (value: string): string => value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案' +const copyWritableExportFile = async (source: string, destination: string): Promise => { + try { + await fs.chmod(destination, 0o644) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + await fs.copyFile(source, destination) + await fs.chmod(destination, 0o644) +} const exportStamp = (): string => { const date = new Date() const pad = (value: number): string => String(value).padStart(2, '0') @@ -31,7 +43,15 @@ const exportStamp = (): string => { } const imageKeys = new ImageKeyConfigService() -export interface HtmlExportArchive { +export interface HtmlExportConversation { + id: string + name: string + type: 'user' | 'group' + avatarUrl?: string + messageCount: number +} + +interface HtmlExportArchiveV1 { version: 1 sourceId: string name: string @@ -39,20 +59,29 @@ export interface HtmlExportArchive { messages: Message[] } +export interface HtmlExportArchive { + version: 2 + name: string + exportedAt: string + conversations: HtmlExportConversation[] + messages: Message[] +} + const archiveDataPrefix = 'window.__WECHAT_EXPORT__ = ' const hashPart = (value: string, length = 16): string => createHash('sha1').update(value).digest('hex').slice(0, length) export const exportMessageKey = (message: Message, sourceId = ''): string => { + const conversationId = message.exportConversationId || sourceId const sessionId = message.sessionId || sourceId if (message.localId && message.createTime) { - return `${sessionId}:local:${message.localId}:${message.createTime}` + return `${conversationId}:${sessionId}:local:${message.localId}:${message.createTime}` } - if (message.serverId) return `${sessionId}:server:${message.serverId}` + if (message.serverId) return `${conversationId}:${sessionId}:server:${message.serverId}` if (message.id && message.createTime && !/^0\.\d+$/.test(message.id)) { - return `${sessionId}:id:${message.id}:${message.createTime}` + return `${conversationId}:${sessionId}:id:${message.id}:${message.createTime}` } - return `${sessionId}:fallback:${hashPart( + return `${conversationId}:${sessionId}:fallback:${hashPart( JSON.stringify([ message.createTime || 0, message.senderId || '', @@ -89,7 +118,8 @@ const mergeArchiveMessage = (previous: Message, current: Message): Message => { export function mergeHtmlArchiveMessages( previous: Message[], current: Message[], - sourceId = '' + sourceId = '', + conversationOrder: string[] = [] ): Message[] { const merged = new Map() for (const message of previous) merged.set(exportMessageKey(message, sourceId), message) @@ -101,6 +131,10 @@ export function mergeHtmlArchiveMessages( return Array.from(merged.values()).sort((left, right) => { const byTime = Number(left.createTime || 0) - Number(right.createTime || 0) if (byTime !== 0) return byTime + const byConversation = + conversationOrder.indexOf(left.exportConversationId || sourceId) - + conversationOrder.indexOf(right.exportConversationId || sourceId) + if (byConversation !== 0) return byConversation return Number(left.localId || 0) - Number(right.localId || 0) }) } @@ -127,7 +161,7 @@ export function normalizeHtmlArchiveSelfNames( export async function readHtmlArchive( outputDir: string, - sourceId: string, + targets: ExportTarget[], name: string ): Promise { const dataPath = join(outputDir, 'data', 'messages.js') @@ -136,7 +170,18 @@ export async function readHtmlArchive( source = await fs.readFile(dataPath, 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return { version: 1, sourceId, name, exportedAt: new Date(0).toISOString(), messages: [] } + return { + version: 2, + name, + exportedAt: new Date(0).toISOString(), + conversations: targets.map((target) => ({ + id: target.userMd5, + name: target.name, + type: target.type, + messageCount: 0 + })), + messages: [] + } } throw error } @@ -146,21 +191,100 @@ export async function readHtmlArchive( .slice(assignment + 1) .trim() .replace(/;\s*$/, '') - let archive: HtmlExportArchive + let parsed: HtmlExportArchive | HtmlExportArchiveV1 try { - archive = JSON.parse(json) as HtmlExportArchive + parsed = JSON.parse(json) as HtmlExportArchive | HtmlExportArchiveV1 } catch { throw new Error('现有 HTML 档案数据已损坏,请从 messages.js.bak 恢复或更换导出名称') } - if (archive.sourceId && archive.sourceId !== sourceId) { - throw new Error('同名导出目录已属于另一个会话,请修改文件名称后重试') + const expectedIds = targets.map((target) => target.userMd5).sort() + if (parsed.version === 1) { + if (targets.length !== 1 || parsed.sourceId !== targets[0].userMd5) { + throw new Error('同名导出目录的聊天集合不同,请修改文件名称后重试') + } + return { + version: 2, + name: parsed.name || name, + exportedAt: parsed.exportedAt || new Date(0).toISOString(), + conversations: [ + { + id: targets[0].userMd5, + name: targets[0].name, + type: targets[0].type, + messageCount: Array.isArray(parsed.messages) ? parsed.messages.length : 0 + } + ], + messages: (Array.isArray(parsed.messages) ? parsed.messages : []).map((message) => ({ + ...message, + exportConversationId: targets[0].userMd5, + exportConversationName: targets[0].name + })) + } + } + const actualIds = (Array.isArray(parsed.conversations) ? parsed.conversations : []) + .map((conversation) => conversation.id) + .sort() + if (actualIds.join('|') !== expectedIds.join('|')) { + throw new Error('同名导出目录的聊天集合不同,请修改文件名称后重试') } return { - version: 1, - sourceId, - name: archive.name || name, - exportedAt: archive.exportedAt || new Date(0).toISOString(), - messages: Array.isArray(archive.messages) ? archive.messages : [] + version: 2, + name: parsed.name || name, + exportedAt: parsed.exportedAt || new Date(0).toISOString(), + conversations: parsed.conversations, + messages: Array.isArray(parsed.messages) ? parsed.messages : [] + } +} + +async function writeZipArchive( + outputDir: string, + zipPath: string, + folderName: string, + jobId: string +): Promise { + const temporaryPath = `${zipPath}.tmp-${process.pid}-${Date.now()}` + await fs.rm(temporaryPath, { force: true }) + const output = createWriteStream(temporaryPath) + await new Promise((resolve, reject) => { + output.once('open', () => resolve()) + output.once('error', reject) + }) + const archive = new ZipArchive({ zlib: { level: 6 } }) + activeArchives.set(jobId, archive) + try { + await new Promise((resolve, reject) => { + let settled = false + const finish = (error?: Error): void => { + if (settled) return + settled = true + if (error) reject(error) + else resolve() + } + output.on('close', () => finish()) + output.on('error', finish) + archive.on('warning', (error) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') finish(error) + }) + archive.on('error', finish) + archive.pipe(output) + archive.directory(outputDir, safeFilePart(folderName)) + void archive.finalize().catch(finish) + }) + if (!jobs.has(jobId)) throw new Error('已取消') + try { + await fs.rename(temporaryPath, zipPath) + } catch (error) { + if (!['EEXIST', 'EPERM'].includes((error as NodeJS.ErrnoException).code || '')) throw error + await fs.rm(zipPath, { force: true }) + await fs.rename(temporaryPath, zipPath) + } + } finally { + activeArchives.delete(jobId) + if (!output.closed) { + output.destroy() + await new Promise((resolve) => output.once('close', () => resolve())) + } + await fs.rm(temporaryPath, { force: true }) } } @@ -298,11 +422,57 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro if (!win.isDestroyed()) win.webContents.send('export:progress', p) } try { + const targets = request.targets || [] + if (targets.length < 1 || targets.length > 5) { + throw new Error('一次导出必须选择 1 到 5 个聊天') + } + if (new Set(targets.map((target) => target.userMd5)).size !== targets.length) { + throw new Error('导出聊天不能重复') + } + if (targets.length > 1 && request.format !== 'html') { + throw new Error('多聊天合并仅支持 HTML 格式') + } + const archiveName = + targets.length > 1 ? `${targets[0].name} 等 ${targets.length} 个聊天` : targets[0].name + const targetById = new Map(targets.map((target) => [target.userMd5, target])) send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 }) await new Promise((resolve) => setImmediate(resolve)) - const messages = ( - await chat.listMessagesAsync(request.userMd5, request.startTime, request.endTime) - ).filter((message) => request.kinds.includes(kindOf(message))) + const messageEntries: { message: Message; targetOrder: number; messageOrder: number }[] = [] + for (const [targetOrder, target] of targets.entries()) { + if (!jobs.has(request.jobId)) { + send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 5 }) + return { success: false, error: '已取消' } + } + const targetMessages = ( + await chat.listMessagesAsync(target.userMd5, request.startTime, request.endTime) + ).filter((message) => request.kinds.includes(kindOf(message))) + for (const [messageOrder, message] of targetMessages.entries()) { + messageEntries.push({ + message: { + ...message, + exportConversationId: target.userMd5, + exportConversationName: target.name + }, + targetOrder, + messageOrder + }) + } + send({ + jobId: request.jobId, + phase: 'reading', + processed: targetOrder + 1, + total: targets.length, + percent: Math.max(1, Math.round(((targetOrder + 1) / targets.length) * 10)) + }) + } + const messages = messageEntries + .sort((left, right) => { + const byTime = Number(left.message.createTime || 0) - Number(right.message.createTime || 0) + if (byTime !== 0) return byTime + if (left.targetOrder !== right.targetOrder) return left.targetOrder - right.targetOrder + return left.messageOrder - right.messageOrder + }) + .map((entry) => entry.message) const rawSelfInfo = await chat.getSelfAccountInfoAsync() const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null const isUsableSelfName = (value: string | undefined): value is string => { @@ -313,13 +483,14 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro return true } for (const message of messages) { + const target = targetById.get(message.exportConversationId || '') message.exportMediaUrl = undefined message.exportMediaType = undefined message.exportMediaName = undefined message.exportMediaError = undefined message.voiceDataUrl = undefined message.exportShowAvatar = request.includeAvatars !== false - const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined + const mappedName = message.senderId ? target?.nameMap?.[message.senderId] : undefined if (mappedName && (!message.isSender || isUsableSelfName(mappedName))) { message.name = mappedName } else if (message.isSender && isUsableSelfName(selfInfo?.nickname)) { @@ -357,7 +528,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro ? join(outputDir, 'index.html') : join(root, `${outputFolder}.${ext}`) if (request.format === 'html') { - const previousArchive = await readHtmlArchive(outputDir, request.userMd5, request.name) + const previousArchive = await readHtmlArchive(outputDir, targets, archiveName) await fs.mkdir(join(outputDir, 'voices'), { recursive: true }) await fs.mkdir(join(outputDir, 'media'), { recursive: true }) await fs.mkdir(join(outputDir, 'avatars'), { recursive: true }) @@ -369,10 +540,14 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro .filter((value): value is string => Boolean(value)) ) ) + const requestedAvatarUrls = Object.assign( + {}, + ...targets.map((target) => target.avatarUrls || {}) + ) as Record const avatarMap = request.includeAvatars === false ? {} - : { ...chat.getContactAvatars(avatarUsernames), ...(request.avatarUrls || {}) } + : { ...(await chat.getContactAvatars(avatarUsernames)), ...requestedAvatarUrls } const imageConfig = imageKeys.getConfig() const imageService = client && imageConfig.aesKey @@ -382,13 +557,27 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro const stickerService = client ? new StickerService(client) : null const fileService = client ? new FileAssetService(client) : null const exportedAvatars = new Map() + const conversationAvatarUrls = new Map() + if (request.includeAvatars !== false) { + for (const target of targets) { + if (!jobs.has(request.jobId)) throw new Error('已取消') + if (!target.avatarUrl) continue + const resolved = await readAvatarAsset(target.avatarUrl) + if (!resolved) continue + const avatarName = `conversation_${hashPart(target.userMd5)}.${resolved.extension}` + await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer) + conversationAvatarUrls.set(target.userMd5, `avatars/${avatarName}`) + } + } const voiceService = request.includeMedia && chat.getChatDb() ? new VoiceService(chat.getChatDb()!.getWcdb4Client()) : null if (voiceService) { for (const message of messages) { + if (!jobs.has(request.jobId)) throw new Error('已取消') if (kindOf(message) !== 'voice') continue + const conversationId = message.exportConversationId || targets[0].userMd5 if (!message.sessionId || message.localId == null || !message.createTime) { keepMediaError(request, message, '语音标识不完整,无法定位本地语音') continue @@ -410,7 +599,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro keepMediaError(request, message, reason) continue } - const voiceName = `voice_${hashPart(exportMessageKey(message, request.userMd5))}.wav` + const voiceName = `voice_${hashPart(exportMessageKey(message, conversationId))}.wav` const audioBuffer = Buffer.from(voice.data, 'base64') await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) message.voiceDataUrl = `voices/${voiceName}` @@ -431,14 +620,26 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } } for (const [index, message] of messages.entries()) { + if (!jobs.has(request.jobId)) { + send({ + jobId: request.jobId, + phase: 'cancelled', + processed: index, + total: messages.length, + percent: 15 + Math.round((index / Math.max(messages.length, 1)) * 75) + }) + return { success: false, error: '已取消' } + } + const conversationId = message.exportConversationId || targets[0].userMd5 message.exportShowAvatar = request.includeAvatars !== false const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img const resolvedAvatar = avatar ? await readAvatarAsset(avatar) : null const avatarBuffer = resolvedAvatar?.buffer || null const avatarExtension = resolvedAvatar?.extension || 'jpg' if (avatarBuffer) { - const avatarKey = - message.senderId || avatar || `message_${exportMessageKey(message, request.userMd5)}` + const avatarKey = `${conversationId}:${ + message.senderId || avatar || `message_${exportMessageKey(message, conversationId)}` + }` let avatarName = exportedAvatars.get(avatarKey) if (!avatarName) { avatarName = `avatar_${hashPart(avatarKey)}.${avatarExtension}` @@ -472,7 +673,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro allowThumbnail: attempt.allowThumbnail, preferThumbnail: attempt.preferThumbnail, sessionId: message.sessionId, - sessionMd5: request.userMd5, + sessionMd5: conversationId, createTime: message.createTime } ) @@ -497,7 +698,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null if (decoded) { - const name = `image_${hashPart(exportMessageKey(message, request.userMd5))}.${decoded.extension}` + const name = `image_${hashPart(exportMessageKey(message, conversationId))}.${decoded.extension}` await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) message.exportMediaUrl = `media/${name}` message.exportMediaType = 'image' @@ -534,8 +735,8 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } else if (extname(source).toLowerCase() !== '.mp4') { keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件') } else { - const name = `video_${hashPart(exportMessageKey(message, request.userMd5))}.mp4` - await fs.copyFile(source, join(outputDir, 'media', name)) + const name = `video_${hashPart(exportMessageKey(message, conversationId))}.mp4` + await copyWritableExportFile(source, join(outputDir, 'media', name)) message.exportMediaUrl = `media/${name}` message.exportMediaType = 'video' } @@ -549,7 +750,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro ? await readAvatarAsset(stickerSource) : null if (decoded) { - const name = `sticker_${hashPart(exportMessageKey(message, request.userMd5))}.${decoded.extension}` + const name = `sticker_${hashPart(exportMessageKey(message, conversationId))}.${decoded.extension}` await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) message.exportMediaUrl = `media/${name}` message.exportMediaType = 'sticker' @@ -564,8 +765,8 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro if (!resolved.success || !resolved.filePath || !resolved.fileName) { keepMediaError(request, message, resolved.error || '本地文件附件缺失') } else { - const name = `file_${hashPart(exportMessageKey(message, request.userMd5))}_${safeFilePart(resolved.fileName)}` - await fs.copyFile(resolved.filePath, join(outputDir, 'media', name)) + const name = `file_${hashPart(exportMessageKey(message, conversationId))}_${safeFilePart(resolved.fileName)}` + await copyWritableExportFile(resolved.filePath, join(outputDir, 'media', name)) message.exportMediaUrl = `media/${name}` message.exportMediaType = 'file' message.exportMediaName = message.contentData.title || resolved.fileName @@ -583,26 +784,53 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro const mergedMessages = mergeHtmlArchiveMessages( previousArchive.messages, messages, - request.userMd5 + '', + targets.map((target) => target.userMd5) ) + const normalizedMessages = normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo) const archive: HtmlExportArchive = { - version: 1, - sourceId: request.userMd5, - name: request.name, + version: 2, + name: archiveName, exportedAt: new Date().toISOString(), - messages: normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo) + conversations: targets.map((target) => ({ + id: target.userMd5, + name: target.name, + type: target.type, + avatarUrl: + conversationAvatarUrls.get(target.userMd5) || + previousArchive.conversations.find((item) => item.id === target.userMd5)?.avatarUrl, + messageCount: normalizedMessages.filter( + (message) => message.exportConversationId === target.userMd5 + ).length + })), + messages: normalizedMessages } - await fs.writeFile(outputPath, renderExportPage(request.name), 'utf8') + if (!jobs.has(request.jobId)) throw new Error('已取消') + await fs.writeFile(outputPath, renderExportPage(archiveName), 'utf8') await writeHtmlArchive(outputDir, archive) + let completedPath = outputPath + if (request.zip) { + if (!jobs.has(request.jobId)) return { success: false, error: '已取消' } + const zipPath = join(root, `${outputFolder}.zip`) + send({ + jobId: request.jobId, + phase: 'compressing', + processed: archive.messages.length, + total: archive.messages.length, + percent: 95 + }) + await writeZipArchive(outputDir, zipPath, outputFolder, request.jobId) + completedPath = zipPath + } send({ jobId: request.jobId, phase: 'completed', processed: archive.messages.length, total: archive.messages.length, percent: 100, - outputPath + outputPath: completedPath }) - return { success: true, outputPath, messageCount: archive.messages.length } + return { success: true, outputPath: completedPath, messageCount: archive.messages.length } } else { send({ jobId: request.jobId, @@ -612,7 +840,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro percent: 90 }) } - await fs.writeFile(outputPath, render(request.format, messages, request.name), 'utf8') + await fs.writeFile(outputPath, render(request.format, messages, archiveName), 'utf8') send({ jobId: request.jobId, phase: 'completed', @@ -624,6 +852,10 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro return { success: true, outputPath, messageCount: messages.length } } catch (error) { const message = error instanceof Error ? error.message : String(error) + if (!jobs.has(request.jobId) || message === '已取消') { + send({ jobId: request.jobId, phase: 'cancelled', processed: 0, error: '已取消' }) + return { success: false, error: '已取消' } + } send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message }) return { success: false, error: message } } finally { @@ -632,6 +864,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } export function cancelExport(jobId: string): void { jobs.delete(jobId) + activeArchives.get(jobId)?.abort() } export async function revealExport(path: string): Promise { shell.showItemInFolder(path) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 37c3b25..65449cb 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -212,7 +212,32 @@ function App(): React.ReactElement { const [exportTasks, setExportTasks] = useState(() => { try { const stored = JSON.parse(localStorage.getItem('wxe_export_tasks') || '[]') - return Array.isArray(stored) ? (stored as ExportTaskRecord[]).slice(0, 20) : [] + if (!Array.isArray(stored)) return [] + return stored.slice(0, 20).map( + ( + value: Partial & { + contactId?: string + contactName?: string + } + ) => { + const targetIds = Array.isArray(value.targetIds) + ? value.targetIds + : value.contactId + ? [value.contactId] + : [] + const targetNames = Array.isArray(value.targetNames) + ? value.targetNames + : value.contactName + ? [value.contactName] + : [] + return { + ...value, + targetIds, + targetNames, + targetLabel: value.targetLabel || targetNames.join('、') || '聊天导出' + } as ExportTaskRecord + } + ) } catch { return [] } @@ -341,10 +366,14 @@ function App(): React.ReactElement { const handleStartExport = async ( request: ExportRequest ): Promise => { + const targetNames = request.targets.map((target) => target.name) + const targetLabel = + targetNames.length > 1 ? `${targetNames[0]} 等 ${targetNames.length} 个聊天` : targetNames[0] const task: ExportTaskRecord = { jobId: request.jobId, - contactId: request.userMd5, - contactName: request.name, + targetIds: request.targets.map((target) => target.userMd5), + targetNames, + targetLabel, format: request.format, status: 'running', progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 }, @@ -1220,16 +1249,14 @@ function App(): React.ReactElement { } } - const loadExportPreview = async (contact: Contact): Promise => { - setSelectedContact(contact) - selectedContactMd5Ref.current = contact.md5 + const loadExportPreviewMessages = async (contact: Contact): Promise => { try { - const previewMessages = await window.api.getMessages(contact.md5, undefined, undefined, { + return await window.api.getMessages(contact.md5, undefined, undefined, { limit: EXPORT_PREVIEW_LIMIT }) - if (selectedContactMd5Ref.current === contact.md5) setMessages(previewMessages) } catch (error) { console.warn('[Export] preview load failed:', error) + return [] } } @@ -1322,7 +1349,6 @@ function App(): React.ReactElement { const handlePageChange = (page: AppPage): void => { setActivePage(page) if (page === 'archive' && selectedContact) void handleSelectContact(selectedContact) - if (page === 'export' && selectedContact) void loadExportPreview(selectedContact) if (page === 'settings') setSettingsCategory('account-database') if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) { setReportSourceContact(selectedContact) @@ -1702,11 +1728,10 @@ function App(): React.ReactElement { return ( void onContactTypeChange: (value: 'all' | 'group' | 'user') => void onSelectContact: (contact: Contact) => void + onCompleteSelection: () => void onOpenSettings: () => void } @@ -20,6 +24,9 @@ export function ExportContactPanel({ contacts, filteredContacts, activeContact, + selectedContactIds, + selectionMode, + selectionLimit, selfInfo, dbReady, contactFilter, @@ -27,6 +34,7 @@ export function ExportContactPanel({ onContactFilterChange, onContactTypeChange, onSelectContact, + onCompleteSelection, onOpenSettings }: ExportContactPanelProps): React.ReactElement { return ( @@ -65,15 +73,30 @@ export function ExportContactPanel({ + {selectionMode && ( +
+ + 已选 {selectedContactIds.length} / {selectionLimit} 个 + + +
+ )} +
{filteredContacts.map((contact) => { const name = displayName(contact) + const selected = selectedContactIds.includes(contact.md5) + const atLimit = selectionMode && !selected && selectedContactIds.length >= selectionLimit return ( ) })} diff --git a/src/renderer/src/components/export/ExportPreviewPanel.tsx b/src/renderer/src/components/export/ExportPreviewPanel.tsx index 91d4be4..396146a 100644 --- a/src/renderer/src/components/export/ExportPreviewPanel.tsx +++ b/src/renderer/src/components/export/ExportPreviewPanel.tsx @@ -10,6 +10,7 @@ interface ExportPreviewPanelProps { previewBytes: number selfInfo: SelfInfo | null progress: ExportJobProgress | null + selectedCount: number jobId: string onCancel: (jobId: string) => void onReveal: (path: string) => void @@ -22,6 +23,7 @@ export function ExportPreviewPanel({ previewBytes, selfInfo, progress, + selectedCount, jobId, onCancel, onReveal @@ -32,7 +34,9 @@ export function ExportPreviewPanel({ <>
导出预览 - 仅预览最近 20 条 + + {selectedCount > 1 ? `${selectedCount} 个聊天 · 合并预览` : '仅预览最近 20 条'} +
最近消息
@@ -50,7 +54,7 @@ export function ExportPreviewPanel({ ] ).map((message) => (
+ {selectedCount > 1 && message.exportConversationName + ? `${message.exportConversationName} · ` + : ''} {message.name || (message.isSender ? '我' : '联系人')} ·{' '} {formatPreviewTime(message)} @@ -108,7 +115,11 @@ export function ExportPreviewPanel({
  1. 准备导出
  2. - {progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'} + {progress?.phase === 'compressing' + ? '压缩 ZIP' + : progress?.phase === 'writing' + ? '生成档案' + : '分批读取聊天记录'}
  3. 解析消息内容
  4. 处理媒体资源
  5. @@ -118,9 +129,11 @@ export function ExportPreviewPanel({
- {progress?.phase === 'writing' - ? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%` - : `正在读取消息... ${progress?.percent ?? 0}%`} + {progress?.phase === 'compressing' + ? `正在压缩资源包... ${progress.percent ?? 0}%` + : progress?.phase === 'writing' + ? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%` + : `正在读取消息... ${progress?.percent ?? 0}%`} - )} -
- )) + tasks.map((task) => { + const detail = taskDetail(task) + return ( +
+ + {task.targetLabel} + + {task.format.toUpperCase()} · {phaseLabels[task.progress.phase]} + + {detail && ( + + {detail} + + )} + + + + {task.progress.percent ?? 0}% + + {task.status === 'running' && ( + + )} +
+ ) + }) )}
)} diff --git a/src/renderer/src/components/export/ExportWorkspace.tsx b/src/renderer/src/components/export/ExportWorkspace.tsx index 9486647..35bbf26 100644 --- a/src/renderer/src/components/export/ExportWorkspace.tsx +++ b/src/renderer/src/components/export/ExportWorkspace.tsx @@ -3,12 +3,15 @@ import type { Message } from '../../../../shared/types' import type { ExportJobProgress, ExportMessageKind, - ExportNameMode + ExportNameMode, + ExportRequest, + ExportTarget } from '../../../../shared/export' import { ExportContactPanel } from './ExportContactPanel' import { ExportPreviewPanel } from './ExportPreviewPanel' import { ExportTaskCenter } from './ExportTaskCenter' import type { + Contact, ExportFormat, ExportRange, ExportStatus, @@ -19,24 +22,33 @@ import { displayName, formatLabels, formatOrder, messageKinds } from './exportUt export function ExportWorkspace({ contacts, - selectedContact, - previewMessages, + initialContact, selfInfo, dbReady, - onSelectContact, + loadPreviewMessages, onOpenSettings, exportTasks, onStartExport, onCancelExport }: ExportWorkspaceProps): React.ReactElement { + const initialSelection = initialContact || contacts[0] || null + const initialContactRef = React.useRef(initialSelection) + const previewLoadingRef = React.useRef(new Set()) const [contactFilter, setContactFilter] = useState('') const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all') + const [selectionMode, setSelectionMode] = useState(false) + const [selectedContacts, setSelectedContacts] = useState(() => + initialSelection ? [initialSelection] : [] + ) + const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '') + const [previewByContact, setPreviewByContact] = useState>({}) const [range, setRange] = useState('today') const [startDate, setStartDate] = useState('') const [endDate, setEndDate] = useState('') const [selectedKinds, setSelectedKinds] = useState>(() => new Set(['text'])) - const [nameMode, setNameMode] = useState('remark') - const [groupMembers, setGroupMembers] = useState([]) + const [nameMode, setNameMode] = useState( + initialSelection?.type === 'group' ? 'groupNickname' : 'remark' + ) const [includeMedia, setIncludeMedia] = useState(true) const [includeAvatars, setIncludeAvatars] = useState(true) const [preferOriginal, setPreferOriginal] = useState(true) @@ -49,6 +61,27 @@ export function ExportWorkspace({ const [jobId, setJobId] = useState('') const [progress, setProgress] = useState(null) const [taskCenterOpen, setTaskCenterOpen] = useState(false) + const selectionLimit = 5 + + React.useEffect(() => { + if (selectedContacts.length > 0) return + const candidate = initialContact || contacts[0] + if (!candidate) return + initialContactRef.current = candidate + setSelectedContacts([candidate]) + setActiveContactId(candidate.md5) + }, [contacts, initialContact, selectedContacts.length]) + + React.useEffect(() => { + for (const contact of selectedContacts) { + if (previewByContact[contact.md5] || previewLoadingRef.current.has(contact.md5)) continue + previewLoadingRef.current.add(contact.md5) + void loadPreviewMessages(contact).then((items) => { + previewLoadingRef.current.delete(contact.md5) + setPreviewByContact((current) => ({ ...current, [contact.md5]: items })) + }) + } + }, [loadPreviewMessages, previewByContact, selectedContacts]) const filteredContacts = useMemo(() => { const keyword = contactFilter.trim().toLowerCase() @@ -61,11 +94,35 @@ export function ExportWorkspace({ }) }, [contactFilter, contactType, contacts]) - const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null - const currentTask = exportTasks.find((task) => task.contactId === activeContact?.md5) + const activeContact = + selectedContacts.find((contact) => contact.md5 === activeContactId) || + selectedContacts[0] || + null + const selectedTargetKey = selectedContacts + .map((contact) => contact.md5) + .sort() + .join('|') + const currentTask = exportTasks.find( + (task) => [...task.targetIds].sort().join('|') === selectedTargetKey + ) const taskCount = exportTasks.filter((task) => task.status === 'running').length const activeName = displayName(activeContact) - const preview = previewMessages.slice(-20) + const selectedNames = selectedContacts.map(displayName) + const selectedLabel = + selectedNames.length > 1 + ? `${selectedNames.join('、')} · 共 ${selectedNames.length} 个聊天` + : selectedNames[0] || '未选择聊天' + const preview = selectedContacts + .flatMap((contact) => + (previewByContact[contact.md5] || []).map((message) => ({ + ...message, + exportConversationId: contact.md5, + exportConversationName: displayName(contact), + exportConversationAvatarUrl: contact.avatar + })) + ) + .sort((left, right) => Number(left.createTime || 0) - Number(right.createTime || 0)) + .slice(-20) const previewMediaCount = preview.filter( (message) => ['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '') || @@ -75,79 +132,59 @@ export function ExportWorkspace({ (total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0), 0 ) - const outputName = fileName.trim() || `${activeName}_聊天档案` - const nameOptions: { value: ExportNameMode; label: string }[] = - activeContact?.type === 'group' - ? [ - { value: 'groupNickname', label: '群昵称' }, - { value: 'remark', label: '备注' }, - { value: 'wechatNickname', label: '微信名' } - ] - : [ - { value: 'remark', label: '备注' }, - { value: 'wechatNickname', label: '微信名' } - ] - - const nameMap = useMemo(() => { - const map: Record = {} - if (activeContact?.type === 'group') { - for (const member of groupMembers) { - const value = - nameMode === 'groupNickname' - ? member.groupNickname || member.nickname || member.wxid - : nameMode === 'remark' - ? member.remark || member.wechatNickname || member.wxid - : member.wechatNickname || member.wxid - map[member.wxid] = value - } - } else if (activeContact) { - map[activeContact.m_nsUsrName] = - nameMode === 'remark' - ? activeContact.remark || activeContact.m_nsNickName || activeContact.m_nsUsrName - : activeContact.wechatNickname || activeContact.m_nsUsrName - } - if (selfInfo?.wxid) map[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid - return map - }, [activeContact, groupMembers, nameMode, selfInfo]) - - const avatarUrls = useMemo(() => { - const map: Record = {} - if (activeContact?.m_nsUsrName && activeContact.avatar) { - map[activeContact.m_nsUsrName] = activeContact.avatar - } - for (const member of groupMembers) { - if (member.avatar) map[member.wxid] = member.avatar - } - if (selfInfo?.wxid && selfInfo.avatar) map[selfInfo.wxid] = selfInfo.avatar - return map - }, [activeContact, groupMembers, selfInfo]) + const defaultOutputName = + selectedContacts.length > 1 + ? `${selectedNames[0]}等${selectedContacts.length}个聊天_合并档案` + : `${activeName}_聊天档案` + const outputName = fileName.trim() || defaultOutputName + const nameOptions: { value: ExportNameMode; label: string }[] = selectedContacts.some( + (contact) => contact.type === 'group' + ) + ? [ + { value: 'groupNickname', label: '群昵称' }, + { value: 'remark', label: '备注' }, + { value: 'wechatNickname', label: '微信名' } + ] + : [ + { value: 'remark', label: '备注' }, + { value: 'wechatNickname', label: '微信名' } + ] const previewName = (message: Message): string => - (message.senderId && nameMap[message.senderId]) || message.name || (message.isSender ? selfInfo?.nickname : undefined) || (message.isSender ? '我' : '联系人') const previewAvatar = (message: Message): string | undefined => - (message.senderId && avatarUrls[message.senderId]) || - message.img || - (message.isSender ? selfInfo?.avatar : undefined) + message.img || (message.isSender ? selfInfo?.avatar : undefined) const previewItems = preview.map((message) => ({ ...message, name: previewName(message), img: previewAvatar(message) })) - React.useEffect(() => { - setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark') - setGroupMembers([]) - if (!activeContact || activeContact.type !== 'group') return - const timer = window.setTimeout(() => { - void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => { - setGroupMembers((snapshot?.members || []) as GroupMemberName[]) - }) - }, 300) - return () => window.clearTimeout(timer) - }, [activeContact]) + const handleSelectContact = (contact: Contact): void => { + if (!selectionMode) { + setSelectedContacts([contact]) + setActiveContactId(contact.md5) + setStatus('idle') + return + } + const selected = selectedContacts.some((item) => item.md5 === contact.md5) + if (selected) { + if (selectedContacts.length === 1) return + const next = selectedContacts.filter((item) => item.md5 !== contact.md5) + setSelectedContacts(next) + if (activeContactId === contact.md5) setActiveContactId(next[0].md5) + setStatus('idle') + return + } + if (selectedContacts.length >= selectionLimit) return + const next = [...selectedContacts, contact] + setSelectedContacts(next) + setActiveContactId(contact.md5) + setFormat('html') + setStatus('idle') + } const toggleKind = (value: string): void => { setSelectedKinds((current) => { @@ -159,42 +196,59 @@ export function ExportWorkspace({ } const handleStart = async (): Promise => { - if (!activeContact || status === 'running') return + if (!activeContact || selectedContacts.length === 0 || status === 'running') return // Runs only from the export button event; a fresh id is required for each job. - // eslint-disable-next-line react-hooks/purity const nextJobId = `export-${Date.now()}` setJobId(nextJobId) setProgress(null) setStatus('running') - let exportNameMap = nameMap - let exportAvatarUrls = avatarUrls - if (activeContact.type === 'group') { - const snapshot = await window.api.getGroupSnapshot(activeContact.md5) - const members = (snapshot?.members || []) as GroupMemberName[] - setGroupMembers(members) - exportNameMap = { ...nameMap } - exportAvatarUrls = { ...avatarUrls } - for (const member of members) { - exportNameMap[member.wxid] = - nameMode === 'groupNickname' - ? member.groupNickname || member.nickname || member.wxid - : nameMode === 'remark' - ? member.remark || member.wechatNickname || member.wxid - : member.wechatNickname || member.wxid - if (member.avatar) exportAvatarUrls[member.wxid] = member.avatar - } - } + const targets: ExportTarget[] = await Promise.all( + selectedContacts.map(async (contact) => { + const nameMap: Record = {} + const avatarUrls: Record = {} + if (contact.type === 'group') { + const snapshot = await window.api.getGroupSnapshot(contact.md5) + for (const member of (snapshot?.members || []) as GroupMemberName[]) { + nameMap[member.wxid] = + nameMode === 'groupNickname' + ? member.groupNickname || member.nickname || member.wxid + : nameMode === 'remark' + ? member.remark || member.wechatNickname || member.wxid + : member.wechatNickname || member.wxid + if (member.avatar) avatarUrls[member.wxid] = member.avatar + } + } else { + nameMap[contact.m_nsUsrName] = + nameMode === 'remark' + ? contact.remark || contact.m_nsNickName || contact.m_nsUsrName + : contact.wechatNickname || contact.m_nsUsrName + if (contact.avatar) avatarUrls[contact.m_nsUsrName] = contact.avatar + } + if (selfInfo?.wxid) { + nameMap[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid + if (selfInfo.avatar) avatarUrls[selfInfo.wxid] = selfInfo.avatar + } + return { + userMd5: contact.md5, + name: displayName(contact), + type: contact.type, + avatarUrl: contact.avatar, + nameMode, + nameMap, + avatarUrls + } + }) + ) const now = new Date() const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0 const startOfRange = days ? new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1) : null - const request = { + const request: ExportRequest = { jobId: nextJobId, - userMd5: activeContact.md5, - name: activeName, - format, + targets, + format: selectedContacts.length > 1 ? 'html' : format, outputName, startTime: startOfRange ? Math.floor(startOfRange.getTime() / 1000) @@ -212,9 +266,6 @@ export function ExportWorkspace({ fallbackThumbnail, keepMissing, includeAvatars, - avatarUrls: exportAvatarUrls, - nameMode, - nameMap: exportNameMap, zip } const result = await onStartExport(request) @@ -257,6 +308,29 @@ export function ExportWorkspace({ ) }, [currentTask]) + const resetDefaults = (): void => { + const contact = initialContactRef.current || contacts[0] || null + setSelectedContacts(contact ? [contact] : []) + setActiveContactId(contact?.md5 || '') + setSelectionMode(false) + setRange('today') + setStartDate('') + setEndDate('') + setSelectedKinds(new Set(['text'])) + setNameMode(contact?.type === 'group' ? 'groupNickname' : 'remark') + setIncludeMedia(true) + setIncludeAvatars(true) + setPreferOriginal(true) + setFallbackThumbnail(true) + setKeepMissing(true) + setFormat('csv') + setZip(false) + setFileName('') + setStatus('idle') + setJobId('') + setProgress(null) + } + const targetPath = format === 'html' ? zip @@ -270,13 +344,17 @@ export function ExportWorkspace({ contacts={contacts} filteredContacts={filteredContacts} activeContact={activeContact} + selectedContactIds={selectedContacts.map((contact) => contact.md5)} + selectionMode={selectionMode} + selectionLimit={selectionLimit} selfInfo={selfInfo} dbReady={dbReady} contactFilter={contactFilter} contactType={contactType} onContactFilterChange={setContactFilter} onContactTypeChange={setContactType} - onSelectContact={onSelectContact} + onSelectContact={handleSelectContact} + onCompleteSelection={() => setSelectionMode(false)} onOpenSettings={onOpenSettings} /> @@ -290,20 +368,28 @@ export function ExportWorkspace({ onCancel={(taskJobId) => void onCancelExport(taskJobId)} />
- - {activeContact?.avatar ? ( - - ) : ( - activeName.slice(0, 1) - )} + + {selectedContacts.slice(0, 3).map((contact) => ( + + {contact.avatar ? ( + + ) : ( + displayName(contact).slice(0, 1) + )} + + ))} - +

导出设置

-

- {activeName} - {activeContact?.type === 'group' ? ' · 群聊' : ''} -

+

{selectedLabel}

+
@@ -314,6 +400,7 @@ export function ExportWorkspace({ key={value} type="button" className={format === value ? 'active' : ''} + disabled={selectedContacts.length > 1 && value !== 'html'} onClick={() => setFormat(value)} > {formatLabels[value].label} @@ -322,7 +409,9 @@ export function ExportWorkspace({ ))}

- CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。 + {selectedContacts.length > 1 + ? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。' + : 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}

{format === 'html' && ( <> @@ -506,45 +595,6 @@ export function ExportWorkspace({
-
-

导出格式

-
- {formatOrder.map((value) => ( - - ))} -
- {format === 'html' && ( -
- - -
- )} -
-

保存设置

@@ -577,7 +627,7 @@ export function ExportWorkspace({ : '准备就绪'} 路径:{targetPath} -
' const text = message.content || (data.type === 'quote' ? data.title : '') - const content = esc(text || (!media && !audio && !quote ? '[' + (message.type || '消息') + ']' : '')) + const content = esc(text || (!media && !audio && !quote && !structured ? '[' + (message.type || '消息') + ']' : '')) + const contentMarkup = content ? '
' + content + '
' : '' const source = conversations.length > 1 && activeConversation === 'all' ? '' + esc(message.exportConversationName || '聊天') + '' : '' + const locateAction = activeKind === 'all' + ? '' + : '' return '
' + '
' + esc(fullTime(message)) + source + '
' + (isSystem ? '' : avatar) + '
' + - (isSystem ? '' : esc(sender)) + '
' + media + audio + quote + - '
' + content + '
' + mediaStatus + '
' + (isSystem ? '' : esc(sender)) + '' + media + audio + quote + structured + + contentMarkup + mediaStatus + '' + locateAction + '' } const renderConversations = () => { @@ -547,11 +912,48 @@ const renderExportScript = (name: string): string => ` timeline.innerHTML = html || '
时间信息不可用
' } + const visibleMessages = () => { + const messages = Array.from(list.querySelectorAll('.message')) + const listBounds = list.getBoundingClientRect() + return messages.filter((message) => { + const bounds = message.getBoundingClientRect() + return bounds.bottom > listBounds.top && bounds.top < listBounds.bottom + }) + } const updateActiveMonth = () => { - const first = list.querySelector('.message') - const key = first && first.dataset.month + const messages = Array.from(list.querySelectorAll('.message')) + const visible = visibleMessages() + const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= 2 + const activeMessage = atBottom + ? visible[visible.length - 1] || messages[messages.length - 1] + : visible[0] || messages[0] + const key = activeMessage && activeMessage.dataset.month + let activeButton timeline.querySelectorAll('.timeline-month').forEach((button) => { - button.classList.toggle('active', button.dataset.month === key) + const active = button.dataset.month === key + button.classList.toggle('active', active) + if (active) activeButton = button + }) + if (!activeButton) return + const timelineBounds = timeline.getBoundingClientRect() + const buttonBounds = activeButton.getBoundingClientRect() + if (buttonBounds.top < timelineBounds.top) { + timeline.scrollTop -= timelineBounds.top - buttonBounds.top + } else if (buttonBounds.bottom > timelineBounds.bottom) { + timeline.scrollTop += buttonBounds.bottom - timelineBounds.bottom + } + if (buttonBounds.left < timelineBounds.left) { + timeline.scrollLeft -= timelineBounds.left - buttonBounds.left + } else if (buttonBounds.right > timelineBounds.right) { + timeline.scrollLeft += buttonBounds.right - timelineBounds.right + } + } + const scheduleActiveMonthUpdate = () => { + if (activeMonthUpdatePending) return + activeMonthUpdatePending = true + nextFrame(() => { + updateActiveMonth() + activeMonthUpdatePending = false }) } const updateCount = () => { @@ -568,12 +970,19 @@ const renderExportScript = (name: string): string => ` list.style.scrollBehavior = 'auto' list.scrollTop = value lastScrollTop = list.scrollTop + updateActiveMonth() nextFrame(() => { list.style.scrollBehavior = previousBehavior lastScrollTop = list.scrollTop + updateActiveMonth() scrollLoadSuppressed = false }) } + const scrollTopForTarget = (target, offset) => { + const targetBounds = target.getBoundingClientRect() + const listBounds = list.getBoundingClientRect() + return list.scrollTop + targetBounds.top - listBounds.top - offset + } const renderWindow = (anchorIndex, anchorOffset) => { const visible = filtered.slice(windowStart, windowEnd) const before = windowStart > 0 ? '
向上滚动加载更早消息
' : '' @@ -583,7 +992,7 @@ const renderExportScript = (name: string): string => ` : '
没有符合条件的消息
可以更换筛选条件或关键词
' if (Number.isInteger(anchorIndex)) { const anchor = list.querySelector('.message[data-index="' + anchorIndex + '"]') - if (anchor) setScrollTop(anchor.offsetTop - anchorOffset) + if (anchor) setScrollTop(scrollTopForTarget(anchor, anchorOffset)) } updateCount() updateActiveMonth() @@ -598,15 +1007,81 @@ const renderExportScript = (name: string): string => ` renderWindow() setScrollTop(preferLatest ? list.scrollHeight : 0) } - const applyFilters = () => { + const tabPositionKey = () => [ + activeConversation, + activeKind, + query.value.trim().toLowerCase() + ].join('\u0000') + const rememberTabPosition = () => { + const anchor = visibleMessages()[0] + const index = anchor ? Number(anchor.dataset.index) : -1 + const message = Number.isInteger(index) ? filtered[index] : undefined + if (!anchor || !message) return + tabPositions.set(tabPositionKey(), { + message, + offset: anchor.getBoundingClientRect().top - list.getBoundingClientRect().top + }) + } + const restoreTabPosition = () => { + const position = tabPositions.get(tabPositionKey()) + if (!position) return false + const index = filtered.indexOf(position.message) + if (index < 0) { + tabPositions.delete(tabPositionKey()) + return false + } + windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 2)) + windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE) + windowStart = Math.max(0, windowEnd - PAGE_SIZE) + renderWindow() + const target = list.querySelector('.message[data-index="' + index + '"]') + if (!target) return false + setScrollTop(Math.max(0, scrollTopForTarget(target, position.offset))) + return true + } + const matchingMessages = () => { const term = query.value.trim().toLowerCase() - filtered = allMessages.filter((message) => + return allMessages.filter((message) => (activeConversation === 'all' || message.exportConversationId === activeConversation) && (activeKind === 'all' || kindOf(message) === activeKind) && (!term || searchText(message).includes(term)) ) + } + const applyFilters = (restorePosition = false) => { + filtered = matchingMessages() renderTimeline() - resetWindow(true) + if (!restorePosition || !restoreTabPosition()) resetWindow(true) + } + const setActiveKind = (kind) => { + activeKind = kind + filters.querySelectorAll('[data-kind]').forEach((item) => { + item.classList.toggle('active', item.dataset.kind === kind) + }) + } + const locateInAll = (sourceIndex) => { + const targetMessage = filtered[sourceIndex] + if (!targetMessage) return + rememberTabPosition() + setActiveKind('all') + filtered = matchingMessages() + renderTimeline() + const targetIndex = filtered.indexOf(targetMessage) + if (targetIndex < 0) { + resetWindow(true) + return + } + windowStart = Math.max(0, targetIndex - Math.floor(PAGE_SIZE / 2)) + windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE) + windowStart = Math.max(0, windowEnd - PAGE_SIZE) + renderWindow() + const target = list.querySelector('.message[data-index="' + targetIndex + '"]') + if (!target) return + setScrollTop(Math.max( + 0, + scrollTopForTarget(target, Math.max(24, (list.clientHeight - target.offsetHeight) / 2)) + )) + target.classList.add('located') + window.setTimeout(() => target.classList.remove('located'), 1600) } const jumpToMonth = (key) => { const index = filtered.findIndex((message) => monthKey(message) === key) @@ -616,7 +1091,7 @@ const renderExportScript = (name: string): string => ` windowStart = Math.max(0, windowEnd - PAGE_SIZE) renderWindow() const target = list.querySelector('.message[data-index="' + index + '"]') - setScrollTop(target ? Math.max(0, target.offsetTop - 24) : 0) + setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0) timeline.querySelectorAll('.timeline-month').forEach((button) => { button.classList.toggle('active', button.dataset.month === key) }) @@ -626,7 +1101,9 @@ const renderExportScript = (name: string): string => ` const anchor = direction < 0 ? renderedMessages[0] : renderedMessages[renderedMessages.length - 1] const anchorIndex = anchor ? Number(anchor.dataset.index) : undefined - const anchorOffset = anchor ? anchor.offsetTop - list.scrollTop : 0 + const anchorOffset = anchor + ? anchor.getBoundingClientRect().top - list.getBoundingClientRect().top + : 0 if (direction < 0) { windowStart = Math.max(0, windowStart - WINDOW_STEP) windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE) @@ -653,6 +1130,7 @@ const renderExportScript = (name: string): string => ` const nearTop = currentTop < 180 const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240 lastScrollTop = currentTop + scheduleActiveMonthUpdate() if (scrollLoadSuppressed) return if (movingUp && nearTop) scheduleWindowSlide(-1) else if (!movingUp && nearBottom) scheduleWindowSlide(1) @@ -662,17 +1140,18 @@ const renderExportScript = (name: string): string => ` scheduleWindowSlide(-1) } }, { passive: true }) - query.addEventListener('input', applyFilters) + query.addEventListener('input', () => applyFilters()) filters.addEventListener('click', (event) => { const button = event.target.closest('[data-kind]') if (!button) return - activeKind = button.dataset.kind - filters.querySelectorAll('[data-kind]').forEach((item) => item.classList.toggle('active', item === button)) - applyFilters() + rememberTabPosition() + setActiveKind(button.dataset.kind) + applyFilters(true) }) conversationSelect.addEventListener('change', () => { + rememberTabPosition() activeConversation = conversationSelect.value - applyFilters() + applyFilters(true) }) timeline.addEventListener('click', (event) => { const button = event.target.closest('[data-month]') @@ -686,6 +1165,11 @@ const renderExportScript = (name: string): string => ` updateZoom() } list.addEventListener('click', (event) => { + const locateButton = event.target.closest('[data-locate-index]') + if (locateButton) { + locateInAll(Number(locateButton.dataset.locateIndex)) + return + } const image = event.target.closest('img[data-preview]') if (!image) return preview.src = image.src diff --git a/src/main/export-service.ts b/src/main/export-service.ts index 7b39afb..1b0fbaf 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -1,6 +1,6 @@ import { app, BrowserWindow, shell } from 'electron' import { createHash } from 'crypto' -import { createWriteStream, promises as fs } from 'fs' +import { createReadStream, createWriteStream, promises as fs } from 'fs' import { extname, join } from 'path' import { fileURLToPath } from 'url' import { ZipArchive, type Archiver } from 'archiver' @@ -67,9 +67,75 @@ export interface HtmlExportArchive { messages: Message[] } +const htmlArchiveResourcePrefixes = { + voices: ['voice_'], + media: ['image_', 'video_', 'sticker_', 'file_'], + files: ['file_'], + avatars: ['avatar_', 'conversation_'] +} as const +type HtmlArchiveResourceDirectory = keyof typeof htmlArchiveResourcePrefixes + const archiveDataPrefix = 'window.__WECHAT_EXPORT__ = ' const hashPart = (value: string, length = 16): string => createHash('sha1').update(value).digest('hex').slice(0, length) +const bufferHashPart = (buffer: Buffer, length = 16): string => + createHash('sha1').update(buffer).digest('hex').slice(0, length) +const fileHashPart = async (filePath: string, length = 16): Promise => { + const hash = createHash('sha1') + await new Promise((resolve, reject) => { + const input = createReadStream(filePath) + input.on('data', (chunk) => hash.update(chunk)) + input.once('end', resolve) + input.once('error', reject) + }) + return hash.digest('hex').slice(0, length) +} +const avatarFileName = (buffer: Buffer, extension: string): string => + `avatar_${bufferHashPart(buffer)}.${extension}` + +const htmlArchiveSourceSignature = (message: Message): string => + JSON.stringify([ + message.type, + message.content, + message.contentData || null, + message.localId || null, + message.serverId || null, + message.createTime || null, + message.sessionId || null, + message.senderId || null, + message.isSender, + message.recalled || false, + message.recalledBy || null + ]) + +const htmlArchiveResourcePath = (outputDir: string, value?: string): string | null => { + if (!value) return null + const normalized = value.replace(/\\/g, '/').split(/[?#]/, 1)[0] + const [directory, fileName, ...rest] = normalized.split('/') + if ( + !directory || + !fileName || + rest.length > 0 || + !Object.prototype.hasOwnProperty.call(htmlArchiveResourcePrefixes, directory) + ) { + return null + } + return join(outputDir, directory, fileName) +} + +const htmlArchiveResourceExists = async (outputDir: string, value?: string): Promise => { + const filePath = htmlArchiveResourcePath(outputDir, value) + if (!filePath) return false + try { + const stat = await fs.stat(filePath) + if (!stat.isFile()) return false + if ((stat.mode & 0o200) === 0) await fs.chmod(filePath, 0o644) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw error + } +} export const exportMessageKey = (message: Message, sourceId = ''): string => { const conversationId = message.exportConversationId || sourceId @@ -159,6 +225,15 @@ export function normalizeHtmlArchiveSelfNames( }) } +export function stripHtmlArchiveInlineAvatars(messages: Message[]): Message[] { + return messages.map((message) => { + if (message.img == null) return message + const archiveMessage = { ...message } + delete archiveMessage.img + return archiveMessage + }) +} + export async function readHtmlArchive( outputDir: string, targets: ExportTarget[], @@ -315,6 +390,54 @@ export async function writeHtmlArchive( } } +export async function pruneHtmlArchiveResources( + outputDir: string, + archive: HtmlExportArchive +): Promise { + const referenced = Object.fromEntries( + Object.keys(htmlArchiveResourcePrefixes).map((directory) => [directory, new Set()]) + ) as Record> + const addReference = (value?: string): void => { + if (!value) return + const path = value.replace(/\\/g, '/').split(/[?#]/, 1)[0] + const separator = path.indexOf('/') + if (separator <= 0 || path.indexOf('/', separator + 1) >= 0) return + const directory = path.slice(0, separator) as HtmlArchiveResourceDirectory + const fileName = path.slice(separator + 1) + if (!referenced[directory] || !fileName) return + referenced[directory].add(fileName) + } + + for (const conversation of archive.conversations) addReference(conversation.avatarUrl) + for (const message of archive.messages) { + addReference(message.voiceDataUrl) + addReference(message.exportMediaUrl) + addReference(message.exportAvatarUrl) + } + + for (const [directory, prefixes] of Object.entries(htmlArchiveResourcePrefixes) as [ + HtmlArchiveResourceDirectory, + readonly string[] + ][]) { + let entries + try { + entries = await fs.readdir(join(outputDir, directory), { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue + throw error + } + for (const entry of entries) { + if ( + entry.isFile() && + prefixes.some((prefix) => entry.name.startsWith(prefix)) && + !referenced[directory].has(entry.name) + ) { + await fs.unlink(join(outputDir, directory, entry.name)) + } + } + } +} + const keepMediaError = (request: ExportRequest, message: Message, error: string): void => { if (request.keepMissing !== false) message.exportMediaError = error } @@ -444,7 +567,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro return { success: false, error: '已取消' } } const targetMessages = ( - await chat.listMessagesAsync(target.userMd5, request.startTime, request.endTime) + await chat.listMessagesForExport(target.userMd5, request.startTime, request.endTime) ).filter((message) => request.kinds.includes(kindOf(message))) for (const [messageOrder, message] of targetMessages.entries()) { messageEntries.push({ @@ -475,6 +598,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro .map((entry) => entry.message) const rawSelfInfo = await chat.getSelfAccountInfoAsync() const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null + const client = chat.getChatDb()?.getWcdb4Client() const isUsableSelfName = (value: string | undefined): value is string => { const name = String(value || '').trim() if (!name) return false @@ -484,14 +608,30 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } for (const message of messages) { const target = targetById.get(message.exportConversationId || '') + const peerUsername = target ? client?.getUsernameByMd5(target.userMd5) || '' : '' + const isGroupChat = target?.type === 'group' || peerUsername.endsWith('@chatroom') message.exportMediaUrl = undefined message.exportMediaType = undefined message.exportMediaName = undefined message.exportMediaError = undefined message.voiceDataUrl = undefined message.exportShowAvatar = request.includeAvatars !== false + if (!isGroupChat && !message.senderId) { + message.senderId = message.isSender ? selfInfo?.wxid : peerUsername || message.sessionId + } const mappedName = message.senderId ? target?.nameMap?.[message.senderId] : undefined - if (mappedName && (!message.isSender || isUsableSelfName(mappedName))) { + if (!isGroupChat) { + message.name = message.isSender + ? isUsableSelfName(selfInfo?.nickname) + ? selfInfo.nickname + : isUsableSelfName(message.name) + ? message.name + : '我' + : target?.name || message.name || '联系人' + if (!message.img) { + message.img = message.isSender ? selfInfo?.avatar : target?.avatarUrl + } + } else if (mappedName && (!message.isSender || isUsableSelfName(mappedName))) { message.name = mappedName } else if (message.isSender && isUsableSelfName(selfInfo?.nickname)) { message.name = selfInfo.nickname @@ -529,10 +669,35 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro : join(root, `${outputFolder}.${ext}`) if (request.format === 'html') { const previousArchive = await readHtmlArchive(outputDir, targets, archiveName) + const previousMessagesByKey = new Map( + previousArchive.messages.map((message) => [exportMessageKey(message), message]) + ) + const reusablePreviousMessages = new Map() + for (const message of messages) { + const previous = previousMessagesByKey.get(exportMessageKey(message)) + if ( + previous && + htmlArchiveSourceSignature(previous) === htmlArchiveSourceSignature(message) + ) { + reusablePreviousMessages.set(message, previous) + } + } + const resourceExistence = new Map>() + const resourceExists = (value?: string): Promise => { + if (!value) return Promise.resolve(false) + const existing = resourceExistence.get(value) + if (existing) return existing + const pending = htmlArchiveResourceExists(outputDir, value) + resourceExistence.set(value, pending) + return pending + } + const markResourceExists = (value: string): void => { + resourceExistence.set(value, Promise.resolve(true)) + } await fs.mkdir(join(outputDir, 'voices'), { recursive: true }) await fs.mkdir(join(outputDir, 'media'), { recursive: true }) + await fs.mkdir(join(outputDir, 'files'), { recursive: true }) await fs.mkdir(join(outputDir, 'avatars'), { recursive: true }) - const client = chat.getChatDb()?.getWcdb4Client() const avatarUsernames = Array.from( new Set( messages @@ -564,9 +729,13 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro if (!target.avatarUrl) continue const resolved = await readAvatarAsset(target.avatarUrl) if (!resolved) continue - const avatarName = `conversation_${hashPart(target.userMd5)}.${resolved.extension}` - await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer) - conversationAvatarUrls.set(target.userMd5, `avatars/${avatarName}`) + const avatarName = avatarFileName(resolved.buffer, resolved.extension) + const avatarUrl = `avatars/${avatarName}` + if (!(await resourceExists(avatarUrl))) { + await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer) + markResourceExists(avatarUrl) + } + conversationAvatarUrls.set(target.userMd5, avatarUrl) } } const voiceService = @@ -577,7 +746,12 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro for (const message of messages) { if (!jobs.has(request.jobId)) throw new Error('已取消') if (kindOf(message) !== 'voice') continue - const conversationId = message.exportConversationId || targets[0].userMd5 + const previous = reusablePreviousMessages.get(message) + if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) { + message.voiceDataUrl = previous.voiceDataUrl + message.voiceDuration = previous.voiceDuration + continue + } if (!message.sessionId || message.localId == null || !message.createTime) { keepMediaError(request, message, '语音标识不完整,无法定位本地语音') continue @@ -599,10 +773,14 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro keepMediaError(request, message, reason) continue } - const voiceName = `voice_${hashPart(exportMessageKey(message, conversationId))}.wav` const audioBuffer = Buffer.from(voice.data, 'base64') - await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) - message.voiceDataUrl = `voices/${voiceName}` + const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav` + const voiceUrl = `voices/${voiceName}` + if (!(await resourceExists(voiceUrl))) { + await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) + markResourceExists(voiceUrl) + } + message.voiceDataUrl = voiceUrl message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2))) } catch (error) { keepMediaError( @@ -632,20 +810,26 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } const conversationId = message.exportConversationId || targets[0].userMd5 message.exportShowAvatar = request.includeAvatars !== false + const previous = reusablePreviousMessages.get(message) const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img - const resolvedAvatar = avatar ? await readAvatarAsset(avatar) : null - const avatarBuffer = resolvedAvatar?.buffer || null - const avatarExtension = resolvedAvatar?.extension || 'jpg' - if (avatarBuffer) { - const avatarKey = `${conversationId}:${ - message.senderId || avatar || `message_${exportMessageKey(message, conversationId)}` - }` - let avatarName = exportedAvatars.get(avatarKey) - if (!avatarName) { - avatarName = `avatar_${hashPart(avatarKey)}.${avatarExtension}` - await fs.writeFile(join(outputDir, 'avatars', avatarName), avatarBuffer) + const avatarKey = `${conversationId}:${ + message.senderId || avatar || `message_${exportMessageKey(message, conversationId)}` + }` + let avatarName = exportedAvatars.get(avatarKey) + if (!avatarName && avatar) { + const resolvedAvatar = await readAvatarAsset(avatar) + if (resolvedAvatar?.buffer) { + const avatarExtension = resolvedAvatar.extension || 'jpg' + avatarName = avatarFileName(resolvedAvatar.buffer, avatarExtension) + const avatarUrl = `avatars/${avatarName}` + if (!(await resourceExists(avatarUrl))) { + await fs.writeFile(join(outputDir, 'avatars', avatarName), resolvedAvatar.buffer) + markResourceExists(avatarUrl) + } exportedAvatars.set(avatarKey, avatarName) } + } + if (avatarName) { message.exportAvatarUrl = `avatars/${avatarName}` } if (!request.includeMedia || !message.contentData) { @@ -658,6 +842,32 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro }) continue } + const reusableMediaType = + message.contentData.type === 'image' || + message.contentData.type === 'video' || + message.contentData.type === 'sticker' + ? message.contentData.type + : message.contentData.type === 'share' && message.contentData.typeVal === '6' + ? 'file' + : null + if ( + reusableMediaType && + previous?.exportMediaUrl && + (!previous.exportMediaType || previous.exportMediaType === reusableMediaType) && + (await resourceExists(previous.exportMediaUrl)) + ) { + message.exportMediaUrl = previous.exportMediaUrl + message.exportMediaType = reusableMediaType + message.exportMediaName = previous.exportMediaName + send({ + jobId: request.jobId, + phase: 'writing', + processed: index + 1, + total: messages.length, + percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75) + }) + continue + } if (message.contentData.type === 'image') { if (!imageService) { keepMediaError(request, message, '未配置图片解密密钥,无法导出图片') @@ -698,9 +908,13 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null if (decoded) { - const name = `image_${hashPart(exportMessageKey(message, conversationId))}.${decoded.extension}` - await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) - message.exportMediaUrl = `media/${name}` + const name = `image_${bufferHashPart(decoded.buffer)}.${decoded.extension}` + const mediaUrl = `media/${name}` + if (!(await resourceExists(mediaUrl))) { + await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) + markResourceExists(mediaUrl) + } + message.exportMediaUrl = mediaUrl message.exportMediaType = 'image' if (usedFallback) { keepMediaError(request, message, '原图不可用,已降级使用缩略图') @@ -740,9 +954,13 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro } else if (extname(source).toLowerCase() !== '.mp4') { keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件') } else { - const name = `video_${hashPart(exportMessageKey(message, conversationId))}.mp4` - await copyWritableExportFile(source, join(outputDir, 'media', name)) - message.exportMediaUrl = `media/${name}` + const name = `video_${await fileHashPart(source)}.mp4` + const mediaUrl = `media/${name}` + if (!(await resourceExists(mediaUrl))) { + await copyWritableExportFile(source, join(outputDir, 'media', name)) + markResourceExists(mediaUrl) + } + message.exportMediaUrl = mediaUrl message.exportMediaType = 'video' } } @@ -755,9 +973,13 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro ? await readAvatarAsset(stickerSource) : null if (decoded) { - const name = `sticker_${hashPart(exportMessageKey(message, conversationId))}.${decoded.extension}` - await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) - message.exportMediaUrl = `media/${name}` + const name = `sticker_${bufferHashPart(decoded.buffer)}.${decoded.extension}` + const mediaUrl = `media/${name}` + if (!(await resourceExists(mediaUrl))) { + await fs.writeFile(join(outputDir, 'media', name), decoded.buffer) + markResourceExists(mediaUrl) + } + message.exportMediaUrl = mediaUrl message.exportMediaType = 'sticker' } else { keepMediaError(request, message, result.error || '表情资源缺失或下载失败') @@ -770,9 +992,13 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro if (!resolved.success || !resolved.filePath || !resolved.fileName) { keepMediaError(request, message, resolved.error || '本地文件附件缺失') } else { - const name = `file_${hashPart(exportMessageKey(message, conversationId))}_${safeFilePart(resolved.fileName)}` - await copyWritableExportFile(resolved.filePath, join(outputDir, 'media', name)) - message.exportMediaUrl = `media/${name}` + const name = `file_${await fileHashPart(resolved.filePath)}_${safeFilePart(resolved.fileName)}` + const mediaUrl = `files/${name}` + if (!(await resourceExists(mediaUrl))) { + await copyWritableExportFile(resolved.filePath, join(outputDir, 'files', name)) + markResourceExists(mediaUrl) + } + message.exportMediaUrl = mediaUrl message.exportMediaType = 'file' message.exportMediaName = message.contentData.title || resolved.fileName } @@ -792,7 +1018,9 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro '', targets.map((target) => target.userMd5) ) - const normalizedMessages = normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo) + const normalizedMessages = stripHtmlArchiveInlineAvatars( + normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo) + ) const archive: HtmlExportArchive = { version: 2, name: archiveName, @@ -813,6 +1041,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro if (!jobs.has(request.jobId)) throw new Error('已取消') await fs.writeFile(outputPath, renderExportPage(archiveName), 'utf8') await writeHtmlArchive(outputDir, archive) + await pruneHtmlArchiveResources(outputDir, archive) let completedPath = outputPath if (request.zip) { if (!jobs.has(request.jobId)) return { success: false, error: '已取消' } diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 67da5ec..f546142 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -447,6 +447,23 @@ export async function listMessagesAsync( return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit) } +export async function listMessagesForExport( + userMd5: string, + startTime?: number, + endTime?: number +): Promise { + if (!dbRef) return [] + const rawMessages = await dbRef.getUserMessagesForExport(userMd5, startTime, endTime) + const sourceMessages = listSourceMessages(userMd5, startTime, endTime, undefined, rawMessages) + const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || '' + recordRecallArchiveMessages(userMd5, username, sourceMessages) + const mergedMessages = mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime) + console.log( + `[ChatService] listMessagesForExport end md5=${userMd5} source=${sourceMessages.length} merged=${mergedMessages.length}` + ) + return mergedMessages +} + export function getGroupSnapshot(userMd5: string): GroupSnapshot | null { if (!dbRef) return null const wcdb4Client = dbRef.getWcdb4Client() diff --git a/src/main/services/recall-archive-service.ts b/src/main/services/recall-archive-service.ts index eb013be..8e0e702 100644 --- a/src/main/services/recall-archive-service.ts +++ b/src/main/services/recall-archive-service.ts @@ -40,12 +40,13 @@ let archivePath = '' let writeTimer: NodeJS.Timeout | null = null let writeQueue: Promise = Promise.resolve() -function messageIdentity(message: Message): string { +export function messageIdentity(message: Message): string { if (message.recoveredFromRecallJournal) { return `recovered:${message.localId || 0}:${message.serverId || message.id}` } - if (message.localId) return `local:${message.localId}` - if (message.serverId) return `server:${message.serverId}` + const serverId = String(message.serverId || '').trim() + if (serverId && serverId !== '0') return `server:${serverId}` + if (message.localId) return `local:${message.localId}:${message.createTime || 0}` if (message.id) return `id:${message.id}` return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}` } diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 29576b8..5a47359 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -1286,11 +1286,13 @@ export class Wcdb4Client { const merged = new Map() for (const message of [...recovered, ...current]) { const recoveredRow = Boolean(message.raw?.['_wxe_recovered']) - const identity = message.mesLocalID - ? `local:${message.mesLocalID}` - : message.serverId - ? `server:${message.serverId}` - : `${message.msgCreateTime}:${message.msgContent}` + const serverId = String(message.serverId || '').trim() + const identity = + serverId && serverId !== '0' + ? `server:${serverId}` + : message.mesLocalID + ? `local:${message.mesLocalID}:${message.msgCreateTime || 0}` + : `${message.msgCreateTime}:${message.msgContent}` const key = recoveredRow ? `recovered:${identity}` : identity merged.set(key, message) } diff --git a/src/main/wechat-db.ts b/src/main/wechat-db.ts index bde5a9b..31afaf7 100644 --- a/src/main/wechat-db.ts +++ b/src/main/wechat-db.ts @@ -34,6 +34,24 @@ export interface GroupMemberInfo { m_nsHeadImgUrl: string } +function mergeExportMessages(messages: WechatMessage[]): WechatMessage[] { + const merged = new Map() + for (const message of messages) { + const serverId = String(message.serverId || '') + const recovered = Boolean(message.raw?.['_wxe_recovered'] || message['_wxe_recovered']) + const identity = + serverId && serverId !== '0' + ? `server:${serverId}` + : `local:${message.mesLocalID}:${message.msgCreateTime}` + merged.set(recovered ? `recovered:${identity}` : identity, message) + } + return Array.from(merged.values()).sort( + (left, right) => + Number(left.msgCreateTime || 0) - Number(right.msgCreateTime || 0) || + Number(left.mesLocalID || 0) - Number(right.mesLocalID || 0) + ) +} + export class WechatDb { private wcdb4Client: Wcdb4Client private chatMd5ToUsername = new Map() @@ -170,8 +188,8 @@ export class WechatDb { const username = this.chatMd5ToUsername.get(userMd5) if (!username) return [] return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({ - ...message, - ...message.raw + ...message.raw, + ...message })) } @@ -185,7 +203,34 @@ export class WechatDb { const username = this.chatMd5ToUsername.get(userMd5) if (!username) return [] const messages = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime, options) - return messages.map((message) => ({ ...message, ...message.raw })) + return messages.map((message) => ({ ...message.raw, ...message })) + } + + public async getUserMessagesForExport( + userMd5: string, + startTime?: number, + endTime?: number + ): Promise { + this.ensureChatTableMapping() + const username = this.chatMd5ToUsername.get(userMd5) + if (!username) return [] + if (startTime && endTime && startTime > endTime) return [] + + // A bounded native cursor can omit rows stored across a message shard boundary. + // Scan without bounds first, then apply the requested range in application code. + const rows = await this.wcdb4Client.getMessagesAsync(username) + const messages = rows + .map((message) => ({ ...message.raw, ...message })) + .filter((message) => { + const createTime = Number(message.msgCreateTime || 0) + if (startTime && createTime < startTime) return false + if (endTime && createTime > endTime) return false + return true + }) + console.log( + `[WechatDb] export scan username=${username} raw=${rows.length} filtered=${messages.length} start=${startTime || 0} end=${endTime || 0}` + ) + return mergeExportMessages(messages) } public searchAllMessages(keyword: string): string | null { diff --git a/tests/e2e/export-archive.spec.ts b/tests/e2e/export-archive.spec.ts index 6b1e679..af116d5 100644 --- a/tests/e2e/export-archive.spec.ts +++ b/tests/e2e/export-archive.spec.ts @@ -168,3 +168,360 @@ test('EXPORT-ARCHIVE-02 legacy single-chat archive keeps its original layout', a rmSync(outputDir, { recursive: true, force: true }) } }) + +test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing tabs', async ({ + page +}) => { + const outputDir = mkdtempSync(join(tmpdir(), 'wxe-timeline-sync-e2e-')) + try { + const dataPath = join(outputDir, 'data', 'messages.js') + mkdirSync(dirname(dataPath), { recursive: true }) + writeFileSync(join(outputDir, 'index.html'), renderExportPage('时间轴同步档案'), 'utf8') + const oldVoiceMessages = Array.from({ length: 240 }, (_, index) => ({ + ...archiveMessage( + `old-voice-${index}`, + 'timeline', + '时间轴同步档案', + `旧语音-${index}`, + Date.UTC(2006 + Math.floor(index / 12), index % 12, 1) / 1000 + ), + type: '语音' + })) + writeFileSync( + dataPath, + `window.__WECHAT_EXPORT__ = ${JSON.stringify({ + version: 1, + sourceId: 'timeline', + name: '时间轴同步档案', + exportedAt: '2026-08-04T00:00:00.000Z', + messages: [ + ...oldVoiceMessages, + { + ...archiveMessage( + 'latest-voice', + 'timeline', + '时间轴同步档案', + '最新语音', + 1_775_520_000 + ), + type: '语音' + } + ] + })};\n`, + 'utf8' + ) + + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(pathToFileURL(join(outputDir, 'index.html')).href) + await page.getByRole('button', { name: '语音', exact: true }).click() + + const messages = page.locator('#messages') + const activeMonth = page.locator('.timeline-month.active') + await expect(activeMonth).toHaveAttribute('data-month', '2026-04') + expect( + await messages.evaluate( + (element) => element.scrollHeight - element.scrollTop - element.clientHeight + ) + ).toBeLessThanOrEqual(2) + const timelinePosition = await activeMonth.evaluate((element) => { + const button = element.getBoundingClientRect() + const timeline = element.parentElement!.getBoundingClientRect() + return { + buttonTop: button.top, + buttonBottom: button.bottom, + timelineTop: timeline.top, + timelineBottom: timeline.bottom + } + }) + expect(timelinePosition.buttonTop).toBeGreaterThanOrEqual(timelinePosition.timelineTop) + expect(timelinePosition.buttonBottom).toBeLessThanOrEqual(timelinePosition.timelineBottom + 1) + + await messages.evaluate((element) => { + element.scrollTop = 0 + }) + await expect(activeMonth).toHaveAttribute('data-month', '2006-01') + } finally { + rmSync(outputDir, { recursive: true, force: true }) + } +}) + +test('EXPORT-ARCHIVE-05 each message tab restores its previous scroll anchor', async ({ page }) => { + const outputDir = mkdtempSync(join(tmpdir(), 'wxe-tab-position-e2e-')) + try { + const dataPath = join(outputDir, 'data', 'messages.js') + mkdirSync(dirname(dataPath), { recursive: true }) + writeFileSync(join(outputDir, 'index.html'), renderExportPage('Tab 位置档案'), 'utf8') + const messages = Array.from({ length: 600 }, (_, index) => ({ + ...archiveMessage( + `message-${index}`, + 'tab-position', + 'Tab 位置档案', + `${index % 2 === 0 ? '文字' : '语音'}消息-${index}`, + 1_735_689_600 + index * 86_400 + ), + type: index % 2 === 0 ? '普通文本' : '语音' + })) + writeFileSync( + dataPath, + `window.__WECHAT_EXPORT__ = ${JSON.stringify({ + version: 1, + sourceId: 'tab-position', + name: 'Tab 位置档案', + exportedAt: '2026-08-04T00:00:00.000Z', + messages + })};\n`, + 'utf8' + ) + + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(pathToFileURL(join(outputDir, 'index.html')).href) + await page.getByRole('button', { name: '文字', exact: true }).click() + + const messageList = page.locator('#messages') + const target = page.locator('.message[data-index="100"]') + await target.evaluate((element) => { + const list = element.parentElement! + list.scrollTop += element.getBoundingClientRect().top - list.getBoundingClientRect().top - 37 + }) + await expect(target).toBeInViewport() + const before = await target.evaluate((element) => { + const message = element.getBoundingClientRect() + const list = element.parentElement!.getBoundingClientRect() + return message.top - list.top + }) + + await page.getByRole('button', { name: '语音', exact: true }).click() + await page.getByRole('button', { name: '文字', exact: true }).click() + + await expect(target).toBeInViewport() + const after = await target.evaluate((element) => { + const message = element.getBoundingClientRect() + const list = element.parentElement!.getBoundingClientRect() + return message.top - list.top + }) + expect(Math.abs(after - before)).toBeLessThanOrEqual(1) + expect( + await messageList.evaluate( + (element) => element.scrollHeight - element.scrollTop - element.clientHeight + ) + ).toBeGreaterThan(100) + } finally { + rmSync(outputDir, { recursive: true, force: true }) + } +}) + +test('EXPORT-ARCHIVE-03 renders shares and locations, and groups payments under system', async ({ + page +}, testInfo) => { + const outputDir = mkdtempSync(join(tmpdir(), 'wxe-structured-archive-e2e-')) + try { + const dataPath = join(outputDir, 'data', 'messages.js') + mkdirSync(dirname(dataPath), { recursive: true }) + writeFileSync(join(outputDir, 'index.html'), renderExportPage('结构化消息档案'), 'utf8') + const message = ( + id: string, + type: string, + createTime: number, + contentData: Message['contentData'] + ): Message => ({ + ...archiveMessage(id, 'structured', '结构化消息档案', '', createTime), + type, + contentData + }) + writeFileSync( + dataPath, + `window.__WECHAT_EXPORT__ = ${JSON.stringify({ + version: 1, + sourceId: 'structured', + name: '结构化消息档案', + exportedAt: '2026-08-04T12:57:32.000Z', + messages: [ + message('article', '公众号链接', 1_775_000_001, { + type: 'share', + typeVal: '5', + title: '真正的公众号标题', + des: '文章摘要与关键内容', + appname: '示例公众号', + url: 'https://example.com/article?a=1&b=2' + }), + message('mini', '小程序', 1_775_000_002, { + type: 'miniProgram', + title: '小程序商品标题', + description: '商品的真实描述', + appName: '示例小程序' + }), + message('channel', '视频号', 1_775_000_003, { + type: 'share', + typeVal: '51', + title: '当前微信版本不支持展示该内容,请升级至最新版本。', + des: '视频号真实标题\n视频号正文内容', + url: 'https://example.com/channel' + }), + message('forward', '合并转发', 1_775_000_004, { + type: 'forwardBundle', + title: '项目群的聊天记录', + description: '项目成员: 项目结论', + items: [ + { + messageType: 1, + sender: '项目成员', + sentAt: '2026-08-04 20:00', + text: '项目结论已经确认' + } + ] + }), + message('red-packet', '微信红包', 1_775_000_005, { + type: 'redPacket', + title: '微信红包', + description: '我给你发了一个红包' + }), + message('transfer', '转账', 1_775_000_006, { + type: 'share', + typeVal: '2000', + title: '微信转账', + des: '收到转账¥1000.00元', + url: '' + }), + message('voip', '通话', 1_775_000_007, { + type: 'voip', + status: '通话时长 2分15秒' + }), + message('location', '位置', 1_775_000_008, { + type: 'location', + poiname: '望和公园南园', + label: '北京市朝阳区望京街道北四环东路41号望和公园', + lat: 39.986984, + lng: 116.448578 + }), + { + ...message('legacy-recall', '系统消息', 1_775_000_009, { + type: 'system', + content: '"联系人" 撤回了一条消息' + }), + from: 'system', + content: '"联系人" 撤回了一条消息', + name: '' + }, + { + ...message('structured-recall', '系统消息', 1_775_000_010, { + type: 'system', + content: '你撤回了一条消息', + recall: { + targetId: 'fixture-target', + replacement: '你撤回了一条消息', + actor: '你' + } + }), + from: 'system', + content: '你撤回了一条消息', + name: '' + }, + { + ...message('location-sharing-ended', '系统消息', 1_775_000_011, { + type: 'system', + content: '位置共享已经结束' + }), + from: 'system', + content: '位置共享已经结束', + name: '' + } + ] + })};\n`, + 'utf8' + ) + + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(pathToFileURL(join(outputDir, 'index.html')).href) + await expect(page.locator('[data-rich-kind="share"]')).toHaveCount(2) + await expect(page.getByText('真正的公众号标题')).toBeVisible() + await expect(page.getByText('文章摘要与关键内容')).toBeVisible() + await expect(page.getByText('小程序商品标题')).toBeVisible() + await expect(page.getByText('视频号真实标题')).toBeVisible() + await expect(page.getByText('当前微信版本不支持展示该内容,请升级至最新版本。')).toHaveCount(0) + await expect(page.getByText('项目群的聊天记录')).toBeVisible() + await page.getByText('展开 1 条消息').click() + await expect(page.getByText('项目结论已经确认')).toBeVisible() + + await page.getByRole('button', { name: '分享', exact: true }).click() + await expect(page.locator('.message')).toHaveCount(5) + await expect(page.locator('[data-rich-kind="forwardBundle"]')).toHaveCount(1) + const locationCard = page.locator('[data-rich-kind="location"]') + await expect(locationCard).toHaveCount(1) + await expect(locationCard.getByText('望和公园南园')).toBeVisible() + await expect(locationCard.getByText('北京市朝阳区望京街道北四环东路41号望和公园')).toBeVisible() + await expect(locationCard.getByText('39.986984, 116.448578')).toBeVisible() + await expect(locationCard.getByText('在地图中打开')).toBeVisible() + await expect(locationCard.locator('xpath=ancestor::a')).toHaveAttribute( + 'href', + /^https:\/\/maps\.apple\.com\/\?q=.*&ll=39\.986984,116\.448578$/ + ) + await expect(page.locator('.content', { hasText: '[位置]' })).toHaveCount(0) + await expect(page.locator('[data-rich-kind="transfer"]')).toHaveCount(0) + const locationMessage = locationCard.locator('xpath=ancestor::article') + const locateButton = locationMessage.getByRole('button', { name: '定位到聊天位置' }) + const locateLabel = locateButton.locator('.locate-label') + await page.mouse.move(0, 0) + await expect(locateButton).toHaveCSS('opacity', '0') + await locationMessage.hover() + await expect(locateButton).toHaveCSS('opacity', '1') + await expect(locateLabel).toHaveCSS('opacity', '0') + await locateButton.hover() + await expect(locateLabel).toHaveCSS('opacity', '1') + await page.screenshot({ path: testInfo.outputPath('locate-hover-1440.png') }) + await locateButton.click() + await expect(page.getByRole('button', { name: '全部', exact: true })).toHaveClass(/active/) + await expect(page.locator('.message.located')).toContainText('望和公园南园') + const locatedPosition = await page.locator('.message.located').evaluate((element) => { + const messageRect = element.getBoundingClientRect() + const listRect = element.parentElement!.getBoundingClientRect() + return { + messageTop: messageRect.top, + messageBottom: messageRect.bottom, + listTop: listRect.top, + listBottom: listRect.bottom + } + }) + expect(locatedPosition.messageBottom).toBeGreaterThan(locatedPosition.listTop) + expect(locatedPosition.messageTop).toBeLessThan(locatedPosition.listBottom) + await page.locator('#messages').evaluate((element) => { + element.scrollTop = 0 + }) + await page.screenshot({ + path: testInfo.outputPath('structured-archive-1440.png'), + fullPage: true + }) + + await page.getByRole('button', { name: '系统 / 其他', exact: true }).click() + await expect(page.locator('.message')).toHaveCount(6) + await expect(page.getByText('我给你发了一个红包')).toBeVisible() + await expect(page.getByText('收到转账¥1000.00元')).toBeVisible() + await expect(page.getByText('通话时长 2分15秒')).toBeVisible() + const systemNotices = page.locator('.message.system') + await expect(systemNotices).toHaveCount(3) + const recallNotices = systemNotices.filter({ hasText: '撤回了一条消息' }) + await expect(recallNotices).toHaveCount(2) + await expect(recallNotices.locator('.avatar')).toHaveCount(0) + await expect(recallNotices.locator('.sender').first()).toBeHidden() + const locationNotice = systemNotices.filter({ hasText: '位置共享已经结束' }) + await expect(locationNotice).toHaveCount(1) + await expect(locationNotice.locator('.avatar')).toHaveCount(0) + const recallAlignment = await recallNotices.first().evaluate((element) => { + const messageRect = element.getBoundingClientRect() + const bubbleRect = element.querySelector('.bubble')!.getBoundingClientRect() + return Math.abs( + messageRect.left + messageRect.width / 2 - (bubbleRect.left + bubbleRect.width / 2) + ) + }) + expect(recallAlignment).toBeLessThan(1) + await page.setViewportSize({ width: 390, height: 844 }) + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) + ).toBe(true) + await page.screenshot({ + path: testInfo.outputPath('structured-archive-390.png'), + fullPage: true + }) + } finally { + rmSync(outputDir, { recursive: true, force: true }) + } +}) diff --git a/tests/integration/export-media-flow.test.ts b/tests/integration/export-media-flow.test.ts index 6ce817a..ba594d7 100644 --- a/tests/integration/export-media-flow.test.ts +++ b/tests/integration/export-media-flow.test.ts @@ -8,6 +8,8 @@ import { readdirSync, rmSync, statSync, + unlinkSync, + utimesSync, writeFileSync } from 'fs' import { tmpdir } from 'os' @@ -22,6 +24,8 @@ const state = vi.hoisted(() => ({ videoPath: '', messages: [] as Message[], messagesByUser: {} as Record, + exportReads: [] as string[], + voiceLookups: [] as number[], videoLookups: [] as { createTime?: number duration?: number @@ -46,8 +50,15 @@ vi.mock('../../src/main/services/chat-service', () => ({ listMessages: () => structuredClone(state.messages), listMessagesAsync: async (userMd5: string) => structuredClone(state.messagesByUser[userMd5] || state.messages), + listMessagesForExport: async (userMd5: string) => { + state.exportReads.push(userMd5) + return structuredClone(state.messagesByUser[userMd5] || state.messages) + }, getChatDb: () => ({ - getWcdb4Client: () => ({ getAccountRoot: () => state.accountRoot }) + getWcdb4Client: () => ({ + getAccountRoot: () => state.accountRoot, + getUsernameByMd5: (userMd5: string) => `wxid_${userMd5}` + }) }), getContactAvatars: () => ({}), getSelfAccountInfoAsync: async () => ({ @@ -69,6 +80,7 @@ vi.mock('../../src/main/voice-service', () => ({ _sessionId: string, localId: number ): Promise<{ success: boolean; data?: string; error?: string }> { + state.voiceLookups.push(localId) return localId === 1 ? { success: true, @@ -187,6 +199,8 @@ describe('media export flow', () => { state.imageLookups = [] state.videoLookups = [] state.messagesByUser = {} + state.exportReads = [] + state.voiceLookups = [] const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08') mkdirSync(fileMonth, { recursive: true }) writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容') @@ -272,7 +286,7 @@ describe('media export flow', () => { expect(html).toContain('') expect(voice.voiceDataUrl).toMatch(/^voices\/voice_[0-9a-f]{16}\.wav$/) expect(video.exportMediaUrl).toMatch(/^media\/video_[0-9a-f]{16}\.mp4$/) - expect(file.exportMediaUrl).toMatch(/^media\/file_[0-9a-f]{16}_测试附件\.txt$/) + expect(file.exportMediaUrl).toMatch(/^files\/file_[0-9a-f]{16}_测试附件\.txt$/) expect(missingVoice.exportMediaError).toBe('语音文件缺失:本地未找到语音数据') expect(state.imageLookups[0]).toMatchObject({ allowThumbnail: false, @@ -288,6 +302,42 @@ describe('media export flow', () => { height: 630 }) expect(progress.length).toBeGreaterThan(0) + expect(state.exportReads).toEqual(['fixture-user']) + }) + + it('keeps one-to-one sender sides and fills both display names', async () => { + const { runExport } = await import('../../src/main/export-service') + const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } + state.messages = [ + message({ id: 'peer-message', content: '对方消息', isSender: false, name: '', senderId: '' }), + message({ id: 'self-message', content: '我的消息', isSender: true, name: '', senderId: '' }) + ] + + const result = await runExport( + { + jobId: 'one-to-one-identity', + targets: [target('jamie', 'Jamie')], + format: 'html', + outputName: 'one-to-one-identity', + kinds: ['text'], + includeMedia: false + }, + win as never + ) + + expect(result.success, result.error).toBe(true) + const archive = readArchive(result.outputPath!) + expect( + archive.messages.map(({ id, isSender, name, senderId }) => ({ + id, + isSender, + name, + senderId + })) + ).toEqual([ + { id: 'peer-message', isSender: false, name: 'Jamie', senderId: 'wxid_jamie' }, + { id: 'self-message', isSender: true, name: '濑岛田井卫', senderId: 'a969409112' } + ]) }) it('incrementally merges the same HTML archive, deduplicates messages, and keeps old media', async () => { @@ -316,6 +366,19 @@ describe('media export flow', () => { expect(first.success).toBe(true) const firstArchive = readArchive(first.outputPath!) const oldVoiceUrl = firstArchive.messages.find((item) => item.id === 'voice-old')!.voiceDataUrl + const outputDir = dirname(first.outputPath!) + firstArchive.messages.find((item) => item.id === 'voice-old')!.img = + 'data:image/jpeg;base64,bGVnYWN5LWlubGluZS1hdmF0YXI=' + writeFileSync( + join(outputDir, 'data', 'messages.js'), + `window.__WECHAT_EXPORT__ = ${JSON.stringify(firstArchive)};\n`, + 'utf8' + ) + writeFileSync(join(outputDir, 'voices', 'voice_orphan.wav'), 'orphan voice') + writeFileSync(join(outputDir, 'media', 'image_orphan.png'), 'orphan image') + writeFileSync(join(outputDir, 'media', 'file_orphan.txt'), 'legacy orphan file') + writeFileSync(join(outputDir, 'files', 'file_orphan.txt'), 'orphan file') + writeFileSync(join(outputDir, 'avatars', 'avatar_orphan.png'), 'orphan avatar') state.messages = [ message({ id: 'text-old', content: '同一条消息已更新', createTime: 1_785_549_660 }), @@ -344,9 +407,80 @@ describe('media export flow', () => { expect(secondArchive.messages.find((item) => item.id === 'voice-old')?.voiceDataUrl).toBe( oldVoiceUrl ) + expect(secondArchive.messages.every((item) => item.img == null)).toBe(true) + expect(existsSync(join(outputDir, oldVoiceUrl!))).toBe(true) + expect(existsSync(join(outputDir, 'voices', 'voice_orphan.wav'))).toBe(false) + expect(existsSync(join(outputDir, 'media', 'image_orphan.png'))).toBe(false) + expect(existsSync(join(outputDir, 'media', 'file_orphan.txt'))).toBe(false) + expect(existsSync(join(outputDir, 'files', 'file_orphan.txt'))).toBe(false) + expect(existsSync(join(outputDir, 'avatars', 'avatar_orphan.png'))).toBe(false) expect(existsSync(join(dirname(second.outputPath!), 'data', 'messages.js.bak'))).toBe(true) }) + it('reuses unchanged resources and retries only missing or unresolved media', async () => { + const { runExport } = await import('../../src/main/export-service') + const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } + const request = { + targets: [target('fixture-user', '资源复用会话')], + format: 'html' as const, + outputName: 'resource-reuse-fixture', + kinds: ['voice', 'image', 'video', 'file'] as const, + includeMedia: true, + keepMissing: true + } + + const first = await runExport( + { ...request, jobId: 'resource-reuse-first', kinds: [...request.kinds] }, + win as never + ) + expect(first.success, first.error).toBe(true) + const firstArchive = readArchive(first.outputPath!) + const outputDir = dirname(first.outputPath!) + const voicePath = join( + outputDir, + firstArchive.messages.find((item) => item.id === 'voice-ok')!.voiceDataUrl! + ) + const imagePath = join( + outputDir, + firstArchive.messages.find((item) => item.id === 'image')!.exportMediaUrl! + ) + const videoPath = join( + outputDir, + firstArchive.messages.find((item) => item.id === 'video')!.exportMediaUrl! + ) + const filePath = join( + outputDir, + firstArchive.messages.find((item) => item.id === 'file')!.exportMediaUrl! + ) + const oldTimestamp = new Date(1_000_000) + utimesSync(videoPath, oldTimestamp, oldTimestamp) + utimesSync(filePath, oldTimestamp, oldTimestamp) + + const second = await runExport( + { ...request, jobId: 'resource-reuse-second', kinds: [...request.kinds] }, + win as never + ) + expect(second.success, second.error).toBe(true) + expect(state.imageLookups).toHaveLength(1) + expect(state.videoLookups).toHaveLength(1) + expect(state.voiceLookups).toEqual([1, 2, 2]) + expect(statSync(videoPath).mtimeMs).toBe(oldTimestamp.getTime()) + expect(statSync(filePath).mtimeMs).toBe(oldTimestamp.getTime()) + + unlinkSync(voicePath) + unlinkSync(imagePath) + const third = await runExport( + { ...request, jobId: 'resource-reuse-third', kinds: [...request.kinds] }, + win as never + ) + expect(third.success, third.error).toBe(true) + expect(state.imageLookups).toHaveLength(2) + expect(state.videoLookups).toHaveLength(1) + expect(state.voiceLookups).toEqual([1, 2, 2, 1, 2]) + expect(existsSync(voicePath)).toBe(true) + expect(existsSync(imagePath)).toBe(true) + }) + it('keeps copied videos writable and can replace a legacy read-only video incrementally', async () => { const { runExport } = await import('../../src/main/export-service') const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } @@ -440,10 +574,11 @@ describe('media export flow', () => { ['beta', 'beta-later', 150], ['alpha', 'alpha-later', 200] ]) + expect(state.exportReads).toEqual(['alpha', 'beta']) const imagePaths = archive.messages .filter((item) => item.id === 'same-id') .map((item) => item.exportMediaUrl) - expect(new Set(imagePaths).size).toBe(2) + expect(new Set(imagePaths).size).toBe(1) for (const imagePath of imagePaths) { expect(existsSync(join(dirname(result.outputPath!), imagePath!))).toBe(true) } @@ -495,9 +630,15 @@ describe('media export flow', () => { expect(firstSize).toBeGreaterThan(0) expect(readFileSync(second.outputPath!).subarray(0, 2).toString()).toBe('PK') const entries = execFileSync('unzip', ['-Z1', second.outputPath!], { encoding: 'utf8' }) + const htmlPath = join(state.documents, 'WechatExplorer', '导出', 'zip-fixture', 'index.html') + const archive = readArchive(htmlPath) expect(entries).toContain('zip-fixture/index.html') expect(entries).toContain('zip-fixture/data/messages.js') - expect(entries).toMatch(/zip-fixture\/avatars\/conversation_[0-9a-f]{16}\.png/) + const avatarEntries = entries + .split('\n') + .filter((entry) => /zip-fixture\/avatars\/avatar_[0-9a-f]{16}\.png$/.test(entry)) + expect(avatarEntries).toHaveLength(1) + expect(archive.conversations[0].avatarUrl).toBe(archive.messages[0].exportAvatarUrl) expect(entries).toMatch(/zip-fixture\/media\/image_[0-9a-f]{16}\.png/) expect(progress.some((args) => (args[1] as { phase?: string })?.phase === 'compressing')).toBe( true diff --git a/tests/unit/export-media.test.ts b/tests/unit/export-media.test.ts index 297b3a4..e15f208 100644 --- a/tests/unit/export-media.test.ts +++ b/tests/unit/export-media.test.ts @@ -148,6 +148,97 @@ describe('export media', () => { dom.window.close() }) + it('locates every filtered message kind in all messages, including outside the latest window', () => { + const html = renderExportPage('定位消息') + const dom = new JSDOM(html, { runScripts: 'outside-only' }) + const categorized: Message[] = [ + { + ...messageForArchive('target-text', 'fixture', '定位消息', '目标文字', 1), + type: '普通文本' + }, + { + ...messageForArchive('target-media', 'fixture', '定位消息', '', 2), + type: '图片', + exportMediaType: 'image', + exportMediaUrl: 'media/target.jpg' + }, + { + ...messageForArchive('target-voice', 'fixture', '定位消息', '', 3), + type: '语音', + voiceDataUrl: 'voices/target.wav' + }, + { + ...messageForArchive('target-file', 'fixture', '定位消息', '', 4), + type: '文件', + exportMediaType: 'file', + exportMediaUrl: 'files/target.pdf' + }, + { + ...messageForArchive('target-share', 'fixture', '定位消息', '', 5), + type: '分享', + contentData: { type: 'share', typeVal: '5', title: '目标分享' } + }, + { + ...messageForArchive('target-system', 'fixture', '定位消息', '目标系统消息', 6), + from: 'system', + type: '系统消息', + contentData: { type: 'system', content: '目标系统消息' } + } + ] + const laterMessages = Array.from({ length: EXPORT_PAGE_SIZE }, (_, index) => ({ + ...messageForArchive( + `later-${index}`, + 'fixture', + '定位消息', + `稍后消息-${index}`, + 100 + index + ), + type: '普通文本' + })) + Object.assign(dom.window, { + __WECHAT_EXPORT__: { + version: 1, + sourceId: 'fixture', + name: '定位消息', + messages: [...categorized, ...laterMessages] + } + }) + + dom.window.eval(inlineScriptOf(html)) + + expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(0) + for (const kind of ['media', 'voice', 'file', 'share', 'system']) { + const filterButton = dom.window.document.querySelector(`[data-kind="${kind}"]`) as HTMLElement + filterButton.click() + const locateButton = dom.window.document.querySelector('.locate-all') as HTMLElement + expect(locateButton?.getAttribute('aria-label')).toBe('定位到聊天位置') + expect(locateButton?.querySelector('.locate-icon')?.textContent).toBe('⌖') + expect(locateButton?.querySelector('.locate-label')?.textContent).toBe('定位到聊天位置') + locateButton.click() + expect( + dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active') + ).toBe(true) + expect( + dom.window.document.querySelector('.message.located')?.getAttribute('data-index') + ).toBe(String(categorized.findIndex((message) => kindOfFixture(message) === kind))) + } + + const textFilter = dom.window.document.querySelector('[data-kind="text"]') as HTMLElement + textFilter.click() + const search = dom.window.document.querySelector('#query') as HTMLInputElement + search.value = '目标文字' + search.dispatchEvent(new dom.window.Event('input')) + ;(dom.window.document.querySelector('.locate-all') as HTMLElement).click() + expect( + dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active') + ).toBe(true) + expect(dom.window.document.querySelector('.message.located')?.getAttribute('data-index')).toBe( + '0' + ) + expect(dom.window.document.querySelector('.message.located')?.textContent).toContain('目标文字') + dom.window.close() + }) + it('keeps relative media, file download, quote, and missing-media renderers', () => { const html = renderExportPage('媒体档案') @@ -190,3 +281,12 @@ function messageForArchive( exportConversationName: conversationName } } + +function kindOfFixture(message: Message): string { + if (message.exportMediaType === 'image') return 'media' + if (message.voiceDataUrl) return 'voice' + if (message.exportMediaType === 'file') return 'file' + if (message.contentData?.type === 'share') return 'share' + if (message.contentData?.type === 'system') return 'system' + return 'text' +} diff --git a/tests/unit/recall-archive-service.test.ts b/tests/unit/recall-archive-service.test.ts new file mode 100644 index 0000000..aeef39f --- /dev/null +++ b/tests/unit/recall-archive-service.test.ts @@ -0,0 +1,78 @@ +import crypto from 'crypto' +import { mkdirSync, mkdtempSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { beforeAll, describe, expect, it, vi } from 'vitest' +import type { Message } from '../../src/shared/types' + +const state = vi.hoisted(() => ({ userData: '' })) + +vi.mock('electron', () => ({ + app: { getPath: () => state.userData } +})) + +const message = (createTime: number, serverId?: string): Message => ({ + id: `message-${createTime}`, + from: 'user', + isSender: false, + type: '普通文本', + datetime: new Date(createTime * 1000).toISOString(), + content: String(createTime), + img: '', + name: 'Jamie', + sessionId: 'fixture-user', + localId: 1, + serverId, + createTime +}) + +describe('recall archive message identity', () => { + beforeAll(() => { + state.userData = mkdtempSync(join(tmpdir(), 'wxe-recall-identity-')) + }) + + it('does not collapse messages whose local ids repeat across database shards', async () => { + const accountRoot = '/fixture/account' + const sessionMd5 = 'fixture-session' + const archiveDir = join(state.userData, 'recall-archive') + const archiveName = crypto + .createHash('sha1') + .update(`${process.platform}:${accountRoot}`) + .digest('hex') + .slice(0, 16) + mkdirSync(archiveDir, { recursive: true }) + writeFileSync( + join(archiveDir, `${archiveName}.json`), + JSON.stringify({ + version: 1, + accountRoot, + updatedAt: Date.now(), + sessions: { + [sessionMd5]: { + username: 'fixture-user', + updatedAt: Date.now(), + messages: [], + recalls: [] + } + } + }) + ) + const { configureRecallArchive, mergeRecallArchiveMessages, messageIdentity } = + await import('../../src/main/services/recall-archive-service') + configureRecallArchive(accountRoot) + const oldMessage = message(1_731_327_263) + const newMessage = message(1_765_000_000) + + expect(messageIdentity(oldMessage)).not.toBe(messageIdentity(newMessage)) + expect(mergeRecallArchiveMessages(sessionMd5, [oldMessage, newMessage])).toEqual([ + oldMessage, + newMessage + ]) + }) + + it('prefers the globally unique server id when one is available', async () => { + const { messageIdentity } = await import('../../src/main/services/recall-archive-service') + + expect(messageIdentity(message(1_731_327_263, 'server-2024'))).toBe('server:server-2024') + }) +}) diff --git a/tests/unit/wcdb-message-shards.test.ts b/tests/unit/wcdb-message-shards.test.ts index 02907ba..a174e93 100644 --- a/tests/unit/wcdb-message-shards.test.ts +++ b/tests/unit/wcdb-message-shards.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { Wcdb4Client, type Wcdb4Message } from '../../src/main/wcdb4-client' -const message = (id: string, year: number): Wcdb4Message => ({ +const message = (id: string, year: number, serverId = `server-${id}`): Wcdb4Message => ({ mesLocalID: id, - serverId: `server-${id}`, + serverId, mesDes: 0, messageType: '1', msgCreateTime: String(Math.floor(Date.UTC(year, 0, 1) / 1000)), @@ -12,6 +12,21 @@ const message = (id: string, year: number): Wcdb4Message => ({ }) describe('WCDB message shard pagination', () => { + it('keeps messages whose local ids repeat across database shards', async () => { + const cursor = vi.fn(async () => [ + message('1', 2024, 'server-2024'), + message('1', 2025, 'server-2025') + ]) + const client = Object.assign(Object.create(Wcdb4Client.prototype), { + getMessagesByCursorAsync: cursor + }) as Wcdb4Client + + const result = await client.getMessagesAsync('fixture@chatroom') + + expect(result).toHaveLength(2) + expect(result.map((item) => item.serverId)).toEqual(['server-2024', 'server-2025']) + }) + it('merges cursor and all-store rows for a bounded cross-year page', async () => { const cursor = vi.fn(async () => [message('2025', 2025)]) const tableScan = vi.fn(async () => [message('2017', 2017), message('2025', 2025)]) diff --git a/tests/unit/wechat-db-messages.test.ts b/tests/unit/wechat-db-messages.test.ts new file mode 100644 index 0000000..987a169 --- /dev/null +++ b/tests/unit/wechat-db-messages.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' +import { WechatDb, type WechatMessage } from '../../src/main/wechat-db' + +describe('WechatDb normalized messages', () => { + it('keeps normalized identity fields when raw table columns conflict', async () => { + const normalized = { + mesLocalID: '1', + serverId: 'server-1', + mesDes: 0, + messageType: '1', + msgCreateTime: '1731327263', + msgContent: 'fixture', + sender: 'wxid_self', + senderNickname: 'Nanin', + raw: { + mesDes: 1, + sender: '', + senderNickname: '' + } + } + const client = { getMessagesAsync: vi.fn(async () => [normalized]) } + const db = Object.assign(Object.create(WechatDb.prototype), { + wcdb4Client: client, + chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]), + ensureChatTableMapping: vi.fn() + }) as WechatDb + + const start = Math.floor(new Date(2024, 10, 11).getTime() / 1000) + const end = Math.floor(new Date(2024, 11, 1).getTime() / 1000) + const messages = await db.getUserMessagesForExport('fixture-md5', start, end) + + expect(messages[0]).toMatchObject({ + mesDes: 0, + sender: 'wxid_self', + senderNickname: 'Nanin' + }) + }) + + it('scans without time bounds, then filters, deduplicates and sorts in application code', async () => { + const row = (id: string, createTime: number): WechatMessage => ({ + mesLocalID: id, + serverId: `server-${id}`, + mesDes: 0, + messageType: '1', + msgCreateTime: String(createTime), + msgContent: id, + raw: {} + }) + const start = Math.floor(new Date(2025, 0, 1).getTime() / 1000) + const end = Math.floor(new Date(2025, 0, 4).getTime() / 1000) + const shardBoundaryMessage = row('jan-2-boundary', start + 32 * 60 * 60) + const getMessagesAsync = vi.fn(async () => [ + row('before-range', start - 1), + row('newest', start + 48 * 60 * 60), + shardBoundaryMessage, + { ...shardBoundaryMessage }, + row('after-range', end + 1) + ]) + const db = Object.assign(Object.create(WechatDb.prototype), { + wcdb4Client: { getMessagesAsync }, + chatMd5ToUsername: new Map([['fixture-md5', 'fixture-user']]), + ensureChatTableMapping: vi.fn() + }) as WechatDb + + const messages = await db.getUserMessagesForExport('fixture-md5', start, end) + + expect(getMessagesAsync).toHaveBeenCalledOnce() + expect(getMessagesAsync).toHaveBeenCalledWith('fixture-user') + expect(messages.map((message) => message.mesLocalID)).toEqual(['jan-2-boundary', 'newest']) + }) +}) From 933a87ebbb40b460c8b6ae5fbd0cb6accb927f2a Mon Sep 17 00:00:00 2001 From: "majun.jason" Date: Wed, 5 Aug 2026 15:00:31 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=A1=A3=E6=A1=88=E6=B5=8F=E8=A7=88=E4=B8=8E=E5=A2=9E?= =?UTF-8?q?=E9=87=8F=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 增加档案加载状态、错误提示和延迟数据加载。 2. 优化移动端工具栏、消息布局及横向滚动控制。 3. 支持按年份折叠时间轴,并同步可见月份定位。 4. 使用自定义文件名作为档案标题。 5. 增量导出时保留历史头像,仅在视觉变化后新增头像版本。 6. 修复附件消息被误判为合并转发的问题。 7. 补充单元、集成及端到端测试覆盖。 --- src/main/export-html-template.ts | 323 +++++++++++++++++--- src/main/export-service.ts | 191 ++++++++++-- src/main/message-parser.ts | 7 +- tests/e2e/export-archive.spec.ts | 225 +++++++++++++- tests/integration/export-media-flow.test.ts | 139 ++++++++- tests/unit/export-media.test.ts | 13 +- tests/unit/message-parser.test.ts | 28 ++ 7 files changed, 842 insertions(+), 84 deletions(-) diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index d38ead5..34157ba 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -20,8 +20,50 @@ body { color: var(--text); font: 14px system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; } +.archive-loading { + position: fixed; + inset: 0; + z-index: 20; + display: grid; + place-content: center; + justify-items: center; + gap: 10px; + padding: 24px; + background: var(--page); + color: var(--text); + text-align: center; + transition: opacity .2s ease, visibility .2s ease; +} +.archive-loading.complete { + visibility: hidden; + opacity: 0; + pointer-events: none; +} +.archive-loading-indicator { + width: 34px; + height: 34px; + margin-bottom: 4px; + border: 3px solid #c9d9d1; + border-top-color: var(--accent); + border-radius: 50%; + animation: archive-loading-spin .8s linear infinite; +} +.archive-loading strong { font-size: 16px; } +.archive-loading span { color: var(--muted); font-size: 13px; } +.archive-loading.error .archive-loading-indicator { + display: grid; + border: 0; + animation: none; + place-items: center; + color: #a33b32; + font-size: 28px; +} +.archive-loading.error .archive-loading-indicator::before { content: '!'; } +@keyframes archive-loading-spin { to { transform: rotate(360deg); } } .page { + width: 100%; max-width: 1380px; + min-width: 0; height: 100vh; margin: auto; padding: 20px 24px; @@ -30,6 +72,8 @@ body { } .toolbar { display: grid; + width: 100%; + min-width: 0; grid-template-columns: minmax(220px, 1fr) auto; gap: 14px 24px; align-items: center; @@ -71,6 +115,9 @@ body { .count { margin-left: auto; color: var(--muted); font-size: 13px; } .archive-layout { display: grid; + width: 100%; + max-width: 100%; + min-width: 0; grid-template-columns: 168px minmax(0, 1fr); gap: 18px; min-height: 0; @@ -80,6 +127,8 @@ body { .archive-layout.single-conversation { grid-template-columns: 168px minmax(0, 1fr); } .archive-navigation { display: flex; + width: 100%; + max-width: 100%; flex-direction: column; gap: 10px; min-width: 0; @@ -138,7 +187,36 @@ body { padding: 12px 9px; } .timeline-empty { padding: 10px; color: var(--muted); font-size: 12px; } -.timeline-year { margin: 6px 7px 5px; color: var(--text); font-size: 13px; font-weight: 700; } +.timeline-year-group + .timeline-year-group { margin-top: 4px; } +.timeline-year { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + margin: 0; + padding: 7px 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text); + font: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; + text-align: left; +} +.timeline-year::after { + content: '⌄'; + color: var(--muted); + font-size: 16px; + font-weight: 400; + line-height: 1; + transform: rotate(-90deg); + transition: transform .15s ease; +} +.timeline-year:hover { background: #eaf2ee; } +.timeline-year[aria-expanded=true]::after { transform: rotate(0); } +.timeline-months[hidden] { display: none; } .timeline-month { width: 100%; display: flex; @@ -159,7 +237,7 @@ body { color: var(--accent); } .timeline-month small { color: inherit; } -.scroll { overflow: auto; min-width: 0; padding: 10px 8px 36px; } +.scroll { width: 100%; max-width: 100%; overflow: auto; min-width: 0; padding: 10px 8px 36px; } .lazy-hint { width: min(100%, 820px); margin: 0 auto 12px; @@ -175,6 +253,7 @@ body { flex-direction: column; gap: 6px; width: min(100%, 820px); + min-width: 0; margin: 0 auto 22px; } .locate-all { @@ -234,6 +313,7 @@ body { } @media (prefers-reduced-motion: reduce) { .message.located .bubble { animation: none; outline: 3px solid #36a477; outline-offset: 3px; } + .archive-loading-indicator { animation: none; } } .message.sent { align-items: flex-end; } .message.system { align-items: center; } @@ -271,7 +351,7 @@ body { background: var(--accent-soft); color: var(--accent); } -.row { display: flex; gap: 12px; align-items: flex-end; max-width: 100%; } +.row { display: flex; width: 100%; min-width: 0; gap: 12px; align-items: flex-end; max-width: 100%; } .sent .row { flex-direction: row-reverse; } .avatar { width: 38px; @@ -470,12 +550,75 @@ body { cursor: pointer; } @media (max-width: 760px) { + html, body { width: 100%; max-width: 100%; overflow-x: hidden; overscroll-behavior-x: none; } .page { padding: 10px; } - .toolbar { grid-template-columns: 1fr; padding: 13px; } - .controls { justify-content: flex-start; } - .controls input[type=search] { width: 100%; } - .filters { grid-column: 1; } - .count { width: 100%; margin-left: 0; } + .toolbar { + grid-template-columns: minmax(112px, .7fr) minmax(0, 1.3fr); + gap: 8px; + padding: 10px; + border-radius: 14px; + } + .archive-heading { + grid-column: 1; + min-height: 36px; + gap: 4px; + align-items: center; + flex-wrap: nowrap; + } + .archive-heading .title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .archive-heading .meta { display: none; } + .conversation-filter:not([hidden]) { + display: inline-flex; + width: 100%; + min-width: 0; + max-width: 100%; + flex: 1 1 auto; + height: 36px; + } + .conversation-filter::after { + right: 10px; + width: 6px; + height: 6px; + } + .conversation-select { + width: 100%; + min-width: 0; + height: 36px; + padding: 0 24px 0 8px; + border-radius: 9px; + font-size: 12px; + font-weight: 650; + box-shadow: none; + } + .controls { grid-column: 2; min-width: 0; justify-content: flex-start; } + .controls input[type=search] { width: 100%; min-width: 0; height: 36px; padding: 0 10px; } + .filters { + grid-column: 1 / -1; + display: flex; + gap: 4px; + flex-wrap: wrap; + } + .filter-button { + min-height: 30px; + border-radius: 8px; + padding: 4px 6px; + font-size: 12px; + line-height: 1.2; + } + .count { + display: block; + width: 100%; + flex: 0 0 100%; + margin-left: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.4; + } .archive-layout { grid-template-columns: 1fr; align-content: start; margin-top: 10px; } .archive-layout.single-conversation { grid-template-columns: 1fr; } .archive-navigation { align-self: start; gap: 8px; } @@ -488,15 +631,22 @@ body { overflow: auto; padding: 8px; } - .archive-heading { align-items: flex-start; } - .conversation-filter { flex-basis: 100%; width: 100%; } + .timeline-year-group { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 6px; + } + .timeline-year-group + .timeline-year-group { margin-top: 0; } .timeline-year { flex: 0 0 auto; - align-self: center; - margin: 0 2px 0 0; - padding: 7px 5px; + width: auto; + gap: 6px; + padding: 7px 8px; white-space: nowrap; } + .timeline-months { display: flex; gap: 6px; } + .timeline-months[hidden] { display: none; } .timeline-month { flex: 0 0 auto; width: auto; @@ -507,8 +657,14 @@ body { border-left-color: transparent; border-bottom-color: var(--accent); } - .bubble { max-width: calc(100vw - 92px); } - .audio-wrap { width: min(260px, calc(100vw - 130px)); } + .scroll { + overflow-x: hidden; + overscroll-behavior-x: none; + touch-action: pan-y; + } + .message:not(.system) .bubble { max-width: calc(100% - 50px); } + .message:not(.system) .row.has-locate .bubble { max-width: calc(100% - 90px); } + .audio-wrap { width: min(260px, 100%); } } ` @@ -523,6 +679,25 @@ const safe = (value: unknown): string => const renderExportScript = (name: string): string => ` (() => { 'use strict' + const loadingOverlay = document.querySelector('#archive-loading') + const loadingTitle = document.querySelector('#archive-loading-title') + const loadingDetail = document.querySelector('#archive-loading-detail') + const finishLoading = () => { + if (!loadingOverlay) return + loadingOverlay.setAttribute('aria-busy', 'false') + loadingOverlay.classList.add('complete') + window.setTimeout(() => loadingOverlay.remove(), 250) + } + const failLoading = (message) => { + if (!loadingOverlay) return + loadingOverlay.setAttribute('aria-busy', 'false') + loadingOverlay.setAttribute('role', 'alert') + loadingOverlay.classList.add('error') + if (loadingTitle) loadingTitle.textContent = '无法加载聊天档案' + if (loadingDetail) loadingDetail.textContent = message + } + const initializeArchive = () => { + try { const PAGE_SIZE = ${EXPORT_PAGE_SIZE} const WINDOW_STEP = Math.floor(PAGE_SIZE / 2) const archive = window.__WECHAT_EXPORT__ || { name: ${JSON.stringify(name)}, messages: [] } @@ -561,6 +736,7 @@ const renderExportScript = (name: string): string => ` let scrollLoadPending = false let scrollLoadSuppressed = false let activeMonthUpdatePending = false + let expandedTimelineYear = '' const tabPositions = new Map() let lastScrollTop = 0 let zoom = 1 @@ -856,7 +1032,8 @@ const renderExportScript = (name: string): string => ` '' return '
' + - '
' + esc(fullTime(message)) + source + '
' + + '
' + esc(fullTime(message)) + source + '
' + (isSystem ? '' : avatar) + '
' + (isSystem ? '' : esc(sender)) + '
' + media + audio + quote + structured + contentMarkup + mediaStatus + '
' + locateAction + '
' @@ -869,26 +1046,26 @@ const renderExportScript = (name: string): string => ` layout.classList.add('single-conversation') return } - const counts = new Map() - for (const message of allMessages) { - const id = message.exportConversationId || '' - counts.set(id, (counts.get(id) || 0) + 1) - } - const option = (id, label, total) => - '' + const option = (id, label) => '' title.hidden = true conversationFilter.hidden = false - conversationSelect.innerHTML = option('all', '全部聊天', allMessages.length) + - conversations.map((conversation) => option( - conversation.id, - conversation.name, - counts.get(conversation.id) || 0 - )).join('') + conversationSelect.innerHTML = option('all', '全部聊天') + + conversations.map((conversation) => option(conversation.id, conversation.name)).join('') conversationSelect.value = activeConversation } + const setExpandedTimelineYear = (year) => { + expandedTimelineYear = year || '' + timeline.querySelectorAll('.timeline-year').forEach((button) => { + const expanded = button.dataset.year === expandedTimelineYear + button.setAttribute('aria-expanded', String(expanded)) + const months = button.nextElementSibling + if (months) months.hidden = !expanded + }) + } const renderTimeline = () => { if (filtered.length === 0) { + expandedTimelineYear = '' timeline.innerHTML = '
没有可跳转的月份
' return } @@ -898,16 +1075,29 @@ const renderExportScript = (name: string): string => ` if (key === 'unknown') continue groups.set(key, (groups.get(key) || 0) + 1) } - let currentYear = '' - let html = '' + const yearGroups = new Map() for (const [key, total] of groups) { const parts = key.split('-') - if (parts[0] !== currentYear) { - currentYear = parts[0] - html += '
' + esc(currentYear) + ' 年
' - } - html += '' + if (!yearGroups.has(parts[0])) yearGroups.set(parts[0], []) + yearGroups.get(parts[0]).push({ key, month: Number(parts[1]), total }) + } + const years = Array.from(yearGroups.keys()) + if (!years.includes(expandedTimelineYear)) { + expandedTimelineYear = years[years.length - 1] || '' + } + let html = '' + for (const year of years) { + const expanded = year === expandedTimelineYear + const monthsId = 'timeline-months-' + year + html += '
' + + '' + + '
' + + yearGroups.get(year).map((entry) => + '' + ).join('') + '
' } timeline.innerHTML = html || '
时间信息不可用
' } @@ -923,10 +1113,16 @@ const renderExportScript = (name: string): string => ` const updateActiveMonth = () => { const messages = Array.from(list.querySelectorAll('.message')) const visible = visibleMessages() + const listBounds = list.getBoundingClientRect() + const topAnchor = listBounds.top + Math.min(24, listBounds.height / 4) const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= 2 + const anchoredMessage = messages.find((message) => { + const bounds = message.getBoundingClientRect() + return bounds.bottom > topAnchor && bounds.top < listBounds.bottom + }) const activeMessage = atBottom ? visible[visible.length - 1] || messages[messages.length - 1] - : visible[0] || messages[0] + : anchoredMessage || visible[0] || messages[0] const key = activeMessage && activeMessage.dataset.month let activeButton timeline.querySelectorAll('.timeline-month').forEach((button) => { @@ -935,6 +1131,8 @@ const renderExportScript = (name: string): string => ` if (active) activeButton = button }) if (!activeButton) return + const activeYear = key.split('-')[0] + if (activeYear !== expandedTimelineYear) setExpandedTimelineYear(activeYear) const timelineBounds = timeline.getBoundingClientRect() const buttonBounds = activeButton.getBoundingClientRect() if (buttonBounds.top < timelineBounds.top) { @@ -1092,6 +1290,7 @@ const renderExportScript = (name: string): string => ` renderWindow() const target = list.querySelector('.message[data-index="' + index + '"]') setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0) + setExpandedTimelineYear(key.split('-')[0]) timeline.querySelectorAll('.timeline-month').forEach((button) => { button.classList.toggle('active', button.dataset.month === key) }) @@ -1154,6 +1353,13 @@ const renderExportScript = (name: string): string => ` applyFilters(true) }) timeline.addEventListener('click', (event) => { + const yearButton = event.target.closest('[data-year]') + if (yearButton) { + setExpandedTimelineYear( + yearButton.dataset.year === expandedTimelineYear ? '' : yearButton.dataset.year + ) + return + } const button = event.target.closest('[data-month]') if (button) jumpToMonth(button.dataset.month) }) @@ -1202,6 +1408,39 @@ const renderExportScript = (name: string): string => ` '更新于 ' + updatedAt renderConversations() applyFilters() + finishLoading() + } catch (error) { + console.error('初始化聊天档案失败', error) + failLoading('消息数据解析失败,请确认档案文件完整后重试') + } + } + + if (window.__WECHAT_EXPORT__) { + initializeArchive() + return + } + + const loadArchiveData = () => { + const dataScript = document.createElement('script') + dataScript.src = 'data/messages.js' + dataScript.onload = () => { + if (!window.__WECHAT_EXPORT__) { + failLoading('消息数据为空或格式不正确,请重新导出聊天档案') + return + } + initializeArchive() + } + dataScript.onerror = () => { + failLoading('未找到消息数据,请确认 data/messages.js 与当前页面在同一档案中') + } + document.body.appendChild(dataScript) + } + + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(() => window.setTimeout(loadArchiveData, 0)) + } else { + window.setTimeout(loadArchiveData, 0) + } })() ` @@ -1215,6 +1454,11 @@ export function renderExportPage(name: string): string { +
+ + 正在加载聊天档案 + 消息较多时可能需要一些时间 +
@@ -1234,7 +1478,7 @@ export function renderExportPage(name: string): string { - +
@@ -1251,7 +1495,6 @@ export function renderExportPage(name: string): string { 预览 - ` diff --git a/src/main/export-service.ts b/src/main/export-service.ts index 1b0fbaf..0de9400 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, shell } from 'electron' +import { app, BrowserWindow, nativeImage, shell } from 'electron' import { createHash } from 'crypto' import { createReadStream, createWriteStream, promises as fs } from 'fs' import { extname, join } from 'path' @@ -51,6 +51,12 @@ export interface HtmlExportConversation { messageCount: number } +interface HtmlExportAvatarVersion { + sourceHash: string + visualHash?: string + avatarUrl: string +} + interface HtmlExportArchiveV1 { version: 1 sourceId: string @@ -65,6 +71,7 @@ export interface HtmlExportArchive { exportedAt: string conversations: HtmlExportConversation[] messages: Message[] + avatarVersions?: Record } const htmlArchiveResourcePrefixes = { @@ -92,6 +99,76 @@ const fileHashPart = async (filePath: string, length = 16): Promise => { } const avatarFileName = (buffer: Buffer, extension: string): string => `avatar_${bufferHashPart(buffer)}.${extension}` +const avatarSourceHash = (source: string): string => hashPart(source, 24) +const avatarIdentityKey = (conversationId: string, message: Message): string => + `${conversationId}:${ + message.senderId || + (message.isSender ? '__self__' : message.from || message.name || '__unknown__') + }` + +const avatarVisualHash = (buffer: Buffer): string | null => { + try { + const image = nativeImage.createFromBuffer(buffer) + if (image.isEmpty()) return null + const bitmap = image.resize({ width: 9, height: 8, quality: 'good' }).toBitmap() + if (bitmap.length < 9 * 8 * 4) return null + let hash = 0n + let red = 0 + let green = 0 + let blue = 0 + for (let y = 0; y < 8; y += 1) { + for (let x = 0; x < 8; x += 1) { + const left = (y * 9 + x) * 4 + const right = left + 4 + blue += bitmap[left] + green += bitmap[left + 1] + red += bitmap[left + 2] + const leftLuma = bitmap[left + 2] * 3 + bitmap[left + 1] * 6 + bitmap[left] + const rightLuma = bitmap[right + 2] * 3 + bitmap[right + 1] * 6 + bitmap[right] + hash = (hash << 1n) | (leftLuma > rightLuma ? 1n : 0n) + } + } + const averageHex = [red, green, blue] + .map((total) => + Math.round(total / 64) + .toString(16) + .padStart(2, '0') + ) + .join('') + return `${hash.toString(16).padStart(16, '0')}:${averageHex}` + } catch { + return null + } +} + +const parseAvatarVisualHash = (value: string): { shape: bigint; color: number[] } | null => { + const match = /^([0-9a-f]{16}):([0-9a-f]{6})$/i.exec(value) + if (!match) return null + return { + shape: BigInt(`0x${match[1]}`), + color: [0, 2, 4].map((offset) => Number.parseInt(match[2].slice(offset, offset + 2), 16)) + } +} + +const avatarHashDistance = (left: bigint, right: bigint): number => { + let difference = left ^ right + let distance = 0 + while (difference) { + difference &= difference - 1n + distance += 1 + } + return distance +} + +const avatarsLookTheSame = (left?: string | null, right?: string | null): boolean => { + if (!left || !right) return false + const leftHash = parseAvatarVisualHash(left) + const rightHash = parseAvatarVisualHash(right) + if (!leftHash || !rightHash || avatarHashDistance(leftHash.shape, rightHash.shape) > 5) { + return false + } + return leftHash.color.every((channel, index) => Math.abs(channel - rightHash.color[index]) <= 12) +} const htmlArchiveSourceSignature = (message: Message): string => JSON.stringify([ @@ -307,7 +384,8 @@ export async function readHtmlArchive( name: parsed.name || name, exportedAt: parsed.exportedAt || new Date(0).toISOString(), conversations: parsed.conversations, - messages: Array.isArray(parsed.messages) ? parsed.messages : [] + messages: Array.isArray(parsed.messages) ? parsed.messages : [], + avatarVersions: parsed.avatarVersions } } @@ -555,8 +633,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro if (targets.length > 1 && request.format !== 'html') { throw new Error('多聊天合并仅支持 HTML 格式') } - const archiveName = - targets.length > 1 ? `${targets[0].name} 等 ${targets.length} 个聊天` : targets[0].name + const archiveName = safeFilePart(request.outputName) const targetById = new Map(targets.map((target) => [target.userMd5, target])) send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 }) await new Promise((resolve) => setImmediate(resolve)) @@ -672,6 +749,15 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro const previousMessagesByKey = new Map( previousArchive.messages.map((message) => [exportMessageKey(message), message]) ) + const latestPreviousAvatarUrls = new Map() + for (const message of previousArchive.messages) { + if (!message.exportAvatarUrl) continue + const conversationId = message.exportConversationId || targets[0].userMd5 + latestPreviousAvatarUrls.set( + avatarIdentityKey(conversationId, message), + message.exportAvatarUrl + ) + } const reusablePreviousMessages = new Map() for (const message of messages) { const previous = previousMessagesByKey.get(exportMessageKey(message)) @@ -722,20 +808,59 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro const stickerService = client ? new StickerService(client) : null const fileService = client ? new FileAssetService(client) : null const exportedAvatars = new Map() + const previousAvatarVersions = previousArchive.avatarVersions || {} + const avatarVersions: Record = { + ...previousAvatarVersions + } + const resolveExportAvatar = async ( + identity: string, + source: string, + legacyAvatarUrl?: string + ): Promise => { + const sourceHash = avatarSourceHash(source) + const previousVersion = previousAvatarVersions[identity] + const resolved = await readAvatarAsset(source) + if (!resolved?.buffer) return undefined + const visualHash = avatarVisualHash(resolved.buffer) + const previousAvatarUrl = previousVersion?.avatarUrl || legacyAvatarUrl + if (previousAvatarUrl && (await resourceExists(previousAvatarUrl))) { + const previousVisualHash = + previousVersion?.visualHash || + avatarVisualHash(await fs.readFile(join(outputDir, previousAvatarUrl))) + if (avatarsLookTheSame(previousVisualHash, visualHash)) { + avatarVersions[identity] = { + sourceHash, + visualHash: visualHash || previousVisualHash || undefined, + avatarUrl: previousAvatarUrl + } + return previousAvatarUrl + } + } + + const avatarName = avatarFileName(resolved.buffer, resolved.extension || 'jpg') + const avatarUrl = `avatars/${avatarName}` + if (!(await resourceExists(avatarUrl))) { + await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer) + markResourceExists(avatarUrl) + } + avatarVersions[identity] = { + sourceHash, + visualHash: visualHash || undefined, + avatarUrl + } + return avatarUrl + } const conversationAvatarUrls = new Map() if (request.includeAvatars !== false) { for (const target of targets) { if (!jobs.has(request.jobId)) throw new Error('已取消') if (!target.avatarUrl) continue - const resolved = await readAvatarAsset(target.avatarUrl) - if (!resolved) continue - const avatarName = avatarFileName(resolved.buffer, resolved.extension) - const avatarUrl = `avatars/${avatarName}` - if (!(await resourceExists(avatarUrl))) { - await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer) - markResourceExists(avatarUrl) - } - conversationAvatarUrls.set(target.userMd5, avatarUrl) + const avatarUrl = await resolveExportAvatar( + `conversation:${target.userMd5}`, + target.avatarUrl, + previousArchive.conversations.find((item) => item.id === target.userMd5)?.avatarUrl + ) + if (avatarUrl) conversationAvatarUrls.set(target.userMd5, avatarUrl) } } const voiceService = @@ -811,27 +936,28 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro const conversationId = message.exportConversationId || targets[0].userMd5 message.exportShowAvatar = request.includeAvatars !== false const previous = reusablePreviousMessages.get(message) + const previousMessage = previousMessagesByKey.get(exportMessageKey(message)) const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img - const avatarKey = `${conversationId}:${ - message.senderId || avatar || `message_${exportMessageKey(message, conversationId)}` - }` - let avatarName = exportedAvatars.get(avatarKey) - if (!avatarName && avatar) { - const resolvedAvatar = await readAvatarAsset(avatar) - if (resolvedAvatar?.buffer) { - const avatarExtension = resolvedAvatar.extension || 'jpg' - avatarName = avatarFileName(resolvedAvatar.buffer, avatarExtension) - const avatarUrl = `avatars/${avatarName}` - if (!(await resourceExists(avatarUrl))) { - await fs.writeFile(join(outputDir, 'avatars', avatarName), resolvedAvatar.buffer) - markResourceExists(avatarUrl) - } - exportedAvatars.set(avatarKey, avatarName) + const avatarKey = avatarIdentityKey(conversationId, message) + let avatarUrl: string | undefined + if ( + request.includeAvatars !== false && + previousMessage?.exportAvatarUrl && + (await resourceExists(previousMessage.exportAvatarUrl)) + ) { + avatarUrl = previousMessage.exportAvatarUrl + } else if (request.includeAvatars !== false && avatar) { + avatarUrl = exportedAvatars.get(avatarKey) + if (!avatarUrl) { + avatarUrl = await resolveExportAvatar( + avatarKey, + avatar, + latestPreviousAvatarUrls.get(avatarKey) + ) + if (avatarUrl) exportedAvatars.set(avatarKey, avatarUrl) } } - if (avatarName) { - message.exportAvatarUrl = `avatars/${avatarName}` - } + if (avatarUrl) message.exportAvatarUrl = avatarUrl if (!request.includeMedia || !message.contentData) { send({ jobId: request.jobId, @@ -1036,7 +1162,8 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro (message) => message.exportConversationId === target.userMd5 ).length })), - messages: normalizedMessages + messages: normalizedMessages, + avatarVersions } if (!jobs.has(request.jobId)) throw new Error('已取消') await fs.writeFile(outputPath, renderExportPage(archiveName), 'utf8') diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index 5b095ca..4309492 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -430,9 +430,14 @@ function parseLocationMessage(content: string): ParsedContent { function parseShareMessage(content: string): ParsedContent { const appMsgType = extractAppMsgType(content) - if (appMsgType === '19' || / 0) return forwardBundle + } if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) { const sticker = parseStickerMessage(content) if (sticker.type === 'sticker') return sticker diff --git a/tests/e2e/export-archive.spec.ts b/tests/e2e/export-archive.spec.ts index af116d5..be78a60 100644 --- a/tests/e2e/export-archive.spec.ts +++ b/tests/e2e/export-archive.spec.ts @@ -44,6 +44,55 @@ const zipDirectory = async ( }) } +test('EXPORT-ARCHIVE-00 shows a loading state while archive data is still loading', async ({ + page +}, testInfo) => { + let releaseData!: () => void + const dataReady = new Promise((resolve) => { + releaseData = resolve + }) + + await page.route('http://archive.test/**', async (route) => { + if (route.request().url().endsWith('/data/messages.js')) { + await dataReady + await route.fulfill({ + contentType: 'application/javascript', + body: `window.__WECHAT_EXPORT__ = ${JSON.stringify({ + version: 1, + sourceId: 'loading-fixture', + name: '大量消息', + exportedAt: '2026-08-05T00:00:00.000Z', + messages: [ + archiveMessage('loading-1', 'loading-fixture', '大量消息', '加载完成', 1_767_225_600) + ] + })};` + }) + return + } + + await route.fulfill({ + contentType: 'text/html', + body: renderExportPage('大量消息') + }) + }) + + await page.setViewportSize({ width: 1440, height: 900 }) + const navigation = page.goto('http://archive.test/index.html') + const loading = page.locator('#archive-loading') + await expect(loading).toBeVisible() + await expect(loading).toContainText('正在加载聊天档案') + await expect(loading).toHaveAttribute('aria-busy', 'true') + await page.screenshot({ path: testInfo.outputPath('archive-loading-1440.png') }) + await page.setViewportSize({ width: 390, height: 844 }) + await expect(loading).toBeVisible() + await page.screenshot({ path: testInfo.outputPath('archive-loading-390.png') }) + + releaseData() + await navigation + await expect(loading).toBeHidden() + await expect(page.getByText('加载完成')).toBeVisible() +}) + test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobile', async ({ page }, testInfo) => { @@ -60,13 +109,24 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil name: '合并聊天档案', exportedAt: '2026-08-04T00:00:00.000Z', conversations: [ - { id: 'alpha', name: '项目群', type: 'group', messageCount: 2 }, + { id: 'alpha', name: '项目群', type: 'group', messageCount: 3 }, { id: 'beta', name: '文件传输助手', type: 'user', messageCount: 1 } ], messages: [ archiveMessage('alpha-1', 'alpha', '项目群', '项目群第一条', 1_764_547_200), archiveMessage('beta-1', 'beta', '文件传输助手', '个人聊天消息', 1_769_904_000), - archiveMessage('alpha-2', 'alpha', '项目群', '项目群第二条', 1_769_990_400) + archiveMessage('alpha-2', 'alpha', '项目群', '项目群第二条', 1_769_990_400), + { + ...archiveMessage( + 'alpha-sent', + 'alpha', + 'Jamie', + '那边多少度呀 热不,这是用于验证移动端右侧头像不会被裁切的消息', + 1_775_315_283 + ), + isSender: true, + name: 'Nanin' + } ] })};\n`, 'utf8' @@ -83,12 +143,17 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil const conversationSelect = page.getByLabel('筛选聊天') await expect(conversationSelect).toHaveValue('all') await expect(conversationSelect.locator('option')).toHaveCount(3) + await expect(conversationSelect.locator('option')).toHaveText([ + '全部聊天', + '项目群', + '文件传输助手' + ]) await expect(page.locator('#archive-title')).toBeHidden() await expect(page.locator('.archive-heading #conversation-filter')).toBeVisible() await expect(page.locator('#archive-meta')).toHaveText(/^更新于 /) await expect(page.locator('#archive-meta')).not.toContainText('条消息') - await expect(page.locator('.message')).toHaveCount(3) - await expect(page.locator('.conversation-source')).toHaveCount(3) + await expect(page.locator('.message')).toHaveCount(4) + await expect(page.locator('.conversation-source')).toHaveCount(4) expect( await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) ).toBe(true) @@ -121,7 +186,91 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil expect(positions.conversationBottom).toBeLessThanOrEqual(positions.toolbarBottom) expect(positions.timelineTop).toBeGreaterThanOrEqual(positions.toolbarBottom) expect(positions.documentWidth).toBeLessThanOrEqual(positions.viewportWidth) - await expect(page.locator('.message')).toHaveCount(3) + await expect(page.locator('.message')).toHaveCount(4) + const searchInput = page.getByLabel('搜索消息') + await expect(conversationSelect).toBeVisible() + const compactControlBounds = await page.evaluate(() => { + const conversations = document.querySelector('#conversation-filter')!.getBoundingClientRect() + const search = document.querySelector('#query')!.getBoundingClientRect() + return { + conversationTop: conversations.top, + conversationBottom: conversations.bottom, + conversationWidth: conversations.width, + searchTop: search.top, + searchBottom: search.bottom, + searchWidth: search.width + } + }) + expect( + Math.abs(compactControlBounds.conversationTop - compactControlBounds.searchTop) + ).toBeLessThanOrEqual(1) + expect( + Math.abs(compactControlBounds.conversationBottom - compactControlBounds.searchBottom) + ).toBeLessThanOrEqual(1) + expect(compactControlBounds.searchWidth).toBeGreaterThan(compactControlBounds.conversationWidth) + await conversationSelect.selectOption('beta') + await expect(page.locator('.message')).toHaveCount(1) + await conversationSelect.selectOption('all') + await expect(page.locator('.message')).toHaveCount(4) + await expect(searchInput).toBeVisible() + const mobileFilterButtons = page.locator('.filter-button:visible') + await expect(mobileFilterButtons).toHaveCount(7) + const filterButtonTops = await mobileFilterButtons.evaluateAll((buttons) => + buttons.map((button) => button.getBoundingClientRect().top) + ) + expect(Math.max(...filterButtonTops) - Math.min(...filterButtonTops)).toBeLessThanOrEqual(1) + const countTop = await page + .locator('#count') + .evaluate((element) => element.getBoundingClientRect().top) + const filterBottom = await mobileFilterButtons + .first() + .evaluate((element) => element.getBoundingClientRect().bottom) + expect(countTop).toBeGreaterThanOrEqual(filterBottom) + expect(positions.toolbarBottom - positions.toolbarTop).toBeLessThanOrEqual(150) + await page.getByRole('button', { name: '文字', exact: true }).click() + const messageList = page.locator('#messages') + const sentMessageBounds = await page.locator('.message.sent').evaluate((element) => { + const list = element.parentElement!.getBoundingClientRect() + const message = element.getBoundingClientRect() + const row = element.querySelector('.row')!.getBoundingClientRect() + const avatar = element.querySelector('.avatar')!.getBoundingClientRect() + return { + listLeft: list.left, + listRight: list.right, + messageLeft: message.left, + messageRight: message.right, + rowLeft: row.left, + rowRight: row.right, + avatarLeft: avatar.left, + avatarRight: avatar.right + } + }) + expect(Math.abs(sentMessageBounds.rowLeft - sentMessageBounds.messageLeft)).toBeLessThanOrEqual( + 1 + ) + expect( + Math.abs(sentMessageBounds.rowRight - sentMessageBounds.messageRight) + ).toBeLessThanOrEqual(1) + expect(sentMessageBounds.rowLeft).toBeGreaterThanOrEqual(sentMessageBounds.listLeft) + expect(sentMessageBounds.rowRight).toBeLessThanOrEqual(sentMessageBounds.listRight) + expect(sentMessageBounds.avatarLeft).toBeGreaterThanOrEqual(sentMessageBounds.listLeft) + expect(sentMessageBounds.avatarRight).toBeLessThanOrEqual(sentMessageBounds.listRight) + const mobileScrollBehavior = await messageList.evaluate((element) => { + const styles = getComputedStyle(element) + return { + overflowX: styles.overflowX, + overscrollBehaviorX: styles.overscrollBehaviorX, + touchAction: styles.touchAction + } + }) + expect(mobileScrollBehavior).toEqual({ + overflowX: 'hidden', + overscrollBehaviorX: 'none', + touchAction: 'pan-y' + }) + await messageList.hover() + await page.mouse.wheel(80, 120) + await expect.poll(() => messageList.evaluate((element) => element.scrollLeft)).toBe(0) await page.screenshot({ path: testInfo.outputPath('merged-archive-390.png'), fullPage: true }) } finally { rmSync(fixtureRoot, { recursive: true, force: true }) @@ -171,7 +320,7 @@ test('EXPORT-ARCHIVE-02 legacy single-chat archive keeps its original layout', a test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing tabs', async ({ page -}) => { +}, testInfo) => { const outputDir = mkdtempSync(join(tmpdir(), 'wxe-timeline-sync-e2e-')) try { const dataPath = join(outputDir, 'data', 'messages.js') @@ -218,6 +367,19 @@ test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing const messages = page.locator('#messages') const activeMonth = page.locator('.timeline-month.active') await expect(activeMonth).toHaveAttribute('data-month', '2026-04') + const expandedYear = page.locator('.timeline-year[aria-expanded="true"]') + const latestYear = page.locator('.timeline-year[data-year="2026"]') + await expect(expandedYear).toHaveCount(1) + await expect(expandedYear).toHaveText('2026 年') + await expect(page.locator('.timeline-month:visible')).toHaveCount(1) + await latestYear.click() + await expect(latestYear).toHaveAttribute('aria-expanded', 'false') + await expect(page.locator('.timeline-month:visible')).toHaveCount(0) + await latestYear.click() + await expect(latestYear).toHaveAttribute('aria-expanded', 'true') + await expect(page.locator('.timeline-month:visible')).toHaveCount(1) + await expect(page.locator('#archive-loading')).toBeHidden() + await page.screenshot({ path: testInfo.outputPath('timeline-collapsed-1440.png') }) expect( await messages.evaluate( (element) => element.scrollHeight - element.scrollTop - element.clientHeight @@ -236,10 +398,59 @@ test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing expect(timelinePosition.buttonTop).toBeGreaterThanOrEqual(timelinePosition.timelineTop) expect(timelinePosition.buttonBottom).toBeLessThanOrEqual(timelinePosition.timelineBottom + 1) + const selectedYear = page.locator('.timeline-year[data-year="2020"]') + await selectedYear.click() + await expect(expandedYear).toHaveText('2020 年') + await expect(selectedYear).toHaveAttribute('aria-expanded', 'true') + await expect(page.locator('.timeline-year[data-year="2026"]')).toHaveAttribute( + 'aria-expanded', + 'false' + ) + await expect(page.locator('.timeline-month:visible')).toHaveCount(12) + const selectedMonth = page.locator('.timeline-month[data-month="2020-07"]') + await selectedMonth.click() + await expect(selectedMonth).toHaveClass(/active/) + await expect(activeMonth).toHaveAttribute('data-month', '2020-07') + const visibleMonths = await messages.evaluate((element) => { + const bounds = element.getBoundingClientRect() + const anchor = bounds.top + Math.min(24, bounds.height / 4) + const items = Array.from(element.querySelectorAll('.message')) + return { + firstVisible: items.find((item) => item.getBoundingClientRect().bottom > bounds.top) + ?.dataset.month, + firstAnchored: items.find((item) => item.getBoundingClientRect().bottom > anchor)?.dataset + .month + } + }) + expect(visibleMonths).toEqual({ firstVisible: '2020-06', firstAnchored: '2020-07' }) + await messages.evaluate((element) => { element.scrollTop = 0 }) await expect(activeMonth).toHaveAttribute('data-month', '2006-01') + await expect(expandedYear).toHaveText('2006 年') + await expect(page.locator('.timeline-month:visible')).toHaveCount(12) + await page.setViewportSize({ width: 390, height: 844 }) + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) + ).toBe(true) + const mobileLayoutBounds = await page.evaluate(() => { + const layout = document.querySelector('.archive-layout')!.getBoundingClientRect() + const messages = document.querySelector('#messages')!.getBoundingClientRect() + return { + viewportWidth: window.innerWidth, + layoutLeft: layout.left, + layoutRight: layout.right, + messagesLeft: messages.left, + messagesRight: messages.right + } + }) + expect(mobileLayoutBounds.layoutLeft).toBeGreaterThanOrEqual(0) + expect(mobileLayoutBounds.layoutRight).toBeLessThanOrEqual(mobileLayoutBounds.viewportWidth) + expect(mobileLayoutBounds.messagesLeft).toBeGreaterThanOrEqual(0) + expect(mobileLayoutBounds.messagesRight).toBeLessThanOrEqual(mobileLayoutBounds.viewportWidth) + await expect(expandedYear).toHaveText('2006 年') + await page.screenshot({ path: testInfo.outputPath('timeline-collapsed-390.png') }) } finally { rmSync(outputDir, { recursive: true, force: true }) } @@ -491,7 +702,7 @@ test('EXPORT-ARCHIVE-03 renders shares and locations, and groups payments under fullPage: true }) - await page.getByRole('button', { name: '系统 / 其他', exact: true }).click() + await page.getByRole('button', { name: '系统', exact: true }).click() await expect(page.locator('.message')).toHaveCount(6) await expect(page.getByText('我给你发了一个红包')).toBeVisible() await expect(page.getByText('收到转账¥1000.00元')).toBeVisible() diff --git a/tests/integration/export-media-flow.test.ts b/tests/integration/export-media-flow.test.ts index ba594d7..845905e 100644 --- a/tests/integration/export-media-flow.test.ts +++ b/tests/integration/export-media-flow.test.ts @@ -22,6 +22,8 @@ const state = vi.hoisted(() => ({ documents: '', accountRoot: '', videoPath: '', + selfAvatar: undefined as string | undefined, + avatarMap: {} as Record, messages: [] as Message[], messagesByUser: {} as Record, exportReads: [] as string[], @@ -44,6 +46,26 @@ const state = vi.hoisted(() => ({ vi.mock('electron', () => ({ app: { getPath: () => state.documents }, shell: { showItemInFolder: vi.fn() }, + nativeImage: { + createFromBuffer: (buffer: Buffer) => { + const reversed = buffer.toString().includes('different-avatar') + const bitmap = Buffer.alloc(9 * 8 * 4) + for (let y = 0; y < 8; y += 1) { + for (let x = 0; x < 9; x += 1) { + const offset = (y * 9 + x) * 4 + const value = reversed ? 240 - x * 20 : 40 + x * 20 + bitmap[offset] = value + bitmap[offset + 1] = value + bitmap[offset + 2] = value + bitmap[offset + 3] = 255 + } + } + return { + isEmpty: () => false, + resize: () => ({ toBitmap: () => bitmap }) + } + } + }, BrowserWindow: class {} })) vi.mock('../../src/main/services/chat-service', () => ({ @@ -60,10 +82,11 @@ vi.mock('../../src/main/services/chat-service', () => ({ getUsernameByMd5: (userMd5: string) => `wxid_${userMd5}` }) }), - getContactAvatars: () => ({}), + getContactAvatars: () => ({ ...state.avatarMap }), getSelfAccountInfoAsync: async () => ({ wxid: 'a969409112', nickname: '濑岛田井卫', + avatar: state.selfAvatar, accountRoot: state.accountRoot }) })) @@ -192,6 +215,8 @@ describe('media export flow', () => { state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-')) state.accountRoot = join(state.documents, 'fixture-account') state.videoPath = join(state.documents, 'fixture.mp4') + state.selfAvatar = undefined + state.avatarMap = {} writeFileSync( state.videoPath, Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex') @@ -283,7 +308,8 @@ describe('media export flow', () => { 'ftyp' ) expect(readFileSync(join(outputDir, file.exportMediaUrl!), 'utf8')).toBe('附件内容') - expect(html).toContain('') + expect(html).toContain("dataScript.src = 'data/messages.js'") + expect(html).toContain('id="archive-loading"') expect(voice.voiceDataUrl).toMatch(/^voices\/voice_[0-9a-f]{16}\.wav$/) expect(video.exportMediaUrl).toMatch(/^media\/video_[0-9a-f]{16}\.mp4$/) expect(file.exportMediaUrl).toMatch(/^files\/file_[0-9a-f]{16}_测试附件\.txt$/) @@ -305,6 +331,33 @@ describe('media export flow', () => { expect(state.exportReads).toEqual(['fixture-user']) }) + it('uses the customized file name as the HTML archive title', async () => { + const { runExport } = await import('../../src/main/export-service') + const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } + state.messages = [message({ id: 'custom-title', content: '标题测试' })] + + const result = await runExport( + { + jobId: 'custom-title', + targets: [target('fixture-user', '联系人原名')], + format: 'html', + outputName: '我修改后的文件名', + kinds: ['text'], + includeMedia: false + }, + win as never + ) + + expect(result.success).toBe(true) + const html = readFileSync(result.outputPath!, 'utf8') + const archive = readArchive(result.outputPath!) + expect(html).toContain('我修改后的文件名 - 聊天记录') + expect(html).toContain('我修改后的文件名') + expect(html).not.toContain('联系人原名 - 聊天记录') + expect(archive.name).toBe('我修改后的文件名') + expect(archive.conversations[0].name).toBe('联系人原名') + }) + it('keeps one-to-one sender sides and fills both display names', async () => { const { runExport } = await import('../../src/main/export-service') const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } @@ -481,6 +534,88 @@ describe('media export flow', () => { expect(existsSync(imagePath)).toBe(true) }) + it('keeps historical avatars and creates a new version only after a real visual change', async () => { + const { runExport } = await import('../../src/main/export-service') + const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } + const encodedAvatar = (value: string): string => + `data:image/jpeg;base64,${Buffer.from(value).toString('base64')}` + const request = { + targets: [target('fixture-user', '头像版本会话')], + format: 'html' as const, + outputName: 'avatar-version-fixture', + kinds: ['text'] as const, + includeMedia: false, + includeAvatars: true + } + const oldMessage = message({ + id: 'avatar-old', + isSender: true, + senderId: 'a969409112', + content: '历史消息', + createTime: 1_785_549_600 + }) + const sameAvatarFirstEncoding = encodedAvatar('same-visual-encoding-one') + state.selfAvatar = sameAvatarFirstEncoding + state.avatarMap = { a969409112: sameAvatarFirstEncoding } + state.messages = [oldMessage] + + const first = await runExport( + { ...request, jobId: 'avatar-version-first', kinds: [...request.kinds] }, + win as never + ) + expect(first.success, first.error).toBe(true) + const firstAvatarUrl = readArchive(first.outputPath!).messages[0].exportAvatarUrl + + const newMessageBeforeChange = message({ + id: 'avatar-new-same', + isSender: true, + senderId: 'a969409112', + content: '头像未变时的新消息', + createTime: 1_785_549_700 + }) + const sameAvatarSecondEncoding = encodedAvatar('same-visual-encoding-two') + state.selfAvatar = sameAvatarSecondEncoding + state.avatarMap = { a969409112: sameAvatarSecondEncoding } + state.messages = [oldMessage, newMessageBeforeChange] + const second = await runExport( + { ...request, jobId: 'avatar-version-second', kinds: [...request.kinds] }, + win as never + ) + expect(second.success, second.error).toBe(true) + expect(readArchive(second.outputPath!).messages.map((item) => item.exportAvatarUrl)).toEqual([ + firstAvatarUrl, + firstAvatarUrl + ]) + + const newMessageAfterChange = message({ + id: 'avatar-new-changed', + isSender: true, + senderId: 'a969409112', + content: '真正换头像后的新消息', + createTime: 1_785_549_800 + }) + const changedAvatar = encodedAvatar('different-avatar') + state.selfAvatar = changedAvatar + state.avatarMap = { a969409112: changedAvatar } + state.messages = [oldMessage, newMessageBeforeChange, newMessageAfterChange] + const third = await runExport( + { ...request, jobId: 'avatar-version-third', kinds: [...request.kinds] }, + win as never + ) + expect(third.success, third.error).toBe(true) + const thirdArchive = readArchive(third.outputPath!) + expect(thirdArchive.messages.slice(0, 2).map((item) => item.exportAvatarUrl)).toEqual([ + firstAvatarUrl, + firstAvatarUrl + ]) + expect(thirdArchive.messages[2].exportAvatarUrl).not.toBe(firstAvatarUrl) + expect( + readdirSync(join(dirname(third.outputPath!), 'avatars')).filter((name) => + name.startsWith('avatar_') + ) + ).toHaveLength(2) + }) + it('keeps copied videos writable and can replace a legacy read-only video incrementally', async () => { const { runExport } = await import('../../src/main/export-service') const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } diff --git a/tests/unit/export-media.test.ts b/tests/unit/export-media.test.ts index e15f208..897a392 100644 --- a/tests/unit/export-media.test.ts +++ b/tests/unit/export-media.test.ts @@ -25,8 +25,13 @@ describe('export media', () => { const html = renderExportPage('脱敏导出') expect(EXPORT_PAGE_SIZE).toBe(240) - expect(html).toContain('') + expect(html).toContain("dataScript.src = 'data/messages.js'") + expect(html).toContain('id="archive-loading"') + expect(html).toContain('正在加载聊天档案') + expect(html).toContain('requestAnimationFrame(() => window.setTimeout(loadArchiveData, 0))') expect(html).toContain('aria-label="聊天时间轴"') + expect(html).toContain('aria-expanded="') + expect(html).toContain('setExpandedTimelineYear') expect(html).toContain('data-kind="media"') expect(html).toContain('placeholder="搜索发送者或消息内容…"') expect(html).toContain('filtered.slice(windowStart, windowEnd)') @@ -91,6 +96,10 @@ describe('export media', () => { search.dispatchEvent(new dom.window.Event('input')) expect(dom.window.document.querySelectorAll('.message')).toHaveLength(5) expect(dom.window.document.querySelectorAll('.timeline-month').length).toBeGreaterThan(1) + expect( + dom.window.document.querySelectorAll('.timeline-year[aria-expanded="true"]') + ).toHaveLength(1) + expect(dom.window.document.querySelectorAll('.timeline-months:not([hidden])')).toHaveLength(1) dom.window.close() }) @@ -124,7 +133,7 @@ describe('export media', () => { expect(dom.window.document.querySelector('#archive-meta')?.textContent).toMatch(/^更新于 /) expect(select.options).toHaveLength(3) expect(select.value).toBe('all') - expect(select.options[0].textContent).toBe('全部聊天(3)') + expect(select.options[0].textContent).toBe('全部聊天') expect(dom.window.document.querySelectorAll('.conversation-source')).toHaveLength(3) expect(dom.window.document.querySelector('#count')?.textContent).toBe( '已显示 3 / 筛选 3 / 全部 3' diff --git a/tests/unit/message-parser.test.ts b/tests/unit/message-parser.test.ts index 372abe0..138c797 100644 --- a/tests/unit/message-parser.test.ts +++ b/tests/unit/message-parser.test.ts @@ -28,6 +28,34 @@ describe('message parser', () => { } }) + it.each([ + ['6', '测试附件.pdf'], + ['74', '发送中的附件.zip'] + ])( + 'keeps file app message type %s when attachment metadata contains record tags', + (typeVal, title) => { + const parsed = parseMessageContent( + `${typeVal}${title}1 MBlegacy metadata${title}`, + 49 + ) + + expect(parsed).toMatchObject({ + type: 'share', + title, + typeVal + }) + } + ) + + it('does not classify empty incidental record metadata as a merged forward', () => { + const parsed = parseMessageContent( + '5普通分享legacy metadata', + 49 + ) + + expect(parsed).toMatchObject({ type: 'share', title: '普通分享', typeVal: '5' }) + }) + it('uses the quoted group member id instead of the chatroom id', () => { const parsed = parseMessageContent( '57回复内容1123456789@chatroomwxid_fixture_member被引用内容', From 17cc99de37c8a7a3108e827ea9db7fd754297373 Mon Sep 17 00:00:00 2001 From: "majun.jason" Date: Wed, 5 Aug 2026 15:24:36 +0800 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=A1=A3=E6=A1=88=E6=90=9C=E7=B4=A2=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 高亮展示搜索结果中的命中词,覆盖普通文本与结构化消息内容 - 为全部及分类搜索结果提供聊天定位,定位后清空搜索并回到完整消息上下文 - 修复移动端搜索框聚焦自动放大,并补充桌面与移动端测试覆盖 --- src/main/export-html-template.ts | 59 ++++++++++++++++++++++++++++++-- tests/e2e/export-archive.spec.ts | 35 ++++++++++++++++++- tests/unit/export-media.test.ts | 19 ++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index 34157ba..9237a45 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -306,6 +306,10 @@ body { opacity: 1; transform: translate(0, -50%); } +@media (hover: none) { + .locate-all { opacity: 1; pointer-events: auto; } + .locate-label { display: none; } +} .message.located .bubble { animation: locate-message 1.5s ease-out; } @keyframes locate-message { 0%, 32% { outline: 3px solid #36a477; outline-offset: 3px; } @@ -376,6 +380,14 @@ body { .sent .bubble { background: var(--mine); border-color: #c7e6d4; border-radius: 18px 10px 18px 18px; } .sender { color: var(--muted); font-size: 12px; margin-bottom: 5px; } .content { line-height: 1.7; word-break: break-word; white-space: pre-wrap; } +.search-highlight { + border-radius: 3px; + padding: 0 1px; + background: #ffe58f; + color: inherit; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} .audio-wrap { width: 260px; max-width: 100%; min-width: 0; } .audio { display: block; width: 100%; max-width: 100%; height: 38px; } .media-status { @@ -596,7 +608,13 @@ body { box-shadow: none; } .controls { grid-column: 2; min-width: 0; justify-content: flex-start; } - .controls input[type=search] { width: 100%; min-width: 0; height: 36px; padding: 0 10px; } + .controls input[type=search] { + width: 100%; + min-width: 0; + height: 36px; + padding: 0 10px; + font-size: 16px; + } .filters { grid-column: 1 / -1; display: flex; @@ -1025,7 +1043,7 @@ const renderExportScript = (name: string): string => ` const source = conversations.length > 1 && activeConversation === 'all' ? '' + esc(message.exportConversationName || '聊天') + '' : '' - const locateAction = activeKind === 'all' + const locateAction = activeKind === 'all' && !query.value.trim() ? '' : '' + conversationMenu.innerHTML = option('all', '全部聊天', avatar(null, allAvatarUrls)) + + conversations.map((conversation) => option( + conversation.id, + conversation.name, + avatar(conversation, conversation.avatarUrl ? [conversation.avatarUrl] : []) + )).join('') + updateConversationTrigger() + } + + const updateConversationTrigger = () => { + const conversation = conversations.find((item) => item.id === activeConversation) + const avatarUrls = activeConversation === 'all' + ? conversations.map((item) => item.avatarUrl).filter(Boolean).slice(0, 2) + : conversation && conversation.avatarUrl ? [conversation.avatarUrl] : [] + const label = conversation ? conversation.name : '全部聊天' + conversationTriggerName.textContent = label + conversationTriggerAvatar.classList.toggle('multiple', avatarUrls.length > 1) + if (avatarUrls.length) { + conversationTriggerAvatar.innerHTML = avatarUrls.map((url) => + '' + ).join('') + } else { + conversationTriggerAvatar.textContent = conversation + ? String(conversation.name || '聊').slice(0, 1) + : '全' + } + conversationMenu.querySelectorAll('[data-conversation-id]').forEach((option) => { + const selected = option.dataset.conversationId === activeConversation + option.setAttribute('aria-selected', String(selected)) + const check = option.querySelector('.conversation-option-check') + if (check) check.innerHTML = selected ? '✓' : '' + }) + } + + const setConversationMenuOpen = (open, focusSelected = false) => { + conversationMenu.hidden = !open + conversationTrigger.setAttribute('aria-expanded', String(open)) + if (open && focusSelected) { + nextFrame(() => { + const selected = conversationMenu.querySelector('[aria-selected=true]') + if (selected) selected.focus() + }) + } + } + + const selectConversation = (id) => { + if (id !== 'all' && !conversations.some((item) => item.id === id)) return + if (id !== activeConversation) { + rememberTabPosition() + activeConversation = id + updateConversationTrigger() + applyFilters(true) + } + setConversationMenuOpen(false) + conversationTrigger.focus() } const setExpandedTimelineYear = (year) => { @@ -1402,10 +1555,34 @@ const renderExportScript = (name: string): string => ` setActiveKind(button.dataset.kind) applyFilters(true) }) - conversationSelect.addEventListener('change', () => { - rememberTabPosition() - activeConversation = conversationSelect.value - applyFilters(true) + conversationTrigger.addEventListener('click', () => { + setConversationMenuOpen(conversationMenu.hidden) + }) + conversationTrigger.addEventListener('keydown', (event) => { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return + event.preventDefault() + setConversationMenuOpen(true, true) + }) + conversationMenu.addEventListener('click', (event) => { + const option = event.target.closest('[data-conversation-id]') + if (option) selectConversation(option.dataset.conversationId) + }) + conversationMenu.addEventListener('keydown', (event) => { + const options = Array.from(conversationMenu.querySelectorAll('[data-conversation-id]')) + const currentIndex = options.indexOf(document.activeElement) + let nextIndex = currentIndex + if (event.key === 'ArrowDown') nextIndex = Math.min(options.length - 1, currentIndex + 1) + else if (event.key === 'ArrowUp') nextIndex = Math.max(0, currentIndex - 1) + else if (event.key === 'Home') nextIndex = 0 + else if (event.key === 'End') nextIndex = options.length - 1 + else if (event.key === 'Escape') { + event.preventDefault() + setConversationMenuOpen(false) + conversationTrigger.focus() + return + } else return + event.preventDefault() + if (options[nextIndex]) options[nextIndex].focus() }) timeline.addEventListener('click', (event) => { const yearButton = event.target.closest('[data-year]') @@ -1452,15 +1629,16 @@ const renderExportScript = (name: string): string => ` }) closeButton.addEventListener('click', closeLightbox) document.addEventListener('keydown', (event) => { - if (event.key === 'Escape') closeLightbox() + if (event.key === 'Escape') { + closeLightbox() + setConversationMenuOpen(false) + } + }) + document.addEventListener('click', (event) => { + if (!conversationFilter.contains(event.target)) setConversationMenuOpen(false) }) title.textContent = archive.name || ${JSON.stringify(name)} - const updatedAt = archive.exportedAt - ? new Date(archive.exportedAt).toLocaleString('zh-CN', { hour12: false }) - : '未知时间' - meta.textContent = (conversations.length > 1 ? '' : allMessages.length.toLocaleString() + ' 条消息 · ') + - '更新于 ' + updatedAt renderConversations() applyFilters() finishLoading() @@ -1518,10 +1696,19 @@ export function renderExportPage(name: string): string {
${safe(name)} - - 正在读取消息… +
diff --git a/tests/e2e/export-archive.spec.ts b/tests/e2e/export-archive.spec.ts index 741013e..1500268 100644 --- a/tests/e2e/export-archive.spec.ts +++ b/tests/e2e/export-archive.spec.ts @@ -109,8 +109,22 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil name: '合并聊天档案', exportedAt: '2026-08-04T00:00:00.000Z', conversations: [ - { id: 'alpha', name: '项目群', type: 'group', messageCount: 3 }, - { id: 'beta', name: '文件传输助手', type: 'user', messageCount: 1 } + { + id: 'alpha', + name: '项目群', + type: 'group', + avatarUrl: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpath fill='%23176b57' d='M0 0h10v10H0z'/%3E%3C/svg%3E", + messageCount: 3 + }, + { + id: 'beta', + name: '文件传输助手', + type: 'user', + avatarUrl: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpath fill='%23d9f0e2' d='M0 0h10v10H0z'/%3E%3C/svg%3E", + messageCount: 1 + } ], messages: [ archiveMessage('alpha-1', 'alpha', '项目群', '项目群第一条', 1_764_547_200), @@ -140,30 +154,62 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil await page.setViewportSize({ width: 1440, height: 900 }) await page.goto(pathToFileURL(offlineIndex).href) - const conversationSelect = page.getByLabel('筛选聊天') - await expect(conversationSelect).toHaveValue('all') - await expect(conversationSelect.locator('option')).toHaveCount(3) - await expect(conversationSelect.locator('option')).toHaveText([ - '全部聊天', - '项目群', - '文件传输助手' - ]) + const conversationTrigger = page.getByRole('button', { name: '筛选聊天' }) + const conversationMenu = page.getByRole('listbox', { name: '选择聊天' }) + const chooseConversation = async (name: string): Promise => { + await conversationTrigger.click() + await conversationMenu.getByRole('option', { name, exact: true }).click() + } + await expect(conversationTrigger).toHaveAttribute('aria-expanded', 'false') + await expect(conversationMenu).toBeHidden() + await expect(conversationTrigger.locator('.conversation-switch-icon')).toBeVisible() + await expect( + conversationTrigger.locator('.conversation-trigger-name + .conversation-switch-icon') + ).toBeVisible() + await expect(conversationTrigger.locator('.conversation-chevron')).toHaveCount(0) + await expect(page.locator('#conversation-trigger-name')).toHaveText('全部聊天') + await expect(page.locator('#conversation-trigger-avatar img')).toHaveCount(2) await expect(page.locator('#archive-title')).toBeHidden() await expect(page.locator('.archive-heading #conversation-filter')).toBeVisible() - await expect(page.locator('#archive-meta')).toHaveText(/^更新于 /) - await expect(page.locator('#archive-meta')).not.toContainText('条消息') + await expect(page.locator('#archive-meta')).toHaveCount(0) await expect(page.locator('.message')).toHaveCount(4) await expect(page.locator('.conversation-source')).toHaveCount(4) + const desktopSwitcherBounds = await page.evaluate(() => { + const trigger = document.querySelector('#conversation-trigger')!.getBoundingClientRect() + const name = document.querySelector('#conversation-trigger-name')!.getBoundingClientRect() + const icon = document.querySelector('.conversation-switch-icon')!.getBoundingClientRect() + return { triggerWidth: trigger.width, nameToIcon: icon.left - name.right } + }) + expect(desktopSwitcherBounds.triggerWidth).toBeLessThan(200) + expect(desktopSwitcherBounds.nameToIcon).toBeLessThanOrEqual(12) expect( await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) ).toBe(true) + await conversationTrigger.click() + await expect(conversationTrigger).toHaveAttribute('aria-expanded', 'true') + await expect(conversationMenu).toBeVisible() + await expect(conversationMenu.getByRole('option')).toHaveCount(3) + await expect(conversationMenu.getByRole('option', { name: '全部聊天' })).toBeVisible() + await expect(conversationMenu.getByRole('option', { name: '项目群' })).toBeVisible() + await expect(conversationMenu.getByRole('option', { name: '文件传输助手' })).toBeVisible() + await expect(conversationMenu).not.toContainText('条消息') + await expect(conversationMenu.getByRole('option', { name: '全部聊天' })).toHaveAttribute( + 'aria-selected', + 'true' + ) + await expect(conversationTrigger).toHaveCSS('border-top-width', '0px') + await page.screenshot({ path: testInfo.outputPath('conversation-menu-1440.png') }) + await page.keyboard.press('Escape') + await expect(conversationMenu).toBeHidden() await page.screenshot({ path: testInfo.outputPath('merged-archive-1440.png'), fullPage: true }) - await conversationSelect.selectOption('beta') + await chooseConversation('文件传输助手') await expect(page.locator('.message')).toHaveCount(1) + await expect(page.locator('#conversation-trigger-name')).toHaveText('文件传输助手') + await expect(page.locator('#conversation-trigger-avatar img')).toHaveCount(1) await expect(page.locator('.conversation-source')).toHaveCount(0) - await conversationSelect.selectOption('all') + await chooseConversation('全部聊天') await page.setViewportSize({ width: 390, height: 844 }) await expect(page.locator('.timeline-year')).toHaveCount(2) await expect(page.locator('.timeline-year').first()).toBeVisible() @@ -188,7 +234,7 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil expect(positions.documentWidth).toBeLessThanOrEqual(positions.viewportWidth) await expect(page.locator('.message')).toHaveCount(4) const searchInput = page.getByLabel('搜索消息') - await expect(conversationSelect).toBeVisible() + await expect(conversationTrigger).toBeVisible() const compactControlBounds = await page.evaluate(() => { const conversations = document.querySelector('#conversation-filter')!.getBoundingClientRect() const search = document.querySelector('#query')!.getBoundingClientRect() @@ -210,9 +256,22 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil ).toBeLessThanOrEqual(1) expect(compactControlBounds.searchWidth).toBeGreaterThan(compactControlBounds.conversationWidth) expect(compactControlBounds.searchFontSize).toBe('16px') - await conversationSelect.selectOption('beta') + await expect(conversationTrigger.locator('.conversation-switch-icon')).toHaveCSS( + 'width', + '13px' + ) + await conversationTrigger.click() + await expect(conversationMenu).toBeVisible() + const mobileMenuBounds = await conversationMenu.evaluate((element) => { + const bounds = element.getBoundingClientRect() + return { left: bounds.left, right: bounds.right, viewportWidth: window.innerWidth } + }) + expect(mobileMenuBounds.left).toBeGreaterThanOrEqual(0) + expect(mobileMenuBounds.right).toBeLessThanOrEqual(mobileMenuBounds.viewportWidth) + await page.screenshot({ path: testInfo.outputPath('conversation-menu-390.png') }) + await conversationMenu.getByRole('option', { name: '文件传输助手' }).click() await expect(page.locator('.message')).toHaveCount(1) - await conversationSelect.selectOption('all') + await chooseConversation('全部聊天') await expect(page.locator('.message')).toHaveCount(4) await expect(searchInput).toBeVisible() await searchInput.fill('个人聊天消息') @@ -319,7 +378,7 @@ test('EXPORT-ARCHIVE-02 legacy single-chat archive keeps its original layout', a await expect(page.locator('#conversation-filter')).toBeHidden() await expect(page.locator('#archive-title')).toBeVisible() await expect(page.locator('#archive-title')).toHaveText('单聊天档案') - await expect(page.locator('#archive-meta')).toContainText('1 条消息 · 更新于 ') + await expect(page.locator('#archive-meta')).toHaveCount(0) await expect(page.locator('.archive-layout')).toHaveClass(/single-conversation/) await expect(page.locator('.message')).toHaveCount(1) await page.screenshot({ path: testInfo.outputPath('single-archive-1440.png'), fullPage: true }) From c4e13ee7d20d3dd95b68254351aa50ea6e24a69a Mon Sep 17 00:00:00 2001 From: Nanin <7017924+nanin@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:19:55 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=A1=A3=E6=A1=88=E6=90=9C=E7=B4=A2=E8=AF=AF=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E5=86=85=E9=83=A8=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除隐藏 senderId 搜索字段,避免 xi 等关键词误命中 wxid - 保留发送者昵称、消息正文及结构化可见内容的搜索能力 - 新增 wxid 与可见正文匹配的回归测试 --- src/main/export-html-template.ts | 1 - tests/unit/export-media.test.ts | 53 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index 89e4977..233b0b5 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -940,7 +940,6 @@ const renderExportScript = (name: string): string => ` const searchText = (message) => [ message.exportConversationName, message.name, - message.senderId, message.content, message.type, message.contentData && message.contentData.title, diff --git a/tests/unit/export-media.test.ts b/tests/unit/export-media.test.ts index c45ead5..d399c44 100644 --- a/tests/unit/export-media.test.ts +++ b/tests/unit/export-media.test.ts @@ -106,6 +106,59 @@ describe('export media', () => { dom.window.close() }) + it('does not match hidden sender ids when searching visible message text', () => { + const html = renderExportPage('搜索测试') + const dom = new JSDOM(html, { runScripts: 'outside-only' }) + const messages: Message[] = [ + { + id: 'hidden-sender-id-match', + from: 'user', + type: '普通文本', + datetime: '', + content: '这条消息不应命中', + name: 'Jamie', + senderId: 'wxid_fixture_member', + isSender: false, + createTime: 1_767_225_600 + }, + { + id: 'visible-content-match', + from: 'user', + type: '普通文本', + datetime: '', + content: 'https://example.com/xi', + name: 'Cherry', + senderId: 'wxid_fixture_self', + isSender: true, + createTime: 1_767_225_601 + } + ] + Object.assign(dom.window, { + __WECHAT_EXPORT__: { + version: 1, + sourceId: 'fixture', + name: '搜索测试', + exportedAt: '2026-08-05T00:00:00.000Z', + messages + } + }) + + dom.window.eval(inlineScriptOf(html)) + const search = dom.window.document.querySelector('#query') as HTMLInputElement + search.value = 'xi' + search.dispatchEvent(new dom.window.Event('input')) + + expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1) + expect(dom.window.document.querySelector('.message')?.textContent).toContain( + 'https://example.com/xi' + ) + expect(dom.window.document.querySelectorAll('.search-highlight')).toHaveLength(1) + expect(dom.window.document.querySelector('#count')?.textContent).toBe( + '已显示 1 / 筛选 1 / 全部 2' + ) + dom.window.close() + }) + it('filters a v2 merged archive by conversation before search and month counts', () => { const html = renderExportPage('合并档案') const dom = new JSDOM(html, { runScripts: 'outside-only' })