1294 lines
35 KiB
Vue
1294 lines
35 KiB
Vue
<template>
|
||
<el-dialog
|
||
:visible.sync="dialogVisible"
|
||
:fullscreen="true"
|
||
append-to-body
|
||
custom-class="article-editor-dialog"
|
||
:close-on-click-modal="false"
|
||
:before-close="handleBeforeClose"
|
||
@open="handleOpen"
|
||
@close="handleDialogClose"
|
||
>
|
||
<div slot="title" class="article-dialog-title">
|
||
<button type="button" class="dialog-back-btn" @click="requestClose">
|
||
<i class="el-icon-arrow-left"></i>
|
||
返回
|
||
</button>
|
||
<span>{{ dialogTitle }}</span>
|
||
</div>
|
||
|
||
<div class="article-editor" :class="{ 'is-editor-fullscreen': editorFullscreen }">
|
||
<div class="article-editor__left" :class="{ 'has-language-form': showEnglishForm }">
|
||
<div class="article-editor__form">
|
||
<el-form :model="form" label-width="76px" size="small">
|
||
<el-form-item>
|
||
<template slot="label"><span class="required-star">*</span>文章标题</template>
|
||
<el-input v-model.trim="form.title" placeholder="请输入文章标题" />
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<template slot="label"><span class="required-star">*</span>文章摘要</template>
|
||
<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>
|
||
<template slot="label"><span class="required-star">*</span>文章类型</template>
|
||
<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>
|
||
<template slot="label"><span class="required-star">*</span>发布时间</template>
|
||
<el-date-picker
|
||
v-model="form.publishTime"
|
||
type="date"
|
||
value-format="yyyy-MM-dd"
|
||
placeholder="选择发布时间"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
<el-form-item>
|
||
<template slot="label"><span class="required-star">*</span>封面配图</template>
|
||
<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 class="language-trigger">
|
||
<div class="language-trigger__title">
|
||
<span class="language-trigger__badge">EN</span>
|
||
<div>
|
||
<strong>多语言输入</strong>
|
||
<small>添加英文版文章内容</small>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="language-trigger__button"
|
||
:aria-expanded="showEnglishForm.toString()"
|
||
@click="showEnglishForm = !showEnglishForm"
|
||
>
|
||
{{ showEnglishForm ? '收起英文版' : '添加英文版' }}
|
||
<i :class="showEnglishForm ? 'el-icon-arrow-up' : 'el-icon-arrow-down'" />
|
||
</button>
|
||
</div>
|
||
</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>
|
||
|
||
<section v-if="showEnglishForm" class="article-editor__language-section">
|
||
<div class="language-section-head">
|
||
<div>
|
||
<span class="language-section-head__eyebrow">ENGLISH VERSION</span>
|
||
<h3>英文版文章</h3>
|
||
<p>英文内容会与中文内容一并保存或发布。</p>
|
||
</div>
|
||
<span class="language-section-head__tag">English</span>
|
||
</div>
|
||
|
||
<div class="language-form-grid">
|
||
<div class="language-field language-field--wide">
|
||
<label>Article Title</label>
|
||
<el-input v-model.trim="english.title" placeholder="Enter article title" />
|
||
</div>
|
||
<div class="language-field language-field--wide">
|
||
<label>Intro</label>
|
||
<el-input
|
||
v-model.trim="english.summary"
|
||
type="textarea"
|
||
:rows="2"
|
||
maxlength="120"
|
||
show-word-limit
|
||
placeholder="Enter article summary"
|
||
/>
|
||
</div>
|
||
<div class="language-field">
|
||
<label>Category</label>
|
||
<el-select v-model="english.category" placeholder="Select category">
|
||
<el-option label="Corporate News" value="Corporate News" />
|
||
<el-option label="Product News" value="Product News" />
|
||
<el-option label="Industry Insights" value="Industry Insights" />
|
||
<el-option label="Event News" value="Event News" />
|
||
</el-select>
|
||
</div>
|
||
<div class="language-field">
|
||
<label>Date</label>
|
||
<el-date-picker
|
||
v-model="english.publishTime"
|
||
type="date"
|
||
value-format="yyyy-MM-dd"
|
||
placeholder="Select date"
|
||
/>
|
||
</div>
|
||
<div class="language-field language-field--wide">
|
||
<label>Cover</label>
|
||
<div class="language-cover">
|
||
<button type="button" class="language-cover__upload" @click="$refs.englishCoverInput.click()">
|
||
<i class="el-icon-upload2" />
|
||
Upload cover
|
||
</button>
|
||
<el-input
|
||
v-model.trim="english.coverUrl"
|
||
class="language-cover__input"
|
||
placeholder="Upload an image or paste its URL"
|
||
/>
|
||
<span v-if="english.coverName" class="language-cover__name">{{ english.coverName }}</span>
|
||
<input ref="englishCoverInput" type="file" accept="image/*" class="hidden-input" @change="handleEnglishCoverUpload">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="language-richtext">
|
||
<div class="language-richtext__head">
|
||
<span>English Content</span>
|
||
|
||
</div>
|
||
<div v-if="dialogVisible" class="wang-editor-wrap wang-editor-wrap--english">
|
||
<Toolbar
|
||
class="wang-toolbar"
|
||
:editor="englishEditor"
|
||
:default-config="toolbarConfig"
|
||
:mode="editorMode"
|
||
/>
|
||
<Editor
|
||
v-model="english.content"
|
||
class="wang-editor"
|
||
:default-config="englishEditorConfig"
|
||
:mode="editorMode"
|
||
@onCreated="handleEnglishEditorCreated"
|
||
@onDestroyed="handleEnglishEditorDestroyed"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</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 class="editor-footer-buttons">
|
||
<el-button size="small" @click="requestClose">取消</el-button>
|
||
<el-button size="small" @click="handleSave('0')">保存草稿</el-button>
|
||
<el-button type="primary" size="small" @click="handlePublishConfirm">保存并发布</el-button>
|
||
</div>
|
||
</div>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<script>
|
||
import '@wangeditor/editor/dist/css/style.css'
|
||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||
import { reqUploadNewsImage } from '@/api/newsapi/newsapi'
|
||
|
||
const normalizeImageUrl = (url) => {
|
||
if (!url) return ''
|
||
if (/^(https?:|blob:|data:|\/\/)/.test(url)) return url
|
||
return `${window.location.origin}/idfile?path=${url}`
|
||
}
|
||
|
||
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,
|
||
englishEditor: null,
|
||
// wangEditor 模式配置。
|
||
editorMode: 'default',
|
||
// wangEditor 工具栏配置。
|
||
toolbarConfig: {},
|
||
// wangEditor 编辑器配置。
|
||
editorConfig: {
|
||
placeholder: '请输入文章内容...',
|
||
MENU_CONF: {
|
||
uploadImage: {
|
||
customUpload: async(file, insertFn) => {
|
||
try {
|
||
// 正文图片上传到文件接口,正文中保存可回显的图片地址。
|
||
const url = await this.uploadNewsImage(file)
|
||
insertFn(normalizeImageUrl(url), file.name, normalizeImageUrl(url))
|
||
} catch (error) {
|
||
this.$message.error('正文图片上传失败')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
englishEditorConfig: {
|
||
placeholder: 'Please enter article content…',
|
||
MENU_CONF: {
|
||
uploadImage: {
|
||
customUpload: async(file, insertFn) => {
|
||
try {
|
||
const url = await this.uploadNewsImage(file)
|
||
insertFn(normalizeImageUrl(url), file.name, normalizeImageUrl(url))
|
||
} catch (error) {
|
||
this.$message.error('英文正文图片上传失败')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
// 上传封面的本地预览地址,只用于页面显示,不提交给接口。
|
||
coverPreview: '',
|
||
imageUploading: false,
|
||
englishImageUploading: false,
|
||
editorFullscreen: false,
|
||
editorFullscreenObserver: null,
|
||
// 右侧预览区域宽度。
|
||
previewWidth: 400,
|
||
// 控制英文版区域展开状态。
|
||
showEnglishForm: false,
|
||
// 英文版内容,与中文字段一起提交到文章保存接口。
|
||
english: this.createEmptyEnglishForm(),
|
||
// 标记预览区域是否正在拖拽调整宽度。
|
||
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
|
||
}
|
||
},
|
||
english: {
|
||
deep: true,
|
||
handler() {
|
||
this.dirty = true
|
||
}
|
||
}
|
||
},
|
||
beforeDestroy() {
|
||
// 组件销毁前释放 wangEditor 实例,避免内存泄漏。
|
||
if (this.editor) {
|
||
this.editor.destroy()
|
||
this.editor = null
|
||
}
|
||
if (this.englishEditor) {
|
||
this.englishEditor.destroy()
|
||
this.englishEditor = null
|
||
}
|
||
// 清理拖拽监听事件。
|
||
this.removeResizeListeners()
|
||
this.stopObserveEditorFullscreen()
|
||
this.editorFullscreen = false
|
||
},
|
||
methods: {
|
||
createEmptyForm() {
|
||
// 创建新增文章时使用的默认表单结构。
|
||
return {
|
||
id: '',
|
||
title: '',
|
||
summary: '',
|
||
type: '企业动态',
|
||
status: '0',
|
||
publishTime: '',
|
||
views: 0,
|
||
content: '',
|
||
coverUrl: '',
|
||
coverName: ''
|
||
}
|
||
},
|
||
createEmptyEnglishForm() {
|
||
return {
|
||
title: '',
|
||
summary: '',
|
||
category: '',
|
||
publishTime: '',
|
||
coverUrl: '',
|
||
coverName: '',
|
||
content: ''
|
||
}
|
||
},
|
||
handleOpen() {
|
||
// 弹窗打开时合并默认值和待编辑文章数据。
|
||
const article = this.article || {}
|
||
const english = article.english || {}
|
||
this.form = {
|
||
...this.createEmptyForm(),
|
||
...article,
|
||
title: article.title || article.title_zh || '',
|
||
summary: article.summary || article.summary_zh || '',
|
||
type: article.type || article.article_type_zh || '企业动态',
|
||
publishTime: article.publishTime || article.publish_time_zh || article.publish_time || '',
|
||
content: article.content || article.content_zh || '',
|
||
coverUrl: article.coverUrl || article.cover_img_zh || article.cover_img || '',
|
||
coverName: article.coverName || article.cover_name_zh || article.cover_name || ''
|
||
}
|
||
// 没有发布时间时默认使用当天日期。
|
||
if (!this.form.publishTime) this.form.publishTime = this.today
|
||
// 初始化封面预览。
|
||
this.coverPreview = this.form.coverUrl || ''
|
||
this.editorFullscreen = false
|
||
this.imageUploading = false
|
||
this.englishImageUploading = false
|
||
this.english = {
|
||
...this.createEmptyEnglishForm(),
|
||
...english,
|
||
title: english.title || article.title_en || '',
|
||
summary: english.summary || article.summary_en || '',
|
||
category: english.category || article.article_type_en || '',
|
||
publishTime: english.publishTime || article.publish_time_en || '',
|
||
content: english.content || article.content_en || '',
|
||
coverUrl: english.coverUrl || article.cover_img_en || '',
|
||
coverName: english.coverName || article.cover_name_en || ''
|
||
}
|
||
this.showEnglishForm = Boolean(
|
||
this.english.title ||
|
||
this.english.summary ||
|
||
this.english.category ||
|
||
this.english.publishTime ||
|
||
this.english.coverUrl ||
|
||
this.english.content
|
||
)
|
||
this.$nextTick(() => {
|
||
// 表单初始化完成后重置未保存状态。
|
||
this.dirty = false
|
||
this.startObserveEditorFullscreen()
|
||
})
|
||
},
|
||
handleEditorCreated(editor) {
|
||
// 保存编辑器实例,后续销毁时使用。
|
||
this.editor = Object.seal(editor)
|
||
},
|
||
handleEnglishEditorCreated(editor) {
|
||
this.englishEditor = Object.seal(editor)
|
||
},
|
||
handleEnglishEditorDestroyed() {
|
||
this.englishEditor = null
|
||
},
|
||
handleDialogClose() {
|
||
this.stopObserveEditorFullscreen()
|
||
this.editorFullscreen = false
|
||
},
|
||
requestClose() {
|
||
this.handleBeforeClose(() => {
|
||
this.dialogVisible = false
|
||
})
|
||
},
|
||
handleBeforeClose(done) {
|
||
if (!this.dirty) {
|
||
done()
|
||
return
|
||
}
|
||
this.$confirm('当前编辑内容尚未保存,是否保存为草稿?', '提示', {
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
customClass: 'news-confirm-box news-confirm-box--warning',
|
||
confirmButtonClass: 'news-confirm-btn news-confirm-btn--confirm',
|
||
cancelButtonClass: 'news-confirm-btn news-confirm-btn--cancel'
|
||
}).then(() => {
|
||
this.handleSave('0')
|
||
}).catch(() => {
|
||
done()
|
||
})
|
||
},
|
||
handlePublishConfirm() {
|
||
this.$confirm('请确认该文章不包含敏感词汇。', '确认发布企业动态文章?', {
|
||
confirmButtonText: '确定发布',
|
||
cancelButtonText: '取消',
|
||
type: 'success',
|
||
customClass: 'news-confirm-box news-confirm-box--success',
|
||
confirmButtonClass: 'news-confirm-btn news-confirm-btn--confirm is-success',
|
||
cancelButtonClass: 'news-confirm-btn news-confirm-btn--cancel'
|
||
}).then(() => {
|
||
this.handleSave('1')
|
||
}).catch(() => {})
|
||
},
|
||
isEditorFullscreenActive() {
|
||
const fullscreenElement = document.fullscreenElement || document.webkitFullscreenElement
|
||
if (fullscreenElement) return true
|
||
return !!document.querySelector('.w-e-full-screen-container, .w-e-full-screen, .w-e-text-container-full-screen')
|
||
},
|
||
updateEditorFullscreenState() {
|
||
this.editorFullscreen = this.isEditorFullscreenActive()
|
||
},
|
||
startObserveEditorFullscreen() {
|
||
this.stopObserveEditorFullscreen()
|
||
this.updateEditorFullscreenState()
|
||
this.editorFullscreenObserver = new MutationObserver(() => {
|
||
this.updateEditorFullscreenState()
|
||
})
|
||
this.editorFullscreenObserver.observe(document.body, {
|
||
childList: true,
|
||
subtree: true,
|
||
attributes: true,
|
||
attributeFilter: ['class', 'style']
|
||
})
|
||
},
|
||
stopObserveEditorFullscreen() {
|
||
if (this.editorFullscreenObserver) {
|
||
this.editorFullscreenObserver.disconnect()
|
||
this.editorFullscreenObserver = null
|
||
}
|
||
},
|
||
getUploadUrl(res) {
|
||
const pickUrl = (data) => {
|
||
if (!data) return ''
|
||
if (typeof data === 'string') return data
|
||
if (typeof data === 'object') {
|
||
return data.file_path ||
|
||
data.file_url ||
|
||
data.save_url ||
|
||
data.url ||
|
||
data.path ||
|
||
data.filePath ||
|
||
data.fileUrl ||
|
||
data.saveUrl ||
|
||
pickUrl(data.data)
|
||
}
|
||
return ''
|
||
}
|
||
return pickUrl(res && (res.data || res))
|
||
},
|
||
async uploadNewsImage(file) {
|
||
const formData = new FormData()
|
||
formData.append('file_data', file)
|
||
formData.append('domain_name', window.location.host)
|
||
const res = await reqUploadNewsImage(formData)
|
||
if (res && (res.status === true || res.status === 'true')) {
|
||
const url = this.getUploadUrl(res)
|
||
if (url) return url
|
||
}
|
||
throw new Error((res && (res.msg || res.message)) || '图片上传失败')
|
||
},
|
||
async handleCoverUpload(event) {
|
||
// 读取用户选择的封面文件。
|
||
const file = event.target.files && event.target.files[0]
|
||
if (!file) return
|
||
// 保存文件名用于页面提示。
|
||
this.form.coverName = file.name
|
||
this.imageUploading = true
|
||
try {
|
||
// 封面图片上传后保存接口返回路径,提交文章时作为 cover_img。
|
||
const url = await this.uploadNewsImage(file)
|
||
this.form.coverUrl = url
|
||
this.coverPreview = normalizeImageUrl(url)
|
||
this.$message.success('封面图片上传成功')
|
||
} catch (error) {
|
||
this.form.coverName = ''
|
||
this.$message.error('封面图片上传失败')
|
||
} finally {
|
||
this.imageUploading = false
|
||
// 清空 input 值,保证重复选择同一文件也能触发 change。
|
||
event.target.value = ''
|
||
}
|
||
},
|
||
async handleEnglishCoverUpload(event) {
|
||
const file = event.target.files && event.target.files[0]
|
||
if (!file) return
|
||
|
||
this.english.coverName = file.name
|
||
this.englishImageUploading = true
|
||
try {
|
||
this.english.coverUrl = await this.uploadNewsImage(file)
|
||
this.$message.success('英文封面图片上传成功')
|
||
} catch (error) {
|
||
this.english.coverName = ''
|
||
this.$message.error('英文封面图片上传失败')
|
||
} finally {
|
||
this.englishImageUploading = false
|
||
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
|
||
}
|
||
if (this.imageUploading || this.englishImageUploading) {
|
||
this.$message.warning('图片上传中,请稍后保存')
|
||
return
|
||
}
|
||
// 第六步:组装保存参数,status 由按钮决定是草稿还是发布。
|
||
const payload = {
|
||
...this.form,
|
||
english: {
|
||
...this.english
|
||
},
|
||
publish_time: this.form.publishTime,
|
||
status
|
||
}
|
||
// 第七步:把保存参数交给父组件,由父组件调用新增或编辑接口。
|
||
this.$emit('save', payload)
|
||
// 第八步:保存触发后重置未保存状态。
|
||
this.dirty = false
|
||
// 第九步:关闭弹窗。
|
||
this.dialogVisible = false
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
::v-deep .el-form-item__content{
|
||
/* width: 100% !important; */
|
||
margin-left: 88px !important;
|
||
}
|
||
::v-deep .el-form-item__label{
|
||
width: auto !important;
|
||
}
|
||
.required-star{
|
||
color: #ff0000;
|
||
font-size: 16px;
|
||
/* font-weight: 700; */
|
||
margin-right: 4px;
|
||
}
|
||
::v-deep .article-editor-dialog {
|
||
--article-editor-radius: 8px;
|
||
margin: 0 !important;
|
||
height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
padding:0 24px!important;
|
||
padding-top: 16px;
|
||
border-bottom: 1px solid #edf0f5;
|
||
}
|
||
|
||
::v-deep .el-dialog__header {
|
||
flex-shrink: 0;
|
||
padding: 16px 24px;
|
||
border-bottom: 1px solid #edf0f5;
|
||
}
|
||
|
||
.article-dialog-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
color: #1f2937;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.dialog-back-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 5px 10px;
|
||
color: #64748b;
|
||
font-size: 13px;
|
||
background: #fff;
|
||
border: 1px solid #d8e0ec;
|
||
border-radius: var(--article-editor-radius);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.dialog-back-btn:hover {
|
||
color: #2563eb;
|
||
border-color: #bfdbfe;
|
||
background: #eff6ff;
|
||
}
|
||
|
||
::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 {
|
||
width: 100%;
|
||
height: 60px;
|
||
min-height: 60px;
|
||
flex-shrink: 0;
|
||
padding: 0 24px !important;
|
||
display: flex;
|
||
align-items: center;
|
||
box-sizing: border-box;
|
||
border-top: 1px solid #edf0f5;
|
||
}
|
||
|
||
::v-deep .article-editor-dialog .el-button {
|
||
border-radius: var(--article-editor-radius);
|
||
}
|
||
.article-editor {
|
||
height: 100%;
|
||
display: flex;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.article-editor__left {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.article-editor__left.has-language-form {
|
||
overflow-y: auto;
|
||
scrollbar-color: #cbd5e1 transparent;
|
||
scrollbar-width: thin;
|
||
}
|
||
|
||
.article-editor__left.has-language-form .article-editor__form {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.article-editor__left.has-language-form .wang-editor-wrap {
|
||
flex: 0 0 420px;
|
||
min-height: 420px;
|
||
}
|
||
|
||
.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.is-editor-fullscreen .resize-handle {
|
||
display: none;
|
||
}
|
||
|
||
.article-editor__form {
|
||
padding: 16px 18px 4px 0;
|
||
border-bottom: 1px solid #edf0f5;
|
||
}
|
||
|
||
.language-trigger {
|
||
min-height: 48px;
|
||
margin: 2px 0 12px 88px;
|
||
padding: 9px 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 14px;
|
||
background: linear-gradient(90deg, #f8fbff 0%, #f7f5ff 100%);
|
||
border: 1px dashed #bfdbfe;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.language-trigger__title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
}
|
||
|
||
.language-trigger__badge {
|
||
width: 26px;
|
||
height: 26px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #4f46e5;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.04em;
|
||
background: #ede9fe;
|
||
border-radius: 7px;
|
||
}
|
||
|
||
.language-trigger__title strong,
|
||
.language-trigger__title small {
|
||
display: block;
|
||
}
|
||
|
||
.language-trigger__title strong {
|
||
color: #334155;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.language-trigger__title small {
|
||
margin-top: 2px;
|
||
color: #94a3b8;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.language-trigger__button {
|
||
padding: 5px 8px;
|
||
color: #4f46e5;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
background: transparent;
|
||
border: 0;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.language-trigger__button i {
|
||
margin-left: 4px;
|
||
}
|
||
|
||
.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__language-section {
|
||
flex-shrink: 0;
|
||
margin-top: 22px;
|
||
padding: 24px 18px 40px 0;
|
||
border-top: 1px solid #dbeafe;
|
||
}
|
||
|
||
.language-section-head {
|
||
margin-bottom: 18px;
|
||
padding: 16px 18px;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
background: linear-gradient(135deg, #f5f9ff 0%, #f7f5ff 100%);
|
||
border: 1px solid #dbeafe;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.language-section-head__eyebrow {
|
||
display: block;
|
||
margin-bottom: 5px;
|
||
color: #6366f1;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.12em;
|
||
}
|
||
|
||
.language-section-head h3 {
|
||
margin: 0;
|
||
color: #1e293b;
|
||
font-size: 16px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.language-section-head p {
|
||
margin: 5px 0 0;
|
||
color: #94a3b8;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.language-section-head__tag {
|
||
padding: 4px 9px;
|
||
color: #4f46e5;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
background: #ede9fe;
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.language-form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.language-field--wide {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.language-field label {
|
||
display: block;
|
||
margin-bottom: 7px;
|
||
color: #475569;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.language-field .el-select,
|
||
.language-field .el-date-editor {
|
||
width: 100%;
|
||
}
|
||
|
||
.language-cover {
|
||
min-height: 32px;
|
||
padding: 5px 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
color: #94a3b8;
|
||
font-size: 12px;
|
||
background: #fff;
|
||
/* border: 1px solid #dcdfe6; */
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.language-cover__upload {
|
||
padding: 5px 9px;
|
||
color: #fff;
|
||
font-size: 12px;
|
||
background: #409eff;
|
||
border: 0;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.language-cover__upload i {
|
||
margin-right: 3px;
|
||
}
|
||
|
||
.language-cover__input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.language-cover__name {
|
||
max-width: 120px;
|
||
overflow: hidden;
|
||
color: #16a34a;
|
||
font-size: 11px;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.language-richtext {
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.language-richtext .wang-editor-wrap {
|
||
height: 420px;
|
||
min-height: 420px;
|
||
padding-right: 0;
|
||
}
|
||
|
||
.language-richtext .wang-toolbar {
|
||
border-radius: 0;
|
||
}
|
||
|
||
.language-richtext__head {
|
||
padding: 12px 14px 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
background: #fff;
|
||
border: 1px solid #edf0f5;
|
||
border-bottom: 0;
|
||
border-radius: 10px 10px 0 0;
|
||
}
|
||
|
||
.language-richtext__head span {
|
||
color: #334155;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.language-richtext__head small {
|
||
color: #94a3b8;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.language-richtext__toolbar {
|
||
min-height: 38px;
|
||
padding: 0 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
color: #64748b;
|
||
background: #f8fafc;
|
||
border-top: 1px solid #edf2f7;
|
||
border-bottom: 1px solid #edf2f7;
|
||
}
|
||
|
||
.language-richtext__toolbar > span {
|
||
min-width: 24px;
|
||
height: 24px;
|
||
padding: 0 5px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.language-richtext__toolbar > span:not(.language-richtext__divider):hover {
|
||
color: #2563eb;
|
||
background: #eff6ff;
|
||
}
|
||
|
||
.language-richtext__strong {
|
||
font-weight: 700;
|
||
}
|
||
|
||
.language-richtext__italic {
|
||
font-family: Georgia, serif;
|
||
font-style: italic;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.language-richtext__toolbar .language-richtext__divider {
|
||
min-width: 1px;
|
||
width: 1px;
|
||
height: 18px;
|
||
padding: 0;
|
||
margin: 0 4px;
|
||
background: #dbe2ea;
|
||
}
|
||
|
||
.language-richtext__canvas {
|
||
min-height: 230px;
|
||
padding: 16px;
|
||
color: #c0c7d1;
|
||
font-size: 13px;
|
||
line-height: 1.8;
|
||
cursor: text;
|
||
outline: none;
|
||
}
|
||
|
||
.language-richtext__canvas:empty::before {
|
||
content: attr(data-placeholder);
|
||
color: #c0c7d1;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.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 {
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 0;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.editor-status {
|
||
color: #9ca3af;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.editor-footer-buttons {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0;
|
||
gap: 10px;
|
||
|
||
.el-button {
|
||
min-height: 32px;
|
||
margin-left: 0 !important;
|
||
border-radius: var(--article-editor-radius);
|
||
}
|
||
}
|
||
|
||
</style>
|