diff --git a/client/package.json b/client/package.json index 9a24999..c8f52bd 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,8 @@ "echarts": "^6.0.0", "highlight": "^0.2.4", "highlight.js": "^11.11.1", + "html2canvas": "^1.4.1", + "jspdf": "^2.5.2", "lucide-vue-next": "^0.446.0", "markdown-it": "^14.1.0", "md-editor-v3": "^6.3.1", diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 2f463ff..fba3176 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -32,6 +32,12 @@ importers: highlight.js: specifier: ^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: specifier: ^0.446.0 version: 0.446.0(vue@3.5.26(typescript@5.6.3)) @@ -92,6 +98,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.28.6': resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} @@ -570,6 +580,9 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + '@vavt/copy2clipboard@1.0.3': resolution: {integrity: sha512-HtG48r2FBYp9eRvGB3QGmtRBH1zzRRAVvFbGgFstOwz4/DDaNiX0uZc3YVKPydqgOav26pibr9MtoCaWxn7aeA==} @@ -651,6 +664,11 @@ packages: argparse@2.0.1: 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: resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==} engines: {node: ^10 || ^12 || >=14} @@ -661,6 +679,10 @@ packages: balanced-match@1.0.2: 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: resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==} hasBin: true @@ -681,6 +703,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} 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: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} @@ -688,6 +715,10 @@ packages: caniuse-lite@1.0.30001764: 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: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -702,9 +733,15 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -725,6 +762,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dompurify@2.5.9: + resolution: {integrity: sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==} + echarts@6.0.0: resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==} @@ -767,6 +807,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -806,6 +849,10 @@ packages: resolution: {integrity: sha512-TEcWU6BolpDYIaVD91KmaYe/kRZwOmQlLWZGO8DK+Cs555+7mawk2KUnF/dBwcLnrvlCDk/xC+BXfz7Zva+Jfg==} deprecated: Not maintained anymore + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -830,6 +877,9 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jspdf@2.5.2: + resolution: {integrity: sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -928,6 +978,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1001,6 +1054,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} @@ -1008,6 +1064,9 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -1017,6 +1076,10 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} 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: resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1029,6 +1092,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 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: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} @@ -1041,11 +1108,18 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} 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: resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} hasBin: true + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -1084,6 +1158,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + vite@6.4.1: resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1175,6 +1252,8 @@ snapshots: dependencies: '@babel/types': 7.28.6 + '@babel/runtime@7.29.7': {} + '@babel/types@7.28.6': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -1721,6 +1800,9 @@ snapshots: '@types/mdurl@2.0.0': {} + '@types/raf@3.4.3': + optional: true + '@vavt/copy2clipboard@1.0.3': {} '@vavt/util@2.1.1': {} @@ -1829,6 +1911,8 @@ snapshots: argparse@2.0.1: {} + atob@2.1.2: {} + autoprefixer@10.4.23(postcss@8.5.6): dependencies: browserslist: 4.28.1 @@ -1840,6 +1924,8 @@ snapshots: balanced-match@1.0.2: {} + base64-arraybuffer@1.0.2: {} + baseline-browser-mapping@2.9.14: {} binary-extensions@2.3.0: {} @@ -1860,10 +1946,24 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + btoa@1.2.1: {} + camelcase-css@2.0.1: {} 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: dependencies: anymatch: 3.1.3 @@ -1890,8 +1990,15 @@ snapshots: commander@4.1.1: {} + core-js@3.49.0: + optional: true + crelt@1.0.6: {} + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + cssesc@3.0.0: {} cssfilter@0.0.10: {} @@ -1904,6 +2011,9 @@ snapshots: dlv@1.1.3: {} + dompurify@2.5.9: + optional: true + echarts@6.0.0: dependencies: tslib: 2.3.0 @@ -1964,6 +2074,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.3: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -1993,6 +2105,11 @@ snapshots: 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: dependencies: binary-extensions: 2.3.0 @@ -2011,6 +2128,18 @@ snapshots: 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: {} lines-and-columns@1.2.4: {} @@ -2112,6 +2241,9 @@ snapshots: path-parse@1.0.7: {} + performance-now@2.1.0: + optional: true + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -2163,6 +2295,11 @@ snapshots: queue-microtask@1.2.3: {} + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + read-cache@1.0.0: dependencies: pify: 2.3.0 @@ -2171,6 +2308,9 @@ snapshots: dependencies: picomatch: 2.3.1 + regenerator-runtime@0.13.11: + optional: true + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -2179,6 +2319,9 @@ snapshots: reusify@1.1.0: {} + rgbcolor@1.0.1: + optional: true + rollup@4.55.1: dependencies: '@types/estree': 1.0.8 @@ -2216,6 +2359,9 @@ snapshots: source-map-js@1.2.1: {} + stackblur-canvas@2.7.0: + optional: true + style-mod@4.1.3: {} sucrase@3.35.1: @@ -2230,6 +2376,9 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svg-pathdata@6.0.3: + optional: true + tailwindcss@3.4.19: dependencies: '@alloc/quick-lru': 5.2.0 @@ -2258,6 +2407,10 @@ snapshots: - tsx - yaml + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -2291,6 +2444,10 @@ snapshots: util-deprecate@1.0.2: {} + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + vite@6.4.1(jiti@1.21.7): dependencies: esbuild: 0.25.12 diff --git a/client/src/components/ArticleExportMenu.vue b/client/src/components/ArticleExportMenu.vue new file mode 100644 index 0000000..cd560b4 --- /dev/null +++ b/client/src/components/ArticleExportMenu.vue @@ -0,0 +1,190 @@ + + + diff --git a/client/src/pages/BlogDetail.vue b/client/src/pages/BlogDetail.vue index 856f4b5..4c2584a 100644 --- a/client/src/pages/BlogDetail.vue +++ b/client/src/pages/BlogDetail.vue @@ -165,9 +165,12 @@
-
- {{ post.categoryName || '未分类' }} - {{ post.date }} +
+
+ {{ post.categoryName || '未分类' }} + {{ post.date }} +
+

{{ post.title }}

返回作品列表
- +
diff --git a/client/src/styles/articleExport.css b/client/src/styles/articleExport.css new file mode 100644 index 0000000..166c0dd --- /dev/null +++ b/client/src/styles/articleExport.css @@ -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; } diff --git a/client/src/utils/articleExport.ts b/client/src/utils/articleExport.ts new file mode 100644 index 0000000..2cc6b29 --- /dev/null +++ b/client/src/utils/articleExport.ts @@ -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 = ` +
+
+ ${escapeHtml(post.categoryName || '未分类')} + ${escapeHtml(metaParts.join(' · '))} +
+

${escapeHtml(post.title)}

+ ${post.userName ? `
作者:${escapeHtml(post.userName)}
` : ''} +
+
${renderedHtml}
+ ` + + // 移除交互元素,避免截图出现按钮 + 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, '"') +} + +/** + * 使用 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 { + 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 { + 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 { + 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: '不支持的导出格式' } + } +} diff --git a/client/src/utils/fileDownload.ts b/client/src/utils/fileDownload.ts new file mode 100644 index 0000000..8ac6ec9 --- /dev/null +++ b/client/src/utils/fileDownload.ts @@ -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) +}