kboss/f/web-kboss/src/views/homePage/news/ArticleEditorDialog.vue
2026-07-16 18:05:03 +08:00

635 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<el-dialog
:visible.sync="dialogVisible"
:title="dialogTitle"
:fullscreen="true"
append-to-body
custom-class="article-editor-dialog"
:close-on-click-modal="false"
@open="handleOpen"
>
<div class="article-editor">
<div class="article-editor__left">
<div class="article-editor__form">
<el-form :model="form" label-width="76px" size="small">
<el-form-item label="文章标题">
<el-input v-model.trim="form.title" placeholder="请输入文章标题" />
</el-form-item>
<el-form-item label="文章摘要">
<el-input
v-model.trim="form.summary"
type="textarea"
:rows="2"
maxlength="120"
show-word-limit
placeholder="请输入文章摘要"
/>
</el-form-item>
<div class="article-editor__inline">
<el-form-item label="文章类型">
<el-select v-model="form.type" placeholder="请选择文章类型">
<el-option label="企业动态" value="企业动态" />
<el-option label="产品动态" value="产品动态" />
<el-option label="行业洞察" value="行业洞察" />
<el-option label="活动资讯" value="活动资讯" />
</el-select>
</el-form-item>
<el-form-item label="发布时间">
<el-date-picker
v-model="form.publishTime"
type="date"
value-format="yyyy-MM-dd"
placeholder="选择发布时间"
/>
</el-form-item>
</div>
<el-form-item label="封面配图">
<div class="cover-row">
<el-button type="primary" size="small" @click="$refs.coverInput.click()">上传图片</el-button>
<el-input
v-model.trim="form.coverUrl"
class="cover-url-input"
placeholder="可填写图片地址,或点击左侧上传本地图片"
@input="handleCoverUrlInput"
/>
<span v-if="form.coverName" class="cover-name">{{ form.coverName }}</span>
<input ref="coverInput" type="file" accept="image/*" class="hidden-input" @change="handleCoverUpload">
</div>
</el-form-item>
</el-form>
</div>
<div v-if="dialogVisible" class="wang-editor-wrap">
<Toolbar
class="wang-toolbar"
:editor="editor"
:default-config="toolbarConfig"
:mode="editorMode"
/>
<Editor
v-model="form.content"
class="wang-editor"
:default-config="editorConfig"
:mode="editorMode"
@onCreated="handleEditorCreated"
/>
</div>
</div>
<div
class="resize-handle"
title="拖动调整预览宽度"
@mousedown="startResize"
></div>
<div class="article-editor__preview" :style="{ width: `${previewWidth}px` }">
<div class="preview-head">
<span>实时预览</span>
<em>自动同步</em>
</div>
<div class="preview-card">
<div v-if="coverImage" class="preview-cover">
<img :src="coverImage" alt="cover">
</div>
<span class="preview-type">{{ form.type || '企业动态' }}</span>
<h1>{{ form.title || '文章标题' }}</h1>
<p class="preview-summary">{{ form.summary || '文章摘要预览...' }}</p>
<p class="preview-date">{{ form.publishTime || today }}</p>
<div class="preview-content" v-html="form.content || '文章内容预览...'"></div>
</div>
</div>
</div>
<div slot="footer" class="article-editor-footer">
<span class="editor-status">{{ dirty ? '未保存' : '已同步' }}</span>
<div>
<el-button size="small" @click="dialogVisible = false">取消</el-button>
<el-button size="small" @click="handleSave('draft')">保存草稿</el-button>
<el-button type="primary" size="small" @click="handleSave('published')">发布文章</el-button>
</div>
</div>
</el-dialog>
</template>
<script>
import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
const normalizeImageUrl = (url) => {
if (!url) return ''
if (/^(https?:|blob:|data:|\/\/)/.test(url)) return url
return `${window.location.origin}/idfile?path=${url}`
}
const compressImageToBase64 = (file, options = {}) => {
const maxWidth = options.maxWidth || 900
const maxHeight = options.maxHeight || 900
const quality = options.quality || 0.65
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const image = new Image()
image.onload = () => {
const ratio = Math.min(maxWidth / image.width, maxHeight / image.height, 1)
const width = Math.round(image.width * ratio)
const height = Math.round(image.height * ratio)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = width
canvas.height = height
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, width, height)
ctx.drawImage(image, 0, 0, width, height)
resolve(canvas.toDataURL('image/jpeg', quality))
}
image.onerror = reject
image.src = reader.result
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
export default {
name: 'ArticleEditorDialog',
components: { Editor, Toolbar },
props: {
visible: {
type: Boolean,
default: false
},
article: {
type: Object,
default: null
}
},
data() {
return {
// 标记当前表单是否有未保存改动。
dirty: false,
// 保存 wangEditor 实例,组件销毁时需要手动释放。
editor: null,
// wangEditor 模式配置。
editorMode: 'default',
// wangEditor 工具栏配置。
toolbarConfig: {},
// wangEditor 编辑器配置。
editorConfig: {
placeholder: '请输入文章内容...',
MENU_CONF: {
uploadImage: {
customUpload: async (file, insertFn) => {
try {
// 正文图片先压缩再转 Base64降低接口请求体积。
const url = await compressImageToBase64(file, {
maxWidth: 900,
maxHeight: 900,
quality: 0.65
})
insertFn(url, file.name, url)
} catch (error) {
this.$message.error('正文图片读取失败')
}
}
}
}
},
// 上传封面的本地预览地址,只用于页面显示,不提交给接口。
coverPreview: '',
// 右侧预览区域宽度。
previewWidth: 400,
// 标记预览区域是否正在拖拽调整宽度。
resizing: false,
// 记录拖拽开始时的鼠标位置。
resizeStartX: 0,
// 记录拖拽开始时的预览宽度。
resizeStartWidth: 400,
// 文章编辑表单数据。
form: this.createEmptyForm()
}
},
computed: {
dialogVisible: {
get() {
// 使用父组件传入的 visible 控制弹窗显示。
return this.visible
},
set(value) {
// 通过 sync 事件通知父组件更新 visible。
this.$emit('update:visible', value)
}
},
dialogTitle() {
// 有文章数据时是编辑,否则是新建。
return this.article ? '编辑文章' : '新建文章'
},
today() {
// 生成默认发布时间,格式与接口要求一致。
const date = new Date()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
},
coverImage() {
// 优先使用本地预览,没有预览时使用接口/手动输入的图片路径。
if (this.coverPreview) return this.coverPreview
return normalizeImageUrl(this.form.coverUrl)
}
},
watch: {
form: {
deep: true,
handler() {
// 任意表单字段变化后标记为未保存。
this.dirty = true
}
}
},
beforeDestroy() {
// 组件销毁前释放 wangEditor 实例,避免内存泄漏。
if (this.editor) {
this.editor.destroy()
this.editor = null
}
// 清理拖拽监听事件。
this.removeResizeListeners()
},
methods: {
createEmptyForm() {
// 创建新增文章时使用的默认表单结构。
return {
id: '',
title: '',
summary: '',
type: '企业动态',
status: '0',
publishTime: '',
views: 0,
content: '',
coverUrl: '',
coverName: ''
}
},
handleOpen() {
// 弹窗打开时合并默认值和待编辑文章数据。
this.form = {
...this.createEmptyForm(),
...(this.article || {})
}
// 没有发布时间时默认使用当天日期。
if (!this.form.publishTime) this.form.publishTime = this.today
// 初始化封面预览。
this.coverPreview = this.form.coverUrl || ''
this.$nextTick(() => {
// 表单初始化完成后重置未保存状态。
this.dirty = false
})
},
handleEditorCreated(editor) {
// 保存编辑器实例,后续销毁时使用。
this.editor = Object.seal(editor)
},
async handleCoverUpload(event) {
// 读取用户选择的封面文件。
const file = event.target.files && event.target.files[0]
if (!file) return
// 保存文件名用于页面提示。
this.form.coverName = file.name
try {
// 封面图片先压缩再转 Base64直接保存到 coverUrl / cover_img。
const base64 = await compressImageToBase64(file, {
maxWidth: 900,
maxHeight: 900,
quality: 0.68
})
this.coverPreview = base64
this.form.coverUrl = base64
} catch (error) {
this.$message.error('封面图片读取失败')
} finally {
// 清空 input 值,保证重复选择同一文件也能触发 change。
event.target.value = ''
}
},
handleCoverUrlInput() {
// 用户手动输入图片路径时,清空本地上传预览状态。
this.coverPreview = ''
this.form.coverName = ''
},
startResize(event) {
// 开始拖拽调整右侧预览区域宽度。
this.resizing = true
// 记录拖拽起点。
this.resizeStartX = event.clientX
// 记录拖拽开始时的宽度。
this.resizeStartWidth = this.previewWidth
// 修改全局鼠标样式,让拖拽反馈更明显。
document.body.style.cursor = 'col-resize'
// 禁止选中文本,避免拖拽时误选页面文字。
document.body.style.userSelect = 'none'
// 监听鼠标移动和松开事件。
window.addEventListener('mousemove', this.handleResize)
window.addEventListener('mouseup', this.stopResize)
},
handleResize(event) {
// 非拖拽状态下不处理移动事件。
if (!this.resizing) return
// 根据鼠标偏移计算新的预览宽度。
const delta = this.resizeStartX - event.clientX
const nextWidth = this.resizeStartWidth + delta
// 限制预览宽度范围,避免过窄或过宽。
this.previewWidth = Math.min(Math.max(nextWidth, 320), 720)
},
stopResize() {
// 结束拖拽状态。
this.resizing = false
// 恢复全局鼠标样式。
document.body.style.cursor = ''
// 恢复页面文本选择。
document.body.style.userSelect = ''
// 移除拖拽事件监听。
this.removeResizeListeners()
},
removeResizeListeners() {
// 移除鼠标移动监听。
window.removeEventListener('mousemove', this.handleResize)
// 移除鼠标松开监听。
window.removeEventListener('mouseup', this.stopResize)
},
handleSave(status) {
// 第一步:校验文章标题。
if (!this.form.title) {
this.$message.warning('请输入文章标题')
return
}
// 第二步:校验文章摘要。
if (!this.form.summary) {
this.$message.warning('请输入文章摘要')
return
}
// 第三步:校验发布时间,确保提交接口时有 publish_time。
if (!this.form.publishTime) {
this.$message.warning('请选择发布时间')
return
}
// 第四步:校验封面图片,确保提交接口时有 cover_img。
if (!this.form.coverUrl) {
this.$message.warning('请输入封面图片')
return
}
// 第五步:校验文章正文。
if (!this.form.content) {
this.$message.warning('请输入文章正文')
return
}
// 第六步组装保存参数status 由按钮决定是草稿还是发布。
const payload = {
...this.form,
publish_time: this.form.publishTime,
status
}
// 第七步:把保存参数交给父组件,由父组件调用新增或编辑接口。
this.$emit('save', payload)
// 第八步:保存触发后重置未保存状态。
this.dirty = false
// 第九步:关闭弹窗。
this.dialogVisible = false
}
}
}
</script>
<style scoped>
::v-deep .article-editor-dialog {
margin: 0 !important;
height: 100vh;
display: flex;
flex-direction: column;
}
::v-deep .article-editor-dialog .el-dialog__header {
flex-shrink: 0;
padding: 16px 24px;
border-bottom: 1px solid #edf0f5;
}
::v-deep .article-editor-dialog .el-dialog__body {
flex: 1;
min-height: 0;
padding: 0 24px;
overflow: hidden;
}
::v-deep .article-editor-dialog .el-dialog__footer {
flex-shrink: 0;
padding: 14px 24px;
border-top: 1px solid #edf0f5;
}
.article-editor {
height: 100%;
display: flex;
overflow: hidden;
}
.article-editor__left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.resize-handle {
position: relative;
flex: 0 0 10px;
width: 10px;
cursor: col-resize;
background: #f8fafc;
border-left: 1px solid #edf0f5;
border-right: 1px solid #edf0f5;
transition: background 0.2s ease;
}
.resize-handle::before {
position: absolute;
top: 50%;
left: 50%;
width: 3px;
height: 42px;
content: '';
border-radius: 999px;
background: #cbd5e1;
transform: translate(-50%, -50%);
transition: background 0.2s ease, height 0.2s ease;
}
.resize-handle:hover {
background: #eff6ff;
}
.resize-handle:hover::before {
height: 58px;
background: #3b82f6;
}
.article-editor__form {
padding: 16px 18px 4px 0;
border-bottom: 1px solid #edf0f5;
}
.article-editor__inline {
display: flex;
gap: 16px;
}
.article-editor__inline .el-form-item {
flex: 1;
}
.cover-row {
display: flex;
align-items: center;
gap: 12px;
}
.cover-url-input {
flex: 1;
}
.cover-name {
color: #16a34a;
font-size: 13px;
}
.hidden-input {
display: none;
}
.article-editor__toolbar-tip {
padding: 10px 18px 10px 0;
color: #8a94a6;
font-size: 12px;
}
.wang-editor-wrap {
flex: 1;
min-height: 0;
padding-right: 18px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.wang-toolbar {
border: 1px solid #edf0f5;
border-bottom: 0;
border-radius: 10px 10px 0 0;
}
.wang-editor {
flex: 1;
min-height: 0;
border: 1px solid #edf0f5;
border-radius: 0 0 10px 10px;
overflow-y: auto;
}
.article-editor__preview {
flex: 0 0 auto;
display: flex;
flex-direction: column;
background: #f8fafc;
}
.preview-head {
height: 48px;
padding: 0 18px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #edf0f5;
}
.preview-head span {
color: #344054;
font-size: 14px;
font-weight: 600;
}
.preview-head em {
color: #9ca3af;
font-size: 12px;
font-style: normal;
}
.preview-card {
flex: 1;
min-height: 0;
margin: 18px;
padding: 24px;
overflow-y: auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.06);
}
.preview-cover {
margin-bottom: 16px;
}
.preview-cover img {
width: 100%;
border-radius: 10px;
}
.preview-type {
display: inline-flex;
padding: 2px 8px;
margin-bottom: 10px;
color: #2563eb;
font-size: 12px;
background: #eff6ff;
border-radius: 999px;
}
.preview-card h1 {
margin: 0 0 8px;
color: #1f2937;
font-size: 30px;
font-weight: 700;
line-height: 1.4;
}
.preview-date {
margin: 0 0 18px;
color: #9ca3af;
font-size: 12px;
}
.preview-summary {
margin: 0 0 10px;
color: #667085;
font-size: 14px;
line-height: 1.7;
}
.preview-content {
color: #4b5563;
font-size: 14px;
line-height: 1.8;
}
.article-editor-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.editor-status {
color: #9ca3af;
font-size: 12px;
}
</style>