Merge pull request #9 from nanin/nanin/develop

feat: 支持多聊天合并导出
This commit is contained in:
qingmao
2026-08-04 20:18:44 +08:00
committed by GitHub
19 changed files with 1930 additions and 287 deletions
+4
View File
@@ -33,6 +33,10 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
if: runner.os == 'macOS'
run: pnpm exec playwright install chromium
- name: Type check - name: Type check
run: pnpm typecheck run: pnpm typecheck
+2
View File
@@ -58,6 +58,7 @@
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@koromix/koffi-win32-x64": "3.1.0", "@koromix/koffi-win32-x64": "3.1.0",
"@tanstack/react-virtual": "^3.14.6", "@tanstack/react-virtual": "^3.14.6",
"archiver": "^8.0.0",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ffmpeg-static": "5.3.0", "ffmpeg-static": "5.3.0",
@@ -79,6 +80,7 @@
"@testing-library/jest-dom": "^7.0.0", "@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/archiver": "^8.0.0",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/node": "^22.19.1", "@types/node": "^22.19.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
+311 -6
View File
@@ -19,12 +19,14 @@ specifiers:
'@testing-library/jest-dom': ^7.0.0 '@testing-library/jest-dom': ^7.0.0
'@testing-library/react': ^16.3.2 '@testing-library/react': ^16.3.2
'@testing-library/user-event': ^14.6.1 '@testing-library/user-event': ^14.6.1
'@types/archiver': ^8.0.0
'@types/fs-extra': ^11.0.4 '@types/fs-extra': ^11.0.4
'@types/node': ^22.19.1 '@types/node': ^22.19.1
'@types/react': ^19.2.7 '@types/react': ^19.2.7
'@types/react-dom': ^19.2.3 '@types/react-dom': ^19.2.3
'@vitejs/plugin-react': ^5.1.1 '@vitejs/plugin-react': ^5.1.1
'@vitest/coverage-v8': ^4.1.10 '@vitest/coverage-v8': ^4.1.10
archiver: ^8.0.0
cross-env: ^10.1.0 cross-env: ^10.1.0
electron: ^43.0.0 electron: ^43.0.0
electron-builder: ^26.0.12 electron-builder: ^26.0.12
@@ -56,6 +58,7 @@ dependencies:
'@electron-toolkit/utils': 4.0.0_electron@43.1.0 '@electron-toolkit/utils': 4.0.0_electron@43.1.0
'@koromix/koffi-win32-x64': 3.1.0 '@koromix/koffi-win32-x64': 3.1.0
'@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq '@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq
archiver: 8.0.0
cross-env: 10.1.0 cross-env: 10.1.0
electron-updater: 6.8.9 electron-updater: 6.8.9
ffmpeg-static: 5.3.0 ffmpeg-static: 5.3.0
@@ -77,6 +80,7 @@ devDependencies:
'@testing-library/jest-dom': 7.0.0_rwcxf7kajngkz26znmtz643644 '@testing-library/jest-dom': 7.0.0_rwcxf7kajngkz26znmtz643644
'@testing-library/react': 16.3.2_ifbmwgqdqpqwyvhobr7vc7fmyu '@testing-library/react': 16.3.2_ifbmwgqdqpqwyvhobr7vc7fmyu
'@testing-library/user-event': 14.6.1_rwcxf7kajngkz26znmtz643644 '@testing-library/user-event': 14.6.1_rwcxf7kajngkz26znmtz643644
'@types/archiver': 8.0.0
'@types/fs-extra': 11.0.4 '@types/fs-extra': 11.0.4
'@types/node': 22.19.2 '@types/node': 22.19.2
'@types/react': 19.2.7 '@types/react': 19.2.7
@@ -1513,6 +1517,13 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
dev: true 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: /@types/aria-query/5.0.4:
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
dev: true dev: true
@@ -1650,6 +1661,12 @@ packages:
csstype: 3.2.3 csstype: 3.2.3
dev: true 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: /@types/responselike/1.0.3:
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
dependencies: dependencies:
@@ -1911,6 +1928,13 @@ packages:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
dev: true 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: /acorn-jsx/5.3.2_acorn@8.15.0:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@@ -2047,6 +2071,25 @@ packages:
- supports-color - supports-color
dev: true 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: /argparse/2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -2176,7 +2219,6 @@ packages:
/async/3.2.6: /async/3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
dev: true
/asynckit/0.4.0: /asynckit/0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -2194,13 +2236,86 @@ packages:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
dev: true 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: /balanced-match/1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
dev: true 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: /base64-js/1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
dev: true
/baseline-browser-mapping/2.9.5: /baseline-browser-mapping/2.9.5:
resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==} resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==}
@@ -2234,6 +2349,13 @@ packages:
balanced-match: 1.0.2 balanced-match: 1.0.2
dev: true 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: /browserslist/4.28.1:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} 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 update-browserslist-db: 1.2.2_browserslist@4.28.1
dev: true 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: /buffer-from/1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -2256,6 +2383,13 @@ packages:
ieee754: 1.2.1 ieee754: 1.2.1
dev: true 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: /builder-util-runtime/9.3.1:
resolution: {integrity: sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==} resolution: {integrity: sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -2496,6 +2630,17 @@ packages:
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true 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: /concat-map/0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
dev: true dev: true
@@ -2523,8 +2668,12 @@ packages:
/core-util-is/1.0.2: /core-util-is/1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} 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: /crc/3.8.0:
resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==}
@@ -2533,6 +2682,14 @@ packages:
dev: true dev: true
optional: 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: /cross-env/10.1.0:
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -3247,6 +3404,24 @@ packages:
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true 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: /expect-type/1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -3270,6 +3445,10 @@ packages:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
dev: true dev: true
/fast-fifo/1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
dev: false
/fast-json-stable-stringify/2.1.0: /fast-json-stable-stringify/2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
dev: true dev: true
@@ -3538,7 +3717,7 @@ packages:
/glob/8.1.0: /glob/8.1.0:
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
engines: {node: '>=12'} 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: dependencies:
fs.realpath: 1.0.0 fs.realpath: 1.0.0
inflight: 1.0.6 inflight: 1.0.6
@@ -3745,7 +3924,6 @@ packages:
/ieee754/1.2.1: /ieee754/1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
dev: true
/ignore/5.3.2: /ignore/5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
@@ -3967,6 +4145,11 @@ packages:
call-bound: 1.0.4 call-bound: 1.0.4
dev: true dev: true
/is-stream/4.0.1:
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
engines: {node: '>=18'}
dev: false
/is-string/1.1.1: /is-string/1.1.1:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4016,6 +4199,10 @@ packages:
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
dev: true dev: true
/isarray/1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
dev: false
/isarray/2.0.5: /isarray/2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
dev: true dev: true
@@ -4207,6 +4394,13 @@ packages:
/lazy-val/1.0.5: /lazy-val/1.0.5:
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} 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: /levn/0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -4391,6 +4585,13 @@ packages:
'@isaacs/brace-expansion': 5.0.0 '@isaacs/brace-expansion': 5.0.0
dev: true 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: /minimatch/3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
dependencies: dependencies:
@@ -4538,6 +4739,11 @@ packages:
abbrev: 1.1.1 abbrev: 1.1.1
dev: true dev: true
/normalize-path/3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
dev: false
/normalize-url/6.1.0: /normalize-url/6.1.0:
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -4834,6 +5040,15 @@ packages:
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
dev: true 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: /progress/2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
@@ -4914,6 +5129,18 @@ packages:
- supports-color - supports-color
dev: true 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: /readable-stream/3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -4922,6 +5149,24 @@ packages:
string_decoder: 1.3.0 string_decoder: 1.3.0
util-deprecate: 1.0.2 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: /readdirp/5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'} engines: {node: '>= 20.19.0'}
@@ -5066,6 +5311,10 @@ packages:
isarray: 2.0.5 isarray: 2.0.5
dev: true dev: true
/safe-buffer/5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
dev: false
/safe-buffer/5.2.1: /safe-buffer/5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -5321,6 +5570,17 @@ packages:
internal-slot: 1.1.0 internal-slot: 1.1.0
dev: true 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: /string-width/4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -5397,6 +5657,12 @@ packages:
es-object-atoms: 1.1.1 es-object-atoms: 1.1.1
dev: true 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: /string_decoder/1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
dependencies: dependencies:
@@ -5459,6 +5725,19 @@ packages:
'@pkgr/core': 0.2.9 '@pkgr/core': 0.2.9
dev: true 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: /tar/6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -5471,6 +5750,15 @@ packages:
yallist: 4.0.0 yallist: 4.0.0
dev: true 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: /temp-file/3.4.0:
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
dependencies: dependencies:
@@ -5478,6 +5766,14 @@ packages:
fs-extra: 10.1.0 fs-extra: 10.1.0
dev: true 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: /tiny-async-pool/1.3.0:
resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==}
dependencies: dependencies:
@@ -6035,6 +6331,15 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
dev: true 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: /zod-validation-error/4.0.2_zod@4.1.13:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
+154 -13
View File
@@ -39,8 +39,16 @@ body {
padding: 16px 20px; padding: 16px 20px;
box-shadow: 0 8px 24px #29483b12; 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; } .title { font-size: 18px; font-weight: 750; }
.meta { color: var(--muted); margin-left: 12px; font-size: 13px; } .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 { display: flex; gap: 8px; align-items: center; justify-content: flex-end; }
.controls input, .filter-button { .controls input, .filter-button {
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -63,13 +71,66 @@ body {
.count { margin-left: auto; color: var(--muted); font-size: 13px; } .count { margin-left: auto; color: var(--muted); font-size: 13px; }
.archive-layout { .archive-layout {
display: grid; display: grid;
grid-template-columns: 150px minmax(0, 1fr); grid-template-columns: 168px minmax(0, 1fr);
gap: 18px; gap: 18px;
min-height: 0; min-height: 0;
flex: 1; flex: 1;
margin-top: 16px; 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 { .timeline {
flex: 1;
overflow: auto; overflow: auto;
background: #f7faf8; background: #f7faf8;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -133,6 +194,14 @@ body {
} }
.message.system .sender { display: none; } .message.system .sender { display: none; }
.time { color: var(--muted); font-size: 11px; margin: 0 12px; } .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%; } .row { display: flex; gap: 12px; align-items: flex-end; max-width: 100%; }
.sent .row { flex-direction: row-reverse; } .sent .row { flex-direction: row-reverse; }
.avatar { .avatar {
@@ -249,9 +318,19 @@ body {
.controls input[type=search] { width: 100%; } .controls input[type=search] { width: 100%; }
.filters { grid-column: 1; } .filters { grid-column: 1; }
.count { width: 100%; margin-left: 0; } .count { width: 100%; margin-left: 0; }
.archive-layout { grid-template-columns: 1fr; margin-top: 10px; } .archive-layout { grid-template-columns: 1fr; align-content: start; margin-top: 10px; }
.timeline { display: flex; gap: 6px; overflow: auto; padding: 8px; } .archive-layout.single-conversation { grid-template-columns: 1fr; }
.timeline-year { display: none; } .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 { .timeline-month {
flex: 0 0 auto; flex: 0 0 auto;
width: auto; width: auto;
@@ -281,8 +360,24 @@ const renderExportScript = (name: string): string => `
const PAGE_SIZE = ${EXPORT_PAGE_SIZE} const PAGE_SIZE = ${EXPORT_PAGE_SIZE}
const WINDOW_STEP = Math.floor(PAGE_SIZE / 2) const WINDOW_STEP = Math.floor(PAGE_SIZE / 2)
const archive = window.__WECHAT_EXPORT__ || { name: ${JSON.stringify(name)}, messages: [] } 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 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 timeline = document.querySelector('#timeline')
const query = document.querySelector('#query') const query = document.querySelector('#query')
const count = document.querySelector('#count') const count = document.querySelector('#count')
@@ -293,6 +388,7 @@ const renderExportScript = (name: string): string => `
const preview = document.querySelector('#lightbox-image') const preview = document.querySelector('#lightbox-image')
const closeButton = document.querySelector('#lightbox-close') const closeButton = document.querySelector('#lightbox-close')
let activeKind = 'all' let activeKind = 'all'
let activeConversation = 'all'
let filtered = [] let filtered = []
let windowStart = 0 let windowStart = 0
let windowEnd = 0 let windowEnd = 0
@@ -345,6 +441,7 @@ const renderExportScript = (name: string): string => `
return 'text' return 'text'
} }
const searchText = (message) => [ const searchText = (message) => [
message.exportConversationName,
message.name, message.name,
message.senderId, message.senderId,
message.content, message.content,
@@ -389,14 +486,42 @@ const renderExportScript = (name: string): string => `
: avatarFallback) + '</div>' : avatarFallback) + '</div>'
const text = message.content || (data.type === 'quote' ? data.title : '') 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 ? '[' + (message.type || '消息') + ']' : ''))
const source = conversations.length > 1 && activeConversation === 'all'
? '<span class="conversation-source">' + esc(message.exportConversationName || '聊天') + '</span>'
: ''
return '<article class="message' + (message.isSender ? ' sent' : '') + (isSystem ? ' system' : '') + return '<article class="message' + (message.isSender ? ' sent' : '') + (isSystem ? ' system' : '') +
'" data-index="' + archiveIndex + '" data-month="' + esc(monthKey(message)) + '">' + '" data-index="' + archiveIndex + '" data-month="' + esc(monthKey(message)) + '">' +
'<div class="time">' + esc(fullTime(message)) + '</div><div class="row">' + '<div class="time">' + esc(fullTime(message)) + source + '</div><div class="row">' +
(isSystem ? '' : avatar) + '<div class="bubble"><div class="sender">' + (isSystem ? '' : avatar) + '<div class="bubble"><div class="sender">' +
(isSystem ? '' : esc(sender)) + '</div>' + media + audio + quote + (isSystem ? '' : esc(sender)) + '</div>' + media + audio + quote +
'<div class="content">' + content + '</div>' + mediaStatus + '</div></div></article>' '<div class="content">' + content + '</div>' + mediaStatus + '</div></div></article>'
} }
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) =>
'<option value="' + esc(id) + '">' + esc(label) + '' + total + '</option>'
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 = () => { const renderTimeline = () => {
if (filtered.length === 0) { if (filtered.length === 0) {
timeline.innerHTML = '<div class="timeline-empty">没有可跳转的月份</div>' timeline.innerHTML = '<div class="timeline-empty">没有可跳转的月份</div>'
@@ -431,7 +556,11 @@ const renderExportScript = (name: string): string => `
} }
const updateCount = () => { const updateCount = () => {
const shown = Math.max(0, windowEnd - windowStart) 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) => { const setScrollTop = (value) => {
scrollLoadSuppressed = true scrollLoadSuppressed = true
@@ -472,6 +601,7 @@ const renderExportScript = (name: string): string => `
const applyFilters = () => { const applyFilters = () => {
const term = query.value.trim().toLowerCase() const term = query.value.trim().toLowerCase()
filtered = allMessages.filter((message) => filtered = allMessages.filter((message) =>
(activeConversation === 'all' || message.exportConversationId === activeConversation) &&
(activeKind === 'all' || kindOf(message) === activeKind) && (activeKind === 'all' || kindOf(message) === activeKind) &&
(!term || searchText(message).includes(term)) (!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)) filters.querySelectorAll('[data-kind]').forEach((item) => item.classList.toggle('active', item === button))
applyFilters() applyFilters()
}) })
conversationSelect.addEventListener('change', () => {
activeConversation = conversationSelect.value
applyFilters()
})
timeline.addEventListener('click', (event) => { timeline.addEventListener('click', (event) => {
const button = event.target.closest('[data-month]') const button = event.target.closest('[data-month]')
if (button) jumpToMonth(button.dataset.month) if (button) jumpToMonth(button.dataset.month)
@@ -577,10 +711,12 @@ const renderExportScript = (name: string): string => `
}) })
title.textContent = archive.name || ${JSON.stringify(name)} title.textContent = archive.name || ${JSON.stringify(name)}
meta.textContent = allMessages.length.toLocaleString() + ' 条消息 · 更新于 ' + const updatedAt = archive.exportedAt
(archive.exportedAt ? new Date(archive.exportedAt).toLocaleString('zh-CN', { hour12: false })
? new Date(archive.exportedAt).toLocaleString('zh-CN', { hour12: false }) : '未知时间'
: '未知时间') meta.textContent = (conversations.length > 1 ? '' : allMessages.length.toLocaleString() + ' 条消息 · ') +
'更新于 ' + updatedAt
renderConversations()
applyFilters() applyFilters()
})() })()
` `
@@ -597,8 +733,11 @@ export function renderExportPage(name: string): string {
<body> <body>
<main class="page"> <main class="page">
<header class="toolbar"> <header class="toolbar">
<div> <div class="archive-heading">
<span class="title" id="archive-title">${safe(name)}</span> <span class="title" id="archive-title">${safe(name)}</span>
<label class="conversation-filter" id="conversation-filter" hidden>
<select class="conversation-select" id="conversation-select" aria-label="筛选聊天"></select>
</label>
<span class="meta" id="archive-meta">正在读取消息…</span> <span class="meta" id="archive-meta">正在读取消息…</span>
</div> </div>
<div class="controls"> <div class="controls">
@@ -616,7 +755,9 @@ export function renderExportPage(name: string): string {
</div> </div>
</header> </header>
<section class="archive-layout"> <section class="archive-layout">
<nav class="timeline" id="timeline" aria-label="聊天时间轴"></nav> <aside class="archive-navigation">
<nav class="timeline" id="timeline" aria-label="聊天时间轴"></nav>
</aside>
<section class="scroll" id="messages"> <section class="scroll" id="messages">
<div class="empty">正在加载聊天档案…</div> <div class="empty">正在加载聊天档案…</div>
</section> </section>
+277 -44
View File
@@ -1,14 +1,16 @@
import { app, BrowserWindow, shell } from 'electron' import { app, BrowserWindow, shell } from 'electron'
import { createHash } from 'crypto' import { createHash } from 'crypto'
import { promises as fs } from 'fs' import { createWriteStream, promises as fs } from 'fs'
import { extname, join } from 'path' import { extname, join } from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { ZipArchive, type Archiver } from 'archiver'
import * as chat from './services/chat-service' import * as chat from './services/chat-service'
import type { import type {
ExportJobProgress, ExportJobProgress,
ExportMessageKind, ExportMessageKind,
ExportRequest, ExportRequest,
ExportResult ExportResult,
ExportTarget
} from '../shared/export' } from '../shared/export'
import type { Message } from '../shared/types' import type { Message } from '../shared/types'
import { VoiceService } from './voice-service' import { VoiceService } from './voice-service'
@@ -22,8 +24,18 @@ import { FileAssetService } from './file-asset-service'
import { mergeCachedSelfInfo } from './services/bootstrap-cache' import { mergeCachedSelfInfo } from './services/bootstrap-cache'
const jobs = new Set<string>() const jobs = new Set<string>()
const activeArchives = new Map<string, Archiver>()
const safeFilePart = (value: string): string => const safeFilePart = (value: string): string =>
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案' value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
const copyWritableExportFile = async (source: string, destination: string): Promise<void> => {
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 exportStamp = (): string => {
const date = new Date() const date = new Date()
const pad = (value: number): string => String(value).padStart(2, '0') const pad = (value: number): string => String(value).padStart(2, '0')
@@ -31,7 +43,15 @@ const exportStamp = (): string => {
} }
const imageKeys = new ImageKeyConfigService() 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 version: 1
sourceId: string sourceId: string
name: string name: string
@@ -39,20 +59,29 @@ export interface HtmlExportArchive {
messages: Message[] messages: Message[]
} }
export interface HtmlExportArchive {
version: 2
name: string
exportedAt: string
conversations: HtmlExportConversation[]
messages: Message[]
}
const archiveDataPrefix = 'window.__WECHAT_EXPORT__ = ' const archiveDataPrefix = 'window.__WECHAT_EXPORT__ = '
const hashPart = (value: string, length = 16): string => const hashPart = (value: string, length = 16): string =>
createHash('sha1').update(value).digest('hex').slice(0, length) createHash('sha1').update(value).digest('hex').slice(0, length)
export const exportMessageKey = (message: Message, sourceId = ''): string => { export const exportMessageKey = (message: Message, sourceId = ''): string => {
const conversationId = message.exportConversationId || sourceId
const sessionId = message.sessionId || sourceId const sessionId = message.sessionId || sourceId
if (message.localId && message.createTime) { 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)) { 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([ JSON.stringify([
message.createTime || 0, message.createTime || 0,
message.senderId || '', message.senderId || '',
@@ -89,7 +118,8 @@ const mergeArchiveMessage = (previous: Message, current: Message): Message => {
export function mergeHtmlArchiveMessages( export function mergeHtmlArchiveMessages(
previous: Message[], previous: Message[],
current: Message[], current: Message[],
sourceId = '' sourceId = '',
conversationOrder: string[] = []
): Message[] { ): Message[] {
const merged = new Map<string, Message>() const merged = new Map<string, Message>()
for (const message of previous) merged.set(exportMessageKey(message, sourceId), message) 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) => { return Array.from(merged.values()).sort((left, right) => {
const byTime = Number(left.createTime || 0) - Number(right.createTime || 0) const byTime = Number(left.createTime || 0) - Number(right.createTime || 0)
if (byTime !== 0) return byTime 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) return Number(left.localId || 0) - Number(right.localId || 0)
}) })
} }
@@ -127,7 +161,7 @@ export function normalizeHtmlArchiveSelfNames(
export async function readHtmlArchive( export async function readHtmlArchive(
outputDir: string, outputDir: string,
sourceId: string, targets: ExportTarget[],
name: string name: string
): Promise<HtmlExportArchive> { ): Promise<HtmlExportArchive> {
const dataPath = join(outputDir, 'data', 'messages.js') const dataPath = join(outputDir, 'data', 'messages.js')
@@ -136,7 +170,18 @@ export async function readHtmlArchive(
source = await fs.readFile(dataPath, 'utf8') source = await fs.readFile(dataPath, 'utf8')
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 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 throw error
} }
@@ -146,21 +191,100 @@ export async function readHtmlArchive(
.slice(assignment + 1) .slice(assignment + 1)
.trim() .trim()
.replace(/;\s*$/, '') .replace(/;\s*$/, '')
let archive: HtmlExportArchive let parsed: HtmlExportArchive | HtmlExportArchiveV1
try { try {
archive = JSON.parse(json) as HtmlExportArchive parsed = JSON.parse(json) as HtmlExportArchive | HtmlExportArchiveV1
} catch { } catch {
throw new Error('现有 HTML 档案数据已损坏,请从 messages.js.bak 恢复或更换导出名称') throw new Error('现有 HTML 档案数据已损坏,请从 messages.js.bak 恢复或更换导出名称')
} }
if (archive.sourceId && archive.sourceId !== sourceId) { const expectedIds = targets.map((target) => target.userMd5).sort()
throw new Error('同名导出目录已属于另一个会话,请修改文件名称后重试') 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 { return {
version: 1, version: 2,
sourceId, name: parsed.name || name,
name: archive.name || name, exportedAt: parsed.exportedAt || new Date(0).toISOString(),
exportedAt: archive.exportedAt || new Date(0).toISOString(), conversations: parsed.conversations,
messages: Array.isArray(archive.messages) ? archive.messages : [] messages: Array.isArray(parsed.messages) ? parsed.messages : []
}
}
async function writeZipArchive(
outputDir: string,
zipPath: string,
folderName: string,
jobId: string
): Promise<void> {
const temporaryPath = `${zipPath}.tmp-${process.pid}-${Date.now()}`
await fs.rm(temporaryPath, { force: true })
const output = createWriteStream(temporaryPath)
await new Promise<void>((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<void>((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<void>((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) if (!win.isDestroyed()) win.webContents.send('export:progress', p)
} }
try { 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 }) send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 })
await new Promise<void>((resolve) => setImmediate(resolve)) await new Promise<void>((resolve) => setImmediate(resolve))
const messages = ( const messageEntries: { message: Message; targetOrder: number; messageOrder: number }[] = []
await chat.listMessagesAsync(request.userMd5, request.startTime, request.endTime) for (const [targetOrder, target] of targets.entries()) {
).filter((message) => request.kinds.includes(kindOf(message))) 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 rawSelfInfo = await chat.getSelfAccountInfoAsync()
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
const isUsableSelfName = (value: string | undefined): value is string => { const isUsableSelfName = (value: string | undefined): value is string => {
@@ -313,13 +483,14 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
return true return true
} }
for (const message of messages) { for (const message of messages) {
const target = targetById.get(message.exportConversationId || '')
message.exportMediaUrl = undefined message.exportMediaUrl = undefined
message.exportMediaType = undefined message.exportMediaType = undefined
message.exportMediaName = undefined message.exportMediaName = undefined
message.exportMediaError = undefined message.exportMediaError = undefined
message.voiceDataUrl = undefined message.voiceDataUrl = undefined
message.exportShowAvatar = request.includeAvatars !== false 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))) { if (mappedName && (!message.isSender || isUsableSelfName(mappedName))) {
message.name = mappedName message.name = mappedName
} else if (message.isSender && isUsableSelfName(selfInfo?.nickname)) { } 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(outputDir, 'index.html')
: join(root, `${outputFolder}.${ext}`) : join(root, `${outputFolder}.${ext}`)
if (request.format === 'html') { 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, 'voices'), { recursive: true })
await fs.mkdir(join(outputDir, 'media'), { recursive: true }) await fs.mkdir(join(outputDir, 'media'), { recursive: true })
await fs.mkdir(join(outputDir, 'avatars'), { 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)) .filter((value): value is string => Boolean(value))
) )
) )
const requestedAvatarUrls = Object.assign(
{},
...targets.map((target) => target.avatarUrls || {})
) as Record<string, string>
const avatarMap = const avatarMap =
request.includeAvatars === false request.includeAvatars === false
? {} ? {}
: { ...chat.getContactAvatars(avatarUsernames), ...(request.avatarUrls || {}) } : { ...(await chat.getContactAvatars(avatarUsernames)), ...requestedAvatarUrls }
const imageConfig = imageKeys.getConfig() const imageConfig = imageKeys.getConfig()
const imageService = const imageService =
client && imageConfig.aesKey client && imageConfig.aesKey
@@ -382,13 +557,27 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
const stickerService = client ? new StickerService(client) : null const stickerService = client ? new StickerService(client) : null
const fileService = client ? new FileAssetService(client) : null const fileService = client ? new FileAssetService(client) : null
const exportedAvatars = new Map<string, string>() const exportedAvatars = new Map<string, string>()
const conversationAvatarUrls = new Map<string, string>()
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 = const voiceService =
request.includeMedia && chat.getChatDb() request.includeMedia && chat.getChatDb()
? new VoiceService(chat.getChatDb()!.getWcdb4Client()) ? new VoiceService(chat.getChatDb()!.getWcdb4Client())
: null : null
if (voiceService) { if (voiceService) {
for (const message of messages) { for (const message of messages) {
if (!jobs.has(request.jobId)) throw new Error('已取消')
if (kindOf(message) !== 'voice') continue if (kindOf(message) !== 'voice') continue
const conversationId = message.exportConversationId || targets[0].userMd5
if (!message.sessionId || message.localId == null || !message.createTime) { if (!message.sessionId || message.localId == null || !message.createTime) {
keepMediaError(request, message, '语音标识不完整,无法定位本地语音') keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
continue continue
@@ -410,7 +599,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
keepMediaError(request, message, reason) keepMediaError(request, message, reason)
continue 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') const audioBuffer = Buffer.from(voice.data, 'base64')
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
message.voiceDataUrl = `voices/${voiceName}` message.voiceDataUrl = `voices/${voiceName}`
@@ -431,14 +620,26 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
} }
} }
for (const [index, message] of messages.entries()) { 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 message.exportShowAvatar = request.includeAvatars !== false
const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img
const resolvedAvatar = avatar ? await readAvatarAsset(avatar) : null const resolvedAvatar = avatar ? await readAvatarAsset(avatar) : null
const avatarBuffer = resolvedAvatar?.buffer || null const avatarBuffer = resolvedAvatar?.buffer || null
const avatarExtension = resolvedAvatar?.extension || 'jpg' const avatarExtension = resolvedAvatar?.extension || 'jpg'
if (avatarBuffer) { if (avatarBuffer) {
const avatarKey = const avatarKey = `${conversationId}:${
message.senderId || avatar || `message_${exportMessageKey(message, request.userMd5)}` message.senderId || avatar || `message_${exportMessageKey(message, conversationId)}`
}`
let avatarName = exportedAvatars.get(avatarKey) let avatarName = exportedAvatars.get(avatarKey)
if (!avatarName) { if (!avatarName) {
avatarName = `avatar_${hashPart(avatarKey)}.${avatarExtension}` avatarName = `avatar_${hashPart(avatarKey)}.${avatarExtension}`
@@ -472,7 +673,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
allowThumbnail: attempt.allowThumbnail, allowThumbnail: attempt.allowThumbnail,
preferThumbnail: attempt.preferThumbnail, preferThumbnail: attempt.preferThumbnail,
sessionId: message.sessionId, sessionId: message.sessionId,
sessionMd5: request.userMd5, sessionMd5: conversationId,
createTime: message.createTime createTime: message.createTime
} }
) )
@@ -497,7 +698,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
} }
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
if (decoded) { 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) await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
message.exportMediaUrl = `media/${name}` message.exportMediaUrl = `media/${name}`
message.exportMediaType = 'image' message.exportMediaType = 'image'
@@ -534,8 +735,8 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
} else if (extname(source).toLowerCase() !== '.mp4') { } else if (extname(source).toLowerCase() !== '.mp4') {
keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件') keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件')
} else { } else {
const name = `video_${hashPart(exportMessageKey(message, request.userMd5))}.mp4` const name = `video_${hashPart(exportMessageKey(message, conversationId))}.mp4`
await fs.copyFile(source, join(outputDir, 'media', name)) await copyWritableExportFile(source, join(outputDir, 'media', name))
message.exportMediaUrl = `media/${name}` message.exportMediaUrl = `media/${name}`
message.exportMediaType = 'video' message.exportMediaType = 'video'
} }
@@ -549,7 +750,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
? await readAvatarAsset(stickerSource) ? await readAvatarAsset(stickerSource)
: null : null
if (decoded) { 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) await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
message.exportMediaUrl = `media/${name}` message.exportMediaUrl = `media/${name}`
message.exportMediaType = 'sticker' message.exportMediaType = 'sticker'
@@ -564,8 +765,8 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
if (!resolved.success || !resolved.filePath || !resolved.fileName) { if (!resolved.success || !resolved.filePath || !resolved.fileName) {
keepMediaError(request, message, resolved.error || '本地文件附件缺失') keepMediaError(request, message, resolved.error || '本地文件附件缺失')
} else { } else {
const name = `file_${hashPart(exportMessageKey(message, request.userMd5))}_${safeFilePart(resolved.fileName)}` const name = `file_${hashPart(exportMessageKey(message, conversationId))}_${safeFilePart(resolved.fileName)}`
await fs.copyFile(resolved.filePath, join(outputDir, 'media', name)) await copyWritableExportFile(resolved.filePath, join(outputDir, 'media', name))
message.exportMediaUrl = `media/${name}` message.exportMediaUrl = `media/${name}`
message.exportMediaType = 'file' message.exportMediaType = 'file'
message.exportMediaName = message.contentData.title || resolved.fileName message.exportMediaName = message.contentData.title || resolved.fileName
@@ -583,26 +784,53 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
const mergedMessages = mergeHtmlArchiveMessages( const mergedMessages = mergeHtmlArchiveMessages(
previousArchive.messages, previousArchive.messages,
messages, messages,
request.userMd5 '',
targets.map((target) => target.userMd5)
) )
const normalizedMessages = normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo)
const archive: HtmlExportArchive = { const archive: HtmlExportArchive = {
version: 1, version: 2,
sourceId: request.userMd5, name: archiveName,
name: request.name,
exportedAt: new Date().toISOString(), 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) 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({ send({
jobId: request.jobId, jobId: request.jobId,
phase: 'completed', phase: 'completed',
processed: archive.messages.length, processed: archive.messages.length,
total: archive.messages.length, total: archive.messages.length,
percent: 100, percent: 100,
outputPath outputPath: completedPath
}) })
return { success: true, outputPath, messageCount: archive.messages.length } return { success: true, outputPath: completedPath, messageCount: archive.messages.length }
} else { } else {
send({ send({
jobId: request.jobId, jobId: request.jobId,
@@ -612,7 +840,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
percent: 90 percent: 90
}) })
} }
await fs.writeFile(outputPath, render(request.format, messages, request.name), 'utf8') await fs.writeFile(outputPath, render(request.format, messages, archiveName), 'utf8')
send({ send({
jobId: request.jobId, jobId: request.jobId,
phase: 'completed', phase: 'completed',
@@ -624,6 +852,10 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
return { success: true, outputPath, messageCount: messages.length } return { success: true, outputPath, messageCount: messages.length }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(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 }) send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
return { success: false, error: message } return { success: false, error: message }
} finally { } finally {
@@ -632,6 +864,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
} }
export function cancelExport(jobId: string): void { export function cancelExport(jobId: string): void {
jobs.delete(jobId) jobs.delete(jobId)
activeArchives.get(jobId)?.abort()
} }
export async function revealExport(path: string): Promise<void> { export async function revealExport(path: string): Promise<void> {
shell.showItemInFolder(path) shell.showItemInFolder(path)
+37 -12
View File
@@ -212,7 +212,32 @@ function App(): React.ReactElement {
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => { const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
try { try {
const stored = JSON.parse(localStorage.getItem('wxe_export_tasks') || '[]') 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<ExportTaskRecord> & {
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 { } catch {
return [] return []
} }
@@ -341,10 +366,14 @@ function App(): React.ReactElement {
const handleStartExport = async ( const handleStartExport = async (
request: ExportRequest request: ExportRequest
): Promise<import('../../shared/export').ExportResult> => { ): Promise<import('../../shared/export').ExportResult> => {
const targetNames = request.targets.map((target) => target.name)
const targetLabel =
targetNames.length > 1 ? `${targetNames[0]}${targetNames.length} 个聊天` : targetNames[0]
const task: ExportTaskRecord = { const task: ExportTaskRecord = {
jobId: request.jobId, jobId: request.jobId,
contactId: request.userMd5, targetIds: request.targets.map((target) => target.userMd5),
contactName: request.name, targetNames,
targetLabel,
format: request.format, format: request.format,
status: 'running', status: 'running',
progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 }, progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 },
@@ -1220,16 +1249,14 @@ function App(): React.ReactElement {
} }
} }
const loadExportPreview = async (contact: Contact): Promise<void> => { const loadExportPreviewMessages = async (contact: Contact): Promise<Message[]> => {
setSelectedContact(contact)
selectedContactMd5Ref.current = contact.md5
try { try {
const previewMessages = await window.api.getMessages(contact.md5, undefined, undefined, { return await window.api.getMessages(contact.md5, undefined, undefined, {
limit: EXPORT_PREVIEW_LIMIT limit: EXPORT_PREVIEW_LIMIT
}) })
if (selectedContactMd5Ref.current === contact.md5) setMessages(previewMessages)
} catch (error) { } catch (error) {
console.warn('[Export] preview load failed:', error) console.warn('[Export] preview load failed:', error)
return []
} }
} }
@@ -1322,7 +1349,6 @@ function App(): React.ReactElement {
const handlePageChange = (page: AppPage): void => { const handlePageChange = (page: AppPage): void => {
setActivePage(page) setActivePage(page)
if (page === 'archive' && selectedContact) void handleSelectContact(selectedContact) if (page === 'archive' && selectedContact) void handleSelectContact(selectedContact)
if (page === 'export' && selectedContact) void loadExportPreview(selectedContact)
if (page === 'settings') setSettingsCategory('account-database') if (page === 'settings') setSettingsCategory('account-database')
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) { if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
setReportSourceContact(selectedContact) setReportSourceContact(selectedContact)
@@ -1702,11 +1728,10 @@ function App(): React.ReactElement {
return ( return (
<ExportWorkspace <ExportWorkspace
contacts={contacts} contacts={contacts}
selectedContact={selectedContact} initialContact={selectedContact}
previewMessages={messages}
selfInfo={selfInfo} selfInfo={selfInfo}
dbReady={isDatabaseConnected} dbReady={isDatabaseConnected}
onSelectContact={loadExportPreview} loadPreviewMessages={loadExportPreviewMessages}
onOpenSettings={openSettings} onOpenSettings={openSettings}
exportTasks={exportTasks} exportTasks={exportTasks}
onStartExport={handleStartExport} onStartExport={handleStartExport}
@@ -6,6 +6,9 @@ interface ExportContactPanelProps {
contacts: Contact[] contacts: Contact[]
filteredContacts: Contact[] filteredContacts: Contact[]
activeContact: Contact | null activeContact: Contact | null
selectedContactIds: string[]
selectionMode: boolean
selectionLimit: number
selfInfo: SelfInfo | null selfInfo: SelfInfo | null
dbReady: boolean dbReady: boolean
contactFilter: string contactFilter: string
@@ -13,6 +16,7 @@ interface ExportContactPanelProps {
onContactFilterChange: (value: string) => void onContactFilterChange: (value: string) => void
onContactTypeChange: (value: 'all' | 'group' | 'user') => void onContactTypeChange: (value: 'all' | 'group' | 'user') => void
onSelectContact: (contact: Contact) => void onSelectContact: (contact: Contact) => void
onCompleteSelection: () => void
onOpenSettings: () => void onOpenSettings: () => void
} }
@@ -20,6 +24,9 @@ export function ExportContactPanel({
contacts, contacts,
filteredContacts, filteredContacts,
activeContact, activeContact,
selectedContactIds,
selectionMode,
selectionLimit,
selfInfo, selfInfo,
dbReady, dbReady,
contactFilter, contactFilter,
@@ -27,6 +34,7 @@ export function ExportContactPanel({
onContactFilterChange, onContactFilterChange,
onContactTypeChange, onContactTypeChange,
onSelectContact, onSelectContact,
onCompleteSelection,
onOpenSettings onOpenSettings
}: ExportContactPanelProps): React.ReactElement { }: ExportContactPanelProps): React.ReactElement {
return ( return (
@@ -65,15 +73,30 @@ export function ExportContactPanel({
</div> </div>
</div> </div>
{selectionMode && (
<div className="export-multi-select-bar">
<span>
{selectedContactIds.length} / {selectionLimit}
</span>
<button type="button" onClick={onCompleteSelection}>
</button>
</div>
)}
<div className="export-contact-list"> <div className="export-contact-list">
{filteredContacts.map((contact) => { {filteredContacts.map((contact) => {
const name = displayName(contact) const name = displayName(contact)
const selected = selectedContactIds.includes(contact.md5)
const atLimit = selectionMode && !selected && selectedContactIds.length >= selectionLimit
return ( return (
<button <button
key={contact.md5} key={contact.md5}
type="button" type="button"
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''}`} className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''} ${selected ? 'selected' : ''}`}
onClick={() => onSelectContact(contact)} onClick={() => onSelectContact(contact)}
disabled={atLimit}
aria-pressed={selected}
> >
<span className="export-contact-avatar"> <span className="export-contact-avatar">
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)} {contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
@@ -82,6 +105,11 @@ export function ExportContactPanel({
<strong>{name}</strong> <strong>{name}</strong>
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small> <small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
</span> </span>
{selectionMode && (
<span className={`export-contact-check ${selected ? 'checked' : ''}`} aria-hidden>
{selected ? '✓' : ''}
</span>
)}
</button> </button>
) )
})} })}
@@ -10,6 +10,7 @@ interface ExportPreviewPanelProps {
previewBytes: number previewBytes: number
selfInfo: SelfInfo | null selfInfo: SelfInfo | null
progress: ExportJobProgress | null progress: ExportJobProgress | null
selectedCount: number
jobId: string jobId: string
onCancel: (jobId: string) => void onCancel: (jobId: string) => void
onReveal: (path: string) => void onReveal: (path: string) => void
@@ -22,6 +23,7 @@ export function ExportPreviewPanel({
previewBytes, previewBytes,
selfInfo, selfInfo,
progress, progress,
selectedCount,
jobId, jobId,
onCancel, onCancel,
onReveal onReveal
@@ -32,7 +34,9 @@ export function ExportPreviewPanel({
<> <>
<div className="export-preview-heading"> <div className="export-preview-heading">
<strong></strong> <strong></strong>
<span> 20 </span> <span>
{selectedCount > 1 ? `${selectedCount} 个聊天 · 合并预览` : '仅预览最近 20 条'}
</span>
</div> </div>
<div className="export-message-preview"> <div className="export-message-preview">
<div className="export-preview-date"></div> <div className="export-preview-date"></div>
@@ -50,7 +54,7 @@ export function ExportPreviewPanel({
] ]
).map((message) => ( ).map((message) => (
<div <div
key={message.id} key={`${message.exportConversationId || 'single'}:${message.id}`}
className={`export-preview-message ${message.isSender ? 'mine' : ''} ${ className={`export-preview-message ${message.isSender ? 'mine' : ''} ${
message.contentData?.type === 'system' && message.contentData.pat ? 'system' : '' message.contentData?.type === 'system' && message.contentData.pat ? 'system' : ''
}`} }`}
@@ -64,6 +68,9 @@ export function ExportPreviewPanel({
</span> </span>
<span className="export-preview-bubble"> <span className="export-preview-bubble">
<small> <small>
{selectedCount > 1 && message.exportConversationName
? `${message.exportConversationName} · `
: ''}
{message.name || (message.isSender ? '我' : '联系人')} ·{' '} {message.name || (message.isSender ? '我' : '联系人')} ·{' '}
{formatPreviewTime(message)} {formatPreviewTime(message)}
</small> </small>
@@ -108,7 +115,11 @@ export function ExportPreviewPanel({
<ol> <ol>
<li className="done"></li> <li className="done"></li>
<li className="current"> <li className="current">
{progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'} {progress?.phase === 'compressing'
? '压缩 ZIP'
: progress?.phase === 'writing'
? '生成档案'
: '分批读取聊天记录'}
</li> </li>
<li></li> <li></li>
<li></li> <li></li>
@@ -118,9 +129,11 @@ export function ExportPreviewPanel({
<span style={{ width: `${progress?.percent ?? 0}%` }} /> <span style={{ width: `${progress?.percent ?? 0}%` }} />
</div> </div>
<strong> <strong>
{progress?.phase === 'writing' {progress?.phase === 'compressing'
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%` ? `正在压缩资源包... ${progress.percent ?? 0}%`
: `正在读取消息... ${progress?.percent ?? 0}%`} : progress?.phase === 'writing'
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
: `正在读取消息... ${progress?.percent ?? 0}%`}
</strong> </strong>
<button type="button" className="export-cancel-button" onClick={() => onCancel(jobId)}> <button type="button" className="export-cancel-button" onClick={() => onCancel(jobId)}>
@@ -9,6 +9,25 @@ interface ExportTaskCenterProps {
onCancel: (jobId: string) => void onCancel: (jobId: string) => void
} }
const phaseLabels: Record<ExportTaskRecord['progress']['phase'], string> = {
reading: '读取消息',
writing: '导出资源',
compressing: '压缩归档',
completed: '已完成',
cancelled: '已取消',
failed: '导出失败'
}
const taskDetail = (task: ExportTaskRecord): string | null => {
if (task.status === 'completed') {
return `成功导出 ${task.progress.total ?? task.progress.processed} 条消息`
}
if (task.status === 'failed') {
return `失败原因:${task.progress.error || '未知错误'}`
}
return null
}
export function ExportTaskCenter({ export function ExportTaskCenter({
open, open,
taskCount, taskCount,
@@ -30,25 +49,36 @@ export function ExportTaskCenter({
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<p></p> <p></p>
) : ( ) : (
tasks.map((task) => ( tasks.map((task) => {
<div className="export-task-row" key={task.jobId}> const detail = taskDetail(task)
<span> return (
<strong>{task.contactName}</strong> <div className="export-task-row" key={task.jobId}>
<small> <span>
{task.format.toUpperCase()} · {task.progress.phase} <strong>{task.targetLabel}</strong>
</small> <small>
</span> {task.format.toUpperCase()} · {phaseLabels[task.progress.phase]}
<span className="export-task-progress"> </small>
<i style={{ width: `${task.progress.percent ?? 0}%` }} /> {detail && (
<b>{task.progress.percent ?? 0}%</b> <small
</span> className={`export-task-detail ${task.status}`}
{task.status === 'running' && ( title={task.status === 'failed' ? detail : undefined}
<button type="button" onClick={() => onCancel(task.jobId)}> >
{detail}
</button> </small>
)} )}
</div> </span>
)) <span className="export-task-progress">
<i style={{ width: `${task.progress.percent ?? 0}%` }} />
<b>{task.progress.percent ?? 0}%</b>
</span>
{task.status === 'running' && (
<button type="button" onClick={() => onCancel(task.jobId)}>
</button>
)}
</div>
)
})
)} )}
</section> </section>
)} )}
@@ -3,12 +3,15 @@ import type { Message } from '../../../../shared/types'
import type { import type {
ExportJobProgress, ExportJobProgress,
ExportMessageKind, ExportMessageKind,
ExportNameMode ExportNameMode,
ExportRequest,
ExportTarget
} from '../../../../shared/export' } from '../../../../shared/export'
import { ExportContactPanel } from './ExportContactPanel' import { ExportContactPanel } from './ExportContactPanel'
import { ExportPreviewPanel } from './ExportPreviewPanel' import { ExportPreviewPanel } from './ExportPreviewPanel'
import { ExportTaskCenter } from './ExportTaskCenter' import { ExportTaskCenter } from './ExportTaskCenter'
import type { import type {
Contact,
ExportFormat, ExportFormat,
ExportRange, ExportRange,
ExportStatus, ExportStatus,
@@ -19,24 +22,33 @@ import { displayName, formatLabels, formatOrder, messageKinds } from './exportUt
export function ExportWorkspace({ export function ExportWorkspace({
contacts, contacts,
selectedContact, initialContact,
previewMessages,
selfInfo, selfInfo,
dbReady, dbReady,
onSelectContact, loadPreviewMessages,
onOpenSettings, onOpenSettings,
exportTasks, exportTasks,
onStartExport, onStartExport,
onCancelExport onCancelExport
}: ExportWorkspaceProps): React.ReactElement { }: ExportWorkspaceProps): React.ReactElement {
const initialSelection = initialContact || contacts[0] || null
const initialContactRef = React.useRef<Contact | null>(initialSelection)
const previewLoadingRef = React.useRef(new Set<string>())
const [contactFilter, setContactFilter] = useState('') const [contactFilter, setContactFilter] = useState('')
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all') const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
const [selectionMode, setSelectionMode] = useState(false)
const [selectedContacts, setSelectedContacts] = useState<Contact[]>(() =>
initialSelection ? [initialSelection] : []
)
const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '')
const [previewByContact, setPreviewByContact] = useState<Record<string, Message[]>>({})
const [range, setRange] = useState<ExportRange>('today') const [range, setRange] = useState<ExportRange>('today')
const [startDate, setStartDate] = useState('') const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('') const [endDate, setEndDate] = useState('')
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text'])) const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
const [nameMode, setNameMode] = useState<ExportNameMode>('remark') const [nameMode, setNameMode] = useState<ExportNameMode>(
const [groupMembers, setGroupMembers] = useState<GroupMemberName[]>([]) initialSelection?.type === 'group' ? 'groupNickname' : 'remark'
)
const [includeMedia, setIncludeMedia] = useState(true) const [includeMedia, setIncludeMedia] = useState(true)
const [includeAvatars, setIncludeAvatars] = useState(true) const [includeAvatars, setIncludeAvatars] = useState(true)
const [preferOriginal, setPreferOriginal] = useState(true) const [preferOriginal, setPreferOriginal] = useState(true)
@@ -49,6 +61,27 @@ export function ExportWorkspace({
const [jobId, setJobId] = useState('') const [jobId, setJobId] = useState('')
const [progress, setProgress] = useState<ExportJobProgress | null>(null) const [progress, setProgress] = useState<ExportJobProgress | null>(null)
const [taskCenterOpen, setTaskCenterOpen] = useState(false) 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 filteredContacts = useMemo(() => {
const keyword = contactFilter.trim().toLowerCase() const keyword = contactFilter.trim().toLowerCase()
@@ -61,11 +94,35 @@ export function ExportWorkspace({
}) })
}, [contactFilter, contactType, contacts]) }, [contactFilter, contactType, contacts])
const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null const activeContact =
const currentTask = exportTasks.find((task) => task.contactId === activeContact?.md5) 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 taskCount = exportTasks.filter((task) => task.status === 'running').length
const activeName = displayName(activeContact) 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( const previewMediaCount = preview.filter(
(message) => (message) =>
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '') || ['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), (total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
0 0
) )
const outputName = fileName.trim() || `${activeName}_聊天档案` const defaultOutputName =
const nameOptions: { value: ExportNameMode; label: string }[] = selectedContacts.length > 1
activeContact?.type === 'group' ? `${selectedNames[0]}${selectedContacts.length}个聊天_合并档案`
? [ : `${activeName}_聊天档案`
{ value: 'groupNickname', label: '群昵称' }, const outputName = fileName.trim() || defaultOutputName
{ value: 'remark', label: '备注' }, const nameOptions: { value: ExportNameMode; label: string }[] = selectedContacts.some(
{ value: 'wechatNickname', label: '微信名' } (contact) => contact.type === 'group'
] )
: [ ? [
{ value: 'remark', label: '备注' }, { value: 'groupNickname', label: '群昵称' },
{ value: 'wechatNickname', label: '微信名' } { value: 'remark', label: '备注' },
] { value: 'wechatNickname', label: '微信名' }
]
const nameMap = useMemo(() => { : [
const map: Record<string, string> = {} { value: 'remark', label: '备注' },
if (activeContact?.type === 'group') { { value: 'wechatNickname', label: '微信名' }
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<string, string> = {}
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 previewName = (message: Message): string => const previewName = (message: Message): string =>
(message.senderId && nameMap[message.senderId]) ||
message.name || message.name ||
(message.isSender ? selfInfo?.nickname : undefined) || (message.isSender ? selfInfo?.nickname : undefined) ||
(message.isSender ? '我' : '联系人') (message.isSender ? '我' : '联系人')
const previewAvatar = (message: Message): string | undefined => 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) => ({ const previewItems = preview.map((message) => ({
...message, ...message,
name: previewName(message), name: previewName(message),
img: previewAvatar(message) img: previewAvatar(message)
})) }))
React.useEffect(() => { const handleSelectContact = (contact: Contact): void => {
setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark') if (!selectionMode) {
setGroupMembers([]) setSelectedContacts([contact])
if (!activeContact || activeContact.type !== 'group') return setActiveContactId(contact.md5)
const timer = window.setTimeout(() => { setStatus('idle')
void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => { return
setGroupMembers((snapshot?.members || []) as GroupMemberName[]) }
}) const selected = selectedContacts.some((item) => item.md5 === contact.md5)
}, 300) if (selected) {
return () => window.clearTimeout(timer) if (selectedContacts.length === 1) return
}, [activeContact]) 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 => { const toggleKind = (value: string): void => {
setSelectedKinds((current) => { setSelectedKinds((current) => {
@@ -159,42 +196,59 @@ export function ExportWorkspace({
} }
const handleStart = async (): Promise<void> => { const handleStart = async (): Promise<void> => {
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. // 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()}` const nextJobId = `export-${Date.now()}`
setJobId(nextJobId) setJobId(nextJobId)
setProgress(null) setProgress(null)
setStatus('running') setStatus('running')
let exportNameMap = nameMap const targets: ExportTarget[] = await Promise.all(
let exportAvatarUrls = avatarUrls selectedContacts.map(async (contact) => {
if (activeContact.type === 'group') { const nameMap: Record<string, string> = {}
const snapshot = await window.api.getGroupSnapshot(activeContact.md5) const avatarUrls: Record<string, string> = {}
const members = (snapshot?.members || []) as GroupMemberName[] if (contact.type === 'group') {
setGroupMembers(members) const snapshot = await window.api.getGroupSnapshot(contact.md5)
exportNameMap = { ...nameMap } for (const member of (snapshot?.members || []) as GroupMemberName[]) {
exportAvatarUrls = { ...avatarUrls } nameMap[member.wxid] =
for (const member of members) { nameMode === 'groupNickname'
exportNameMap[member.wxid] = ? member.groupNickname || member.nickname || member.wxid
nameMode === 'groupNickname' : nameMode === 'remark'
? member.groupNickname || member.nickname || member.wxid ? member.remark || member.wechatNickname || member.wxid
: nameMode === 'remark' : member.wechatNickname || member.wxid
? member.remark || member.wechatNickname || member.wxid if (member.avatar) avatarUrls[member.wxid] = member.avatar
: member.wechatNickname || member.wxid }
if (member.avatar) exportAvatarUrls[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 now = new Date()
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0 const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0
const startOfRange = days const startOfRange = days
? new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1) ? new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1)
: null : null
const request = { const request: ExportRequest = {
jobId: nextJobId, jobId: nextJobId,
userMd5: activeContact.md5, targets,
name: activeName, format: selectedContacts.length > 1 ? 'html' : format,
format,
outputName, outputName,
startTime: startOfRange startTime: startOfRange
? Math.floor(startOfRange.getTime() / 1000) ? Math.floor(startOfRange.getTime() / 1000)
@@ -212,9 +266,6 @@ export function ExportWorkspace({
fallbackThumbnail, fallbackThumbnail,
keepMissing, keepMissing,
includeAvatars, includeAvatars,
avatarUrls: exportAvatarUrls,
nameMode,
nameMap: exportNameMap,
zip zip
} }
const result = await onStartExport(request) const result = await onStartExport(request)
@@ -257,6 +308,29 @@ export function ExportWorkspace({
) )
}, [currentTask]) }, [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 = const targetPath =
format === 'html' format === 'html'
? zip ? zip
@@ -270,13 +344,17 @@ export function ExportWorkspace({
contacts={contacts} contacts={contacts}
filteredContacts={filteredContacts} filteredContacts={filteredContacts}
activeContact={activeContact} activeContact={activeContact}
selectedContactIds={selectedContacts.map((contact) => contact.md5)}
selectionMode={selectionMode}
selectionLimit={selectionLimit}
selfInfo={selfInfo} selfInfo={selfInfo}
dbReady={dbReady} dbReady={dbReady}
contactFilter={contactFilter} contactFilter={contactFilter}
contactType={contactType} contactType={contactType}
onContactFilterChange={setContactFilter} onContactFilterChange={setContactFilter}
onContactTypeChange={setContactType} onContactTypeChange={setContactType}
onSelectContact={onSelectContact} onSelectContact={handleSelectContact}
onCompleteSelection={() => setSelectionMode(false)}
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}
/> />
@@ -290,20 +368,28 @@ export function ExportWorkspace({
onCancel={(taskJobId) => void onCancelExport(taskJobId)} onCancel={(taskJobId) => void onCancelExport(taskJobId)}
/> />
<header className="export-config-header"> <header className="export-config-header">
<span className="export-chat-avatar"> <span className="export-chat-avatar-stack" aria-hidden>
{activeContact?.avatar ? ( {selectedContacts.slice(0, 3).map((contact) => (
<img src={activeContact.avatar} alt="" /> <span className="export-chat-avatar" key={contact.md5}>
) : ( {contact.avatar ? (
activeName.slice(0, 1) <img src={contact.avatar} alt="" />
)} ) : (
displayName(contact).slice(0, 1)
)}
</span>
))}
</span> </span>
<span> <span className="export-config-title">
<h1></h1> <h1></h1>
<p> <p>{selectedLabel}</p>
{activeName}
{activeContact?.type === 'group' ? ' · 群聊' : ''}
</p>
</span> </span>
<button
type="button"
className="export-add-chat-button"
onClick={() => setSelectionMode((current) => !current)}
>
{selectionMode ? '完成选择' : '+ 添加聊天'}
</button>
</header> </header>
<section className="export-section export-format-top"> <section className="export-section export-format-top">
@@ -314,6 +400,7 @@ export function ExportWorkspace({
key={value} key={value}
type="button" type="button"
className={format === value ? 'active' : ''} className={format === value ? 'active' : ''}
disabled={selectedContacts.length > 1 && value !== 'html'}
onClick={() => setFormat(value)} onClick={() => setFormat(value)}
> >
<strong>{formatLabels[value].label}</strong> <strong>{formatLabels[value].label}</strong>
@@ -322,7 +409,9 @@ export function ExportWorkspace({
))} ))}
</div> </div>
<p className="export-helper-text"> <p className="export-helper-text">
CSV HTML {selectedContacts.length > 1
? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。'
: 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}
</p> </p>
{format === 'html' && ( {format === 'html' && (
<> <>
@@ -506,45 +595,6 @@ export function ExportWorkspace({
</label> </label>
</section> </section>
<section className="export-section">
<h3></h3>
<div className="export-format-grid">
{formatOrder.map((value) => (
<button
key={value}
type="button"
className={format === value ? 'active' : ''}
onClick={() => setFormat(value)}
>
<strong>{formatLabels[value].label}</strong>
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
</button>
))}
</div>
{format === 'html' && (
<div className="export-html-options">
<label>
<input
type="radio"
name="html-package"
checked={!zip}
onChange={() => setZip(false)}
/>{' '}
HTML
</label>
<label>
<input
type="radio"
name="html-package"
checked={zip}
onChange={() => setZip(true)}
/>{' '}
HTML ZIP
</label>
</div>
)}
</section>
<section className="export-section export-save-section"> <section className="export-section export-save-section">
<h3></h3> <h3></h3>
<label> <label>
@@ -552,7 +602,7 @@ export function ExportWorkspace({
<input <input
value={fileName} value={fileName}
onChange={(event) => setFileName(event.target.value)} onChange={(event) => setFileName(event.target.value)}
placeholder={`${activeName}_聊天档案`} placeholder={defaultOutputName}
/> />
</label> </label>
<div className="export-target-path"> <div className="export-target-path">
@@ -577,7 +627,7 @@ export function ExportWorkspace({
: '准备就绪'} : '准备就绪'}
</span> </span>
<span className="export-target-summary">{targetPath}</span> <span className="export-target-summary">{targetPath}</span>
<button type="button" className="export-reset-button" onClick={() => setStatus('idle')}> <button type="button" className="export-reset-button" onClick={resetDefaults}>
</button> </button>
<button <button
@@ -598,6 +648,7 @@ export function ExportWorkspace({
previewBytes={previewBytes} previewBytes={previewBytes}
selfInfo={selfInfo} selfInfo={selfInfo}
progress={progress} progress={progress}
selectedCount={selectedContacts.length}
jobId={jobId} jobId={jobId}
onCancel={(exportJobId) => { onCancel={(exportJobId) => {
void window.api.cancelExport(exportJobId) void window.api.cancelExport(exportJobId)
@@ -30,15 +30,21 @@ export interface SelfInfo {
export interface ExportWorkspaceProps { export interface ExportWorkspaceProps {
contacts: Contact[] contacts: Contact[]
selectedContact: Contact | null initialContact: Contact | null
previewMessages: Message[]
selfInfo: SelfInfo | null selfInfo: SelfInfo | null
dbReady: boolean dbReady: boolean
onSelectContact: (contact: Contact) => void loadPreviewMessages: (contact: Contact) => Promise<Message[]>
onOpenSettings: () => void onOpenSettings: () => void
exportTasks: ExportTaskRecord[] exportTasks: ExportTaskRecord[]
onStartExport: (request: ExportRequest) => Promise<ExportResult> onStartExport: (request: ExportRequest) => Promise<ExportResult>
onCancelExport: (jobId: string) => Promise<void> onCancelExport: (jobId: string) => Promise<void>
} }
export type { Contact, ExportJobProgress, ExportMessageKind, Message, ExportNameMode, ExportTaskRecord } export type {
Contact,
ExportJobProgress,
ExportMessageKind,
Message,
ExportNameMode,
ExportTaskRecord
}
+101
View File
@@ -116,6 +116,25 @@
padding: 8px 0; padding: 8px 0;
} }
.export-multi-select-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 16px;
border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-size: 12px;
button {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
}
.export-contact-item { .export-contact-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -137,6 +156,28 @@
border-left-color: var(--wxex-brand); border-left-color: var(--wxex-brand);
background: var(--wxex-brand-soft); background: var(--wxex-brand-soft);
} }
&:disabled {
cursor: not-allowed;
opacity: 0.48;
}
}
.export-contact-check {
width: 18px;
height: 18px;
display: grid;
place-items: center;
flex: 0 0 auto;
border: 1px solid var(--wxex-border-strong);
border-radius: 4px;
color: #fff;
font-size: 12px;
&.checked {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
}
} }
.export-contact-avatar, .export-contact-avatar,
@@ -246,6 +287,46 @@
font-size: 13px; font-size: 13px;
} }
} }
.export-config-title {
min-width: 0;
}
.export-config-title p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.export-chat-avatar-stack {
position: relative;
width: 70px;
height: 58px;
flex: 0 0 70px;
.export-chat-avatar {
position: absolute;
top: 3px;
border: 2px solid var(--wxex-bg-main);
}
.export-chat-avatar:nth-child(2) {
left: 12px;
top: 8px;
}
.export-chat-avatar:nth-child(3) {
left: 24px;
top: 13px;
}
}
.export-add-chat-button {
margin-left: auto;
padding: 7px 10px;
border: 1px solid var(--wxex-brand);
border-radius: 6px;
background: transparent;
color: var(--wxex-brand);
cursor: pointer;
white-space: nowrap;
font: 600 12px/18px var(--wxex-font);
}
.export-chat-avatar { .export-chat-avatar {
width: 52px; width: 52px;
height: 52px; height: 52px;
@@ -259,6 +340,11 @@
} }
} }
.export-format-grid button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.export-section { .export-section {
margin-bottom: 25px; margin-bottom: 25px;
@@ -764,6 +850,21 @@
color: var(--wxex-text-primary); color: var(--wxex-text-primary);
} }
.export-task-detail {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
&.completed {
color: var(--wxex-brand);
}
&.failed {
color: var(--wxex-danger, #c43d3d);
}
}
button { button {
border: 1px solid var(--wxex-border); border: 1px solid var(--wxex-border);
border-radius: 5px; border-radius: 5px;
+15 -8
View File
@@ -14,10 +14,19 @@ export type ExportMessageKind =
export type ExportNameMode = 'groupNickname' | 'remark' | 'wechatNickname' export type ExportNameMode = 'groupNickname' | 'remark' | 'wechatNickname'
export interface ExportRequest { export interface ExportTarget {
jobId: string
userMd5: string userMd5: string
name: string name: string
type: 'user' | 'group'
avatarUrl?: string
nameMode?: ExportNameMode
nameMap?: Record<string, string>
avatarUrls?: Record<string, string>
}
export interface ExportRequest {
jobId: string
targets: ExportTarget[]
format: ExportFormat format: ExportFormat
outputName: string outputName: string
startTime?: number startTime?: number
@@ -28,15 +37,12 @@ export interface ExportRequest {
fallbackThumbnail?: boolean fallbackThumbnail?: boolean
keepMissing?: boolean keepMissing?: boolean
includeAvatars?: boolean includeAvatars?: boolean
avatarUrls?: Record<string, string>
nameMode?: ExportNameMode
nameMap?: Record<string, string>
zip?: boolean zip?: boolean
} }
export interface ExportJobProgress { export interface ExportJobProgress {
jobId: string jobId: string
phase: 'reading' | 'writing' | 'completed' | 'cancelled' | 'failed' phase: 'reading' | 'writing' | 'compressing' | 'completed' | 'cancelled' | 'failed'
processed: number processed: number
total?: number total?: number
percent?: number percent?: number
@@ -46,8 +52,9 @@ export interface ExportJobProgress {
export interface ExportTaskRecord { export interface ExportTaskRecord {
jobId: string jobId: string
contactId: string targetIds: string[]
contactName: string targetNames: string[]
targetLabel: string
format: ExportFormat format: ExportFormat
status: 'running' | 'completed' | 'cancelled' | 'failed' status: 'running' | 'completed' | 'cancelled' | 'failed'
progress: ExportJobProgress progress: ExportJobProgress
+3
View File
@@ -36,6 +36,9 @@ export interface Message {
exportShowAvatar?: boolean exportShowAvatar?: boolean
exportMediaError?: string exportMediaError?: string
exportAvatarUrl?: string exportAvatarUrl?: string
exportConversationId?: string
exportConversationName?: string
exportConversationAvatarUrl?: string
} }
type TextContent = { type: 'text'; content: string } type TextContent = { type: 'text'; content: string }
+162
View File
@@ -0,0 +1,162 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ExportWorkspace } from '../../src/renderer/src/components/export/ExportWorkspace'
import { ExportTaskCenter } from '../../src/renderer/src/components/export/ExportTaskCenter'
import type { ExportTaskRecord } from '../../src/shared/export'
import type { Contact, Message } from '../../src/shared/types'
const contacts: Contact[] = Array.from({ length: 6 }, (_, index) => ({
md5: `contact-${index + 1}`,
m_nsUsrName: `wxid_contact_${index + 1}`,
m_nsNickName: `聊天 ${String.fromCharCode(65 + index)}`,
type: index === 2 ? 'group' : 'user'
}))
const previewMessage = (contact: Contact): Message => ({
id: `preview-${contact.md5}`,
from: 'user',
type: '普通文本',
datetime: '',
content: `${contact.m_nsNickName} 的预览`,
isSender: false,
createTime: contacts.indexOf(contact) + 1
})
describe('ExportWorkspace multi-chat selection', () => {
beforeEach(() => {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
onExportProgress: vi.fn(() => vi.fn()),
getGroupSnapshot: vi.fn(async () => ({ members: [] })),
cancelExport: vi.fn(async () => ({ success: true })),
revealExport: vi.fn(async () => ({ success: true }))
}
})
})
const renderWorkspace = (
onStartExport = vi.fn(async () => ({ success: false }))
): { loadPreviewMessages: ReturnType<typeof vi.fn> } => {
const loadPreviewMessages = vi.fn(async (contact: Contact) => [previewMessage(contact)])
render(
<ExportWorkspace
contacts={contacts}
initialContact={contacts[0]}
selfInfo={{ wxid: 'self', nickname: '本人', accountRoot: '/fixture' }}
dbReady
loadPreviewMessages={loadPreviewMessages}
onOpenSettings={vi.fn()}
exportTasks={[]}
onStartExport={onStartExport}
onCancelExport={vi.fn(async () => undefined)}
/>
)
return { loadPreviewMessages }
}
it('defaults to one chat, forces HTML after adding another, merges the preview, and resets locally', async () => {
const onStartExport = vi.fn(async () => ({ success: false }))
const { loadPreviewMessages } = renderWorkspace(onStartExport)
expect(screen.getAllByText('聊天 A')).toHaveLength(2)
expect(screen.getByRole('button', { name: 'CSV' })).toBeEnabled()
expect(await screen.findByText('聊天 A 的预览')).toBeVisible()
await userEvent.click(screen.getByRole('button', { name: '+ 添加聊天' }))
await userEvent.click(screen.getByRole('button', { name: /聊天 B/ }))
expect(screen.getByText('已选 2 / 5 个')).toBeVisible()
expect(screen.getByRole('button', { name: 'CSV' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'JSON' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Markdown' })).toBeDisabled()
expect(screen.getByRole('button', { name: /HTML/ })).toHaveClass('active')
expect(await screen.findByText('聊天 B 的预览')).toBeVisible()
expect(screen.getByText('2 个聊天 · 合并预览')).toBeVisible()
await userEvent.click(screen.getByRole('button', { name: '开始导出' }))
await waitFor(() => expect(onStartExport).toHaveBeenCalledOnce())
expect(onStartExport.mock.calls[0][0]).toMatchObject({
format: 'html',
outputName: '聊天 A等2个聊天_合并档案',
targets: [
{ userMd5: 'contact-1', name: '聊天 A' },
{ userMd5: 'contact-2', name: '聊天 B' }
]
})
await userEvent.click(screen.getByRole('button', { name: '恢复默认' }))
expect(screen.queryByText('已选 2 / 5 个')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'CSV' })).toBeEnabled()
expect(screen.getAllByText('聊天 A').length).toBeGreaterThanOrEqual(2)
expect(loadPreviewMessages).toHaveBeenCalledWith(contacts[0])
expect(contacts[0].md5).toBe('contact-1')
})
it('does not allow removing the last chat and disables unselected chats at five', async () => {
renderWorkspace()
await userEvent.click(screen.getByRole('button', { name: '+ 添加聊天' }))
await userEvent.click(screen.getByRole('button', { name: /聊天 A/ }))
expect(screen.getByText('已选 1 / 5 个')).toBeVisible()
for (const name of ['聊天 B', '聊天 C', '聊天 D', '聊天 E']) {
await userEvent.click(screen.getByRole('button', { name: new RegExp(name) }))
}
expect(screen.getByText('已选 5 / 5 个')).toBeVisible()
expect(screen.getByRole('button', { name: /聊天 F/ })).toBeDisabled()
await userEvent.click(screen.getByRole('button', { name: /聊天 B/ }))
expect(screen.getByText('已选 4 / 5 个')).toBeVisible()
expect(screen.getByRole('button', { name: /聊天 F/ })).toBeEnabled()
})
})
describe('ExportTaskCenter details', () => {
it('shows the exported message count for success and the reason for failure', () => {
const tasks: ExportTaskRecord[] = [
{
jobId: 'success',
targetIds: ['contact-1'],
targetNames: ['聊天 A'],
targetLabel: '聊天 A',
format: 'html',
status: 'completed',
progress: {
jobId: 'success',
phase: 'completed',
processed: 125,
total: 125,
percent: 100
},
createdAt: 1
},
{
jobId: 'failure',
targetIds: ['contact-1', 'contact-2'],
targetNames: ['聊天 A', '聊天 B'],
targetLabel: '聊天 A 等 2 个聊天',
format: 'html',
status: 'failed',
progress: {
jobId: 'failure',
phase: 'failed',
processed: 0,
percent: 0,
error: '视频文件没有写入权限'
},
createdAt: 2
}
]
render(
<ExportTaskCenter open taskCount={0} tasks={tasks} onToggle={vi.fn()} onCancel={vi.fn()} />
)
expect(screen.getByText('HTML · 已完成')).toBeVisible()
expect(screen.getByText('成功导出 125 条消息')).toBeVisible()
expect(screen.getByText('HTML · 导出失败')).toBeVisible()
expect(screen.getByText('失败原因:视频文件没有写入权限')).toBeVisible()
})
})
+32
View File
@@ -114,6 +114,38 @@ test('NAV-01 NAV-02 every top-level page is unique and switchable', async () =>
} }
}) })
test('EXPORT-01 multi-chat selection stays local to export and forces HTML', async () => {
const fixture = await launchTestApp()
try {
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
await fixture.page.getByRole('button', { name: '联系人 (1)' }).click()
await fixture.page.getByText('文件传输助手', { exact: true }).click()
await expect(fixture.page.getByText('转发多条内容', { exact: true })).toBeVisible()
await navigation.getByRole('button', { name: '导出' }).click()
const contactList = fixture.page.locator('.export-contact-list')
await expect(contactList.getByRole('button', { name: /文件传输助手/ })).toHaveAttribute(
'aria-pressed',
'true'
)
await fixture.page.getByRole('button', { name: '+ 添加聊天' }).click()
await contactList.getByRole('button', { name: /产品测试群/ }).click()
await expect(fixture.page.getByText('已选 2 / 5 个')).toBeVisible()
await expect(fixture.page.getByRole('button', { name: 'CSV' })).toBeDisabled()
await expect(fixture.page.getByRole('button', { name: /HTML/ })).toHaveClass(/active/)
await expect(fixture.page.getByText('文件传输助手、产品测试群 · 共 2 个聊天')).toBeVisible()
await expect(
fixture.page.locator('.export-preview-bubble').filter({ hasText: '这是一条脱敏测试消息' })
).toHaveCount(1)
await navigation.getByRole('button', { name: '档案' }).click()
await expect(fixture.page.getByText('转发多条内容', { exact: true })).toBeVisible()
} finally {
await fixture.close()
}
})
test('ARCH-01 ARCH-02 folded chats and supported message types are represented explicitly', async () => { test('ARCH-01 ARCH-02 folded chats and supported message types are represented explicitly', async () => {
const fixture = await launchTestApp() const fixture = await launchTestApp()
try { try {
+170
View File
@@ -0,0 +1,170 @@
import { expect, test } from '@playwright/test'
import { execFileSync } from 'child_process'
import { createWriteStream, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { dirname, join } from 'path'
import { pathToFileURL } from 'url'
import { ZipArchive } from 'archiver'
import { renderExportPage } from '../../src/main/export-html-template'
import type { Message } from '../../src/shared/types'
const archiveMessage = (
id: string,
conversationId: string,
conversationName: string,
content: string,
createTime: number
): Message => ({
id,
from: 'user',
type: '普通文本',
datetime: '',
content,
isSender: false,
name: '脱敏成员',
createTime,
exportConversationId: conversationId,
exportConversationName: conversationName
})
const zipDirectory = async (
sourceDir: string,
zipPath: string,
folderName: string
): Promise<void> => {
const output = createWriteStream(zipPath)
const archive = new ZipArchive({ zlib: { level: 6 } })
await new Promise<void>((resolve, reject) => {
output.on('close', resolve)
output.on('error', reject)
archive.on('error', reject)
archive.pipe(output)
archive.directory(sourceDir, folderName)
void archive.finalize().catch(reject)
})
}
test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobile', async ({
page
}, testInfo) => {
const fixtureRoot = mkdtempSync(join(tmpdir(), 'wxe-merged-archive-e2e-'))
const outputDir = join(fixtureRoot, 'source')
try {
const dataPath = join(outputDir, 'data', 'messages.js')
mkdirSync(dirname(dataPath), { recursive: true })
writeFileSync(join(outputDir, 'index.html'), renderExportPage('合并聊天档案'), 'utf8')
writeFileSync(
dataPath,
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 2,
name: '合并聊天档案',
exportedAt: '2026-08-04T00:00:00.000Z',
conversations: [
{ id: 'alpha', name: '项目群', type: 'group', messageCount: 2 },
{ 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)
]
})};\n`,
'utf8'
)
const zipPath = join(fixtureRoot, 'merged-archive.zip')
const extractedDir = join(fixtureRoot, 'extracted')
await zipDirectory(outputDir, zipPath, '合并聊天档案')
mkdirSync(extractedDir, { recursive: true })
execFileSync('unzip', ['-q', zipPath, '-d', extractedDir])
const offlineIndex = join(extractedDir, '合并聊天档案', 'index.html')
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(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)
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
await page.screenshot({ path: testInfo.outputPath('merged-archive-1440.png'), fullPage: true })
await conversationSelect.selectOption('beta')
await expect(page.locator('.message')).toHaveCount(1)
await expect(page.locator('.conversation-source')).toHaveCount(0)
await conversationSelect.selectOption('all')
await page.setViewportSize({ width: 390, height: 844 })
await expect(page.locator('.timeline-year')).toHaveCount(2)
await expect(page.locator('.timeline-year').first()).toBeVisible()
await expect(page.locator('.timeline-year').first()).toHaveText('2025 年')
const positions = await page.evaluate(() => {
const conversations = document.querySelector('#conversation-filter')!.getBoundingClientRect()
const toolbar = document.querySelector('.toolbar')!.getBoundingClientRect()
const timeline = document.querySelector('#timeline')!.getBoundingClientRect()
return {
conversationTop: conversations.top,
conversationBottom: conversations.bottom,
toolbarTop: toolbar.top,
toolbarBottom: toolbar.bottom,
timelineTop: timeline.top,
documentWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth
}
})
expect(positions.conversationTop).toBeGreaterThanOrEqual(positions.toolbarTop)
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 page.screenshot({ path: testInfo.outputPath('merged-archive-390.png'), fullPage: true })
} finally {
rmSync(fixtureRoot, { recursive: true, force: true })
}
})
test('EXPORT-ARCHIVE-02 legacy single-chat archive keeps its original layout', async ({
page
}, testInfo) => {
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-single-archive-e2e-'))
try {
const dataPath = join(outputDir, 'data', 'messages.js')
mkdirSync(dirname(dataPath), { recursive: true })
writeFileSync(join(outputDir, 'index.html'), renderExportPage('单聊天档案'), 'utf8')
writeFileSync(
dataPath,
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 1,
sourceId: 'single',
name: '单聊天档案',
exportedAt: '2026-08-04T00:00:00.000Z',
messages: [archiveMessage('single-1', 'single', '单聊天档案', '单聊天消息', 1_767_225_600)]
})};\n`,
'utf8'
)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(pathToFileURL(join(outputDir, 'index.html')).href)
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-layout')).toHaveClass(/single-conversation/)
await expect(page.locator('.message')).toHaveCount(1)
await page.screenshot({ path: testInfo.outputPath('single-archive-1440.png'), fullPage: true })
await page.setViewportSize({ width: 390, height: 844 })
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
await expect(page.locator('.message')).toHaveCount(1)
await page.screenshot({ path: testInfo.outputPath('single-archive-390.png'), fullPage: true })
} finally {
rmSync(outputDir, { recursive: true, force: true })
}
})
+279 -23
View File
@@ -1,7 +1,19 @@
import { dirname, join } from 'path' import { dirname, join } from 'path'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync
} from 'fs'
import { tmpdir } from 'os' import { tmpdir } from 'os'
import { execFileSync } from 'child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExportTarget } from '../../src/shared/export'
import type { Message } from '../../src/shared/types' import type { Message } from '../../src/shared/types'
const state = vi.hoisted(() => ({ const state = vi.hoisted(() => ({
@@ -9,6 +21,7 @@ const state = vi.hoisted(() => ({
accountRoot: '', accountRoot: '',
videoPath: '', videoPath: '',
messages: [] as Message[], messages: [] as Message[],
messagesByUser: {} as Record<string, Message[]>,
imageLookups: [] as { imageLookups: [] as {
allowThumbnail?: boolean allowThumbnail?: boolean
preferThumbnail?: boolean preferThumbnail?: boolean
@@ -25,7 +38,8 @@ vi.mock('electron', () => ({
})) }))
vi.mock('../../src/main/services/chat-service', () => ({ vi.mock('../../src/main/services/chat-service', () => ({
listMessages: () => structuredClone(state.messages), listMessages: () => structuredClone(state.messages),
listMessagesAsync: async () => structuredClone(state.messages), listMessagesAsync: async (userMd5: string) =>
structuredClone(state.messagesByUser[userMd5] || state.messages),
getChatDb: () => ({ getChatDb: () => ({
getWcdb4Client: () => ({ getAccountRoot: () => state.accountRoot }) getWcdb4Client: () => ({ getAccountRoot: () => state.accountRoot })
}), }),
@@ -129,17 +143,26 @@ const message = (overrides: Partial<Message>): Message => ({
...overrides ...overrides
}) })
const readArchive = (outputPath: string): { sourceId: string; messages: Message[] } => { const target = (userMd5 = 'fixture-user', name = '脱敏会话'): ExportTarget => ({
userMd5,
name,
type: 'user'
})
const readArchive = (
outputPath: string
): {
version: 2
conversations: { id: string; name: string; avatarUrl?: string; messageCount: number }[]
messages: Message[]
} => {
const source = readFileSync(join(dirname(outputPath), 'data', 'messages.js'), 'utf8') const source = readFileSync(join(dirname(outputPath), 'data', 'messages.js'), 'utf8')
return JSON.parse( return JSON.parse(
source source
.slice(source.indexOf('=') + 1) .slice(source.indexOf('=') + 1)
.trim() .trim()
.replace(/;\s*$/, '') .replace(/;\s*$/, '')
) as { )
sourceId: string
messages: Message[]
}
} }
describe('media export flow', () => { describe('media export flow', () => {
@@ -152,6 +175,7 @@ describe('media export flow', () => {
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex') Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
) )
state.imageLookups = [] state.imageLookups = []
state.messagesByUser = {}
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08') const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
mkdirSync(fileMonth, { recursive: true }) mkdirSync(fileMonth, { recursive: true })
writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容') writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容')
@@ -201,8 +225,7 @@ describe('media export flow', () => {
const result = await runExport( const result = await runExport(
{ {
jobId: 'fixture-export', jobId: 'fixture-export',
userMd5: 'fixture-user', targets: [target()],
name: '脱敏会话',
format: 'html', format: 'html',
outputName: 'fixture', outputName: 'fixture',
kinds: ['voice', 'image', 'video', 'file'], kinds: ['voice', 'image', 'video', 'file'],
@@ -249,8 +272,7 @@ describe('media export flow', () => {
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const request = { const request = {
jobId: 'incremental-first', jobId: 'incremental-first',
userMd5: 'fixture-user', targets: [target('fixture-user', '增量会话')],
name: '增量会话',
format: 'html' as const, format: 'html' as const,
outputName: 'incremental-fixture', outputName: 'incremental-fixture',
kinds: ['voice', 'text'] as const, kinds: ['voice', 'text'] as const,
@@ -302,14 +324,245 @@ describe('media export flow', () => {
expect(existsSync(join(dirname(second.outputPath!), 'data', 'messages.js.bak'))).toBe(true) expect(existsSync(join(dirname(second.outputPath!), 'data', 'messages.js.bak'))).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() } }
chmodSync(state.videoPath, 0o444)
state.messages = [
message({
id: 'read-only-video',
type: '视频',
contentData: { type: 'video', md5: 'b'.repeat(32) }
})
]
const request = {
targets: [target('fixture-user', '只读视频会话')],
format: 'html' as const,
outputName: 'read-only-video-fixture',
kinds: ['video'] as const,
includeMedia: true
}
const first = await runExport(
{ ...request, jobId: 'read-only-video-first', kinds: [...request.kinds] },
win as never
)
expect(first.success, first.error).toBe(true)
const firstArchive = readArchive(first.outputPath!)
const videoPath = join(
dirname(first.outputPath!),
firstArchive.messages[0].exportMediaUrl as string
)
expect(statSync(videoPath).mode & 0o777).toBe(0o644)
chmodSync(videoPath, 0o444)
const second = await runExport(
{ ...request, jobId: 'read-only-video-second', kinds: [...request.kinds] },
win as never
)
expect(second.success, second.error).toBe(true)
expect(second.outputPath).toBe(first.outputPath)
expect(statSync(videoPath).mode & 0o777).toBe(0o644)
})
it('merges two conversations in stable order without colliding identical message ids or media', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
state.messagesByUser = {
alpha: [
message({
id: 'same-id',
type: '图片',
createTime: 100,
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'same.dat' }
}),
message({ id: 'alpha-later', content: 'A2', createTime: 200 })
],
beta: [
message({
id: 'same-id',
type: '图片',
createTime: 100,
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'same.dat' }
}),
message({ id: 'beta-later', content: 'B2', createTime: 150 })
]
}
const result = await runExport(
{
jobId: 'multi-conversation',
targets: [target('alpha', '聊天 A'), target('beta', '聊天 B')],
format: 'html',
outputName: 'multi-conversation',
kinds: ['text', 'image'],
includeMedia: true
},
win as never
)
expect(result.success).toBe(true)
const archive = readArchive(result.outputPath!)
expect(archive.version).toBe(2)
expect(archive.conversations.map(({ id, messageCount }) => ({ id, messageCount }))).toEqual([
{ id: 'alpha', messageCount: 2 },
{ id: 'beta', messageCount: 2 }
])
expect(
archive.messages.map((item) => [item.exportConversationId, item.id, item.createTime])
).toEqual([
['alpha', 'same-id', 100],
['beta', 'same-id', 100],
['beta', 'beta-later', 150],
['alpha', 'alpha-later', 200]
])
const imagePaths = archive.messages
.filter((item) => item.id === 'same-id')
.map((item) => item.exportMediaUrl)
expect(new Set(imagePaths).size).toBe(2)
for (const imagePath of imagePaths) {
expect(existsSync(join(dirname(result.outputPath!), imagePath!))).toBe(true)
}
})
it('creates a replaceable ZIP containing the complete top-level archive folder', async () => {
const { runExport } = await import('../../src/main/export-service')
const progress: unknown[][] = []
const win = {
isDestroyed: () => false,
webContents: { send: (...args: unknown[]) => progress.push(args) }
}
state.messages = [
message({
id: 'zip-image',
type: '图片',
contentData: { type: 'image', md5: 'a'.repeat(32), datName: 'fixture.dat' }
})
]
const request = {
targets: [
{
...target('fixture-user', 'ZIP 会话'),
avatarUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
}
],
format: 'html' as const,
outputName: 'zip-fixture',
kinds: ['image'] as const,
includeMedia: true,
includeAvatars: true,
zip: true
}
const first = await runExport(
{ ...request, jobId: 'zip-first', kinds: [...request.kinds] },
win as never
)
expect(first.success, first.error).toBe(true)
const firstSize = readFileSync(first.outputPath!).length
const second = await runExport(
{ ...request, jobId: 'zip-second', kinds: [...request.kinds] },
win as never
)
expect(first.success).toBe(true)
expect(second.success).toBe(true)
expect(second.outputPath).toBe(first.outputPath)
expect(firstSize).toBeGreaterThan(0)
expect(readFileSync(second.outputPath!).subarray(0, 2).toString()).toBe('PK')
const entries = execFileSync('unzip', ['-Z1', second.outputPath!], { encoding: 'utf8' })
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/)
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
)
expect(
readdirSync(join(state.documents, 'WechatExplorer', '导出')).some((name) =>
name.startsWith('zip-fixture.zip.tmp-')
)
).toBe(false)
})
it('keeps the last complete ZIP when a replacement is cancelled during compression', async () => {
const { cancelExport, runExport } = await import('../../src/main/export-service')
state.messages = [message({ id: 'zip-cancel', content: '保留完整压缩包' })]
const request = {
targets: [target('fixture-user', '取消压缩会话')],
format: 'html' as const,
outputName: 'zip-cancel-fixture',
kinds: ['text'] as const,
includeMedia: false,
zip: true
}
const silentWindow = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const first = await runExport(
{ ...request, jobId: 'zip-cancel-first', kinds: [...request.kinds] },
silentWindow as never
)
expect(first.success, first.error).toBe(true)
const completeZip = readFileSync(first.outputPath!)
const cancellingWindow = {
isDestroyed: () => false,
webContents: {
send: (_channel: string, progress: { phase: string }): void => {
if (progress.phase === 'compressing') cancelExport('zip-cancel-second')
}
}
}
const cancelled = await runExport(
{ ...request, jobId: 'zip-cancel-second', kinds: [...request.kinds] },
cancellingWindow as never
)
expect(cancelled).toEqual({ success: false, error: '已取消' })
expect(readFileSync(first.outputPath!)).toEqual(completeZip)
expect(
readdirSync(join(state.documents, 'WechatExplorer', '导出')).some((name) =>
name.startsWith('zip-cancel-fixture.zip.tmp-')
)
).toBe(false)
})
it('normalizes a legacy v1 single-chat archive into v2', async () => {
const { readHtmlArchive } = await import('../../src/main/export-service')
const outputDir = join(state.documents, 'legacy-archive')
mkdirSync(join(outputDir, 'data'), { recursive: true })
writeFileSync(
join(outputDir, 'data', 'messages.js'),
`window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 1,
sourceId: 'legacy-user',
name: '旧档案',
exportedAt: '2026-08-01T00:00:00.000Z',
messages: [message({ id: 'legacy-message', content: '旧消息' })]
})};\n`,
'utf8'
)
const archive = await readHtmlArchive(outputDir, [target('legacy-user', '旧档案')], '旧档案')
expect(archive.version).toBe(2)
expect(archive.conversations).toEqual([
expect.objectContaining({ id: 'legacy-user', name: '旧档案', messageCount: 1 })
])
expect(archive.messages[0]).toMatchObject({
id: 'legacy-message',
exportConversationId: 'legacy-user',
exportConversationName: '旧档案'
})
})
it('refuses to merge a different conversation into an existing named archive', async () => { it('refuses to merge a different conversation into an existing named archive', async () => {
const { runExport } = await import('../../src/main/export-service') const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
state.messages = [message({ id: 'text', content: 'fixture' })] state.messages = [message({ id: 'text', content: 'fixture' })]
const baseRequest = { const baseRequest = {
jobId: 'source-first', jobId: 'source-first',
userMd5: 'first-user', targets: [target('first-user', '第一个会话')],
name: '第一个会话',
format: 'html' as const, format: 'html' as const,
outputName: 'same-name', outputName: 'same-name',
kinds: ['text'] as const, kinds: ['text'] as const,
@@ -320,8 +573,7 @@ describe('media export flow', () => {
{ {
...baseRequest, ...baseRequest,
jobId: 'source-second', jobId: 'source-second',
userMd5: 'second-user', targets: [target('second-user', '第二个会话')],
name: '第二个会话',
kinds: [...baseRequest.kinds] kinds: [...baseRequest.kinds]
}, },
win as never win as never
@@ -329,8 +581,10 @@ describe('media export flow', () => {
expect(first.success).toBe(true) expect(first.success).toBe(true)
expect(second.success).toBe(false) expect(second.success).toBe(false)
expect(second.error).toContain('另一个会话') expect(second.error).toContain('聊天集合不同')
expect(readArchive(first.outputPath!).sourceId).toBe('first-user') expect(readArchive(first.outputPath!).conversations.map((item) => item.id)).toEqual([
'first-user'
])
}) })
it('uses message content as the stable fallback when the database supplies a random id', async () => { it('uses message content as the stable fallback when the database supplies a random id', async () => {
@@ -357,13 +611,16 @@ describe('media export flow', () => {
const result = await runExport( const result = await runExport(
{ {
jobId: 'self-name', jobId: 'self-name',
userMd5: 'fixture-user', targets: [
name: '本人昵称', {
...target('fixture-user', '本人昵称'),
nameMap: { a969409112: 'a969409112' }
}
],
format: 'html', format: 'html',
outputName: 'self-name', outputName: 'self-name',
kinds: ['text'], kinds: ['text'],
includeMedia: false, includeMedia: false
nameMap: { a969409112: 'a969409112' }
}, },
win as never win as never
) )
@@ -376,8 +633,7 @@ describe('media export flow', () => {
const { runExport } = await import('../../src/main/export-service') const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const request = { const request = {
userMd5: 'fixture-user', targets: [target('fixture-user', '增量昵称')],
name: '增量昵称',
format: 'html' as const, format: 'html' as const,
outputName: 'incremental-self-name', outputName: 'incremental-self-name',
kinds: ['text'] as const, kinds: ['text'] as const,
+74
View File
@@ -94,6 +94,60 @@ describe('export media', () => {
dom.window.close() 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' })
Object.assign(dom.window, {
__WECHAT_EXPORT__: {
version: 2,
name: '合并档案',
exportedAt: '2026-08-04T00:00:00.000Z',
conversations: [
{ id: 'alpha', name: '聊天 A', type: 'user', messageCount: 2 },
{ id: 'beta', name: '聊天 B', type: 'group', messageCount: 1 }
],
messages: [
messageForArchive('alpha-1', 'alpha', '聊天 A', '共同关键词', 1_767_225_600),
messageForArchive('beta-1', 'beta', '聊天 B', '共同关键词', 1_769_904_000),
messageForArchive('alpha-2', 'alpha', '聊天 A', '仅 A 可见', 1_769_990_400)
]
}
})
dom.window.eval(inlineScriptOf(html))
const filter = dom.window.document.querySelector('#conversation-filter')!
const select = dom.window.document.querySelector('#conversation-select') as HTMLSelectElement
expect(filter.hasAttribute('hidden')).toBe(false)
expect(filter.parentElement?.classList.contains('archive-heading')).toBe(true)
expect((dom.window.document.querySelector('#archive-title') as HTMLElement).hidden).toBe(true)
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(dom.window.document.querySelectorAll('.conversation-source')).toHaveLength(3)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 3 / 筛选 3 / 全部 3'
)
select.value = 'alpha'
select.dispatchEvent(new dom.window.Event('change'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(2)
expect(dom.window.document.querySelectorAll('.conversation-source')).toHaveLength(0)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 2 / 筛选 2 / 当前聊天 2'
)
expect(dom.window.document.querySelectorAll('.timeline-month')).toHaveLength(2)
const search = dom.window.document.querySelector('#query') as HTMLInputElement
search.value = '共同关键词'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 1 / 筛选 1 / 当前聊天 2'
)
dom.window.close()
})
it('keeps relative media, file download, quote, and missing-media renderers', () => { it('keeps relative media, file download, quote, and missing-media renderers', () => {
const html = renderExportPage('媒体档案') const html = renderExportPage('媒体档案')
@@ -116,3 +170,23 @@ describe('export media', () => {
expect(html).toContain("if (event.key === 'Escape') closeLightbox()") expect(html).toContain("if (event.key === 'Escape') closeLightbox()")
}) })
}) })
function messageForArchive(
id: string,
conversationId: string,
conversationName: string,
content: string,
createTime: number
): Message {
return {
id,
from: 'user',
type: '普通文本',
datetime: '',
content,
isSender: false,
createTime,
exportConversationId: conversationId,
exportConversationName: conversationName
}
}