feat(F006): 资讯列表封面上传 2M→5M + 内容改富文本编辑器(RichEditor, 支持图文粘贴)

This commit is contained in:
zou 2026-08-28 16:51:36 +08:00
parent d60df0dc06
commit 22d3ecd71e
5 changed files with 236 additions and 4 deletions

74
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"@vueup/vue-quill": "^1.5.5",
"axios": "^1.6.7", "axios": "^1.6.7",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"echarts": "^5.5.0", "echarts": "^5.5.0",
@ -1489,6 +1490,19 @@
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vueup/vue-quill": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@vueup/vue-quill/-/vue-quill-1.5.5.tgz",
"integrity": "sha512-ygA61ZZPaRzTHXi8H1w5td1XCtjwRsO6oBuJHdCbfotGQ7hBQukVp5dsYeVXWKuSiF8hGDKwIlROyNNrMalQCQ==",
"license": "MIT",
"dependencies": {
"quill": "2.0.2 || >=2.0.4 <3",
"quill-delta": "^5.1.0"
},
"peerDependencies": {
"vue": "^3.2.41"
}
},
"node_modules/@vueuse/core": { "node_modules/@vueuse/core": {
"version": "14.3.0", "version": "14.3.0",
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz",
@ -1938,6 +1952,12 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/exsolve": { "node_modules/exsolve": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz", "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz",
@ -1945,6 +1965,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"license": "Apache-2.0"
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
@ -2285,6 +2311,19 @@
"lodash-es": "*" "lodash-es": "*"
} }
}, },
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/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.npmjs.org/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/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@ -2463,6 +2502,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"license": "BSD-3-Clause"
},
"node_modules/path-browserify": { "node_modules/path-browserify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@ -2627,6 +2672,35 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/quill": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.2.tgz",
"integrity": "sha512-QfazNrhMakEdRG57IoYFwffUIr04LWJxbS/ZkidRFXYCQt63c1gK6Z7IHUXMx/Vh25WgPBU42oBaNzQ0K1R/xw==",
"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.npmjs.org/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/readdirp": { "node_modules/readdirp": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz", "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",

View File

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"@vueup/vue-quill": "^1.5.5",
"axios": "^1.6.7", "axios": "^1.6.7",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"echarts": "^5.5.0", "echarts": "^5.5.0",

View File

@ -0,0 +1,146 @@
<template>
<div class="rich-editor">
<quill-editor
ref="editorRef"
v-model:content="model"
:options="options"
content-type="html"
class="quill-editor-area"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from "vue"
import { Quill, QuillEditor } from "@vueup/vue-quill"
import "@vueup/vue-quill/dist/vue-quill.snow.css"
const model = defineModel<string>({ default: "" })
const editorRef = ref<any>(null)
let quill: any = null
// === 图片上传 handler (支持多选, 5MB) ===
const imageHandler = () => {
const input = document.createElement("input")
input.type = "file"
input.accept = "image/*"
input.multiple = true
input.click()
input.onchange = async () => {
const files = input.files ? Array.from(input.files) : []
if (!files.length) return
try {
const urls = await Promise.all(files.map(uploadFile))
const quillNow = quill || editorRef.value?.getQuill()
if (!quillNow) return
const sel = quillNow.getSelection(true)
let idx = sel ? sel.index : quillNow.getLength()
urls.forEach((url) => {
quillNow.insertEmbed(idx, "image", url)
quillNow.insertText(idx + 1, "\n", "api")
idx += 2
})
quillNow.setSelection(idx)
} catch (e: any) {
console.error("图片上传失败:", e)
alert(e?.message || "图片上传失败,请重试")
}
}
}
// === 单文件上传 (admin_new: /api 前缀 + access_token) ===
async function uploadFile(file: File): Promise<string> {
if (file.size > 5 * 1024 * 1024) throw new Error("图片大小不能超过5MB,请压缩后重试")
const fd = new FormData()
fd.append("file", file)
const token = localStorage.getItem("access_token") || ""
let resp: Response
try {
resp = await fetch("/api/sys/file/upload", { method: "POST", headers: { Authorization: `Bearer ${token}` }, body: fd })
} catch {
throw new Error("图片上传失败,请检查网络后重试")
}
if (resp.status === 413) {
throw new Error("图片超过大小限制(5MB),请压缩后重试")
}
const data = await resp.json()
if (data.code === 0) return data?.data?.url || data?.url || ""
throw new Error(data.msg || "图片上传失败")
}
// === Quill 配置 ===
const options = ref({
theme: "snow",
modules: {
toolbar: {
container: [
["bold", "italic", "underline", "strike"],
[{ color: [] }, { background: [] }],
[{ align: [] }],
[{ size: ["small", false, "large", "huge"] }],
[{ font: [] }],
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ indent: "-1" }, { indent: "+1" }],
[{ list: "ordered" }, { list: "bullet" }],
[{ script: "sub" }, { script: "super" }],
["blockquote", "code-block"],
["clean"],
["link", "image", "video"],
],
handlers: {
image: imageHandler,
},
},
},
})
// === 工具栏中文提示 ===
const titleConfig: { Choice: string; title: string }[] = [
{ Choice: ".ql-bold", title: "加粗" },
{ Choice: ".ql-italic", title: "斜体" },
{ Choice: ".ql-underline", title: "下划线" },
{ Choice: ".ql-strike", title: "删除线" },
{ Choice: ".ql-blockquote", title: "块引用" },
{ Choice: ".ql-code-block", title: "插入代码段" },
{ Choice: ".ql-font", title: "字体" },
{ Choice: ".ql-size", title: "字体大小" },
{ Choice: ".ql-list[value=\"ordered\"]", title: "编号列表" },
{ Choice: ".ql-list[value=\"bullet\"]", title: "项目列表" },
{ Choice: ".ql-header", title: "标题" },
{ Choice: ".ql-align", title: "对齐方式" },
{ Choice: ".ql-color", title: "字体颜色" },
{ Choice: ".ql-background", title: "背景颜色" },
{ Choice: ".ql-image", title: "图片(支持多选)" },
{ Choice: ".ql-video", title: "视频" },
{ Choice: ".ql-link", title: "添加链接" },
{ Choice: ".ql-clean", title: "清除格式" },
{ Choice: ".ql-script[value=\"sub\"]", title: "下标" },
{ Choice: ".ql-script[value=\"super\"]", title: "上标" },
{ Choice: ".ql-indent[value=\"-1\"]", title: "向左缩进" },
{ Choice: ".ql-indent[value=\"+1\"]", title: "向右缩进" },
]
onMounted(() => nextTick(initTitle))
function initTitle() {
for (const item of titleConfig) {
const el = document.querySelector(`.rich-editor ${item.Choice}`) as HTMLElement | null
if (el) el.setAttribute("title", item.title)
}
}
</script>
<style scoped>
.rich-editor :deep(.ql-toolbar) {
border-radius: 6px 6px 0 0;
}
.rich-editor :deep(.ql-container) {
min-height: 280px;
font-size: 14px;
border-radius: 0 0 6px 6px;
}
.rich-editor :deep(.ql-editor) {
min-height: 280px;
}
</style>

View File

@ -14,6 +14,7 @@ import { httpGet, httpPost, httpDelete } from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue' import PageHeader from '@/components/PageHeader/PageHeader.vue'
import RichEditor from '@/components/RichEditor/RichEditor.vue'
interface Info { interface Info {
id: string id: string
@ -152,7 +153,7 @@ async function onDelete(row: Info) {
// === 图片上传 (FormData + Bearer 头由拦截器自动带) === // === 图片上传 (FormData + Bearer 头由拦截器自动带) ===
async function customUpload(opt: any) { async function customUpload(opt: any) {
const file = opt.file as File const file = opt.file as File
if (file.size > 2 * 1024 * 1024) { ElMessage.error('图片大小不能超过 2MB!'); return } if (file.size > 5 * 1024 * 1024) { ElMessage.error('图片大小不能超过 5MB,请压缩后重试'); return }
uploadLoading.value = true uploadLoading.value = true
try { try {
const fd = new FormData() const fd = new FormData()
@ -235,11 +236,11 @@ async function customUpload(opt: any) {
<img v-if="editForm.images" :src="editForm.images" alt="封面" class="upload-preview" referrerpolicy="no-referrer" /> <img v-if="editForm.images" :src="editForm.images" alt="封面" class="upload-preview" referrerpolicy="no-referrer" />
<el-button v-else :loading="uploadLoading">上传封面</el-button> <el-button v-else :loading="uploadLoading">上传封面</el-button>
</el-upload> </el-upload>
<span class="rr-muted">支持 JPG/PNG, 不超过 2MB</span> <span class="rr-muted">支持 JPG/PNG, 不超过 5MB</span>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="资讯内容"> <el-form-item label="资讯内容">
<el-input v-model="editForm.content" type="textarea" :rows="8" placeholder="资讯内容(支持 HTML)" maxlength="20000" /> <RichEditor v-model="editForm.content" style="margin-bottom: 8px" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>

View File

@ -34,6 +34,7 @@ const enterpriseId = computed(() => String(route.params.id || ''))
const loading = ref(false) const loading = ref(false)
const activeTab = ref('basic') const activeTab = ref('basic')
const detail = ref<any>({}) const detail = ref<any>({})
function isPdfUrl(u: string) { return (u || '').toLowerCase().endsWith('.pdf') }
const otherList = ref<any[]>([]) const otherList = ref<any[]>([])
const performanceList = ref<any[]>([]) const performanceList = ref<any[]>([])
@ -486,7 +487,14 @@ onMounted(load)
</div> </div>
</div> </div>
<div v-if="cert.imageList?.length" class="cert-imgs"> <div v-if="cert.imageList?.length" class="cert-imgs">
<el-image v-for="(img, j) in cert.imageList" :key="j" :src="img" fit="cover" class="cert-thumb" :preview-src-list="cert.imageList" preview-teleported /> <template v-for="(img, j) in cert.imageList" :key="j">
<a v-if="isPdfUrl(img)" :href="img" target="_blank" rel="noopener"
class="pdf-card" :title="'打开 PDF 文件'">
<el-icon style="font-size:22px;color:#e34d4d"><Document /></el-icon>
<span style="font-size:11px;font-weight:bold;color:#e34d4d">PDF</span>
</a>
<el-image v-else :src="img" fit="cover" class="cert-thumb" :preview-src-list="[img]" preview-teleported />
</template>
</div> </div>
</div> </div>
</div> </div>
@ -744,6 +752,8 @@ onMounted(load)
.cert-row .label { min-width: 76px; } .cert-row .label { min-width: 76px; }
.cert-imgs { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 12px; } .cert-imgs { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 12px; }
.cert-thumb { width: 96px; height: 120px; border-radius: 6px; border: 1px solid var(--border); cursor: zoom-in; } .cert-thumb { width: 96px; height: 120px; border-radius: 6px; border: 1px solid var(--border); cursor: zoom-in; }
.pdf-card { width: 96px; height: 120px; border-radius: 6px; background: #fef2f2; border: 1px solid #fecaca; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; cursor: pointer; text-decoration: none; }
.pdf-card:hover { border-color: #fca5a5; background: #fee2e2; }
/* ===== 履约 ===== */ /* ===== 履约 ===== */
.kpi-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; } .kpi-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }