feat: unified in-app attachment preview (image/PDF/text/docx via mammoth/xlsx via SheetJS/audio/video) with lazy-loaded renderers

This commit is contained in:
root
2026-10-16 23:23:08 +08:00
parent c25c39a0aa
commit 3ba44aa2de
22 changed files with 902 additions and 358 deletions
+5 -3
View File
@@ -18,19 +18,21 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"echarts": "^5.6.0", "echarts": "^5.6.0",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"mammoth": "^1.13.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^7.6.0", "react-router-dom": "^7.6.0",
"tailwind-merge": "^3.3.0" "tailwind-merge": "^3.3.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.1.0", "@tailwindcss/vite": "^4.1.0",
"@tauri-apps/cli": "^2.5.0",
"@types/react": "^19.1.0", "@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0", "@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.5.0", "@vitejs/plugin-react": "^4.5.0",
"tailwindcss": "^4.1.0", "tailwindcss": "^4.1.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vite": "^6.3.5", "vite": "^6.3.5"
"@tauri-apps/cli": "^2.5.0"
} }
} }
+233
View File
@@ -26,6 +26,9 @@ importers:
lucide-react: lucide-react:
specifier: ^0.475.0 specifier: ^0.475.0
version: 0.475.0(react@19.3.1) version: 0.475.0(react@19.3.1)
mammoth:
specifier: ^1.13.0
version: 1.13.0
react: react:
specifier: ^19.1.0 specifier: ^19.1.0
version: 19.3.1 version: 19.3.1
@@ -38,6 +41,9 @@ importers:
tailwind-merge: tailwind-merge:
specifier: ^3.3.0 specifier: ^3.3.0
version: 3.7.0 version: 3.7.0
xlsx:
specifier: ^0.18.5
version: 0.18.5
devDependencies: devDependencies:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.0 specifier: ^4.1.0
@@ -679,6 +685,20 @@ packages:
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@xmldom/xmldom@0.8.15':
resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==}
engines: {node: '>=10.0.0'}
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.11.28: baseline-browser-mapping@2.11.28:
resolution: {integrity: sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==} resolution: {integrity: sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
@@ -692,6 +712,10 @@ packages:
caniuse-lite@1.0.30001816: caniuse-lite@1.0.30001816:
resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==} resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -699,6 +723,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -706,6 +734,14 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -722,6 +758,12 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
dingbat-to-unicode@1.0.2:
resolution: {integrity: sha512-R0bRerYzy/EZP6QzD3Hkl6JjUDA1Mnn+pd928w0nWjPzihnbAR6dcjGJGiRyznj5E4S1duEKzWrTqw02DPcdzg==}
duck@0.1.12:
resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==}
echarts@5.6.0: echarts@5.6.0:
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
@@ -750,6 +792,10 @@ packages:
picomatch: picomatch:
optional: true optional: true
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -762,6 +808,15 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
jiti@2.7.0: jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
@@ -779,6 +834,12 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
jszip@3.10.2:
resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -853,6 +914,9 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
lop@0.4.2:
resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==}
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -864,6 +928,11 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mammoth@1.13.0:
resolution: {integrity: sha512-2cawcYFcP7ISfXbBcL9lmVgTNWo88v/52ND1gnywHtVT/pX0//puPnf0iMPV9ZgmuvNOE+zkIhDgZ9M2c2dDDg==}
engines: {node: '>=12.0.0'}
hasBin: true
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -876,6 +945,12 @@ packages:
resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==} resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==}
engines: {node: '>=18'} engines: {node: '>=18'}
option@0.2.4:
resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -887,6 +962,9 @@ packages:
resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==} resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
react-dom@19.3.1: react-dom@19.3.1:
resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==}
peerDependencies: peerDependencies:
@@ -917,11 +995,17 @@ packages:
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
rollup@4.64.7: rollup@4.64.7:
resolution: {integrity: sha512-aCiJONoZp0lNGJ6sYqspLkh6UXaWEey/7Ko2TZyXQ4kgzAhDUxAgbt/oulXKmD+zFHXw1k+RF6ZZWOrjp7DZnQ==} resolution: {integrity: sha512-aCiJONoZp0lNGJ6sYqspLkh6UXaWEey/7Ko2TZyXQ4kgzAhDUxAgbt/oulXKmD+zFHXw1k+RF6ZZWOrjp7DZnQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
scheduler@0.28.0: scheduler@0.28.0:
resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
@@ -932,10 +1016,23 @@ packages:
set-cookie-parser@2.7.2: set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
source-map-js@1.2.2: source-map-js@1.2.2:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
tailwind-merge@3.7.0: tailwind-merge@3.7.0:
resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==}
@@ -958,12 +1055,18 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
underscore@1.13.8:
resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==}
update-browserslist-db@1.3.4: update-browserslist-db@1.3.4:
resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
browserslist: '>= 4.21.0' browserslist: '>= 4.21.0'
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite@6.4.4: vite@6.4.4:
resolution: {integrity: sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==} resolution: {integrity: sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -1004,6 +1107,23 @@ packages:
yaml: yaml:
optional: true optional: true
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
xmlbuilder@10.1.1:
resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==}
engines: {node: '>=4.0'}
yallist@3.1.1: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -1468,6 +1588,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@xmldom/xmldom@0.8.15': {}
adler-32@1.3.1: {}
argparse@1.0.10:
dependencies:
sprintf-js: 1.0.3
base64-js@1.5.1: {}
baseline-browser-mapping@2.11.28: {} baseline-browser-mapping@2.11.28: {}
browserslist@4.29.3: browserslist@4.29.3:
@@ -1480,16 +1610,27 @@ snapshots:
caniuse-lite@1.0.30001816: {} caniuse-lite@1.0.30001816: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1
clsx@2.1.1: {} clsx@2.1.1: {}
codepage@1.15.0: {}
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
cookie@1.1.1: {} cookie@1.1.1: {}
core-util-is@1.0.3: {}
crc-32@1.2.2: {}
csstype@3.2.3: {} csstype@3.2.3: {}
debug@4.4.3: debug@4.4.3:
@@ -1498,6 +1639,12 @@ snapshots:
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
dingbat-to-unicode@1.0.2: {}
duck@0.1.12:
dependencies:
underscore: 1.13.8
echarts@5.6.0: echarts@5.6.0:
dependencies: dependencies:
tslib: 2.3.0 tslib: 2.3.0
@@ -1545,6 +1692,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.7 picomatch: 4.0.7
frac@1.1.2: {}
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -1552,6 +1701,12 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
immediate@3.0.6: {}
inherits@2.0.4: {}
isarray@1.0.0: {}
jiti@2.7.0: {} jiti@2.7.0: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -1560,6 +1715,17 @@ snapshots:
json5@2.2.3: {} json5@2.2.3: {}
jszip@3.10.2:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
lie@3.3.0:
dependencies:
immediate: 3.0.6
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
optional: true optional: true
@@ -1609,6 +1775,12 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0
lop@0.4.2:
dependencies:
duck: 0.1.12
option: 0.2.4
underscore: 1.13.8
lru-cache@5.1.1: lru-cache@5.1.1:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
@@ -1621,12 +1793,27 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/sourcemap-codec': 1.6.0
mammoth@1.13.0:
dependencies:
'@xmldom/xmldom': 0.8.15
argparse: 1.0.10
base64-js: 1.5.1
dingbat-to-unicode: 1.0.2
jszip: 3.10.2
lop: 0.4.2
underscore: 1.13.8
xmlbuilder: 10.1.1
ms@2.1.3: {} ms@2.1.3: {}
nanoid@3.3.20: {} nanoid@3.3.20: {}
node-releases@2.0.59: {} node-releases@2.0.59: {}
option@0.2.4: {}
pako@1.0.11: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@4.0.7: {} picomatch@4.0.7: {}
@@ -1637,6 +1824,8 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.2 source-map-js: 1.2.2
process-nextick-args@2.0.1: {}
react-dom@19.3.1(react@19.3.1): react-dom@19.3.1(react@19.3.1):
dependencies: dependencies:
react: 19.3.1 react: 19.3.1
@@ -1660,6 +1849,16 @@ snapshots:
react@19.3.1: {} react@19.3.1: {}
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
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
rollup@4.64.7: rollup@4.64.7:
dependencies: dependencies:
'@types/estree': 1.0.9 '@types/estree': 1.0.9
@@ -1692,14 +1891,28 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.64.7 '@rollup/rollup-win32-x64-msvc': 4.64.7
fsevents: 2.3.3 fsevents: 2.3.3
safe-buffer@5.1.2: {}
scheduler@0.28.0: {} scheduler@0.28.0: {}
semver@6.3.1: {} semver@6.3.1: {}
set-cookie-parser@2.7.2: {} set-cookie-parser@2.7.2: {}
setimmediate@1.0.5: {}
source-map-js@1.2.2: {} source-map-js@1.2.2: {}
sprintf-js@1.0.3: {}
ssf@0.11.2:
dependencies:
frac: 1.1.2
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
tailwind-merge@3.7.0: {} tailwind-merge@3.7.0: {}
tailwindcss@4.3.3: {} tailwindcss@4.3.3: {}
@@ -1715,12 +1928,16 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
underscore@1.13.8: {}
update-browserslist-db@1.3.4(browserslist@4.29.3): update-browserslist-db@1.3.4(browserslist@4.29.3):
dependencies: dependencies:
browserslist: 4.29.3 browserslist: 4.29.3
escalade: 3.2.0 escalade: 3.2.0
picocolors: 1.1.1 picocolors: 1.1.1
util-deprecate@1.0.2: {}
vite@6.4.4(jiti@2.7.0)(lightningcss@1.32.0): vite@6.4.4(jiti@2.7.0)(lightningcss@1.32.0):
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
@@ -1734,6 +1951,22 @@ snapshots:
jiti: 2.7.0 jiti: 2.7.0
lightningcss: 1.32.0 lightningcss: 1.32.0
wmf@1.0.2: {}
word@0.3.0: {}
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xmlbuilder@10.1.1: {}
yallist@3.1.1: {} yallist@3.1.1: {}
zrender@5.6.1: zrender@5.6.1:
@@ -0,0 +1,132 @@
import { useEffect, useState } from 'react';
import { Download, Loader2 } from 'lucide-react';
import { Button, Dialog } from '@/components/ui';
import { readFull, b64ToObjectUrl } from '@/lib/att-cache';
import type { Attachment } from '@/lib/types';
export type AttKind = 'image' | 'pdf' | 'text' | 'audio' | 'video' | 'docx' | 'xlsx' | 'other';
export function attKind(a: { file_name: string; mime?: string | null }): AttKind {
const mime = (a.mime || '').toLowerCase();
const ext = (a.file_name.split('.').pop() || '').toLowerCase();
if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return 'image';
if (mime === 'application/pdf' || ext === 'pdf') return 'pdf';
if (ext === 'docx' || mime.includes('wordprocessingml')) return 'docx';
if (ext === 'xlsx' || ext === 'xls' || mime.includes('spreadsheetml') || mime === 'application/vnd.ms-excel') return 'xlsx';
if (mime.startsWith('audio/') || ['mp3', 'wav', 'm4a', 'flac', 'ogg'].includes(ext)) return 'audio';
if (mime.startsWith('video/') || ['mp4', 'mov', 'webm', 'mkv'].includes(ext)) return 'video';
if (mime.startsWith('text/') || ['txt', 'md', 'csv', 'json', 'log', 'srt'].includes(ext)) return 'text';
return 'other';
}
function b64ToUint8(dataB64: string): Uint8Array<ArrayBuffer> {
return Uint8Array.from(atob(dataB64), c => c.charCodeAt(0)) as Uint8Array<ArrayBuffer>;
}
function forceDownload(fileName: string, mime: string | undefined | null, dataB64: string) {
const url = URL.createObjectURL(new Blob([b64ToUint8(dataB64)], { type: mime || 'application/octet-stream' }));
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
const KIND_LABEL: Record<AttKind, string> = {
image: '图片', pdf: 'PDF 文档', text: '文本文件', audio: '音频', video: '视频',
docx: 'Word 文档(.docx)', xlsx: 'Excel 表格(.xlsx)', other: '该类型暂不支持在线预览',
};
export function AttachmentPreview({ att, onClose }: { att: Attachment | null; onClose: () => void }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [content, setContent] = useState<{ kind: AttKind; objectUrl?: string; html?: string; text?: string } | null>(null);
useEffect(() => {
if (!att) { setContent(null); setError(''); return; }
let cancelled = false;
const created: string[] = [];
setLoading(true); setError(''); setContent(null);
(async () => {
try {
const kind = attKind(att);
const { data_b64, mime } = await readFull(att);
if (cancelled) return;
if (kind === 'image' || kind === 'pdf' || kind === 'audio' || kind === 'video') {
const url = b64ToObjectUrl(data_b64, mime);
if (cancelled) { URL.revokeObjectURL(url); return; }
created.push(url);
setContent({ kind, objectUrl: url });
} else if (kind === 'text') {
const text = new TextDecoder().decode(b64ToUint8(data_b64));
setContent({ kind, text: text.length > 400_000 ? text.slice(0, 400_000) + '\n…(已截断)' : text });
} else if (kind === 'docx') {
const mammoth = await import('mammoth/mammoth.browser') as any;
const html = (await mammoth.convertToHtml({ arrayBuffer: b64ToUint8(data_b64).buffer })).value as string;
if (cancelled) return;
setContent({ kind, html });
} else if (kind === 'xlsx') {
const XLSX = await import('xlsx');
const wb = XLSX.read(b64ToUint8(data_b64).buffer, { type: 'array' });
const html = XLSX.utils.sheet_to_html(wb.Sheets[wb.SheetNames[0]]);
if (cancelled) return;
setContent({ kind, html });
} else {
setContent({ kind: 'other' });
}
} catch (e) {
if (!cancelled) setError((e as Error).message || '预览加载失败');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; created.forEach(u => URL.revokeObjectURL(u)); };
}, [att?.id, att?.file_name]);
const kind = att ? attKind(att) : 'other';
return (
<Dialog
open={!!att}
onClose={onClose}
width={kind === 'image' || kind === 'pdf' ? 880 : 680}
title={(att ? '附件预览 · ' + att.file_name : '附件预览') + (att ? '(' + KIND_LABEL[kind] + ')' : '')}
footer={att ? (
<>
<Button variant="secondary" className="mr-auto" onClick={() => { void readFull(att).then(r => forceDownload(att.file_name, att.mime, r.data_b64)); }}>
<Download size={13} /> 下载
</Button>
<Button variant="primary" onClick={onClose}>关闭</Button>
</>
) : null}
>
{loading && (
<div className="flex items-center justify-center gap-2 py-16 text-[13px] text-ink-3">
<Loader2 className="animate-spin" size={16} /> 正在加载预览…
</div>
)}
{!loading && !!error && <div className="py-10 text-center text-[13px] text-danger">{error}</div>}
{!loading && !error && content && (
<>
{content.kind === 'image' && <img src={content.objectUrl} alt={att?.file_name} className="mx-auto max-h-[72vh] rounded-md" />}
{content.kind === 'pdf' && <iframe src={content.objectUrl} title={att?.file_name} className="h-[72vh] w-full rounded-md border border-line bg-white" />}
{content.kind === 'audio' && <audio controls src={content.objectUrl} className="w-full" />}
{content.kind === 'video' && <video controls src={content.objectUrl} className="max-h-[70vh] w-full rounded-md bg-black" />}
{content.kind === 'text' && <pre className="max-h-[68vh] overflow-auto whitespace-pre-wrap rounded-md bg-surface-2 p-3 text-[12.5px] leading-relaxed text-ink-1">{content.text}</pre>}
{(content.kind === 'docx' || content.kind === 'xlsx') && (
<div
className="max-h-[70vh] overflow-auto rounded-md border border-line bg-white p-4 text-[13px] leading-relaxed [&_img]:max-w-full [&_table]:border-collapse [&_td]:border [&_td]:border-line [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:border-line [&_th]:px-2 [&_th]:py-1"
dangerouslySetInnerHTML={{ __html: content.html || '' }}
/>
)}
{content.kind === 'other' && (
<div className="py-10 text-center">
<p className="text-[13px] text-ink-2">该文件类型暂不支持在线预览</p>
<p className="mt-1 text-[12px] text-ink-3">请点击左下角「下载」,用本地应用打开</p>
</div>
)}
</>
)}
</Dialog>
);
}
@@ -5,13 +5,12 @@ import { attachmentRead, attachmentThumb, footprintGet, listDimensions, listType
import type { Attachment, FormField } from '@/lib/types'; import type { Attachment, FormField } from '@/lib/types';
import { Badge, Button, Confirm, Dialog, useToast } from '@/components/ui'; import { Badge, Button, Confirm, Dialog, useToast } from '@/components/ui';
import { StatusBadge, LevelBadge, GradeBadge, RoleBadge, NegativeBadge } from '@/components/common/badges'; import { StatusBadge, LevelBadge, GradeBadge, RoleBadge, NegativeBadge } from '@/components/common/badges';
import { openOrDownload } from '@/lib/file'; import { AttachmentPreview } from '@/components/common/AttachmentPreview';
import { makeThumb } from '@/lib/img'; import { makeThumb } from '@/lib/img';
import { fmtRange, fmtDateTime, fmtSize } from '@/lib/format'; import { fmtRange, fmtDateTime, fmtSize } from '@/lib/format';
// 缩略图/原图会话级缓存:同一附件一次会话只读一次 // 缩略图会话级缓存:同一附件一次会话只读一次(原图缓存统一在 lib/att-cache)
const thumbCache = new Map<number, string>(); const thumbCache = new Map<number, string>();
const fullCache = new Map<number, string>();
export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: { export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
open: boolean; open: boolean;
@@ -36,6 +35,7 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
.filter(Boolean) as string[]; .filter(Boolean) as string[];
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [previewAtt, setPreviewAtt] = useState<Attachment | null>(null);
const delMut = useMutation({ const delMut = useMutation({
mutationFn: () => import('@/lib/api').then(m => m.footprintDelete(footprintId!)), mutationFn: () => import('@/lib/api').then(m => m.footprintDelete(footprintId!)),
onSuccess: () => { onSuccess: () => {
@@ -48,19 +48,7 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
} }
}); });
const openAtt = async (a: Attachment) => {
try {
let b64 = fullCache.get(a.id);
if (!b64) {
const r = await attachmentRead(a.id);
b64 = r.data_b64;
if ((a.size ?? 0) <= 2 * 1024 * 1024) fullCache.set(a.id, b64); // 大原图不驻留内存
}
openOrDownload(a.file_name, a.mime ?? undefined, b64);
} catch (e) {
toast('error', (e as Error).message);
}
};
// 图片直出:逐张读取为 data URL(渐进渲染) // 图片直出:逐张读取为 data URL(渐进渲染)
const [imgMap, setImgMap] = useState<Record<number, string>>({}); const [imgMap, setImgMap] = useState<Record<number, string>>({});
@@ -217,8 +205,8 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
<button <button
key={a.id} key={a.id}
className="group relative aspect-square overflow-hidden rounded-md border border-line bg-surface-2" className="group relative aspect-square overflow-hidden rounded-md border border-line bg-surface-2"
onClick={() => openAtt(a)} onClick={() => setPreviewAtt(a)}
title={'点击查看原图:' + a.file_name} title={'点击预览:' + a.file_name}
> >
{imgMap[a.id] ? ( {imgMap[a.id] ? (
<img <img
@@ -243,7 +231,7 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
<button <button
key={a.id} key={a.id}
className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-2 text-left text-[12.5px] transition-colors hover:border-line-strong" className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-2 text-left text-[12.5px] transition-colors hover:border-line-strong"
onClick={() => openAtt(a)} onClick={() => setPreviewAtt(a)}
> >
<FileText size={14} className="shrink-0 text-ink-3" /> <FileText size={14} className="shrink-0 text-ink-3" />
<span className="truncate text-ink-1">{a.file_name}</span> <span className="truncate text-ink-1">{a.file_name}</span>
@@ -253,7 +241,7 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
))} ))}
</div> </div>
)} )}
<div className="text-[11px] text-ink-3">点击图片查看原图(原始比例)</div> <div className="text-[11px] text-ink-3">点击图片 / 文件可在线预览(图片 · PDF · 文本 · Word · Excel · 音视频)</div>
</div> </div>
) : ( ) : (
<p className="text-[12px] text-ink-3">暂无佐证附件</p> <p className="text-[12px] text-ink-3">暂无佐证附件</p>
@@ -262,6 +250,8 @@ export function RecordDetailDialog({ open, onClose, footprintId, onEdit }: {
</div> </div>
)} )}
<AttachmentPreview att={previewAtt} onClose={() => setPreviewAtt(null)} />
<Confirm <Confirm
open={confirmOpen} open={confirmOpen}
title="删除记录" title="删除记录"
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
attachmentAdd, attachmentDelete, attachmentList, attachmentRead, attachmentAdd, attachmentDelete, attachmentList,
footprintGet, footprintList, footprintUpsert, listDimensions, listTaxonomy, listTypes footprintGet, footprintList, footprintUpsert, listDimensions, listTaxonomy, listTypes
} from '@/lib/api'; } from '@/lib/api';
import { AttachmentPreview } from '@/components/common/AttachmentPreview';
import type { Attachment } from '@/lib/types'; import type { Attachment } from '@/lib/types';
import type { FootprintInput } from '@/lib/types'; import type { FootprintInput } from '@/lib/types';
import { Button, Dialog, Field, Input, Select, Textarea, useToast } from '@/components/ui'; import { Button, Dialog, Field, Input, Select, Textarea, useToast } from '@/components/ui';
@@ -49,7 +50,6 @@ const STATUS_OPTS = [
{ value: 'finished', label: '已完结' } { value: 'finished', label: '已完结' }
]; ];
import { openOrDownload } from '@/lib/file';
export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: { export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
open: boolean; open: boolean;
@@ -62,6 +62,7 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
const [form, setForm] = useState<FormState>(BLANK); const [form, setForm] = useState<FormState>(BLANK);
const [pendingAtts, setPendingAtts] = useState<PendingAtt[]>([]); const [pendingAtts, setPendingAtts] = useState<PendingAtt[]>([]);
const [uploadingAtt, setUploadingAtt] = useState(false); const [uploadingAtt, setUploadingAtt] = useState(false);
const [previewAtt, setPreviewAtt] = useState<Attachment | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes, enabled: open }); const typesQ = useQuery({ queryKey: ['types'], queryFn: listTypes, enabled: open });
@@ -362,17 +363,10 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
}} }}
onDeleteSaved={id => delAtt.mutate(id)} onDeleteSaved={id => delAtt.mutate(id)}
onDeletePending={() => {}} onDeletePending={() => {}}
onOpenSaved={async (a: Attachment) => { onOpenSaved={a => setPreviewAtt(a)}
try {
const r = await attachmentRead(a.id);
openOrDownload(r.file_name, r.mime ?? undefined, r.data_b64);
} catch (e) {
toast('error', (e as Error).message);
}
}}
/> />
<div className="mt-1.5 text-[11px] text-ink-3"> <div className="mt-1.5 text-[11px] text-ink-3">
点击文件名可预览(支持图片 / PDF,其余下载)· 单个文件不超过 80MB 点击文件名可在线预览(图片 · PDF · 文本 · Word · Excel · 音视频)· 单个文件不超过 80MB
</div> </div>
</div> </div>
) : ( ) : (
@@ -400,6 +394,7 @@ export function RecordEditorDialog({ open, onClose, footprintId, onSaved }: {
</div> </div>
)} )}
</div> </div>
<AttachmentPreview att={previewAtt} onClose={() => setPreviewAtt(null)} />
</Dialog> </Dialog>
); );
} }
+18
View File
@@ -0,0 +1,18 @@
// 附件内容会话级缓存:同一附件一次会话只从服务器读一次(>25MB 不驻留内存)
import { attachmentRead } from './api';
const fullCache = new Map<number, { data_b64: string; mime?: string; file_name: string }>();
const MAX_CACHE_BYTES = 25 * 1024 * 1024;
export async function readFull(a: { id: number; size?: number }): Promise<{ data_b64: string; mime?: string; file_name: string }> {
const hit = fullCache.get(a.id);
if (hit) return hit;
const r = await attachmentRead(a.id);
if ((a.size ?? 0) <= MAX_CACHE_BYTES) fullCache.set(a.id, r);
return r;
}
export function b64ToObjectUrl(dataB64: string, mime?: string): string {
const bytes = Uint8Array.from(atob(dataB64), c => c.charCodeAt(0)) as Uint8Array<ArrayBuffer>;
return URL.createObjectURL(new Blob([bytes], { type: mime || 'application/octet-stream' }));
}
+5
View File
@@ -0,0 +1,5 @@
declare module 'mammoth/mammoth.browser' {
export function convertToHtml(input: { arrayBuffer: ArrayBuffer }): Promise<{ value: string; messages: unknown[] }>;
const _default: { convertToHtml: typeof convertToHtml };
export default _default;
}
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r as g,e as v,j as t,S as j,s as S}from"./index-DrwMmj76.js";import{C as c,a as d,b as x}from"./card-DvRCiB1a.js";import{u as N,i as u,a as m}from"./useChart-BCLvza0E.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.count>0).slice().reverse();return{grid:{left:64,right:24,top:8,bottom:22},tooltip:{trigger:"axis"},xAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-6 py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage}; import{r as g,e as v,j as t,S as j,s as S}from"./index-C2sli-Mk.js";import{C as c,a as d,b as x}from"./card-DL3ec4KP.js";import{u as N,i as u,a as m}from"./useChart-f1LAeREn.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.count>0).slice().reverse();return{grid:{left:64,right:24,top:8,bottom:22},tooltip:{trigger:"axis"},xAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-6 py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage};
@@ -1,4 +1,4 @@
import{c as K,e as I,j as e,E as O,t as $,u as k,b as S,r as u,f as y,v as H,w as W,B as g,P as q,C as Q,x as J,y as T,z as U,A as N,k as G,T as E,I as j,D as Y,F as C,H as B,J as V}from"./index-DrwMmj76.js";import{C as f,a as z,b as _}from"./card-DvRCiB1a.js";import{u as X,i as M,a as L}from"./useChart-BCLvza0E.js";import{u as Z,i as ee,b as se,a as te,c as ae,d as re,e as ne,f as le}from"./echarts-BIWefrIc.js";/** import{c as K,e as I,j as e,E as O,t as $,u as k,b as S,r as u,f as y,v as H,w as W,B as g,P as q,C as Q,x as J,y as T,z as U,A as N,k as G,T as E,I as j,D as Y,F as C,H as B,J as V}from"./index-C2sli-Mk.js";import{C as f,a as z,b as _}from"./card-DL3ec4KP.js";import{u as X,i as M,a as L}from"./useChart-f1LAeREn.js";import{u as Z,i as ee,b as se,a as te,c as ae,d as re,e as ne,f as le}from"./echarts-BIWefrIc.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as re,j as e,a as u,r as t,u as oe,b as ce,d as de,e as C,f as ue,I as xe,S as m,B as b,P as Y,g as pe,L as me,G as ve,R as he,h as je,i as ge,k as fe,T as ye,E as be,l as Ne,m as ke,C as Se,n as we,o as Ce,p as Ee,s as qe,q as _e}from"./index-DrwMmj76.js";/** import{c as re,j as e,a as u,r as t,u as oe,b as ce,d as de,e as C,f as ue,I as xe,S as m,B as b,P as Y,g as pe,L as me,G as ve,R as he,h as je,i as ge,k as fe,T as ye,E as be,l as Ne,m as ke,C as Se,n as we,o as Ce,p as Ee,s as qe,q as _e}from"./index-C2sli-Mk.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as P,u as Q,b as z,e as B,r as i,f as T,j as e,B as p,P as F,K as q,A as E,k as D,T as I,C as K,M as L,D as G,F as r,I as j,N as H,S as R,O as W,o as J}from"./index-DrwMmj76.js";import{C as U}from"./card-DvRCiB1a.js";/** import{c as P,u as Q,b as z,e as B,r as i,f as T,j as e,B as p,P as F,K as q,A as E,k as D,T as I,C as K,M as L,D as G,F as r,I as j,N as H,S as R,O as W,o as J}from"./index-C2sli-Mk.js";import{C as U}from"./card-DL3ec4KP.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1 +1 @@
import{j as s,a}from"./index-DrwMmj76.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b}; import{j as s,a}from"./index-C2sli-Mk.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r}from"./index-DrwMmj76.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u}; import{r}from"./index-C2sli-Mk.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u};
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,8 +8,8 @@
<link rel="icon" href="/icons/pwa-192.png" /> <link rel="icon" href="/icons/pwa-192.png" />
<link rel="apple-touch-icon" href="/icons/pwa-192.png" /> <link rel="apple-touch-icon" href="/icons/pwa-192.png" />
<title>足迹 Footprints</title> <title>足迹 Footprints</title>
<script type="module" crossorigin src="/assets/index-DrwMmj76.js"></script> <script type="module" crossorigin src="/assets/index-C2sli-Mk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-zuv9BCiF.css"> <link rel="stylesheet" crossorigin href="/assets/index-BgM7aYS9.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>