From abeb140e430b85357b992efa6ce8ac3db622d4df Mon Sep 17 00:00:00 2001 From: Mohan Date: Mon, 10 Aug 2026 18:46:28 +0800 Subject: [PATCH] feat(editor): replace textarea with Quill.js WYSIWYG editor - Install quill + @types/quill dependencies - Add QuillEditor.vue component with snow theme, toolbar, image upload - Rebuild ArticleEditorView with left/right split layout (editor + settings panel) - Remove old markdown paste handler, Quill output is HTML directly --- package-lock.json | 99 ++++++++ package.json | 3 + src/components/QuillEditor.vue | 99 ++++++++ src/views/admin/ArticleEditorView.vue | 350 ++++++++++++++------------ 4 files changed, 388 insertions(+), 163 deletions(-) create mode 100644 src/components/QuillEditor.vue diff --git a/package-lock.json b/package-lock.json index e7cae7d..73551de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,13 @@ "dependencies": { "axios": "^1.7.9", "dompurify": "^3.2.4", + "github-markdown-css": "^5.9.0", "highlight.js": "^11.11.1", "lxgw-wenkai-webfont": "^1.7.0", "marked": "^18.0.9", "marked-highlight": "^2.2.4", "pinia": "^2.3.0", + "quill": "^2.0.3", "vue": "^3.5.13", "vue-router": "^4.5.0" }, @@ -23,6 +25,7 @@ "@types/dompurify": "^3.0.5", "@types/marked": "^5.0.2", "@types/node": "^22.10.2", + "@types/quill": "^2.0.14", "@vitejs/plugin-vue": "^5.2.1", "playwright": "^1.62.1", "typescript": "~5.6.3", @@ -1178,6 +1181,24 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/quill": { + "version": "2.0.14", + "resolved": "https://registry.npmmirror.com/@types/quill/-/quill-2.0.14.tgz", + "integrity": "sha512-zvoXCRnc2Dl8g+7/9VSAmRWPN6oH+MVhTPizmCR+GJCITplZ5VRVzMs4+a/nOE3yzNwEZqylJJrMB07bwbM1/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "parchment": "^1.1.2", + "quill-delta": "^5.1.0" + } + }, + "node_modules/@types/quill/node_modules/parchment": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/parchment/-/parchment-1.1.4.tgz", + "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -2116,6 +2137,18 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -2308,6 +2341,18 @@ "node": ">= 20" } }, + "node_modules/github-markdown-css": { + "version": "5.9.0", + "resolved": "https://registry.npmmirror.com/github-markdown-css/-/github-markdown-css-5.9.0.tgz", + "integrity": "sha512-tmT5sY+zvg2302XLYEfH2mtkViIM1SWf2nvYoF5N1ZsO0V6B2qZTiw3GOzw4vpjLygK/KG35qRlPFweHqfzz5w==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmmirror.com/glob/-/glob-13.0.6.tgz", @@ -2535,6 +2580,25 @@ "node": ">=13.2.0" } }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.2.tgz", @@ -2771,6 +2835,12 @@ "quickjs-wasi": "^2.2.0" } }, + "node_modules/parchment": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/parchment/-/parchment-3.0.0.tgz", + "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", + "license": "BSD-3-Clause" + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", @@ -2998,6 +3068,35 @@ "dev": true, "license": "MIT" }, + "node_modules/quill": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/quill/-/quill-2.0.3.tgz", + "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", + "license": "BSD-3-Clause", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash-es": "^4.17.21", + "parchment": "^3.0.0", + "quill-delta": "^5.1.0" + }, + "engines": { + "npm": ">=8.2.3" + } + }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "license": "MIT", + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz", diff --git a/package.json b/package.json index b6444ad..89e9b22 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,13 @@ "dependencies": { "axios": "^1.7.9", "dompurify": "^3.2.4", + "github-markdown-css": "^5.9.0", "highlight.js": "^11.11.1", "lxgw-wenkai-webfont": "^1.7.0", "marked": "^18.0.9", "marked-highlight": "^2.2.4", "pinia": "^2.3.0", + "quill": "^2.0.3", "vue": "^3.5.13", "vue-router": "^4.5.0" }, @@ -25,6 +27,7 @@ "@types/dompurify": "^3.0.5", "@types/marked": "^5.0.2", "@types/node": "^22.10.2", + "@types/quill": "^2.0.14", "@vitejs/plugin-vue": "^5.2.1", "playwright": "^1.62.1", "typescript": "~5.6.3", diff --git a/src/components/QuillEditor.vue b/src/components/QuillEditor.vue new file mode 100644 index 0000000..9730c52 --- /dev/null +++ b/src/components/QuillEditor.vue @@ -0,0 +1,99 @@ + + + + + \ No newline at end of file diff --git a/src/views/admin/ArticleEditorView.vue b/src/views/admin/ArticleEditorView.vue index 2702b0b..1ee0843 100644 --- a/src/views/admin/ArticleEditorView.vue +++ b/src/views/admin/ArticleEditorView.vue @@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { articleAdminApi, articleApi, tagApi, mediaApi } from '@/api/client' import type { ArticleDetail, Tag } from '@/generated/api' +import QuillEditor from '@/components/QuillEditor.vue' const route = useRoute() const router = useRouter() @@ -19,45 +20,6 @@ const loading = ref(false) const saving = ref(false) const error = ref(null) const uploading = ref(false) -const pasting = ref(false) - -async function uploadFile(file: File): Promise { - try { - const resp = await mediaApi.apiMediaUploadPost(file, 'images') - const stored = resp.data.data - return stored?.url ?? null - } catch (e) { - console.error(e) - error.value = '图片上传失败' - return null - } -} - -async function onPaste(event: ClipboardEvent) { - const items = event.clipboardData?.items - if (!items) return - const files: File[] = [] - for (const item of items) { - if (item.type.startsWith('image/')) { - const file = item.getAsFile() - if (file) files.push(file) - } - } - if (files.length === 0) return - event.preventDefault() - pasting.value = true - error.value = null - try { - for (const file of files) { - const url = await uploadFile(file) - if (url) { - content.value += `\n![${file.name}](${url})\n` - } - } - } finally { - pasting.value = false - } -} async function loadTags() { try { @@ -128,6 +90,10 @@ async function onUpload(event: Event) { } } +async function removeCover() { + coverImage.value = '' +} + async function onSubmit() { if (!title.value.trim() || !content.value.trim()) { error.value = '标题和内容为必填项' @@ -170,121 +136,218 @@ onMounted(() => {