更新文章推送界面

This commit is contained in:
2025-10-04 20:25:05 +08:00
parent f750b91f9d
commit 2dea178fde
7 changed files with 913 additions and 271 deletions

View File

@@ -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>

View File

@@ -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',

View File

@@ -1,10 +1,8 @@
<template>
<div>
<h2>使用 Quill 富文本编辑器</h2>
<QuillEditor />
</div>
</template>
<script lang="ts" setup>
import QuillEditor from './QuillEditor.vue'
</script>

View File

@@ -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>

View File

@@ -0,0 +1,9 @@
<template>
<div>
<QuillEditor />
</div>
</template>
<script lang="ts" setup>
import QuillEditor from './QuillEditor.vue'
</script>

View 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>