更新文章推送界面
This commit is contained in:
@@ -47,6 +47,10 @@
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
<template #title>个人信息</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/publish">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
<template #title>发布文章</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { RouteRecordRaw } from 'vue-router'
|
||||
const HomeView = () => import('../views/HomeView.vue')
|
||||
const AboutView = () => import('../views/AboutView.vue')
|
||||
const NewsView = () => import('../views/news/NewsView.vue')
|
||||
const PublishView = () => import('../views/publish/PublishView.vue')
|
||||
|
||||
// 定义路由规则(现在 RouteRecordRaw 导入正确)
|
||||
const routes: RouteRecordRaw[] = [
|
||||
@@ -53,6 +54,16 @@ const routes: RouteRecordRaw[] = [
|
||||
title: '个人中心',
|
||||
requiresAuth: false
|
||||
}
|
||||
}
|
||||
,
|
||||
{
|
||||
path: '/publish',
|
||||
name: 'Publish',
|
||||
component: PublishView,
|
||||
meta: {
|
||||
title: '文章发布',
|
||||
requiresAuth: false
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>使用 Quill 富文本编辑器</h2>
|
||||
<QuillEditor />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import QuillEditor from './QuillEditor.vue'
|
||||
</script>
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
<template>
|
||||
<div class="editor-wrapper">
|
||||
<!-- 富文本编辑器 -->
|
||||
<div ref="editor" class="quill-container"></div>
|
||||
<!-- 新增:打印内容按钮 -->
|
||||
<button
|
||||
class="print-content-btn"
|
||||
@click="printEditorContent"
|
||||
>
|
||||
打印当前编辑器内容到控制台
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import Quill from 'quill';
|
||||
import 'quill/dist/quill.snow.css';
|
||||
|
||||
// 编辑器容器引用
|
||||
const editor = ref<HTMLDivElement | null>(null);
|
||||
let quillInstance: Quill | null = null;
|
||||
|
||||
// 组件挂载后初始化 Quill
|
||||
onMounted(() => {
|
||||
if (editor.value) {
|
||||
// 1. 初始化 Quill 编辑器
|
||||
quillInstance = new Quill(editor.value, {
|
||||
theme: 'snow', // 使用带工具栏的主题
|
||||
modules: {
|
||||
toolbar: {
|
||||
container: [
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
['blockquote', 'code-block'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
[{ indent: '-1' }, { indent: '+1' }],
|
||||
['link', 'image', 'video'], // 图片、视频按钮
|
||||
['clean']
|
||||
],
|
||||
handlers: {
|
||||
image: handleClickUpload // 点击工具栏「图片」按钮时触发上传本地图片
|
||||
}
|
||||
}
|
||||
},
|
||||
placeholder: '请上传或粘贴图片...',
|
||||
});
|
||||
|
||||
// 2. 监听文本变化,用于处理粘贴图片(自动提取 base64 并上传)
|
||||
quillInstance.on('text-change', (delta, oldDelta, source) => {
|
||||
if (source === 'user') {
|
||||
const pastedBase64 = getPastedBase64Image(delta);
|
||||
if (pastedBase64) {
|
||||
const selection = quillInstance?.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
// 🔍 打印关键调试信息
|
||||
console.log('🔍 text-change 原始 delta.ops:', delta.ops);
|
||||
console.log('🔍 source:', source);
|
||||
console.log('🔍 当前 selection:', selection);
|
||||
console.log('🔍 当前光标位置(selection.index):', selection.index);
|
||||
console.log('🔍 推测的 base64 图片位置(imageIndex = selection.index - 1):', selection.index - 1);
|
||||
|
||||
const imageIndex = selection.index ;
|
||||
|
||||
// 1. 删除 base64 图片
|
||||
quillInstance?.deleteText(imageIndex, 1);
|
||||
|
||||
// 2. 转 base64 为 File 并上传
|
||||
const blob = base64ToBlob(pastedBase64);
|
||||
if (blob) {
|
||||
const file = new File([blob], `pasted-${Date.now()}.png`, { type: blob.type });
|
||||
uploadImageToServer(file, imageIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 点击工具栏「图片」按钮时触发:选择本地图片并上传
|
||||
*/
|
||||
function handleClickUpload() {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
|
||||
fileInput.onchange = () => {
|
||||
if (!fileInput.files || !fileInput.files[0]) return;
|
||||
const selectedFile = fileInput.files[0];
|
||||
|
||||
const selection = quillInstance?.getSelection();
|
||||
const insertIndex = selection ? selection.index : quillInstance?.getLength() || 0;
|
||||
|
||||
// 调用统一上传函数
|
||||
uploadImageToServer(selectedFile, insertIndex);
|
||||
|
||||
// 清空 input,避免重复选择同一文件不触发 onchange
|
||||
fileInput.value = '';
|
||||
};
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一图片上传函数:上传 File 到你的后端接口,插入返回的真实图片 URL
|
||||
* @param file 用户上传的图片文件(本地选择 or 粘贴转成 File)
|
||||
* @param insertIndex 图片要插入到编辑器的位置
|
||||
*/
|
||||
async function uploadImageToServer(file: File, insertIndex: number) {
|
||||
if (!quillInstance) return;
|
||||
|
||||
// 1. 插入「上传中」占位文本
|
||||
quillInstance.insertText(insertIndex, '[图片上传中...]', { color: '#666', italic: true });
|
||||
|
||||
try {
|
||||
// 2. 构造 FormData,用于上传文件
|
||||
const formData = new FormData();
|
||||
formData.append('image', file); // ⬅️ 'image' 字段名请根据你的后端接口调整!
|
||||
|
||||
// 3. 调用你的真实后端图片上传接口(请替换为你的真实地址!)
|
||||
const UPLOAD_API_URL = 'http://localhost:8080/api/upload/image'; // ⬅️ 一定要替换成你自己的上传接口!!
|
||||
|
||||
const response = await fetch(UPLOAD_API_URL, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
// 注意:不要手动设置 Content-Type,浏览器会自动生成 multipart/form-data + boundary
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`图片上传失败,HTTP 状态码:${response.status}`);
|
||||
}
|
||||
|
||||
// 4. 解析返回的 JSON,获取图片 URL(重要!根据你的后端返回字段名调整!!)
|
||||
const result = await response.json();
|
||||
|
||||
// ⬅️ 下面这行是关键!根据你后端返回的数据结构修改,比如:
|
||||
// - { url: 'https://xxx.com/img.png' } → 取 result.url
|
||||
// - { success: true, data: { imageUrl: '...' } } → 取 result.data.imageUrl
|
||||
// - { data: { url: '...' } } → 取 result.data.url
|
||||
const imageUrl = result.data?.url; // ✅ 请根据实际返回字段修改!!!
|
||||
|
||||
if (!imageUrl) {
|
||||
throw new Error('服务器未返回有效的图片 URL');
|
||||
}
|
||||
|
||||
// 5. 删除「上传中」占位文本
|
||||
quillInstance.deleteText(insertIndex, '[图片上传中...]'.length);
|
||||
|
||||
// 6. 插入真实的图片 URL(不是 base64!)
|
||||
quillInstance.insertEmbed(insertIndex, 'image', imageUrl);
|
||||
|
||||
// 7. 光标移动到图片后面
|
||||
quillInstance.setSelection(insertIndex + 1);
|
||||
|
||||
} catch (error) {
|
||||
console.error('图片上传失败:', error);
|
||||
quillInstance.deleteText(insertIndex, '[图片上传中...]'.length);
|
||||
quillInstance.insertText(insertIndex, '[图片上传失败,请重试]', { color: '#ff4444', italic: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印当前编辑器内容(HTML + 纯文本)到控制台(调试用)
|
||||
*/
|
||||
function printEditorContent() {
|
||||
if (!quillInstance) {
|
||||
console.warn('编辑器未初始化,无法获取内容');
|
||||
return;
|
||||
}
|
||||
|
||||
const htmlContent = quillInstance.root.innerHTML;
|
||||
const plainText = quillInstance.getText();
|
||||
|
||||
console.log('=== 编辑器 HTML 内容 ===');
|
||||
console.log(htmlContent);
|
||||
console.log('\n=== 编辑器纯文本内容 ===');
|
||||
console.log(plainText);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Delta 操作中提取粘贴的 base64 图片
|
||||
*/
|
||||
function getPastedBase64Image(delta: any): string | null {
|
||||
for (const op of delta.ops) {
|
||||
if (op.insert && typeof op.insert === 'object' && op.insert.image && op.insert.image.startsWith('data:image')) {
|
||||
return op.insert.image;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 base64 图片字符串转为 Blob 对象
|
||||
*/
|
||||
function base64ToBlob(base64: string): Blob | null {
|
||||
try {
|
||||
const [prefix, data] = base64.split(',');
|
||||
if (!prefix || !data) throw new Error('base64 格式错误');
|
||||
|
||||
const mimeMatch = prefix.match(/data:(.*?);/);
|
||||
if (!mimeMatch) throw new Error('无法解析 MIME 类型');
|
||||
const mime = mimeMatch[1];
|
||||
|
||||
const binaryData = atob(data);
|
||||
const bytes = new Uint8Array(binaryData.length);
|
||||
for (let i = 0; i < binaryData.length; i++) {
|
||||
bytes[i] = binaryData.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new Blob([bytes], { type: mime });
|
||||
} catch (error) {
|
||||
console.error('base64 转 Blob 失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 外层容器:控制编辑器和按钮的布局 */
|
||||
.editor-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px; /* 编辑器和按钮之间的间距 */
|
||||
max-width: 800px; /* 限制最大宽度,避免编辑器过宽 */
|
||||
margin: 20px auto; /* 水平居中 */
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.quill-container {
|
||||
width: 100%;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden; /* 避免工具栏边框与容器重叠 */
|
||||
}
|
||||
|
||||
/* 调整编辑器最小高度和图片显示 */
|
||||
::v-deep .ql-editor {
|
||||
min-height: 300px;
|
||||
}
|
||||
::v-deep .ql-editor img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 打印按钮样式 */
|
||||
.print-content-btn {
|
||||
padding: 8px 16px;
|
||||
background-color: #4096ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
width: fit-content; /* 按钮宽度适应内容 */
|
||||
}
|
||||
|
||||
.print-content-btn:disabled {
|
||||
background-color: #c9c9c9;
|
||||
cursor: not-allowed;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.print-content-btn:hover:not(:disabled) {
|
||||
background-color: #3086e8;
|
||||
}
|
||||
</style>
|
||||
9
management/src/views/publish/PublishView.vue
Normal file
9
management/src/views/publish/PublishView.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<QuillEditor />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import QuillEditor from './QuillEditor.vue'
|
||||
</script>
|
||||
884
management/src/views/publish/QuillEditor.vue
Normal file
884
management/src/views/publish/QuillEditor.vue
Normal file
@@ -0,0 +1,884 @@
|
||||
<template>
|
||||
|
||||
|
||||
<div class="article-publish-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1>发表新文章</h1>
|
||||
<p class="page-desc">创作优质内容,分享你的观点</p>
|
||||
</div>
|
||||
|
||||
<!-- 发表表单容器 -->
|
||||
<div class="publish-form-container">
|
||||
<!-- 1. 文章标题输入 -->
|
||||
<div class="form-group title-group">
|
||||
<label class="form-label">文章标题</label>
|
||||
<input
|
||||
v-model="articleTitle"
|
||||
type="text"
|
||||
class="title-input"
|
||||
placeholder="请输入文章标题(不少于5个字,不超过50字)"
|
||||
@input="checkTitleValid"
|
||||
>
|
||||
<!-- 标题校验提示 -->
|
||||
<p class="valid-hint" :class="{ error: !titleIsValid && articleTitle.length > 0 }">
|
||||
{{ titleHintText }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 2. 封面图上传 -->
|
||||
<div class="form-group cover-group">
|
||||
<label class="form-label">文章封面</label>
|
||||
<p class="form-desc">建议上传16:9比例图片,支持JPG/PNG/WEBP格式,大小不超过5MB</p>
|
||||
|
||||
<div class="cover-uploader">
|
||||
<!-- 已上传封面预览 -->
|
||||
<div v-if="coverUrl" class="cover-preview">
|
||||
<img :src="coverUrl" alt="文章封面" class="cover-img">
|
||||
<button
|
||||
class="remove-cover-btn"
|
||||
@click="removeCover"
|
||||
title="删除封面"
|
||||
>
|
||||
<el-icon><Close /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 未上传时的上传区域 -->
|
||||
<el-upload
|
||||
v-else
|
||||
class="cover-upload-area"
|
||||
action="http://localhost:8080/api/upload/cover"
|
||||
name="image"
|
||||
:show-file-list="false"
|
||||
:on-success="handleCoverSuccess"
|
||||
:before-upload="beforeCoverUpload"
|
||||
:on-error="handleCoverError"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<el-icon class="upload-icon"><Upload /></el-icon>
|
||||
<p class="upload-text">点击或拖拽图片至此处上传</p>
|
||||
<p class="upload-subtext">支持JPG/PNG/WEBP,最大5MB</p>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 文章内容富文本编辑 -->
|
||||
<div class="form-group content-group">
|
||||
<label class="form-label">文章内容</label>
|
||||
<p class="form-desc">请使用编辑器创作文章,支持文字、图片、代码块等格式</p>
|
||||
|
||||
<!-- 富文本编辑器容器 -->
|
||||
<div class="editor-wrapper">
|
||||
<div ref="editor" class="quill-editor"></div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器提示(字数统计/状态) -->
|
||||
<p class="editor-hint">
|
||||
<span v-if="wordCount !== null">字数:{{ wordCount }}</span>
|
||||
<span class="content-required" :class="{ active: !contentIsValid }">
|
||||
{{ contentHintText }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 4. 底部操作按钮 -->
|
||||
<div class="form-actions">
|
||||
<el-button
|
||||
type="default"
|
||||
size="large"
|
||||
class="draft-btn"
|
||||
@click="saveDraft"
|
||||
:loading="isSavingDraft"
|
||||
>
|
||||
<el-icon v-if="isSavingDraft"><Loading /></el-icon>
|
||||
<span>保存草稿</span>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="publish-btn"
|
||||
@click="submitArticle"
|
||||
:disabled="!isFormValid"
|
||||
:loading="isSubmitting"
|
||||
>
|
||||
<el-icon v-if="isSubmitting"><Loading /></el-icon>
|
||||
<span>发布文章</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import Quill from 'quill';
|
||||
import 'quill/dist/quill.snow.css';
|
||||
// Element Plus 组件与图标
|
||||
import { ElUpload, ElButton, ElIcon, ElMessage } from 'element-plus';
|
||||
import { Upload, Close, Loading } from '@element-plus/icons-vue';
|
||||
import type { UploadProps } from 'element-plus';
|
||||
|
||||
// -------------------------- 状态管理 --------------------------
|
||||
// 文章标题
|
||||
const articleTitle = ref('');
|
||||
// 封面图URL
|
||||
const coverUrl = ref('');
|
||||
// 富文本编辑器容器引用
|
||||
const editor = ref<HTMLDivElement | null>(null);
|
||||
// 富文本实例
|
||||
let quillInstance: Quill | null = null;
|
||||
|
||||
// 加载状态
|
||||
const isSubmitting = ref(false); // 发布中
|
||||
const isSavingDraft = ref(false); // 保存草稿中
|
||||
|
||||
//抽屉状态
|
||||
|
||||
// -------------------------- 表单校验 --------------------------
|
||||
// 标题校验
|
||||
const titleIsValid = ref(false);
|
||||
const titleHintText = ref('请输入5-50字的文章标题');
|
||||
const checkTitleValid = () => {
|
||||
const len = articleTitle.value.trim().length;
|
||||
if (len === 0) {
|
||||
titleHintText.value = '请输入文章标题';
|
||||
titleIsValid.value = false;
|
||||
} else if (len < 5) {
|
||||
titleHintText.value = '标题长度不能少于5个字';
|
||||
titleIsValid.value = false;
|
||||
} else if (len > 50) {
|
||||
titleHintText.value = '标题长度不能超过50个字';
|
||||
titleIsValid.value = false;
|
||||
} else {
|
||||
titleHintText.value = '标题格式正确';
|
||||
titleIsValid.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 内容校验(是否有有效内容)
|
||||
const contentIsValid = ref(false);
|
||||
const contentHintText = ref('请输入文章内容');
|
||||
const checkContentValid = () => {
|
||||
console.log('校验触发:quillInstance是否存在', quillInstance !== null);
|
||||
if (quillInstance) {
|
||||
const content = quillInstance.getText().trim();
|
||||
console.log('当前内容(去空后):', content);
|
||||
console.log('内容长度:', content.length);
|
||||
}
|
||||
|
||||
if (!quillInstance) return;
|
||||
const contentLen = quillInstance.getText().trim().length;
|
||||
if (contentLen === 0) {
|
||||
contentHintText.value = '文章内容不能为空';
|
||||
contentIsValid.value = false;
|
||||
} else if (contentLen < 100) {
|
||||
contentHintText.value = '建议文章内容不少于100字,提升阅读体验';
|
||||
contentIsValid.value = true; // 非强制,仅提示
|
||||
} else {
|
||||
contentHintText.value = '内容格式正确';
|
||||
contentIsValid.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 计算属性:安全获取编辑器字数
|
||||
const wordCount = computed(() => {
|
||||
if (!quillInstance) return null;
|
||||
return quillInstance.getText().length;
|
||||
});
|
||||
|
||||
// 表单整体是否有效(发布按钮是否可点击)
|
||||
const isFormValid = computed(() => {
|
||||
return titleIsValid.value && contentIsValid.value && !isSubmitting.value && !isSavingDraft.value;
|
||||
});
|
||||
|
||||
// -------------------------- 封面上传逻辑 --------------------------
|
||||
// 封面上传成功
|
||||
const handleCoverSuccess: UploadProps['onSuccess'] = (response) => {
|
||||
const ossUrl = response.data?.url;
|
||||
if (ossUrl) {
|
||||
coverUrl.value = ossUrl;
|
||||
ElMessage.success('封面上传成功');
|
||||
} else {
|
||||
ElMessage.error('封面上传失败:未获取到图片地址');
|
||||
}
|
||||
};
|
||||
|
||||
// 封面上传前校验
|
||||
const beforeCoverUpload: UploadProps['beforeUpload'] = (rawFile) => {
|
||||
const allowTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
if (!allowTypes.includes(rawFile.type)) {
|
||||
ElMessage.error('仅支持JPG/PNG/WEBP格式的图片');
|
||||
return false;
|
||||
}
|
||||
if (rawFile.size / 1024 / 1024 > 5) {
|
||||
ElMessage.error('图片大小不能超过5MB');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 封面上传失败
|
||||
const handleCoverError: UploadProps['onError'] = (err) => {
|
||||
ElMessage.error(`封面上传失败:${err.message || '网络错误'}`);
|
||||
};
|
||||
|
||||
// 删除封面
|
||||
const removeCover = () => {
|
||||
coverUrl.value = '';
|
||||
ElMessage.info('封面已删除');
|
||||
};
|
||||
|
||||
// -------------------------- 富文本编辑器逻辑 --------------------------
|
||||
onMounted(() => {
|
||||
if (editor.value) {
|
||||
// 初始化Quill编辑器
|
||||
quillInstance = new Quill(editor.value, {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
editor: {
|
||||
scrollingContainer: '.quill-editor'
|
||||
},
|
||||
toolbar: {
|
||||
container: [
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ 'color': [] }, { 'background': [] }],
|
||||
[{ 'font': [] }, { 'size': ['small', false, 'large', 'huge'] }],
|
||||
[{ 'align': [] }],
|
||||
['blockquote', 'code-block'],
|
||||
[{ 'list': 'ordered' }, { 'list': 'bullet' }, { 'list': 'check' }],
|
||||
[{ 'indent': '-1' }, { 'indent': '+1' }],
|
||||
['link', 'image', 'video'],
|
||||
['clean']
|
||||
],
|
||||
handlers: {
|
||||
image: handleEditorImageUpload
|
||||
}
|
||||
}
|
||||
},
|
||||
placeholder: '请开始创作你的文章...(支持文字、图片、代码块等格式)'
|
||||
});
|
||||
|
||||
// 初始化后延迟校验,确保实例就绪
|
||||
setTimeout(() => {
|
||||
checkContentValid();
|
||||
}, 100);
|
||||
|
||||
// 监听所有文本变化,实时触发校验
|
||||
quillInstance.on('text-change', (delta, oldDelta, source) => {
|
||||
// 先触发内容校验,覆盖所有输入场景
|
||||
checkContentValid();
|
||||
|
||||
// 处理粘贴图片逻辑
|
||||
if (source === 'user') {
|
||||
const pastedBase64 = getPastedBase64Image(delta);
|
||||
if (pastedBase64) {
|
||||
const selection = quillInstance?.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
const imageIndex = selection.index;
|
||||
quillInstance?.deleteText(imageIndex, 1); // 删除base64占位符
|
||||
|
||||
const blob = base64ToBlob(pastedBase64);
|
||||
if (blob) {
|
||||
const file = new File(
|
||||
[blob],
|
||||
`editor-image-${Date.now()}.png`,
|
||||
{ type: blob.type }
|
||||
);
|
||||
uploadEditorImage(file, imageIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 加载本地草稿
|
||||
const savedDraft = localStorage.getItem('articleDraft');
|
||||
if (savedDraft) {
|
||||
const draft = JSON.parse(savedDraft);
|
||||
articleTitle.value = draft.title;
|
||||
coverUrl.value = draft.coverUrl;
|
||||
if (quillInstance && draft.contentHtml) {
|
||||
quillInstance.root.innerHTML = draft.contentHtml;
|
||||
// 草稿加载后触发校验
|
||||
setTimeout(() => {
|
||||
checkContentValid();
|
||||
checkTitleValid();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 编辑器工具栏「图片」按钮点击
|
||||
function handleEditorImageUpload() {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
|
||||
fileInput.onchange = () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const selection = quillInstance?.getSelection();
|
||||
const insertIndex = selection ? selection.index : quillInstance?.getLength() || 0;
|
||||
uploadEditorImage(file, insertIndex);
|
||||
fileInput.value = '';
|
||||
};
|
||||
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
// 编辑器图片上传到服务器
|
||||
async function uploadEditorImage(file: File, insertIndex: number) {
|
||||
if (!quillInstance) return;
|
||||
|
||||
// 插入「上传中」占位文本
|
||||
const loadingIndex = insertIndex;
|
||||
quillInstance.insertText(
|
||||
loadingIndex,
|
||||
'[图片上传中...]',
|
||||
{ color: '#666', italic: true }
|
||||
);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const response = await fetch('http://localhost:8080/api/upload/image', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP状态码:${response.status}`);
|
||||
const result = await response.json();
|
||||
const imageUrl = result.data?.url;
|
||||
|
||||
if (!imageUrl) throw new Error('未获取到图片地址');
|
||||
|
||||
// 替换占位文本为真实图片
|
||||
quillInstance.deleteText(loadingIndex, '[图片上传中...]'.length);
|
||||
quillInstance.insertEmbed(loadingIndex, 'image', imageUrl);
|
||||
quillInstance.setSelection(loadingIndex + 1);
|
||||
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : '未知错误';
|
||||
quillInstance.deleteText(loadingIndex, '[图片上传中...]'.length);
|
||||
quillInstance.insertText(
|
||||
loadingIndex,
|
||||
`[图片上传失败:${errMsg}]`,
|
||||
{ color: '#ff4444', italic: true }
|
||||
);
|
||||
ElMessage.error(`编辑器图片上传失败:${errMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 提取粘贴的base64图片
|
||||
function getPastedBase64Image(delta: any): string | null {
|
||||
if (!delta?.ops) return null;
|
||||
for (const op of delta.ops) {
|
||||
if (
|
||||
op.insert &&
|
||||
typeof op.insert === 'object' &&
|
||||
op.insert.image &&
|
||||
op.insert.image.startsWith('data:image')
|
||||
) {
|
||||
return op.insert.image;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// base64转Blob
|
||||
function base64ToBlob(base64: string): Blob | null {
|
||||
try {
|
||||
const [prefix, data] = base64.split(',');
|
||||
if (!prefix || !data) throw new Error('base64格式错误');
|
||||
|
||||
const mimeMatch = prefix.match(/data:(.*?);/);
|
||||
if (!mimeMatch) throw new Error('无法解析图片类型');
|
||||
const mime = mimeMatch[1];
|
||||
|
||||
const binaryData = atob(data);
|
||||
const bytes = new Uint8Array(binaryData.length);
|
||||
for (let i = 0; i < binaryData.length; i++) {
|
||||
bytes[i] = binaryData.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new Blob([bytes], { type: mime });
|
||||
} catch (error) {
|
||||
console.error('base64转Blob失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------- 表单提交逻辑 --------------------------
|
||||
// 保存草稿
|
||||
const saveDraft = async () => {
|
||||
if (!titleIsValid.value && articleTitle.value.trim().length > 0) {
|
||||
ElMessage.warning('标题格式不正确,请调整后再保存');
|
||||
return;
|
||||
}
|
||||
|
||||
isSavingDraft.value = true;
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
const draftData = {
|
||||
title: articleTitle.value.trim() || '未命名草稿',
|
||||
coverUrl: coverUrl.value,
|
||||
contentHtml: quillInstance?.root.innerHTML || '',
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
localStorage.setItem('articleDraft', JSON.stringify(draftData));
|
||||
ElMessage.success('草稿保存成功');
|
||||
|
||||
} catch (err) {
|
||||
ElMessage.error(`草稿保存失败:${err instanceof Error ? err.message : '未知错误'}`);
|
||||
} finally {
|
||||
isSavingDraft.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 发布文章
|
||||
const submitArticle = async () => {
|
||||
if (!isFormValid.value) return;
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const articleData = {
|
||||
title: articleTitle.value.trim(),
|
||||
coverUrl: coverUrl.value,
|
||||
contentHtml: quillInstance?.root.innerHTML || '',
|
||||
contentText: quillInstance?.getText().trim() || '',
|
||||
status: 'published',
|
||||
publishTime: new Date().toISOString()
|
||||
};
|
||||
console.log('提交的文章数据:', articleData);
|
||||
|
||||
const response = await fetch('http://localhost:8080/api/articles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(articleData),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`发布失败,HTTP状态码:${response.status}`);
|
||||
const result = await response.json();
|
||||
|
||||
ElMessage.success('文章发布成功!即将跳转到文章详情页');
|
||||
setTimeout(() => {
|
||||
window.location.href = `/article/${result.data?.id || ''}`;
|
||||
}, 1500);
|
||||
|
||||
} catch (err) {
|
||||
ElMessage.error(`文章发布失败:${err instanceof Error ? err.message : '网络错误'}`);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 全局样式变量
|
||||
$primary-color: #165DFF;
|
||||
$primary-light: #E8F3FF;
|
||||
$danger-color: #FF4D4F;
|
||||
$warning-color: #FF9F43;
|
||||
$success-color: #00B42A;
|
||||
$text-primary: #333333;
|
||||
$text-secondary: #666666;
|
||||
$text-placeholder: #999999;
|
||||
$border-color: #E5E7EB;
|
||||
$card-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
|
||||
$hover-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.12);
|
||||
$radius: 8px;
|
||||
$transition: all 0.3s ease;
|
||||
|
||||
// 页面容器
|
||||
.article-publish-page {
|
||||
min-height: 100vh;
|
||||
background-color: #FAFAFC;
|
||||
padding: 30px 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 页面标题
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
color: $text-primary;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 16px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
// 表单容器
|
||||
.publish-form-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background-color: #FFFFFF;
|
||||
border-radius: $radius;
|
||||
box-shadow: $card-shadow;
|
||||
padding: 36px 40px;
|
||||
box-sizing: border-box;
|
||||
transition: $transition;
|
||||
|
||||
&:hover {
|
||||
box-shadow: $hover-shadow;
|
||||
}
|
||||
}
|
||||
|
||||
// 表单组通用样式
|
||||
.form-group {
|
||||
margin-bottom: 32px;
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
color: $text-primary;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-desc {
|
||||
font-size: 14px;
|
||||
color: $text-secondary;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// 标题输入框样式
|
||||
.title-group {
|
||||
.title-input {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius;
|
||||
font-size: 18px;
|
||||
color: $text-primary;
|
||||
transition: $transition;
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
color: $text-placeholder;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary-color;
|
||||
box-shadow: 0 0 0 3px $primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
.valid-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
|
||||
&.error {
|
||||
color: $danger-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 封面上传样式(核心修改:紧贴左侧)
|
||||
.cover-group {
|
||||
.cover-uploader {
|
||||
width: 100%;
|
||||
border-radius: $radius;
|
||||
overflow: hidden;
|
||||
padding: 0; // 移除父容器内边距,确保子元素贴边
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// 已上传封面预览框:紧贴左侧
|
||||
.cover-preview {
|
||||
width: 100%;
|
||||
max-width: 600px; // 保留最大宽度限制
|
||||
margin: 0; // 移除水平居中,改为靠左
|
||||
position: relative;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius;
|
||||
overflow: hidden;
|
||||
background-color: #F9FAFB;
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start; // 图片靠左显示
|
||||
}
|
||||
|
||||
.cover-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 400px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.remove-cover-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: #FFFFFF;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: $transition;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.cover-preview:hover .remove-cover-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// 未上传封面的上传框:紧贴左侧
|
||||
.cover-upload-area {
|
||||
width: 100%;
|
||||
max-width: 600px; // 与预览框宽度一致
|
||||
margin: 0; // 移除水平居中,改为靠左
|
||||
}
|
||||
|
||||
// 上传占位框:内部内容靠左
|
||||
.upload-placeholder {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
border: 2px dashed $border-color;
|
||||
border-radius: $radius;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start; // 内容靠左对齐
|
||||
justify-content: center;
|
||||
background-color: #F9FAFB;
|
||||
transition: $transition;
|
||||
cursor: pointer;
|
||||
padding-left: 20px; // 左侧内边距,避免内容贴边框
|
||||
|
||||
&:hover {
|
||||
border-color: $primary-color;
|
||||
background-color: $primary-light;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 32px;
|
||||
color: $text-placeholder;
|
||||
margin-bottom: 12px;
|
||||
transition: $transition;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 16px;
|
||||
color: $text-secondary;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upload-subtext {
|
||||
font-size: 14px;
|
||||
color: $text-placeholder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 富文本编辑器样式
|
||||
.content-group {
|
||||
.editor-wrapper {
|
||||
width: 100%;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius;
|
||||
overflow: hidden;
|
||||
transition: $transition;
|
||||
|
||||
&:hover {
|
||||
border-color: #D1D5DB;
|
||||
}
|
||||
}
|
||||
|
||||
.quill-editor {
|
||||
min-height: 400px;
|
||||
max-height: 800px;
|
||||
overflow-y: auto;
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
|
||||
.ql-editor {
|
||||
min-height: 400px;
|
||||
padding: 20px;
|
||||
color: $text-primary;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: $radius;
|
||||
margin: 16px auto;
|
||||
display: block;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
pre.ql-syntax {
|
||||
background-color: #F3F4F6;
|
||||
border-radius: $radius;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ql-toolbar {
|
||||
border-bottom: 1px solid $border-color;
|
||||
background-color: #F9FAFB;
|
||||
|
||||
.ql-picker-label, .ql-button {
|
||||
color: $text-secondary;
|
||||
transition: $transition;
|
||||
|
||||
&:hover {
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.ql-active {
|
||||
color: $primary-color !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-hint {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
color: $text-secondary;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.content-required {
|
||||
color: $text-placeholder;
|
||||
|
||||
&.active {
|
||||
color: $warning-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 底部操作按钮样式
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
margin-top: 40px;
|
||||
|
||||
.draft-btn, .publish-btn {
|
||||
padding: 12px 24px;
|
||||
border-radius: $radius;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
transition: $transition;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.draft-btn {
|
||||
color: $text-secondary;
|
||||
border-color: $border-color;
|
||||
|
||||
&:hover {
|
||||
color: $text-primary;
|
||||
border-color: #D1D5DB;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-btn {
|
||||
background-color: $primary-color;
|
||||
border-color: $primary-color;
|
||||
|
||||
&:hover {
|
||||
background-color: #0E4BD8;
|
||||
border-color: #0E4BD8;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: #A3C5FF;
|
||||
border-color: #A3C5FF;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式适配
|
||||
@media (max-width: 768px) {
|
||||
.article-publish-page {
|
||||
padding: 20px 12px;
|
||||
}
|
||||
|
||||
.publish-form-container {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.cover-preview .cover-img {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
height: 150px;
|
||||
padding-left: 16px; // 移动端减少左内边距
|
||||
}
|
||||
|
||||
.quill-editor {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.ql-editor {
|
||||
min-height: 300px;
|
||||
padding: 16px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.draft-btn, .publish-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -34,6 +34,10 @@ func main() {
|
||||
|
||||
// 3. 图片上传接口
|
||||
r.POST("/api/upload/image", uploadImageHandler)
|
||||
r.POST("/api/upload/cover", func(c *gin.Context) {
|
||||
// 直接复用已有的上传逻辑
|
||||
uploadImageHandler(c)
|
||||
})
|
||||
|
||||
// 4. 启动服务
|
||||
fmt.Println("后端服务启动成功,地址:http://localhost:8080")
|
||||
|
||||
Reference in New Issue
Block a user