优化页面、修复BUG
This commit is contained in:
@@ -17,6 +17,8 @@
|
|||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"highlight": "^0.2.4",
|
"highlight": "^0.2.4",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"jspdf": "^2.5.2",
|
||||||
"lucide-vue-next": "^0.446.0",
|
"lucide-vue-next": "^0.446.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"md-editor-v3": "^6.3.1",
|
"md-editor-v3": "^6.3.1",
|
||||||
|
|||||||
157
client/pnpm-lock.yaml
generated
157
client/pnpm-lock.yaml
generated
@@ -32,6 +32,12 @@ importers:
|
|||||||
highlight.js:
|
highlight.js:
|
||||||
specifier: ^11.11.1
|
specifier: ^11.11.1
|
||||||
version: 11.11.1
|
version: 11.11.1
|
||||||
|
html2canvas:
|
||||||
|
specifier: ^1.4.1
|
||||||
|
version: 1.4.1
|
||||||
|
jspdf:
|
||||||
|
specifier: ^2.5.2
|
||||||
|
version: 2.5.2
|
||||||
lucide-vue-next:
|
lucide-vue-next:
|
||||||
specifier: ^0.446.0
|
specifier: ^0.446.0
|
||||||
version: 0.446.0(vue@3.5.26(typescript@5.6.3))
|
version: 0.446.0(vue@3.5.26(typescript@5.6.3))
|
||||||
@@ -92,6 +98,10 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.7':
|
||||||
|
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/types@7.28.6':
|
'@babel/types@7.28.6':
|
||||||
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
|
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -570,6 +580,9 @@ packages:
|
|||||||
'@types/mdurl@2.0.0':
|
'@types/mdurl@2.0.0':
|
||||||
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
||||||
|
|
||||||
|
'@types/raf@3.4.3':
|
||||||
|
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
|
||||||
|
|
||||||
'@vavt/copy2clipboard@1.0.3':
|
'@vavt/copy2clipboard@1.0.3':
|
||||||
resolution: {integrity: sha512-HtG48r2FBYp9eRvGB3QGmtRBH1zzRRAVvFbGgFstOwz4/DDaNiX0uZc3YVKPydqgOav26pibr9MtoCaWxn7aeA==}
|
resolution: {integrity: sha512-HtG48r2FBYp9eRvGB3QGmtRBH1zzRRAVvFbGgFstOwz4/DDaNiX0uZc3YVKPydqgOav26pibr9MtoCaWxn7aeA==}
|
||||||
|
|
||||||
@@ -651,6 +664,11 @@ packages:
|
|||||||
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==}
|
||||||
|
|
||||||
|
atob@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==}
|
||||||
|
engines: {node: '>= 4.5.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
autoprefixer@10.4.23:
|
autoprefixer@10.4.23:
|
||||||
resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==}
|
resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
@@ -661,6 +679,10 @@ packages:
|
|||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
|
base64-arraybuffer@1.0.2:
|
||||||
|
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||||
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
baseline-browser-mapping@2.9.14:
|
baseline-browser-mapping@2.9.14:
|
||||||
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
|
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -681,6 +703,11 @@ packages:
|
|||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
btoa@1.2.1:
|
||||||
|
resolution: {integrity: sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==}
|
||||||
|
engines: {node: '>= 0.4.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
camelcase-css@2.0.1:
|
camelcase-css@2.0.1:
|
||||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -688,6 +715,10 @@ packages:
|
|||||||
caniuse-lite@1.0.30001764:
|
caniuse-lite@1.0.30001764:
|
||||||
resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==}
|
resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==}
|
||||||
|
|
||||||
|
canvg@3.0.11:
|
||||||
|
resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||||
engines: {node: '>= 8.10.0'}
|
engines: {node: '>= 8.10.0'}
|
||||||
@@ -702,9 +733,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
core-js@3.49.0:
|
||||||
|
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
|
||||||
|
|
||||||
crelt@1.0.6:
|
crelt@1.0.6:
|
||||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||||
|
|
||||||
|
css-line-break@2.1.0:
|
||||||
|
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
|
||||||
|
|
||||||
cssesc@3.0.0:
|
cssesc@3.0.0:
|
||||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -725,6 +762,9 @@ packages:
|
|||||||
dlv@1.1.3:
|
dlv@1.1.3:
|
||||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||||
|
|
||||||
|
dompurify@2.5.9:
|
||||||
|
resolution: {integrity: sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==}
|
||||||
|
|
||||||
echarts@6.0.0:
|
echarts@6.0.0:
|
||||||
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||||
|
|
||||||
@@ -767,6 +807,9 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
fflate@0.8.3:
|
||||||
|
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -806,6 +849,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-TEcWU6BolpDYIaVD91KmaYe/kRZwOmQlLWZGO8DK+Cs555+7mawk2KUnF/dBwcLnrvlCDk/xC+BXfz7Zva+Jfg==}
|
resolution: {integrity: sha512-TEcWU6BolpDYIaVD91KmaYe/kRZwOmQlLWZGO8DK+Cs555+7mawk2KUnF/dBwcLnrvlCDk/xC+BXfz7Zva+Jfg==}
|
||||||
deprecated: Not maintained anymore
|
deprecated: Not maintained anymore
|
||||||
|
|
||||||
|
html2canvas@1.4.1:
|
||||||
|
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
is-binary-path@2.1.0:
|
is-binary-path@2.1.0:
|
||||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -830,6 +877,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jspdf@2.5.2:
|
||||||
|
resolution: {integrity: sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==}
|
||||||
|
|
||||||
lilconfig@3.1.3:
|
lilconfig@3.1.3:
|
||||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -928,6 +978,9 @@ packages:
|
|||||||
path-parse@1.0.7:
|
path-parse@1.0.7:
|
||||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||||
|
|
||||||
|
performance-now@2.1.0:
|
||||||
|
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -1001,6 +1054,9 @@ packages:
|
|||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
|
raf@3.4.1:
|
||||||
|
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
|
||||||
|
|
||||||
read-cache@1.0.0:
|
read-cache@1.0.0:
|
||||||
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
||||||
|
|
||||||
@@ -1008,6 +1064,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||||
engines: {node: '>=8.10.0'}
|
engines: {node: '>=8.10.0'}
|
||||||
|
|
||||||
|
regenerator-runtime@0.13.11:
|
||||||
|
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||||
|
|
||||||
resolve@1.22.11:
|
resolve@1.22.11:
|
||||||
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1017,6 +1076,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
|
|
||||||
|
rgbcolor@1.0.1:
|
||||||
|
resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
|
||||||
|
engines: {node: '>= 0.8.15'}
|
||||||
|
|
||||||
rollup@4.55.1:
|
rollup@4.55.1:
|
||||||
resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==}
|
resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
@@ -1029,6 +1092,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
stackblur-canvas@2.7.0:
|
||||||
|
resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
|
||||||
|
engines: {node: '>=0.1.14'}
|
||||||
|
|
||||||
style-mod@4.1.3:
|
style-mod@4.1.3:
|
||||||
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
|
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
|
||||||
|
|
||||||
@@ -1041,11 +1108,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
svg-pathdata@6.0.3:
|
||||||
|
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
tailwindcss@3.4.19:
|
tailwindcss@3.4.19:
|
||||||
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
|
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
text-segmentation@1.0.3:
|
||||||
|
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
|
||||||
|
|
||||||
thenify-all@1.6.0:
|
thenify-all@1.6.0:
|
||||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
@@ -1084,6 +1158,9 @@ packages:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
utrie@1.0.2:
|
||||||
|
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||||
|
|
||||||
vite@6.4.1:
|
vite@6.4.1:
|
||||||
resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
|
resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
|
||||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||||
@@ -1175,6 +1252,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.28.6
|
'@babel/types': 7.28.6
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.7': {}
|
||||||
|
|
||||||
'@babel/types@7.28.6':
|
'@babel/types@7.28.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
@@ -1721,6 +1800,9 @@ snapshots:
|
|||||||
|
|
||||||
'@types/mdurl@2.0.0': {}
|
'@types/mdurl@2.0.0': {}
|
||||||
|
|
||||||
|
'@types/raf@3.4.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@vavt/copy2clipboard@1.0.3': {}
|
'@vavt/copy2clipboard@1.0.3': {}
|
||||||
|
|
||||||
'@vavt/util@2.1.1': {}
|
'@vavt/util@2.1.1': {}
|
||||||
@@ -1829,6 +1911,8 @@ snapshots:
|
|||||||
|
|
||||||
argparse@2.0.1: {}
|
argparse@2.0.1: {}
|
||||||
|
|
||||||
|
atob@2.1.2: {}
|
||||||
|
|
||||||
autoprefixer@10.4.23(postcss@8.5.6):
|
autoprefixer@10.4.23(postcss@8.5.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.28.1
|
browserslist: 4.28.1
|
||||||
@@ -1840,6 +1924,8 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
|
base64-arraybuffer@1.0.2: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.9.14: {}
|
baseline-browser-mapping@2.9.14: {}
|
||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
@@ -1860,10 +1946,24 @@ snapshots:
|
|||||||
node-releases: 2.0.27
|
node-releases: 2.0.27
|
||||||
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
||||||
|
|
||||||
|
btoa@1.2.1: {}
|
||||||
|
|
||||||
camelcase-css@2.0.1: {}
|
camelcase-css@2.0.1: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001764: {}
|
caniuse-lite@1.0.30001764: {}
|
||||||
|
|
||||||
|
canvg@3.0.11:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.7
|
||||||
|
'@types/raf': 3.4.3
|
||||||
|
core-js: 3.49.0
|
||||||
|
raf: 3.4.1
|
||||||
|
regenerator-runtime: 0.13.11
|
||||||
|
rgbcolor: 1.0.1
|
||||||
|
stackblur-canvas: 2.7.0
|
||||||
|
svg-pathdata: 6.0.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
anymatch: 3.1.3
|
anymatch: 3.1.3
|
||||||
@@ -1890,8 +1990,15 @@ snapshots:
|
|||||||
|
|
||||||
commander@4.1.1: {}
|
commander@4.1.1: {}
|
||||||
|
|
||||||
|
core-js@3.49.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
crelt@1.0.6: {}
|
crelt@1.0.6: {}
|
||||||
|
|
||||||
|
css-line-break@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
utrie: 1.0.2
|
||||||
|
|
||||||
cssesc@3.0.0: {}
|
cssesc@3.0.0: {}
|
||||||
|
|
||||||
cssfilter@0.0.10: {}
|
cssfilter@0.0.10: {}
|
||||||
@@ -1904,6 +2011,9 @@ snapshots:
|
|||||||
|
|
||||||
dlv@1.1.3: {}
|
dlv@1.1.3: {}
|
||||||
|
|
||||||
|
dompurify@2.5.9:
|
||||||
|
optional: true
|
||||||
|
|
||||||
echarts@6.0.0:
|
echarts@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.3.0
|
tslib: 2.3.0
|
||||||
@@ -1964,6 +2074,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
fflate@0.8.3: {}
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
@@ -1993,6 +2105,11 @@ snapshots:
|
|||||||
|
|
||||||
highlight@0.2.4: {}
|
highlight@0.2.4: {}
|
||||||
|
|
||||||
|
html2canvas@1.4.1:
|
||||||
|
dependencies:
|
||||||
|
css-line-break: 2.1.0
|
||||||
|
text-segmentation: 1.0.3
|
||||||
|
|
||||||
is-binary-path@2.1.0:
|
is-binary-path@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
binary-extensions: 2.3.0
|
binary-extensions: 2.3.0
|
||||||
@@ -2011,6 +2128,18 @@ snapshots:
|
|||||||
|
|
||||||
jiti@1.21.7: {}
|
jiti@1.21.7: {}
|
||||||
|
|
||||||
|
jspdf@2.5.2:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.7
|
||||||
|
atob: 2.1.2
|
||||||
|
btoa: 1.2.1
|
||||||
|
fflate: 0.8.3
|
||||||
|
optionalDependencies:
|
||||||
|
canvg: 3.0.11
|
||||||
|
core-js: 3.49.0
|
||||||
|
dompurify: 2.5.9
|
||||||
|
html2canvas: 1.4.1
|
||||||
|
|
||||||
lilconfig@3.1.3: {}
|
lilconfig@3.1.3: {}
|
||||||
|
|
||||||
lines-and-columns@1.2.4: {}
|
lines-and-columns@1.2.4: {}
|
||||||
@@ -2112,6 +2241,9 @@ snapshots:
|
|||||||
|
|
||||||
path-parse@1.0.7: {}
|
path-parse@1.0.7: {}
|
||||||
|
|
||||||
|
performance-now@2.1.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.1: {}
|
||||||
@@ -2163,6 +2295,11 @@ snapshots:
|
|||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
|
raf@3.4.1:
|
||||||
|
dependencies:
|
||||||
|
performance-now: 2.1.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
read-cache@1.0.0:
|
read-cache@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
pify: 2.3.0
|
pify: 2.3.0
|
||||||
@@ -2171,6 +2308,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
|
|
||||||
|
regenerator-runtime@0.13.11:
|
||||||
|
optional: true
|
||||||
|
|
||||||
resolve@1.22.11:
|
resolve@1.22.11:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
@@ -2179,6 +2319,9 @@ snapshots:
|
|||||||
|
|
||||||
reusify@1.1.0: {}
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
|
rgbcolor@1.0.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
rollup@4.55.1:
|
rollup@4.55.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
@@ -2216,6 +2359,9 @@ snapshots:
|
|||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
stackblur-canvas@2.7.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
style-mod@4.1.3: {}
|
style-mod@4.1.3: {}
|
||||||
|
|
||||||
sucrase@3.35.1:
|
sucrase@3.35.1:
|
||||||
@@ -2230,6 +2376,9 @@ snapshots:
|
|||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
svg-pathdata@6.0.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
tailwindcss@3.4.19:
|
tailwindcss@3.4.19:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@alloc/quick-lru': 5.2.0
|
'@alloc/quick-lru': 5.2.0
|
||||||
@@ -2258,6 +2407,10 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
|
text-segmentation@1.0.3:
|
||||||
|
dependencies:
|
||||||
|
utrie: 1.0.2
|
||||||
|
|
||||||
thenify-all@1.6.0:
|
thenify-all@1.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
thenify: 3.3.1
|
thenify: 3.3.1
|
||||||
@@ -2291,6 +2444,10 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
|
utrie@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
base64-arraybuffer: 1.0.2
|
||||||
|
|
||||||
vite@6.4.1(jiti@1.21.7):
|
vite@6.4.1(jiti@1.21.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
|
|||||||
190
client/src/components/ArticleExportMenu.vue
Normal file
190
client/src/components/ArticleExportMenu.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文章导出下拉菜单
|
||||||
|
* 支持 Markdown / 纯文本 / PDF / PNG 四种格式,PDF 与 PNG 可选深浅色主题
|
||||||
|
*/
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
|
import type { Post } from '../services/api'
|
||||||
|
import { exportArticle, type ExportFormat, type ExportTheme } from '../utils/articleExport'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 文章完整数据 */
|
||||||
|
post: Post
|
||||||
|
/** 已渲染的正文 HTML */
|
||||||
|
renderedHtml: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const isExporting = ref(false)
|
||||||
|
/** 当前展开二级菜单的格式(pdf / png) */
|
||||||
|
const expandedFormat = ref<ExportFormat | null>(null)
|
||||||
|
const menuRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
/** 导出格式配置 */
|
||||||
|
const formatOptions: Array<{
|
||||||
|
format: ExportFormat
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
needsTheme: boolean
|
||||||
|
description: string
|
||||||
|
}> = [
|
||||||
|
{ format: 'md', label: 'Markdown', icon: 'file-text', needsTheme: false, description: '含元信息的 .md 文件' },
|
||||||
|
{ format: 'txt', label: '纯文本', icon: 'file-type', needsTheme: false, description: '去除 Markdown 语法' },
|
||||||
|
{ format: 'pdf', label: 'PDF', icon: 'file-down', needsTheme: true, description: '分页 PDF 文档' },
|
||||||
|
{ format: 'png', label: 'PNG 图片', icon: 'image', needsTheme: true, description: '完整文章长图' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 主题选项 */
|
||||||
|
const themeOptions: Array<{ theme: ExportTheme; label: string }> = [
|
||||||
|
{ theme: 'light', label: '浅色风格' },
|
||||||
|
{ theme: 'dark', label: '深色风格' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 切换主菜单显示 */
|
||||||
|
const toggleMenu = () => {
|
||||||
|
if (isExporting.value) return
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
if (!isOpen.value) expandedFormat.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击外部关闭菜单 */
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
|
||||||
|
isOpen.value = false
|
||||||
|
expandedFormat.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行导出
|
||||||
|
* @param format 导出格式
|
||||||
|
* @param theme 视觉主题(PDF/PNG 使用)
|
||||||
|
*/
|
||||||
|
const handleExport = async (format: ExportFormat, theme: ExportTheme = 'light') => {
|
||||||
|
if (isExporting.value) return
|
||||||
|
|
||||||
|
const formatLabel = formatOptions.find((o) => o.format === format)?.label || format
|
||||||
|
isExporting.value = true
|
||||||
|
isOpen.value = false
|
||||||
|
expandedFormat.value = null
|
||||||
|
|
||||||
|
toast.info(`正在生成 ${formatLabel}…`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await exportArticle(format, props.post, props.renderedHtml, theme)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('导出成功')
|
||||||
|
if (result.warning) toast.warning(result.warning)
|
||||||
|
} else {
|
||||||
|
toast.error(result.warning || '导出失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Export failed:', err)
|
||||||
|
toast.error(err instanceof Error ? err.message : '导出失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
isExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击需要主题的格式项,展开二级选项 */
|
||||||
|
const handleFormatClick = (format: ExportFormat, needsTheme: boolean) => {
|
||||||
|
if (needsTheme) {
|
||||||
|
expandedFormat.value = expandedFormat.value === format ? null : format
|
||||||
|
} else {
|
||||||
|
void handleExport(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||||
|
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="menuRef" class="relative inline-block">
|
||||||
|
<!-- 主导出按钮 -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="isExporting"
|
||||||
|
class="flex items-center gap-2 rounded-full border border-white/10 bg-white/5 backdrop-blur-md px-3 py-1.5 md:px-4 md:py-2 text-xs md:text-sm text-art-muted hover:text-white hover:bg-white/10 hover:border-art-accent/30 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
@click.stop="toggleMenu"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="isExporting ? 'loader-2' : 'download'"
|
||||||
|
:size="14"
|
||||||
|
:class="isExporting ? 'animate-spin' : ''"
|
||||||
|
/>
|
||||||
|
<span class="font-medium">{{ isExporting ? '导出中…' : '导出' }}</span>
|
||||||
|
<Icon v-if="!isExporting" name="chevron-down" :size="12" class="opacity-60" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 下拉菜单 -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-150 ease-out"
|
||||||
|
enter-from-class="opacity-0 scale-95 -translate-y-1"
|
||||||
|
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||||
|
leave-active-class="transition duration-100 ease-in"
|
||||||
|
leave-from-class="opacity-100 scale-100"
|
||||||
|
leave-to-class="opacity-0 scale-95 -translate-y-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="absolute right-0 top-full mt-2 w-56 rounded-xl border border-white/10 bg-[#141414]/95 backdrop-blur-xl shadow-2xl z-50 overflow-hidden"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="px-3 py-2 border-b border-white/5">
|
||||||
|
<span class="text-[10px] font-bold text-white/40 uppercase tracking-wider">选择导出格式</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="py-1">
|
||||||
|
<li v-for="option in formatOptions" :key="option.format">
|
||||||
|
<!-- 格式主项 -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-3 px-3 py-2.5 text-left text-sm text-art-muted hover:text-white hover:bg-white/5 transition-colors"
|
||||||
|
@click="handleFormatClick(option.format, option.needsTheme)"
|
||||||
|
>
|
||||||
|
<Icon :name="option.icon" :size="16" class="text-art-accent shrink-0" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-white/90">{{ option.label }}</div>
|
||||||
|
<div class="text-[10px] text-white/40 truncate">{{ option.description }}</div>
|
||||||
|
</div>
|
||||||
|
<Icon
|
||||||
|
v-if="option.needsTheme"
|
||||||
|
:name="expandedFormat === option.format ? 'chevron-up' : 'chevron-right'"
|
||||||
|
:size="14"
|
||||||
|
class="opacity-40 shrink-0"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- PDF / PNG 二级主题选项 -->
|
||||||
|
<div
|
||||||
|
v-if="option.needsTheme && expandedFormat === option.format"
|
||||||
|
class="bg-white/[0.02] border-t border-white/5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="themeOpt in themeOptions"
|
||||||
|
:key="themeOpt.theme"
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-2 pl-10 pr-3 py-2 text-xs text-art-muted hover:text-white hover:bg-white/5 transition-colors"
|
||||||
|
@click="handleExport(option.format, themeOpt.theme)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="w-3 h-3 rounded-full border shrink-0"
|
||||||
|
:class="themeOpt.theme === 'light'
|
||||||
|
? 'bg-white border-gray-300'
|
||||||
|
: 'bg-[#050505] border-white/30'"
|
||||||
|
></span>
|
||||||
|
{{ themeOpt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -165,9 +165,12 @@
|
|||||||
<div class="flex-1 min-w-0 max-w-full order-2">
|
<div class="flex-1 min-w-0 max-w-full order-2">
|
||||||
<!-- 文章头图与信息 -->
|
<!-- 文章头图与信息 -->
|
||||||
<div class="border-b border-white/5 pb-6 md:pb-8 mb-6 md:mb-10">
|
<div class="border-b border-white/5 pb-6 md:pb-8 mb-6 md:mb-10">
|
||||||
<div class="flex flex-wrap items-center gap-2 md:gap-3 mb-4 md:mb-6">
|
<div class="flex flex-wrap items-center justify-between gap-2 md:gap-3 mb-4 md:mb-6">
|
||||||
<span class="px-2 py-0.5 md:px-3 md:py-1 border border-white/20 rounded-full text-[10px] md:text-xs font-mono text-art-accent uppercase tracking-wider">{{ post.categoryName || '未分类' }}</span>
|
<div class="flex flex-wrap items-center gap-2 md:gap-3">
|
||||||
<span class="text-xs md:text-sm text-art-muted">{{ post.date }}</span>
|
<span class="px-2 py-0.5 md:px-3 md:py-1 border border-white/20 rounded-full text-[10px] md:text-xs font-mono text-art-accent uppercase tracking-wider">{{ post.categoryName || '未分类' }}</span>
|
||||||
|
<span class="text-xs md:text-sm text-art-muted">{{ post.date }}</span>
|
||||||
|
</div>
|
||||||
|
<ArticleExportMenu :post="post" :rendered-html="renderedContent" />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="font-serif text-2xl md:text-5xl lg:text-6xl text-white leading-snug md:leading-tight mb-6 md:mb-8 break-words">{{ post.title }}</h1>
|
<h1 class="font-serif text-2xl md:text-5xl lg:text-6xl text-white leading-snug md:leading-tight mb-6 md:mb-8 break-words">{{ post.title }}</h1>
|
||||||
<AuthorInfo
|
<AuthorInfo
|
||||||
@@ -405,6 +408,7 @@ import { renderMarkdown, preloadMarkdownRenderer } from '../utils/markdownRender
|
|||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
import AuthorInfo from '../components/AuthorInfo.vue'
|
import AuthorInfo from '../components/AuthorInfo.vue'
|
||||||
import SnippetModal from '../components/SnippetModal.vue'
|
import SnippetModal from '../components/SnippetModal.vue'
|
||||||
|
import ArticleExportMenu from '../components/ArticleExportMenu.vue'
|
||||||
import 'highlight.js/styles/atom-one-dark.css'
|
import 'highlight.js/styles/atom-one-dark.css'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
<h2 class="font-serif text-5xl text-white group-hover:italic transition-all">返回作品列表</h2>
|
<h2 class="font-serif text-5xl text-white group-hover:italic transition-all">返回作品列表</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer class="py-8 text-center border-t border-white/5 relative z-10 bg-[#050505]"><p class="text-art-muted text-xs font-mono tracking-widest opacity-50">© 2024 年糕崽崽. 保留所有权利.</p></footer>
|
<!-- <footer class="py-8 text-center border-t border-white/5 relative z-10 bg-[#050505]"><p class="text-art-muted text-xs font-mono tracking-widest opacity-50">© 2024 年糕崽崽. 保留所有权利.</p></footer>-->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
252
client/src/styles/articleExport.css
Normal file
252
client/src/styles/articleExport.css
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* 文章离屏导出样式(PDF / PNG 共用)
|
||||||
|
* 支持浅色打印风格与深色页面风格
|
||||||
|
*/
|
||||||
|
|
||||||
|
.article-export-root {
|
||||||
|
width: 800px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: 'Noto Sans SC', 'Inter', system-ui, sans-serif;
|
||||||
|
line-height: 1.75;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 浅色导出主题 ---------- */
|
||||||
|
.export-theme-light {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-header {
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-meta {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-category {
|
||||||
|
color: #92400e;
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-title {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-body h1,
|
||||||
|
.export-theme-light .export-body h2,
|
||||||
|
.export-theme-light .export-body h3,
|
||||||
|
.export-theme-light .export-body h4 {
|
||||||
|
color: #111827;
|
||||||
|
font-family: 'Playfair Display', Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-body h2 {
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
padding-bottom: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-body a {
|
||||||
|
color: #b45309;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-body blockquote {
|
||||||
|
border-left: 3px solid #d4b383;
|
||||||
|
background: #f9fafb;
|
||||||
|
color: #4b5563;
|
||||||
|
padding: 0.8rem 1.2rem;
|
||||||
|
border-radius: 0 0.5rem 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-body :not(pre) > code {
|
||||||
|
color: #92400e;
|
||||||
|
background: #fef3c7;
|
||||||
|
padding: 0.15em 0.35em;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-body .ios-code-container {
|
||||||
|
background: #f3f4f6 !important;
|
||||||
|
border: 1px solid #e5e7eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-body .mac-window-header {
|
||||||
|
background: #e5e7eb !important;
|
||||||
|
border-bottom: 1px solid #d1d5db !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-body .line-numbers-wrapper,
|
||||||
|
.export-theme-light .export-body .code-block-wrapper {
|
||||||
|
background: #f9fafb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-body .line-numbers-wrapper {
|
||||||
|
border-right: 1px solid #e5e7eb !important;
|
||||||
|
color: #9ca3af !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-body img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-body table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-light .export-body th,
|
||||||
|
.export-theme-light .export-body td {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 深色导出主题 ---------- */
|
||||||
|
.export-theme-dark {
|
||||||
|
background: #050505;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-header {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-bottom: 24px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-meta {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-category {
|
||||||
|
color: #d4b383;
|
||||||
|
background: rgba(212, 179, 131, 0.1);
|
||||||
|
border: 1px solid rgba(212, 179, 131, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-title {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-body h1,
|
||||||
|
.export-theme-dark .export-body h2,
|
||||||
|
.export-theme-dark .export-body h3,
|
||||||
|
.export-theme-dark .export-body h4 {
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: 'Playfair Display', Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-body h2 {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-bottom: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-body h3 {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-body a {
|
||||||
|
color: #d4b383;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-body blockquote {
|
||||||
|
border-left: 3px solid #d4b383;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
padding: 0.8rem 1.2rem;
|
||||||
|
border-radius: 0 0.5rem 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-body :not(pre) > code {
|
||||||
|
color: #d4b383;
|
||||||
|
background: rgba(212, 179, 131, 0.1);
|
||||||
|
padding: 0.15em 0.35em;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-body .ios-code-container {
|
||||||
|
background: #1e1e1e !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-body .mac-window-header {
|
||||||
|
background: #282828 !important;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-body .line-numbers-wrapper,
|
||||||
|
.export-theme-dark .export-body .code-block-wrapper {
|
||||||
|
background: #1e1e1e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-body .line-numbers-wrapper {
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.05) !important;
|
||||||
|
color: rgba(255, 255, 255, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-body img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-body table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-theme-dark .export-body th,
|
||||||
|
.export-theme-dark .export-body td {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 公共结构 ---------- */
|
||||||
|
.export-header-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-category {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-author {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-body {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-body h1 { font-size: 1.75rem; margin-top: 1.5em; margin-bottom: 0.6em; }
|
||||||
|
.export-body h2 { font-size: 1.5rem; margin-top: 1.5em; margin-bottom: 0.6em; }
|
||||||
|
.export-body h3 { font-size: 1.25rem; margin-top: 1.25em; margin-bottom: 0.5em; }
|
||||||
|
.export-body p { margin: 0.75em 0; }
|
||||||
|
.export-body ul, .export-body ol { padding-left: 1.5em; margin: 0.75em 0; }
|
||||||
|
.export-body pre { margin: 0; overflow: visible; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.export-body img { display: block; margin: 1em auto; }
|
||||||
310
client/src/utils/articleExport.ts
Normal file
310
client/src/utils/articleExport.ts
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* 文章多格式导出工具
|
||||||
|
* 支持 Markdown、纯文本、PDF、PNG 四种格式
|
||||||
|
*/
|
||||||
|
import html2canvas from 'html2canvas'
|
||||||
|
import { jsPDF } from 'jspdf'
|
||||||
|
import type { Post } from '../services/api'
|
||||||
|
import { buildExportBasename, downloadBlob, downloadText } from './fileDownload'
|
||||||
|
import '../styles/articleExport.css'
|
||||||
|
|
||||||
|
/** 导出视觉主题:浅色适合打印,深色与页面一致 */
|
||||||
|
export type ExportTheme = 'light' | 'dark'
|
||||||
|
|
||||||
|
/** 支持的导出格式 */
|
||||||
|
export type ExportFormat = 'md' | 'txt' | 'pdf' | 'png'
|
||||||
|
|
||||||
|
/** 导出操作结果 */
|
||||||
|
export interface ExportResult {
|
||||||
|
success: boolean
|
||||||
|
warning?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPORT_CONTAINER_WIDTH = 800
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 Markdown frontmatter 元信息块
|
||||||
|
* @param post 文章数据
|
||||||
|
*/
|
||||||
|
function buildMarkdownFrontmatter(post: Post): string {
|
||||||
|
const tags = post.tags?.map((t) => t.name).join(', ') || ''
|
||||||
|
const lines = [
|
||||||
|
'---',
|
||||||
|
`title: ${JSON.stringify(post.title)}`,
|
||||||
|
`date: ${post.date || ''}`,
|
||||||
|
`category: ${JSON.stringify(post.categoryName || '未分类')}`,
|
||||||
|
]
|
||||||
|
if (tags) lines.push(`tags: [${post.tags?.map((t) => JSON.stringify(t.name)).join(', ')}]`)
|
||||||
|
if (post.userName) lines.push(`author: ${JSON.stringify(post.userName)}`)
|
||||||
|
lines.push('---', '')
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Markdown 转为可读纯文本(轻量剥离语法)
|
||||||
|
* @param markdown 原始 Markdown 内容
|
||||||
|
*/
|
||||||
|
function markdownToPlainText(markdown: string): string {
|
||||||
|
let text = markdown
|
||||||
|
// 代码块:保留内容,去掉围栏
|
||||||
|
text = text.replace(/```[\s\S]*?```/g, (block) =>
|
||||||
|
block.replace(/^```[\w-]*\n?/, '').replace(/```$/, '').trim()
|
||||||
|
)
|
||||||
|
// 行内代码
|
||||||
|
text = text.replace(/`([^`]+)`/g, '$1')
|
||||||
|
// 图片:保留 alt 文本
|
||||||
|
text = text.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
|
||||||
|
// 链接:保留文字
|
||||||
|
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
// 标题前缀
|
||||||
|
text = text.replace(/^#{1,6}\s+/gm, '')
|
||||||
|
// 粗体/斜体
|
||||||
|
text = text.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
||||||
|
text = text.replace(/(\*|_)(.*?)\1/g, '$2')
|
||||||
|
// 引用
|
||||||
|
text = text.replace(/^>\s+/gm, '')
|
||||||
|
// 无序列表
|
||||||
|
text = text.replace(/^[-*+]\s+/gm, '• ')
|
||||||
|
// HTML 标签
|
||||||
|
text = text.replace(/<[^>]+>/g, '')
|
||||||
|
// 多余空行
|
||||||
|
text = text.replace(/\n{3,}/g, '\n\n')
|
||||||
|
return text.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建纯文本元信息头部
|
||||||
|
* @param post 文章数据
|
||||||
|
*/
|
||||||
|
function buildTextHeader(post: Post): string {
|
||||||
|
const lines = [
|
||||||
|
post.title,
|
||||||
|
'='.repeat(Math.min(post.title.length, 40)),
|
||||||
|
`日期:${post.date || '未知'}`,
|
||||||
|
`分类:${post.categoryName || '未分类'}`,
|
||||||
|
]
|
||||||
|
if (post.userName) lines.push(`作者:${post.userName}`)
|
||||||
|
if (post.tags?.length) lines.push(`标签:${post.tags.map((t) => t.name).join('、')}`)
|
||||||
|
lines.push('', '---', '')
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建离屏导出 DOM 容器(PDF/PNG 共用)
|
||||||
|
* @param post 文章数据
|
||||||
|
* @param renderedHtml 已渲染的正文 HTML
|
||||||
|
* @param theme 导出主题
|
||||||
|
*/
|
||||||
|
function buildExportContainer(post: Post, renderedHtml: string, theme: ExportTheme): HTMLDivElement {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.className = `article-export-root export-theme-${theme}`
|
||||||
|
container.style.position = 'fixed'
|
||||||
|
container.style.left = '-9999px'
|
||||||
|
container.style.top = '0'
|
||||||
|
container.style.width = `${EXPORT_CONTAINER_WIDTH}px`
|
||||||
|
container.style.zIndex = '-1'
|
||||||
|
|
||||||
|
const tagNames = post.tags?.map((t) => t.name).join(' · ') || ''
|
||||||
|
const metaParts = [post.date, post.categoryName || '未分类', tagNames].filter(Boolean)
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="export-header">
|
||||||
|
<div class="export-header-meta">
|
||||||
|
<span class="export-category">${escapeHtml(post.categoryName || '未分类')}</span>
|
||||||
|
<span class="export-meta">${escapeHtml(metaParts.join(' · '))}</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="export-title">${escapeHtml(post.title)}</h1>
|
||||||
|
${post.userName ? `<div class="export-meta export-author">作者:${escapeHtml(post.userName)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="export-body">${renderedHtml}</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
// 移除交互元素,避免截图出现按钮
|
||||||
|
container.querySelectorAll('.copy-btn').forEach((el) => el.remove())
|
||||||
|
|
||||||
|
document.body.appendChild(container)
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 特殊字符转义(用于拼接模板字符串)
|
||||||
|
* @param str 原始字符串
|
||||||
|
*/
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 html2canvas 将 DOM 转为 Canvas
|
||||||
|
* @param element 待截图元素
|
||||||
|
*/
|
||||||
|
async function domToCanvas(element: HTMLElement): Promise<{ canvas: HTMLCanvasElement; hasImageWarning: boolean }> {
|
||||||
|
let hasImageWarning = false
|
||||||
|
|
||||||
|
const canvas = await html2canvas(element, {
|
||||||
|
useCORS: true,
|
||||||
|
allowTaint: true,
|
||||||
|
scale: 2,
|
||||||
|
backgroundColor: null,
|
||||||
|
logging: false,
|
||||||
|
scrollY: -window.scrollY,
|
||||||
|
scrollX: -window.scrollX,
|
||||||
|
onclone: (_doc, clonedEl) => {
|
||||||
|
// 检测跨域图片是否可能加载失败
|
||||||
|
const imgs = clonedEl.querySelectorAll('img')
|
||||||
|
imgs.forEach((img) => {
|
||||||
|
if (img.src && !img.src.startsWith(window.location.origin) && !img.complete) {
|
||||||
|
hasImageWarning = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { canvas, hasImageWarning }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Canvas 保存为 PNG 文件
|
||||||
|
* @param canvas 画布
|
||||||
|
* @param filename 文件名
|
||||||
|
*/
|
||||||
|
function canvasToPng(canvas: HTMLCanvasElement, filename: string): void {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) downloadBlob(blob, filename)
|
||||||
|
}, 'image/png')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Canvas 分页写入 PDF 并下载
|
||||||
|
* @param canvas 画布
|
||||||
|
* @param filename 文件名
|
||||||
|
*/
|
||||||
|
function canvasToPdf(canvas: HTMLCanvasElement, filename: string): void {
|
||||||
|
const pdf = new jsPDF('p', 'mm', 'a4')
|
||||||
|
const pageWidth = pdf.internal.pageSize.getWidth()
|
||||||
|
const pageHeight = pdf.internal.pageSize.getHeight()
|
||||||
|
|
||||||
|
const imgData = canvas.toDataURL('image/png')
|
||||||
|
const imgWidth = pageWidth
|
||||||
|
const imgHeight = (canvas.height * imgWidth) / canvas.width
|
||||||
|
|
||||||
|
let heightLeft = imgHeight
|
||||||
|
let position = 0
|
||||||
|
|
||||||
|
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
|
||||||
|
heightLeft -= pageHeight
|
||||||
|
|
||||||
|
while (heightLeft > 0) {
|
||||||
|
position -= pageHeight
|
||||||
|
pdf.addPage()
|
||||||
|
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
|
||||||
|
heightLeft -= pageHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.save(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出文章为 Markdown 文件
|
||||||
|
* @param post 文章数据
|
||||||
|
*/
|
||||||
|
export function exportAsMarkdown(post: Post): ExportResult {
|
||||||
|
const content = post.content || ''
|
||||||
|
const frontmatter = buildMarkdownFrontmatter(post)
|
||||||
|
const basename = buildExportBasename(post.title, post.date)
|
||||||
|
downloadText(frontmatter + content, `${basename}.md`, 'text/markdown;charset=utf-8')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出文章为纯文本文件
|
||||||
|
* @param post 文章数据
|
||||||
|
*/
|
||||||
|
export function exportAsTxt(post: Post): ExportResult {
|
||||||
|
const header = buildTextHeader(post)
|
||||||
|
const body = markdownToPlainText(post.content || '')
|
||||||
|
const basename = buildExportBasename(post.title, post.date)
|
||||||
|
downloadText(header + body, `${basename}.txt`, 'text/plain;charset=utf-8')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出文章为 PNG 长图
|
||||||
|
* @param post 文章数据
|
||||||
|
* @param renderedHtml 已渲染正文 HTML
|
||||||
|
* @param theme 导出主题
|
||||||
|
*/
|
||||||
|
export async function exportAsPng(
|
||||||
|
post: Post,
|
||||||
|
renderedHtml: string,
|
||||||
|
theme: ExportTheme
|
||||||
|
): Promise<ExportResult> {
|
||||||
|
const container = buildExportContainer(post, renderedHtml, theme)
|
||||||
|
try {
|
||||||
|
const { canvas, hasImageWarning } = await domToCanvas(container)
|
||||||
|
const basename = buildExportBasename(post.title, post.date)
|
||||||
|
canvasToPng(canvas, `${basename}.png`)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
warning: hasImageWarning ? '部分图片可能因跨域限制未能导出' : undefined,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
container.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出文章为 PDF 文件
|
||||||
|
* @param post 文章数据
|
||||||
|
* @param renderedHtml 已渲染正文 HTML
|
||||||
|
* @param theme 导出主题
|
||||||
|
*/
|
||||||
|
export async function exportAsPdf(
|
||||||
|
post: Post,
|
||||||
|
renderedHtml: string,
|
||||||
|
theme: ExportTheme
|
||||||
|
): Promise<ExportResult> {
|
||||||
|
const container = buildExportContainer(post, renderedHtml, theme)
|
||||||
|
try {
|
||||||
|
const { canvas, hasImageWarning } = await domToCanvas(container)
|
||||||
|
const basename = buildExportBasename(post.title, post.date)
|
||||||
|
canvasToPdf(canvas, `${basename}.pdf`)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
warning: hasImageWarning ? '部分图片可能因跨域限制未能导出' : undefined,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
container.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一导出入口
|
||||||
|
* @param format 导出格式
|
||||||
|
* @param post 文章数据
|
||||||
|
* @param renderedHtml 已渲染正文 HTML(PDF/PNG 需要)
|
||||||
|
* @param theme 导出主题(PDF/PNG 需要)
|
||||||
|
*/
|
||||||
|
export async function exportArticle(
|
||||||
|
format: ExportFormat,
|
||||||
|
post: Post,
|
||||||
|
renderedHtml: string,
|
||||||
|
theme: ExportTheme = 'light'
|
||||||
|
): Promise<ExportResult> {
|
||||||
|
switch (format) {
|
||||||
|
case 'md':
|
||||||
|
return exportAsMarkdown(post)
|
||||||
|
case 'txt':
|
||||||
|
return exportAsTxt(post)
|
||||||
|
case 'png':
|
||||||
|
return exportAsPng(post, renderedHtml, theme)
|
||||||
|
case 'pdf':
|
||||||
|
return exportAsPdf(post, renderedHtml, theme)
|
||||||
|
default:
|
||||||
|
return { success: false, warning: '不支持的导出格式' }
|
||||||
|
}
|
||||||
|
}
|
||||||
56
client/src/utils/fileDownload.ts
Normal file
56
client/src/utils/fileDownload.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* 通用文件下载工具
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理文件名,去除 Windows/Unix 非法字符并限制长度
|
||||||
|
* @param title 原始标题
|
||||||
|
* @param maxLength 最大字符数,默认 80
|
||||||
|
*/
|
||||||
|
export function sanitizeFilename(title: string, maxLength = 80): string {
|
||||||
|
const cleaned = title
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, maxLength)
|
||||||
|
return cleaned || 'untitled'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据标题和日期生成导出文件名(不含扩展名)
|
||||||
|
* @param title 文章标题
|
||||||
|
* @param date 发布日期字符串
|
||||||
|
*/
|
||||||
|
export function buildExportBasename(title: string, date?: string): string {
|
||||||
|
const safeTitle = sanitizeFilename(title)
|
||||||
|
const safeDate = date ? date.replace(/[^\d-]/g, '').slice(0, 10) : ''
|
||||||
|
return safeDate ? `${safeTitle}-${safeDate}` : safeTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发浏览器下载 Blob 文件
|
||||||
|
* @param blob 文件内容
|
||||||
|
* @param filename 完整文件名(含扩展名)
|
||||||
|
*/
|
||||||
|
export function downloadBlob(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
link.style.display = 'none'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文本内容下载为指定扩展名的文件
|
||||||
|
* @param content 文本内容
|
||||||
|
* @param filename 完整文件名
|
||||||
|
* @param mimeType MIME 类型,默认 text/plain
|
||||||
|
*/
|
||||||
|
export function downloadText(content: string, filename: string, mimeType = 'text/plain;charset=utf-8'): void {
|
||||||
|
const blob = new Blob([content], { type: mimeType })
|
||||||
|
downloadBlob(blob, filename)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user