完整新闻信息管理
This commit is contained in:
1019
management/src/components/ArticlePublish.vue
Normal file
1019
management/src/components/ArticlePublish.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -1,198 +1,403 @@
|
||||
<template>
|
||||
<div class="news-manage">
|
||||
<!-- 查询表单 -->
|
||||
<el-form :model="searchForm" label-width="100px" class="search-form">
|
||||
<el-form-item label="新闻标题">
|
||||
<el-input v-model="searchForm.title" placeholder="请输入标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="副标题">
|
||||
<el-input v-model="searchForm.subtitle" placeholder="请输入副标题" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-config-provider :locale="zhCn">
|
||||
<!-- 主容器 -->
|
||||
<div class="p-6 bg-white rounded-lg shadow-md min-h-screen pb-24" v-loading="loading">
|
||||
|
||||
<!-- 新闻表格 -->
|
||||
<el-table :data="filteredNewsList" border style="width: 100%" class="news-table">
|
||||
<el-table-column prop="title" label="标题" />
|
||||
<el-table-column prop="subtitle" label="副标题" />
|
||||
<el-table-column prop="cover" label="封面" width="120">
|
||||
<template #default="scope">
|
||||
<!-- 封面图片展示(模拟URL) -->
|
||||
<img
|
||||
v-if="scope.row.cover"
|
||||
:src="scope.row.cover"
|
||||
style="width: 100px; height: auto; object-fit: cover;"
|
||||
alt="封面"
|
||||
/>
|
||||
<span v-else>无封面</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="content" label="文章内容" width="300" show-overflow-tooltip />
|
||||
<el-table-column label="操作">
|
||||
<template #default="scope">
|
||||
<el-button type="text" @click="handleEdit(scope.row)">编辑</el-button>
|
||||
<el-button type="text" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 头部区域 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<span class="text-2xl font-bold text-gray-700">文章管理列表</span>
|
||||
<el-button type="primary" :icon="Plus" @click="handleCreate">新建文章</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog title="编辑新闻" v-model="dialogVisible" width="60%">
|
||||
<el-form :model="currentNews" label-width="100px">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="currentNews.title" />
|
||||
</el-form-item>
|
||||
<el-form-item label="副标题">
|
||||
<el-input v-model="currentNews.subtitle" />
|
||||
</el-form-item>
|
||||
<el-form-item label="封面地址">
|
||||
<el-input
|
||||
v-model="currentNews.cover"
|
||||
placeholder="请输入封面图片URL(如:https://xxx.jpg)"
|
||||
<!-- 文章表格 -->
|
||||
<el-table :data="tableData" style="width: 100%" row-key="id">
|
||||
<el-table-column prop="id" label="ID" width="80" sortable fixed></el-table-column>
|
||||
<el-table-column label="封面" width="180">
|
||||
<template #default="scope">
|
||||
<el-image style="width: 120px; height: 70px; border-radius: 6px;" :src="scope.row.cover" :preview-src-list="[scope.row.cover]" fit="cover" :preview-teleported="true" hide-on-click-modal>
|
||||
<template #error>
|
||||
<div class="flex items-center justify-center w-full h-full bg-gray-100 text-gray-500">加载失败</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span class="font-semibold">{{ scope.row.title }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="文章内容" min-width="300">
|
||||
<template #default="scope">
|
||||
<div class="content-preview" :title="scope.row.content">
|
||||
{{ scope.row.content.length > 100 ? `${scope.row.content.slice(0, 100)}...` : scope.row.content }}
|
||||
</div>
|
||||
<el-button size="mini" type="text" class="mt-1 text-blue-600" @click="handleViewContent(scope.row)">查看详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="topic" label="主题" width="120"></el-table-column>
|
||||
<el-table-column prop="excerpt" label="摘要" min-width="250" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="create_at" label="创建时间" width="180" sortable></el-table-column>
|
||||
<el-table-column prop="update_at" label="更新时间" width="180" sortable></el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="scope">
|
||||
<div class="action-buttons">
|
||||
<el-button size="small" type="primary" :icon="Edit" @click="handleEdit(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" :icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- Content 详情弹窗 -->
|
||||
<el-dialog v-model="contentDialogVisible" title="文章内容详情" :width="`800px`" :before-close="handleCloseDialog">
|
||||
<el-card class="p-4">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-4">{{ currentArticle.title }}</h3>
|
||||
<div class="content-full text-gray-700 leading-relaxed whitespace-pre-wrap">
|
||||
{{ currentArticle.content || "当前文章暂无内容" }}
|
||||
</div>
|
||||
</el-card>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<!-- 分页器容器 -->
|
||||
<div class="fixed bottom-0 right-0 w-full p-4 bg-white shadow-[0_-2px_5px_rgba(0,0,0,0.05)] flex justify-end z-10 border-t border-gray-200">
|
||||
<el-pagination class="custom-pagination" background layout="total, sizes, prev, pager, next, jumper" :total="total" v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50, 100]" @size-change="handleSizeChange" @current-change="handleCurrentChange"></el-pagination>
|
||||
</div>
|
||||
|
||||
<!-- ==================================================== -->
|
||||
<!-- 🔥 文章编辑/新建抽屉 (已修改) 🔥 -->
|
||||
<!-- ==================================================== -->
|
||||
<el-drawer v-model="drawerVisible" :title="isEditMode ? '编辑文章' : '发表新文章'" direction="rtl" size="60%" :before-close="handleDrawerClose" destroy-on-close>
|
||||
<div class="publish-form-container">
|
||||
<!-- 1. 文章标题输入 -->
|
||||
<div class="form-group title-group">
|
||||
<label class="form-label">文章标题</label>
|
||||
<el-input v-model="form.title" placeholder="请输入文章标题" clearable :disabled="isSubmitting" />
|
||||
</div>
|
||||
|
||||
<!-- 2. 专题选择 (仅在新建时显示) -->
|
||||
<div v-if="!isEditMode" class="form-group topic-group">
|
||||
<label class="form-label">发表到专题</label>
|
||||
<el-radio-group v-model="form.topic" :disabled="isSubmitting">
|
||||
<el-radio label="news">新闻</el-radio>
|
||||
<el-radio label="cases">案例资源</el-radio>
|
||||
<el-radio label="community">社区服务</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 3. 封面图上传 -->
|
||||
<div class="form-group cover-group">
|
||||
<label class="form-label">文章封面</label>
|
||||
<el-upload action="http://localhost:8080/api/upload/cover" name="image" :show-file-list="false" :on-success="handleCoverSuccess" :before-upload="beforeCoverUpload" :on-error="handleCoverError" :disabled="isSubmitting">
|
||||
<img v-if="form.cover" :src="form.cover" class="cover-preview" alt="封面"/>
|
||||
<el-icon v-else class="cover-uploader-icon"><Plus /></el-icon>
|
||||
</el-upload>
|
||||
</div>
|
||||
|
||||
<!-- 4. 文章摘要编辑 -->
|
||||
<div class="form-group excerpt-group">
|
||||
<label class="form-label">文章摘要</label>
|
||||
<el-input
|
||||
v-model="form.excerpt"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入文章摘要(可选,若不填则自动从正文截取)"
|
||||
clearable
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="文章内容">
|
||||
<!-- 富文本内容(用 textarea 模拟,实际可替换为专业富文本编辑器) -->
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="currentNews.content"
|
||||
rows="8"
|
||||
placeholder="请输入富文本内容(支持换行等格式)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 5. 文章内容富文本编辑 -->
|
||||
<div class="form-group content-group">
|
||||
<label class="form-label">文章内容</label>
|
||||
<div ref="editorRef" class="quill-editor"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveEdit">保存</el-button>
|
||||
</span>
|
||||
<div style="flex: auto">
|
||||
<el-button @click="handleDrawerClose">取消</el-button>
|
||||
<el-button type="primary" @click="submitArticle" :loading="isSubmitting">
|
||||
{{ isEditMode ? '更新文章' : '发布文章' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import { ElMessage} from 'element-plus';
|
||||
import { ref, onMounted, watch, nextTick } from 'vue';
|
||||
import { ElMessage, ElMessageBox, ElDialog, ElConfigProvider, ElDrawer, ElInput, ElRadioGroup, ElRadio, ElUpload } from 'element-plus';
|
||||
import { Edit, Delete, Plus } from '@element-plus/icons-vue';
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs';
|
||||
import Quill from 'quill';
|
||||
import 'quill/dist/quill.snow.css';
|
||||
import type { UploadProps } from 'element-plus';
|
||||
|
||||
// 模拟“写死”的新闻数据
|
||||
const newsList = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '人工智能最新进展',
|
||||
subtitle: '自然语言处理领域取得突破',
|
||||
cover: 'https://ts2.tc.mm.bing.net/th/id/OIP-C.Mk--gp8OIJtRPOJEByk4qwHaHa?w=80&h=80&c=1&bgcl=1065f3&r=0&o=7&cb=12&pid=ImgRC&rm=3', // 模拟封面图URL
|
||||
content: '人工智能在自然语言处理领域取得重大突破,能够更精准地理解人类意图,实现了多轮对话的连贯性和准确性提升。',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '新能源汽车技术革新',
|
||||
subtitle: '续航与充电效率双提升',
|
||||
cover: 'https://picsum.photos/200/150?random=2',
|
||||
content: '某车企发布新一代新能源汽车,续航里程突破800公里,同时快充技术实现10分钟充电80%,极大缓解用户里程焦虑。',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '全球气候变化研讨会',
|
||||
subtitle: '多国专家共商应对策略',
|
||||
cover: '', // 无封面示例
|
||||
content: '近日,全球气候变化研讨会在瑞士举行,来自30多个国家的气候专家齐聚一堂,共同探讨减少碳排放、应对极端天气的有效策略。',
|
||||
},
|
||||
]);
|
||||
// --- 类型定义 ---
|
||||
interface Article {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
cover: string;
|
||||
create_at: string;
|
||||
update_at: string;
|
||||
is_delete: number;
|
||||
topic: string;
|
||||
excerpt: string;
|
||||
}
|
||||
|
||||
// 查询表单:双向绑定的查询条件
|
||||
const searchForm = reactive({
|
||||
title: '', // 标题关键字
|
||||
subtitle: '',// 副标题关键字
|
||||
});
|
||||
interface ListArticleReq {
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// 【计算属性】根据查询条件过滤后的新闻列表
|
||||
const filteredNewsList = computed(() => {
|
||||
return newsList.value.filter((news) => {
|
||||
// 标题匹配:包含关键字
|
||||
if (searchForm.title && !news.title.includes(searchForm.title)) {
|
||||
return false;
|
||||
}
|
||||
// 副标题匹配:包含关键字
|
||||
if (searchForm.subtitle && !news.subtitle.includes(searchForm.subtitle)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
// --- API 基地址 ---
|
||||
const API_BASE_URL = 'http://localhost:8080/api';
|
||||
|
||||
// 编辑弹窗状态与当前编辑的新闻
|
||||
const dialogVisible = ref(false); // 弹窗显隐
|
||||
const currentNews = reactive({
|
||||
id: 0,
|
||||
// =================================================================
|
||||
// 列表页相关状态与逻辑
|
||||
// =================================================================
|
||||
const loading = ref(true);
|
||||
const tableData = ref<Article[]>([]);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const total = ref(0);
|
||||
const contentDialogVisible = ref(false);
|
||||
const currentArticle = ref<Article>({ id: 0, title: '', content: '', cover: '', create_at: '', update_at: '', is_delete: 0, topic: '', excerpt: '' });
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const reqData: ListArticleReq = { page: currentPage.value, size: pageSize.value };
|
||||
const response = await fetch(`${API_BASE_URL}/articles/getarticle`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqData)
|
||||
});
|
||||
if (!response.ok) throw new Error(`请求失败!状态码:${response.status}`);
|
||||
const data = await response.json();
|
||||
tableData.value = data.Article_list || [];
|
||||
total.value = data.total || 0;
|
||||
} catch (error) {
|
||||
console.error('[ERROR] 获取文章列表失败:', error);
|
||||
ElMessage.error('获取文章列表失败,请检查接口或网络!');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchData);
|
||||
|
||||
const handleViewContent = (row: Article) => {
|
||||
currentArticle.value = { ...row };
|
||||
contentDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
contentDialogVisible.value = false;
|
||||
};
|
||||
|
||||
const handleDelete = (row: Article) => {
|
||||
ElMessageBox.confirm(`确定要删除文章《${row.title}》吗?此操作无法撤销!`, '警告', { confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/articles/${row.id}`, { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('删除失败');
|
||||
ElMessage.success('删除成功!');
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('[ERROR] 删除文章失败:', error);
|
||||
ElMessage.error('删除文章失败,请重试!');
|
||||
}
|
||||
})
|
||||
.catch(() => ElMessage.info('已取消删除'));
|
||||
};
|
||||
|
||||
const handleSizeChange = (val: number) => {
|
||||
pageSize.value = val;
|
||||
currentPage.value = 1;
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const handleCurrentChange = (val: number) => {
|
||||
currentPage.value = val;
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// 抽屉编辑/新建相关状态与逻辑
|
||||
// =================================================================
|
||||
const drawerVisible = ref(false);
|
||||
const isEditMode = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const defaultFormState = () => ({
|
||||
id: null as number | null,
|
||||
title: '',
|
||||
subtitle: '',
|
||||
topic: 'news',
|
||||
cover: '',
|
||||
content: '',
|
||||
excerpt: '',
|
||||
});
|
||||
const form = ref(defaultFormState());
|
||||
|
||||
// --- 富文本编辑器 ---
|
||||
const editorRef = ref<HTMLDivElement | null>(null);
|
||||
let quillInstance: Quill | null = null;
|
||||
|
||||
const initQuillEditor = () => {
|
||||
if (editorRef.value && !quillInstance) {
|
||||
quillInstance = new Quill(editorRef.value, {
|
||||
theme: 'snow',
|
||||
modules: { toolbar: [['bold', 'italic', 'underline'], ['link', 'image']] },
|
||||
placeholder: '请开始创作你的文章...'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
watch(drawerVisible, (visible) => {
|
||||
if (visible) {
|
||||
nextTick(() => {
|
||||
initQuillEditor();
|
||||
if (isEditMode.value) {
|
||||
if (quillInstance) {
|
||||
quillInstance.root.innerHTML = form.value.content;
|
||||
}
|
||||
} else {
|
||||
if (quillInstance) {
|
||||
quillInstance.setContents([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 🔥 关键修复:当抽屉关闭时,将实例重置为 null。
|
||||
// `destroy-on-close` 属性会处理 DOM 的销毁。
|
||||
quillInstance = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 点击“编辑”:打开弹窗并赋值当前行数据
|
||||
const handleEdit = (row: any) => {
|
||||
// 深拷贝避免直接修改原数据
|
||||
Object.assign(currentNews, JSON.parse(JSON.stringify(row)));
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 点击“保存”:更新原数据并关闭弹窗
|
||||
const saveEdit = () => {
|
||||
const targetIndex = newsList.value.findIndex((item) => item.id === currentNews.id);
|
||||
if (targetIndex !== -1) {
|
||||
newsList.value[targetIndex] = { ...currentNews }; // 替换原数据
|
||||
ElMessage.success('编辑成功');
|
||||
// --- 操作处理 ---
|
||||
const resetForm = () => {
|
||||
form.value = defaultFormState();
|
||||
if (quillInstance) {
|
||||
quillInstance.setContents([]);
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 点击“删除”:弹窗确认后删除数据
|
||||
const handleDelete = (row: any) => {
|
||||
// ElMessage.confirm('确定要删除这条新闻吗?', '提示', {
|
||||
// confirmButtonText: '确定',
|
||||
// cancelButtonText: '取消',
|
||||
// type: 'warning',
|
||||
// })
|
||||
// .then(() => {
|
||||
// newsList.value = newsList.value.filter((item) => item.id !== row.id);
|
||||
// ElMessage.success('删除成功');
|
||||
// })
|
||||
// .catch(() => {
|
||||
// ElMessage.info('已取消删除');
|
||||
// });
|
||||
const handleCreate = () => {
|
||||
isEditMode.value = false;
|
||||
resetForm();
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 重置查询条件
|
||||
const resetSearch = () => {
|
||||
searchForm.title = '';
|
||||
searchForm.subtitle = '';
|
||||
const handleEdit = (row: Article) => {
|
||||
isEditMode.value = true;
|
||||
form.value = {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
topic: row.topic,
|
||||
cover: row.cover,
|
||||
excerpt: row.excerpt,
|
||||
content: row.content,
|
||||
};
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
// 执行查询(计算属性自动过滤,这里仅作提示)
|
||||
const search = () => {
|
||||
ElMessage.info('查询完成');
|
||||
const handleDrawerClose = () => {
|
||||
drawerVisible.value = false;
|
||||
};
|
||||
|
||||
const submitArticle = async () => {
|
||||
if (!form.value.title) {
|
||||
ElMessage.warning('请输入文章标题');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
const contentHTML = quillInstance?.root.innerHTML || '';
|
||||
const excerpt = form.value.excerpt.trim() || quillInstance?.getText().trim().slice(0, 150) || '';
|
||||
|
||||
const submitData = {
|
||||
...form.value,
|
||||
content: contentHTML,
|
||||
excerpt: excerpt,
|
||||
};
|
||||
|
||||
try {
|
||||
const url = isEditMode.value ? `${API_BASE_URL}/articles/${form.value.id}` : `${API_BASE_URL}/articles`;
|
||||
const method = isEditMode.value ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(submitData),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('提交失败');
|
||||
|
||||
ElMessage.success(isEditMode.value ? '文章更新成功!' : '文章发布成功!');
|
||||
drawerVisible.value = false;
|
||||
fetchData(); // 刷新列表
|
||||
|
||||
} catch (error) {
|
||||
ElMessage.error('提交文章失败!');
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- 封面上传处理 ---
|
||||
const handleCoverSuccess: UploadProps['onSuccess'] = (response) => {
|
||||
const ossUrl = response.data?.url;
|
||||
if (ossUrl) {
|
||||
form.value.cover = ossUrl;
|
||||
ElMessage.success('封面上传成功');
|
||||
} else {
|
||||
ElMessage.error('封面上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const beforeCoverUpload: UploadProps['beforeUpload'] = (rawFile) => {
|
||||
const isLt5M = rawFile.size / 1024 / 1024 < 5;
|
||||
if (!isLt5M) {
|
||||
ElMessage.error('图片大小不能超过 5MB!');
|
||||
}
|
||||
return isLt5M;
|
||||
};
|
||||
|
||||
const handleCoverError = () => {
|
||||
ElMessage.error('封面上传失败');
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.news-manage {
|
||||
padding: 20px;
|
||||
}
|
||||
.search-form {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
.news-table {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.dialog-footer {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
/* 表格样式 */
|
||||
.el-table .el-table__cell { vertical-align: middle; }
|
||||
.el-table__header-wrapper th { background-color: #fafafa !important; font-weight: 600; color: #333; }
|
||||
.action-buttons .el-button { margin-right: 8px; }
|
||||
.content-preview { color: #666; line-height: 1.5; word-break: break-all; }
|
||||
.content-full { min-height: 300px; padding: 20px; background-color: #f9fafb; border-radius: 8px; }
|
||||
.el-dialog__title { font-size: 18px !important; font-weight: 600 !important; }
|
||||
|
||||
/* 分页器样式 */
|
||||
.custom-pagination { justify-content: flex-end !important; }
|
||||
.custom-pagination .el-pagination__total,
|
||||
.custom-pagination .el-pagination__sizes,
|
||||
.custom-pagination .el-pagination__jump { margin-right: 16px !important; }
|
||||
.custom-pagination .el-pagination__jump .el-input { width: 60px !important; }
|
||||
|
||||
/* 抽屉内表单样式 */
|
||||
.publish-form-container { padding: 0 20px; }
|
||||
.form-group { margin-bottom: 24px; }
|
||||
.form-label { display: block; margin-bottom: 8px; color: #333; font-size: 14px; font-weight: 600; }
|
||||
.quill-editor { height: 350px; border-radius: 4px; border: 1px solid #dcdfe6; }
|
||||
.cover-uploader .el-upload { border: 1px dashed #d9d9d9; border-radius: 6px; cursor: pointer; position: relative; overflow: hidden; }
|
||||
.cover-uploader .el-upload:hover { border-color: #409EFF; }
|
||||
.cover-uploader-icon { font-size: 28px; color: #8c939d; width: 178px; height: 178px; text-align: center; }
|
||||
.cover-preview { width: 178px; height: 178px; display: block; object-fit: cover; }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
<template>
|
||||
<div class="article-publish-page">
|
||||
<!-- 页面标题 -->
|
||||
<!-- 页面标题:根据是否编辑动态显示 -->
|
||||
<div class="page-header">
|
||||
<h1>发表新文章</h1>
|
||||
<p class="page-desc">创作优质内容,分享你的观点</p>
|
||||
<h1>{{ articleId ? '编辑文章' : '发表新文章' }}</h1>
|
||||
<p class="page-desc">{{ articleId ? '修改文章内容,更新后即时生效' : '创作优质内容,分享你的观点' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 发表表单容器 -->
|
||||
<div class="publish-form-container">
|
||||
<!-- 发表/编辑表单容器 -->
|
||||
<div class="publish-form-container" v-loading="isLoading">
|
||||
<!-- 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"
|
||||
v-model="articleTitle"
|
||||
type="text"
|
||||
class="title-input"
|
||||
placeholder="请输入文章标题(不少于5个字,不超过50字)"
|
||||
@input="checkTitleValid"
|
||||
:disabled="isSubmitting || isSavingDraft"
|
||||
>
|
||||
<!-- 标题校验提示 -->
|
||||
<p class="valid-hint" :class="{ error: !titleIsValid && articleTitle.length > 0 }">
|
||||
@@ -24,12 +25,16 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 新增:专题选择 -->
|
||||
<!-- 2. 专题选择 -->
|
||||
<div class="form-group topic-group">
|
||||
<label class="form-label">发表到专题</label>
|
||||
<p class="form-desc">选择文章所属的专题分类</p>
|
||||
|
||||
<el-radio-group v-model="selectedTopic" class="topic-radio-group">
|
||||
|
||||
<el-radio-group
|
||||
v-model="selectedTopic"
|
||||
class="topic-radio-group"
|
||||
:disabled="isSubmitting || isSavingDraft"
|
||||
>
|
||||
<el-radio label="news" class="topic-radio">
|
||||
<el-icon><News /></el-icon>
|
||||
<span>新闻</span>
|
||||
@@ -53,19 +58,20 @@
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 2. 封面图上传 -->
|
||||
<!-- 3. 封面图上传 -->
|
||||
<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="删除封面"
|
||||
<button
|
||||
class="remove-cover-btn"
|
||||
@click="removeCover"
|
||||
title="删除封面"
|
||||
:disabled="isSubmitting || isSavingDraft"
|
||||
>
|
||||
<el-icon><Close /></el-icon>
|
||||
</button>
|
||||
@@ -73,14 +79,15 @@
|
||||
|
||||
<!-- 未上传时的上传区域 -->
|
||||
<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"
|
||||
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"
|
||||
:disabled="isSubmitting || isSavingDraft"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<el-icon class="upload-icon"><Upload /></el-icon>
|
||||
@@ -91,11 +98,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 文章内容富文本编辑 -->
|
||||
<!-- 4. 文章内容富文本编辑(编辑时加载已有内容) -->
|
||||
<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>
|
||||
@@ -110,29 +117,30 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 4. 底部操作按钮 -->
|
||||
<!-- 5. 底部操作按钮 -->
|
||||
<div class="form-actions">
|
||||
<el-button
|
||||
type="default"
|
||||
size="large"
|
||||
class="draft-btn"
|
||||
@click="saveDraft"
|
||||
:loading="isSavingDraft"
|
||||
<el-button
|
||||
type="default"
|
||||
size="large"
|
||||
class="draft-btn"
|
||||
@click="saveDraft"
|
||||
:loading="isSavingDraft"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
<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-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="publish-btn"
|
||||
@click="submitArticle"
|
||||
:disabled="!isFormValid || isLoading"
|
||||
:loading="isSubmitting"
|
||||
>
|
||||
<el-icon v-if="isSubmitting"><Loading /></el-icon>
|
||||
<span>发布文章</span>
|
||||
<span>{{ articleId ? '更新文章' : '发布文章' }}</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,29 +148,33 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import {onMounted, ref, computed, onUnmounted} from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router'; // 路由相关:获取ID、跳转
|
||||
import Quill from 'quill';
|
||||
import 'quill/dist/quill.snow.css';
|
||||
// Element Plus 组件与图标
|
||||
import { ElUpload, ElButton, ElIcon, ElMessage, ElRadioGroup, ElRadio } from 'element-plus';
|
||||
import { ElUpload, ElButton, ElIcon, ElMessage, ElRadioGroup, ElRadio, ElLoading } from 'element-plus';
|
||||
import { Upload, Close, Loading, Document, UserFilled, Trophy, Files } from '@element-plus/icons-vue';
|
||||
import type { UploadProps } from 'element-plus';
|
||||
import type { UploadProps, LoadingInstance } from 'element-plus';
|
||||
|
||||
// -------------------------- 状态管理 --------------------------
|
||||
// 文章标题
|
||||
const articleTitle = ref('');
|
||||
// 选中的专题
|
||||
const selectedTopic = ref('news'); // 默认选中"新闻"
|
||||
// 封面图URL
|
||||
const coverUrl = ref('');
|
||||
// 富文本编辑器容器引用
|
||||
const editor = ref<HTMLDivElement | null>(null);
|
||||
// 富文本实例
|
||||
let quillInstance: Quill | null = null;
|
||||
// -------------------------- 基础状态管理 --------------------------
|
||||
// 路由相关:获取编辑的文章ID(从路由参数如 /article/edit/123 中获取)
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const articleId = ref<number | null>(null); // 文章ID:null=新增,有值=编辑
|
||||
|
||||
// 加载状态
|
||||
const isSubmitting = ref(false); // 发布中
|
||||
// 表单核心字段
|
||||
const articleTitle = ref(''); // 标题
|
||||
const selectedTopic = ref('news'); // 专题(默认新闻)
|
||||
const coverUrl = ref(''); // 封面图URL
|
||||
const editor = ref<HTMLDivElement | null>(null); // 富文本容器
|
||||
let quillInstance: Quill | null = null; // 富文本实例
|
||||
|
||||
// 加载/提交状态
|
||||
const isLoading = ref(false); // 整体加载(如加载详情)
|
||||
const isSubmitting = ref(false); // 提交中(发布/更新)
|
||||
const isSavingDraft = ref(false); // 保存草稿中
|
||||
let loadingInstance: LoadingInstance | null = null; // 加载遮罩实例
|
||||
|
||||
// -------------------------- 表单校验 --------------------------
|
||||
// 标题校验
|
||||
@@ -185,19 +197,13 @@ const checkTitleValid = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 内容校验(是否有有效内容)
|
||||
// 内容校验
|
||||
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;
|
||||
@@ -210,13 +216,12 @@ const checkContentValid = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 计算属性:安全获取编辑器字数
|
||||
// 字数统计
|
||||
const wordCount = computed(() => {
|
||||
if (!quillInstance) return null;
|
||||
return quillInstance.getText().length;
|
||||
return quillInstance ? quillInstance.getText().length : null;
|
||||
});
|
||||
|
||||
// 表单整体是否有效(发布按钮是否可点击)
|
||||
// 表单整体有效性(提交按钮是否可点击)
|
||||
const isFormValid = computed(() => {
|
||||
return titleIsValid.value && contentIsValid.value && !isSubmitting.value && !isSavingDraft.value;
|
||||
});
|
||||
@@ -259,91 +264,72 @@ const removeCover = () => {
|
||||
};
|
||||
|
||||
// -------------------------- 富文本编辑器逻辑 --------------------------
|
||||
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
|
||||
}
|
||||
}
|
||||
// 初始化富文本编辑器
|
||||
const initQuillEditor = () => {
|
||||
if (!editor.value) return;
|
||||
|
||||
// 防止重复初始化
|
||||
if (quillInstance) return quillInstance;
|
||||
|
||||
quillInstance = new Quill(editor.value, {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
editor: {
|
||||
scrollingContainer: '.quill-editor'
|
||||
},
|
||||
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);
|
||||
}
|
||||
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: '请开始创作你的文章...(支持文字、图片、代码块等格式)'
|
||||
});
|
||||
|
||||
// 加载本地草稿
|
||||
const savedDraft = localStorage.getItem('articleDraft');
|
||||
if (savedDraft) {
|
||||
const draft = JSON.parse(savedDraft);
|
||||
articleTitle.value = draft.title;
|
||||
coverUrl.value = draft.coverUrl;
|
||||
// 加载保存的专题选择
|
||||
if (draft.topic) {
|
||||
selectedTopic.value = draft.topic;
|
||||
}
|
||||
if (quillInstance && draft.contentHtml) {
|
||||
quillInstance.root.innerHTML = draft.contentHtml;
|
||||
// 草稿加载后触发校验
|
||||
setTimeout(() => {
|
||||
checkContentValid();
|
||||
checkTitleValid();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
// 监听内容变化,实时校验
|
||||
quillInstance.on('text-change', (delta, _, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化后校验内容
|
||||
setTimeout(checkContentValid, 100);
|
||||
return quillInstance;
|
||||
};
|
||||
|
||||
// 编辑器工具栏图片上传
|
||||
function handleEditorImageUpload() {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
@@ -366,13 +352,9 @@ function handleEditorImageUpload() {
|
||||
async function uploadEditorImage(file: File, insertIndex: number) {
|
||||
if (!quillInstance) return;
|
||||
|
||||
// 插入「上传中」占位文本
|
||||
// 插入上传中占位符
|
||||
const loadingIndex = insertIndex;
|
||||
quillInstance.insertText(
|
||||
loadingIndex,
|
||||
'[图片上传中...]',
|
||||
{ color: '#666', italic: true }
|
||||
);
|
||||
quillInstance.insertText(loadingIndex, '[图片上传中...]', { color: '#666', italic: true });
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -390,7 +372,7 @@ async function uploadEditorImage(file: File, insertIndex: number) {
|
||||
|
||||
if (!imageUrl) throw new Error('未获取到图片地址');
|
||||
|
||||
// 替换占位文本为真实图片
|
||||
// 替换占位符为真实图片
|
||||
quillInstance.deleteText(loadingIndex, '[图片上传中...]'.length);
|
||||
quillInstance.insertEmbed(loadingIndex, 'image', imageUrl);
|
||||
quillInstance.setSelection(loadingIndex + 1);
|
||||
@@ -398,11 +380,7 @@ async function uploadEditorImage(file: File, insertIndex: number) {
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : '未知错误';
|
||||
quillInstance.deleteText(loadingIndex, '[图片上传中...]'.length);
|
||||
quillInstance.insertText(
|
||||
loadingIndex,
|
||||
`[图片上传失败:${errMsg}]`,
|
||||
{ color: '#ff4444', italic: true }
|
||||
);
|
||||
quillInstance.insertText(loadingIndex, `[图片上传失败:${errMsg}]`, { color: '#ff4444', italic: true });
|
||||
ElMessage.error(`编辑器图片上传失败:${errMsg}`);
|
||||
}
|
||||
}
|
||||
@@ -412,10 +390,10 @@ 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')
|
||||
op.insert &&
|
||||
typeof op.insert === 'object' &&
|
||||
op.insert.image &&
|
||||
op.insert.image.startsWith('data:image')
|
||||
) {
|
||||
return op.insert.image;
|
||||
}
|
||||
@@ -446,8 +424,58 @@ function base64ToBlob(base64: string): Blob | null {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------- 表单提交逻辑 --------------------------
|
||||
// 保存草稿
|
||||
// -------------------------- 编辑核心:加载文章详情 --------------------------
|
||||
// 根据文章ID获取详情(编辑时调用)
|
||||
const fetchArticleDetail = async (id: number) => {
|
||||
isLoading.value = true;
|
||||
loadingInstance = ElLoading.service({
|
||||
lock: true,
|
||||
text: '正在加载文章数据...',
|
||||
background: 'rgba(255, 255, 255, 0.7)'
|
||||
});
|
||||
|
||||
try {
|
||||
// 调用后端文章详情接口(请确保后端有该接口)
|
||||
const response = await fetch(`http://localhost:8080/api/articles/${id}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`加载失败,HTTP状态码:${response.status}`);
|
||||
const result = await response.json();
|
||||
const article = result.data || {};
|
||||
|
||||
// 1. 回显基础字段
|
||||
articleTitle.value = article.title || '';
|
||||
selectedTopic.value = article.topic || 'news';
|
||||
coverUrl.value = article.cover || '';
|
||||
|
||||
// 2. 回显富文本内容(需等编辑器初始化完成)
|
||||
const quill = initQuillEditor();
|
||||
if (quill && article.content) {
|
||||
// 清空编辑器并设置已有内容(保留格式)
|
||||
quill.root.innerHTML = article.content;
|
||||
// 重新校验内容
|
||||
setTimeout(checkContentValid, 100);
|
||||
}
|
||||
|
||||
// 3. 触发标题校验
|
||||
checkTitleValid();
|
||||
ElMessage.success('文章数据加载成功');
|
||||
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : '未知错误';
|
||||
ElMessage.error(`加载文章失败:${errMsg}`);
|
||||
console.error('文章详情加载错误:', err);
|
||||
// 加载失败返回列表页
|
||||
setTimeout(() => router.push('/article/list'), 1500);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
if (loadingInstance) loadingInstance.close();
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------- 草稿保存逻辑 --------------------------
|
||||
const saveDraft = async () => {
|
||||
if (!titleIsValid.value && articleTitle.value.trim().length > 0) {
|
||||
ElMessage.warning('标题格式不正确,请调整后再保存');
|
||||
@@ -458,62 +486,133 @@ const saveDraft = async () => {
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
// 草稿数据(包含文章ID,区分编辑/新增)
|
||||
const draftData = {
|
||||
id: articleId.value, // 编辑时携带ID
|
||||
title: articleTitle.value.trim() || '未命名草稿',
|
||||
topic: selectedTopic.value, // 保存选中的专题
|
||||
topic: selectedTopic.value,
|
||||
coverUrl: coverUrl.value,
|
||||
contentHtml: quillInstance?.root.innerHTML || '',
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('articleDraft', JSON.stringify(draftData));
|
||||
ElMessage.success('草稿保存成功');
|
||||
// 打印草稿数据到控制台
|
||||
console.log('【草稿保存】当前草稿数据:', draftData);
|
||||
|
||||
} catch (err) {
|
||||
ElMessage.error(`草稿保存失败:${err instanceof Error ? err.message : '未知错误'}`);
|
||||
console.error('草稿保存错误:', err);
|
||||
} finally {
|
||||
isSavingDraft.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 发布文章
|
||||
// -------------------------- 提交逻辑(区分新增/编辑) --------------------------
|
||||
const submitArticle = async () => {
|
||||
if (!isFormValid.value) return;
|
||||
|
||||
// 1. 组装提交数据
|
||||
const submitData = {
|
||||
id: articleId.value, // 编辑时携带ID,新增时为null
|
||||
title: articleTitle.value.trim(),
|
||||
topic: selectedTopic.value,
|
||||
cover: coverUrl.value, // 后端通常用cover字段接收封面
|
||||
content: quillInstance?.root.innerHTML || '', // 富文本HTML内容
|
||||
contentText: quillInstance?.getText().trim() || '', // 纯文本内容(用于搜索/摘要)
|
||||
excerpt: quillInstance?.getText().trim().slice(0, 150) || '' // 自动生成摘要(前150字)
|
||||
};
|
||||
|
||||
// 2. 打印修改后的信息到控制台(核心需求)
|
||||
console.log('【文章提交】修改后的完整数据:', submitData);
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const articleData = {
|
||||
title: articleTitle.value.trim(),
|
||||
topic: selectedTopic.value, // 提交选中的专题
|
||||
coverUrl: coverUrl.value,
|
||||
contentHtml: quillInstance?.root.innerHTML || '',
|
||||
contentText: quillInstance?.getText().trim() || '',
|
||||
status: 'published',
|
||||
publishTime: new Date().toISOString()
|
||||
};
|
||||
console.log('提交的文章数据:', articleData);
|
||||
let response: Response;
|
||||
const apiBase = 'http://localhost:8080/api/articles';
|
||||
|
||||
const response = await fetch('http://localhost:8080/api/articles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(articleData),
|
||||
credentials: 'include'
|
||||
});
|
||||
// 3. 区分新增/编辑:调用不同接口
|
||||
if (articleId.value) {
|
||||
// 编辑:调用PUT更新接口(后端需支持)
|
||||
response = await fetch(`${apiBase}/${articleId.value}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(submitData),
|
||||
credentials: 'include'
|
||||
});
|
||||
} else {
|
||||
// 新增:调用POST创建接口
|
||||
response = await fetch(apiBase, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(submitData),
|
||||
credentials: 'include'
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error(`发布失败,HTTP状态码:${response.status}`);
|
||||
// 4. 处理响应
|
||||
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);
|
||||
// 5. 提示并跳转
|
||||
const successMsg = articleId.value ? '文章更新成功' : '文章发布成功';
|
||||
ElMessage.success(`${successMsg}!即将跳转到文章列表`);
|
||||
console.log('【提交成功】后端返回结果:', result);
|
||||
|
||||
// 跳转到文章列表页(根据你的路由调整)
|
||||
setTimeout(() => router.push('/article/list'), 1500);
|
||||
|
||||
} catch (err) {
|
||||
ElMessage.error(`文章发布失败:${err instanceof Error ? err.message : '网络错误'}`);
|
||||
const errMsg = err instanceof Error ? err.message : '网络错误';
|
||||
ElMessage.error(`${articleId.value ? '更新' : '发布'}文章失败:${errMsg}`);
|
||||
console.error('文章提交错误:', err);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------- 页面初始化 --------------------------
|
||||
onMounted(() => {
|
||||
// 1. 初始化富文本编辑器
|
||||
initQuillEditor();
|
||||
|
||||
// 2. 从路由参数获取文章ID(如 /article/edit/123 → route.params.id = '123')
|
||||
const idFromRoute = route.params.id;
|
||||
if (idFromRoute && typeof idFromRoute === 'string') {
|
||||
articleId.value = parseInt(idFromRoute, 10);
|
||||
// 3. 编辑模式:加载文章详情
|
||||
if (!isNaN(articleId.value)) {
|
||||
fetchArticleDetail(articleId.value);
|
||||
} else {
|
||||
ElMessage.error('无效的文章ID');
|
||||
router.push('/article/list');
|
||||
}
|
||||
} else {
|
||||
// 4. 新增模式:加载本地草稿
|
||||
const savedDraft = localStorage.getItem('articleDraft');
|
||||
if (savedDraft) {
|
||||
const draft = JSON.parse(savedDraft);
|
||||
articleTitle.value = draft.title || '';
|
||||
selectedTopic.value = draft.topic || 'news';
|
||||
coverUrl.value = draft.coverUrl || '';
|
||||
if (quillInstance && draft.contentHtml) {
|
||||
quillInstance.root.innerHTML = draft.contentHtml;
|
||||
setTimeout(checkContentValid, 100);
|
||||
}
|
||||
checkTitleValid();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------- 页面卸载前清理 --------------------------
|
||||
onUnmounted(() => {
|
||||
// 清除本地草稿(可选:如需保留草稿可注释)
|
||||
// localStorage.removeItem('articleDraft');
|
||||
quillInstance = null;
|
||||
if (loadingInstance) loadingInstance.close();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -617,6 +716,11 @@ $transition: all 0.3s ease;
|
||||
border-color: $primary-color;
|
||||
box-shadow: 0 0 0 3px $primary-light;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: #F9FAFB;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.valid-hint {
|
||||
@@ -654,6 +758,15 @@ $transition: all 0.3s ease;
|
||||
background-color: $primary-light;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
&:hover {
|
||||
border-color: $border-color;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 18px;
|
||||
color: $text-secondary;
|
||||
@@ -730,9 +843,14 @@ $transition: all 0.3s ease;
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.cover-preview:hover .remove-cover-btn {
|
||||
.cover-preview:hover .remove-cover-btn:not(:disabled) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -761,6 +879,15 @@ $transition: all 0.3s ease;
|
||||
background-color: $primary-light;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
&:hover {
|
||||
border-color: $border-color;
|
||||
background-color: #F9FAFB;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 32px;
|
||||
color: $text-placeholder;
|
||||
@@ -836,6 +963,14 @@ $transition: all 0.3s ease;
|
||||
&:hover {
|
||||
color: $primary-color;
|
||||
}
|
||||
|
||||
&.ql-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
&:hover {
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ql-active {
|
||||
@@ -879,6 +1014,12 @@ $transition: all 0.3s ease;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.draft-btn {
|
||||
@@ -889,6 +1030,12 @@ $transition: all 0.3s ease;
|
||||
color: $text-primary;
|
||||
border-color: #D1D5DB;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: #F9FAFB;
|
||||
border-color: $border-color;
|
||||
color: $text-placeholder;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-btn {
|
||||
@@ -903,9 +1050,7 @@ $transition: all 0.3s ease;
|
||||
&:disabled {
|
||||
background-color: #A3C5FF;
|
||||
border-color: #A3C5FF;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -929,7 +1074,7 @@ $transition: all 0.3s ease;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
.topic-radio {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
@@ -968,4 +1113,4 @@ $transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user