635 lines
17 KiB
Vue
635 lines
17 KiB
Vue
<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>
|