第二阶段:用户端与身份认证 & 内容管理升级
目标: 在这一阶段,我们要给系统加上“全局配置”显得更专业,并实现最核心的功能——写文章。我们将引入一个很酷的 Markdown 编辑器,让发布新闻变得像写博客一样简单。
2.1 课程全局信息 (作业要求)
任务书要求: 把课程设计开发基本信息通过上下文读取出来,放置到用户登录首页的底部。
对你们的要求: 理解 Vue 的 provide (提供) 和 inject (注入) 机制。
给零基础同学的话:
想象一下,爷爷(main.ts)有一笔传家宝(全局配置),他通过 provide 把它存进了家族信托。
孙子(Login/index.vue)不管隔了多少代,都可以直接通过 inject 拿到这笔钱,而不需要通过爸爸、叔叔一层层传递。
这样做的好处是:以后改版权年份,只需要改一个文件,全站都变了。
前端实现
- 新建全局配置文件
- 在 src/lib 目录下新建 siteConfig.js。
// src/lib/siteConfig.js
export default {
teamName: 'XHU Headline 团队', // 团队名称
copyright: 'Copyright © 2025 XHU Headline' // 版权信息
}
- 在 main.ts 中注入
- 修改 src/main.ts,把配置“存”到应用里。
TypeScript
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
// 1. 引入配置文件
import siteConfig from '@/lib/siteConfig.js'
import App from './App.vue'
import router from './router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
const app = createApp(App)
// 2. 使用 provide 将配置注入到全局,key 为 'siteConfig'
app.provide('siteConfig', siteConfig)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')
- 在登录页展示
修改 src/views/Login/index.vue,在底部读取并显示信息。
<template>
<AuroraBackground class="bg-wrapper">
<div class="login-footer">
<div>{{ siteConfig?.teamName }}</div>
<div>{{ siteConfig?.copyright }}</div>
</div>
</AuroraBackground>
</template>
<script setup lang="ts">
import { inject } from 'vue' // 引入 inject
// ... 原有的 import ...
// 使用 inject 读取全局配置,并设置默认值防止为空
const siteConfig = inject('siteConfig', {
teamName: '默认团队',
copyright: 'Copyright © 默认',
}) as {
teamName: string
copyright: string
}
// ... 原有的逻辑 ...
</script>
<style scoped>
/* ... 原有的样式 ... */
/* 新增底部样式 */
.login-footer {
position: absolute; /* 绝对定位到底部 */
bottom: 20px;
width: 100%;
font-size: 12px;
color: #999; /* 灰色文字 */
text-align: center;
pointer-events: auto;
z-index: 20;
}
.login-footer > div + div {
margin-top: 2px;
}
</style>
2.2 文本编辑功能 (核心功能)
目标: 实现新闻的增删改查,并集成 Cherry Markdown 编辑器。
注意: 这里我们将新闻实体命名为 newsPort (对应数据库 news_post)。
后端实现 (SpringBoot)
对你们的要求:
代码量比较大,请务必看清包路径,不要建错文件。
- Controller (服务员)
- 新建 NewsPortController.java。
package com.xhu.headline_server.controller.admin; // 注意包路径
import com.xhu.headline_server.entity.newsPort; // 需确保创建了 entity 类
import com.xhu.headline_server.service.NewPortService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/admin/port")
public class NewsPortController {
@Autowired
private NewPortService newPortService;
private static final Logger log = LoggerFactory.getLogger(NewsPortController.class);
// 增:添加新闻
@PostMapping("/add")
public Map<String, Object> addNewsPort(@RequestBody newsPort newsPortDTO) {
Map<String, Object> res = new HashMap<>();
try {
// 使用服务层的 saveNewsPort 完成新增
newPortService.saveNewsPort(newsPortDTO);
res.put("code", 1);
res.put("message", "新闻添加成功");
} catch (Exception e) {
log.error("添加失败", e);
res.put("code", 0);
res.put("message", "新闻添加失败");
}
return res;
}
// 查:根据 ID 获取单条新闻详情
@PostMapping("/get")
public Map<String, Object> getNewsPort(@RequestBody Map<String, Object> params) {
Map<String, Object> res = new HashMap<>();
try {
Object idObj = params.get("id");
if (idObj == null) {
res.put("code", 0);
res.put("message", "缺少新闻 id");
return res;
}
Long id = Long.valueOf(idObj.toString());
newsPort newsPort = newPortService.getNewsPortById(id);
if (newsPort == null) {
res.put("code", 0);
res.put("message", "新闻不存在");
} else {
res.put("code", 1);
res.put("data", newsPort);
}
} catch (Exception e) {
res.put("code", 0);
res.put("message", "新闻获取失败");
}
return res;
}
// 查列表:支持分页和多条件筛选
// 请求体示例 params: { pageNum, pageSize, title, authorId, categoryId, status }
@PostMapping("/list")
public Map<String, Object> listNewsPorts(@RequestBody Map<String, Object> params) {
Map<String, Object> res = new HashMap<>();
// 1. 获取前端传来的筛选条件
String title = params.get("title") != null ? params.get("title").toString() : null;
String authorId = params.get("authorId") != null ? params.get("authorId").toString() : null;
String categoryId = params.get("categoryId") != null ? params.get("categoryId").toString() : null;
String status = params.get("status") != null ? params.get("status").toString() : null;
int page = params.get("pageNum") != null
? Integer.parseInt(params.get("pageNum").toString())
: 1;
int size = params.get("pageSize") != null
? Integer.parseInt(params.get("pageSize").toString())
: 10;
// 2. 获取所有数据 (注意:实际生产中应该在 SQL 层做分页,这里为了简化逻辑先查全量再内存分页,或者你需要引入 PageHelper)
List<newsPort> allNews = newPortService.getAllNewsPorts();
// 3. 构造筛选条件
Map<String, String> contains = new HashMap<>();
contains.put("title", title); // 模糊查询标题
Map<String, String> equals = new HashMap<>();
if (authorId != null && !authorId.isEmpty()) {
equals.put("authorId", authorId);
}
if (categoryId != null && !categoryId.isEmpty()) {
equals.put("categoryId", categoryId);
}
if (status != null && !status.isEmpty()) {
equals.put("status", status);
}
// **重要提示**:这里的 util1 需要你自己实现或引入工具类,用于内存分页和过滤
// 如果没有 util1,你需要手动写一个简单的过滤逻辑
Map<String, Object> data = util1.filterAndPage(allNews, contains, equals, page, size);
res.put("code", 1);
res.put("data", data);
return res;
}
// 改:更新新闻
@PostMapping("/update")
public Map<String, Object> updateNewsPort(@RequestBody newsPort newsPortDTO) {
Map<String, Object> res = new HashMap<>();
if (newsPortDTO.getId() == null || newsPortDTO.getId() == 0) {
res.put("code", 0);
res.put("message", "新闻 id 不能为空");
return res;
}
try {
// 仍然复用 saveNewsPort,带 id 即更新
newPortService.saveNewsPort(newsPortDTO);
res.put("code", 1);
res.put("message", "新闻更新成功");
} catch (Exception e) {
res.put("code", 0);
res.put("message", "新闻更新失败");
}
return res;
}
// 删:删除新闻
@PostMapping("/delete")
public Map<String, Object> deleteNewsPort(@RequestBody Map<String, Object> params) {
Map<String, Object> res = new HashMap<>();
try {
Object idObj = params.get("id");
if (idObj == null) {
res.put("code", 0);
res.put("message", "缺少新闻 id");
return res;
}
Long id = Long.valueOf(idObj.toString());
boolean success = newPortService.deleteNewsPortById(id);
if (success) {
res.put("code", 1);
res.put("message", "新闻删除成功");
} else {
res.put("code", 0);
res.put("message", "新闻删除失败,记录可能不存在");
}
} catch (Exception e) {
res.put("code", 0);
res.put("message", "新闻删除异常");
}
return res;
}
}
- Service (接口)
- 新建 NewPortService.java。
package com.xhu.headline_server.service;
import com.xhu.headline_server.entity.newsPort;
import java.util.List;
public interface NewPortService {
/** * 新增或更新新闻
* - id 为 null 或 0 时新增
* - 否则更新
*/
void saveNewsPort(newsPort newsPortDTO);
/** * 根据 id 查询新闻
*/
newsPort getNewsPortById(Long id);
/** * 根据 id 删除新闻
*/
boolean deleteNewsPortById(Long id);
/** * 获取所有新闻
*/
List<newsPort> getAllNewsPorts();
}
- Service Implementation (实现类)
- 新建 NewPortServiceImpl.java。
package com.xhu.headline_server.service.impl;
import com.xhu.headline_server.entity.newsPort;
import com.xhu.headline_server.mapper.NewsPortMapper;
import com.xhu.headline_server.service.NewPortService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class NewPortServiceImpl implements NewPortService {
@Autowired
private NewsPortMapper newsPortMapper;
/** * 新增或更新新闻
*/
@Override
public void saveNewsPort(newsPort newsPortDTO) {
if (newsPortDTO == null) {
return;
}
Long id = newsPortDTO.getId();
if (id == null || id == 0) {
// id 不存在,执行新增 SQL
newsPortMapper.insert(newsPortDTO);
} else {
// id 存在,执行更新 SQL
newsPortMapper.update(newsPortDTO);
}
}
/** * 根据 id 查询新闻
*/
@Override
public newsPort getNewsPortById(Long id) {
if (id == null) {
return null;
}
return newsPortMapper.getById(id);
}
/** * 根据 id 删除新闻
*/
@Override
public boolean deleteNewsPortById(Long id) {
if (id == null) {
return false;
}
int affected = newsPortMapper.deleteById(id);
return affected > 0;
}
@Override
public List<newsPort> getAllNewsPorts() {
// 调用 Mapper 查询所有未删除的帖子
List<newsPort> ports = newsPortMapper.listAll();
return ports;
}
}
- Mapper (接口)
新建 NewsPortMapper.java。
package com.xhu.headline_server.mapper;
import com.xhu.headline_server.entity.newsPort;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface NewsPortMapper {
newsPort getById(@Param("id") Long id);
List<newsPort> listAll();
List<newsPort> listByAuthorId(@Param("authorId") Long authorId);
int insert(newsPort post);
int update(newsPort post);
int deleteById(@Param("id") Long id);
int incrViewCount(@Param("id") Long id);
int incrLikeCount(@Param("id") Long id);
int decrLikeCount(@Param("id") Long id);
int incrCommentCount(@Param("id") Long id);
}
- Mapper XML (SQL)
- 在 resources/mapper 下新建 NewsPortMapper.xml。
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.xhu.headline_server.mapper.NewsPortMapper">
<resultMap id="NewsPostResultMap" type="com.xhu.headline_server.entity.newsPort">
<id column="id" property="id"/>
<result column="author_id" property="authorId"/>
<result column="category_id" property="categoryId"/>
<result column="title" property="title"/>
<result column="content" property="content"/>
<result column="cover_images" property="coverImages"/>
<result column="status" property="status"/>
<result column="view_count" property="viewCount"/>
<result column="like_count" property="likeCount"/>
<result column="comment_count" property="commentCount"/>
<result column="source" property="source"/>
<result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/>
<result column="deleted" property="deleted"/>
</resultMap>
<sql id="Base_Column_List">
id,
author_id, category_id, title, content, cover_images, status, view_count, like_count, comment_count, source, create_time, update_time, deleted </sql>
<select id="getById" parameterType="long" resultMap="NewsPostResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM news_post
WHERE id = #{id} AND deleted = 0 </select>
<select id="listAll" resultMap="NewsPostResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM news_post
WHERE deleted = 0 ORDER BY create_time DESC </select>
<select id="listByAuthorId" parameterType="long" resultMap="NewsPostResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM news_post
WHERE deleted = 0 AND author_id = #{authorId} ORDER BY create_time DESC </select>
<insert id="insert" parameterType="com.xhu.headline_server.entity.newsPort" useGeneratedKeys="true" keyProperty="id">
INSERT INTO news_post (
author_id, category_id, title, content, cover_images, status, view_count, like_count, comment_count, source, create_time, update_time, deleted ) VALUES ( #{authorId}, #{categoryId}, #{title}, #{content}, #{coverImages}, #{status}, #{viewCount}, #{likeCount}, #{commentCount}, #{source}, NOW(), NOW(), #{deleted} ) </insert>
<update id="update" parameterType="com.xhu.headline_server.entity.newsPort">
UPDATE news_post
SET author_id = #{authorId}, category_id = #{categoryId}, title = #{title}, content = #{content}, cover_images = #{coverImages}, status = #{status}, view_count = #{viewCount}, like_count = #{likeCount}, comment_count = #{commentCount}, source = #{source}, update_time = NOW() WHERE id = #{id} AND deleted = 0 </update>
<update id="logicDeleteById" parameterType="long">
UPDATE news_post
SET deleted = 1, update_time = NOW() WHERE id = #{id} </update>
<delete id="deleteById" parameterType="long">
DELETE FROM news_post
WHERE id = #{id} </delete>
<update id="incrViewCount" parameterType="long">
UPDATE news_post
SET view_count = view_count + 1 WHERE id = #{id} AND deleted = 0 </update>
<update id="incrLikeCount" parameterType="long">
UPDATE news_post
SET like_count = like_count + 1 WHERE id = #{id} AND deleted = 0 </update>
<update id="decrLikeCount" parameterType="long">
UPDATE news_post
SET like_count = CASE WHEN like_count > 0 THEN like_count - 1 ELSE 0 END WHERE id = #{id} AND deleted = 0 </update>
<update id="incrCommentCount" parameterType="long">
UPDATE news_post
SET comment_count = comment_count + 1 WHERE id = #{id} AND deleted = 0 </update>
</mapper>
前端实现 (Vue + Markdown)
给零基础同学的话:
我们使用腾讯开源的 Cherry Markdown 编辑器。它就像 typora 一样,左边写源码,右边实时预览。
- 安装编辑器依赖
- 在前端项目目录下运行:
Bash
npm install cherry-markdown --save
2. 新建视图文件
-
src/views/port/index.vue(文章列表页) -
src/views/port/editor.vue(文章编辑器页) -
编写 API (src/api/port.js)
- 先准备好和后端通信的接口。
import http from './http' // 确保引入了你配置的 axios 实例
/**
* 分页查询新闻
* 对应后端 POST /admin/port/list
* params: { pageNum, pageSize, title, authorId, categoryId, status }
*/
export const queryPagePortApi = (params = {}) =>
http.post(
'/admin/port/list',
{
pageNum: params.pageNum ?? 1,
pageSize: params.pageSize ?? 10,
title: params.title ?? '',
authorId: params.authorId ?? '',
categoryId: params.categoryId ?? '',
status: params.status,
},
{
headers: {
'Content-Type': 'application/json',
},
},
)
/**
* 新增新闻
* 对应后端 POST /admin/port/add
*/
export const addPortApi = (data) =>
http.post('/admin/port/add', data, {
headers: { 'Content-Type': 'application/json' },
})
/**
* 更新新闻
* 对应后端 POST /admin/port/update
*/
export const updatePortApi = (data) =>
http.post('/admin/port/update', data, {
headers: { 'Content-Type': 'application/json' },
})
/**
* 删除新闻
* 对应后端 POST /admin/port/delete
* 后端期望 { id }
*/
export const deletePortApi = (row) =>
http.post(
'/admin/port/delete',
{ id: row.id },
{
headers: { 'Content-Type': 'application/json' },
},
)
/**
* 按 id 查询单条新闻
* 对应后端 POST /admin/port/get
*/
export const getPortByIdApi = (id) =>
http.post(
'/admin/port/get',
{ id },
{
headers: { 'Content-Type': 'application/json' },
},
)
- (2) 编写文章列表页 (
src/views/port/index.vue)
<script lang="ts">
export default { name: 'PortView' }
</script>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import {
CirclePlusFilled,
Search,
CircleCloseFilled,
EditPen,
Delete,
} from '@element-plus/icons-vue'
import { curd } from '@/api/curd'
import {
queryPagePortApi,
addPortApi,
updatePortApi,
deletePortApi,
getPortByIdApi,
} from '@/api/port'
// 路由
const router = useRouter()
// 初始文章对象(字段名要和后端 newsPort 保持一致)
const initialPort = {
id: null as number | null,
title: '',
content: '',
authorId: '',
categoryId: '',
status: 0,
coverImage: '',
viewCount: 0,
likeCount: 0,
createTime: '',
updateTime: '',
}
// 搜索 / 表单 字段配置
const formSchemaConfig = [
{ prop: 'title', label: '标题', type: 'input', placeholder: '请输入标题' },
{ prop: 'authorId', label: '作者ID', type: 'input', placeholder: '请输入作者ID' },
{ prop: 'categoryId', label: '分类ID', type: 'input', placeholder: '请输入分类ID' },
{
prop: 'status',
label: '状态',
type: 'select',
dicData: [
{ label: '草稿', value: 0 },
{ label: '已发布', value: 1 },
{ label: '已删除', value: 2 },
],
},
]
// 使用通用 curd
const {
item,
searchForm,
formSchema,
list,
currentPage,
pageSize,
total,
dialogShow,
dialogTitle,
search,
addItem,
updateItem,
saveItem,
deleteItem,
cancel,
handleSizeChange,
handleCurrentChange,
} = curd(
{ queryPage: queryPagePortApi, add: addPortApi, update: updatePortApi, del: deletePortApi },
{
initialItem: initialPort,
formSchema: formSchemaConfig,
title: '文章管理',
labels: {
title: '标题',
authorId: '作者ID',
categoryId: '分类ID',
status: '状态',
coverImage: '封面图',
actions: '操作',
},
},
)
// 简化使用
const port = item
const searchPortForm = searchForm
const portList = list
// 清空搜索
const clear = () => {
formSchema.forEach((f: any) => {
searchPortForm[f.prop] = ''
})
currentPage.value = 1
search()
}
// 删除文章
const delPort = async (row: any) => {
if (!row) return
try {
const res = await deleteItem(row)
if (res && (res.code || res.message)) {
ElMessage.success(res.message || '删除成功')
search()
} else {
ElMessage.error(res?.message || '删除失败')
console.error('删除失败', res)
}
} catch (err) {
console.error(err)
ElMessage.error('请求出错')
}
}
// 新增走 markdown 编辑页(无 id)
const goCreate = () => {
router.push({ name: 'PortEditor' })
}
// 编辑走 markdown 编辑页(带 id)
const goEdit = (row: any) => {
if (!row || !row.id) {
ElMessage.error('无效的文章 ID')
return
}
router.push({
name: 'PortEditor',
params: { id: row.id },
})
}
// 更改状态:0 草稿,1 已发布,2 已删除
const changeStatus = async (row: any, status: number) => {
try {
const payload = { ...row, status }
const res = await updatePortApi(payload)
if (res?.code === 1) {
ElMessage.success('状态已更新')
search()
} else {
ElMessage.error(res?.message || '状态更新失败')
}
} catch (e) {
console.error(e)
ElMessage.error('请求出错')
}
}
// 原弹窗编辑逻辑(暂时保留,但现在主要用 editor.vue)
const addPort = addItem
const updatePort = updateItem
const savePort = async () => {
const payload: any = JSON.parse(JSON.stringify(port))
if (!payload.title) {
ElMessage.error('请填写标题')
return
}
// 数字/可空字段的类型清洗
if (payload.authorId === '') payload.authorId = null
else if (typeof payload.authorId === 'string') payload.authorId = Number(payload.authorId)
if (payload.categoryId === '') payload.categoryId = null
else if (typeof payload.categoryId === 'string') payload.categoryId = Number(payload.categoryId)
if (payload.viewCount === '') payload.viewCount = 0
else if (typeof payload.viewCount === 'string') payload.viewCount = Number(payload.viewCount)
if (payload.likeCount === '') payload.likeCount = 0
else if (typeof payload.likeCount === 'string') payload.likeCount = Number(payload.likeCount)
if (payload.createTime === '') payload.createTime = null
if (payload.updateTime === '') payload.updateTime = null
Object.assign(port, payload)
try {
const res = await saveItem()
if (res && (res.code || res.message)) {
ElMessage.success(res.message || (port.id ? '更新成功' : '添加成功'))
cancel()
search()
} else {
ElMessage.error(res?.message || '保存失败')
}
} catch (err) {
console.error(err)
ElMessage.error('请求出错')
}
}
const cancelEdit = cancel
</script>
<template>
<h2>文章管理</h2>
<div class="container">
<el-form :inline="true" :model="searchPortForm" class="search-form">
<el-form-item v-for="field in formSchema" :key="field.prop" :label="field.label">
<el-input
v-if="field.type === 'input'"
v-model="searchPortForm[field.prop]"
:placeholder="field.placeholder || `请输入${field.label}`"
style="width: 200px"
/>
<el-select
v-else-if="field.type === 'select'"
v-model="searchPortForm[field.prop]"
:placeholder="`请选择${field.label}`"
style="width: 200px"
>
<el-option
v-for="option in field.dicData || []"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="success" @click="goCreate">
<el-icon><CirclePlusFilled /></el-icon> 新增
</el-button>
<el-button type="primary" @click="search">
<el-icon><Search /></el-icon> 搜索
</el-button>
<el-button @click="clear">
<el-icon><CircleCloseFilled /></el-icon> 清空
</el-button>
</el-form-item>
</el-form>
</div>
<div class="container" style="margin-top: 10px">
<el-table :data="portList" style="width: 100%" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column prop="authorId" label="作者ID" width="80" />
<el-table-column prop="categoryId" label="分类ID" width="80" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : row.status === 2 ? 'info' : 'warning'">
{{ row.status === 0 ? '草稿' : row.status === 1 ? '已发布' : '已删除' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="viewCount" label="浏览量" width="100" />
<el-table-column prop="likeCount" label="点赞量" width="100" />
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column prop="updateTime" label="更新时间" width="180" />
<el-table-column label="封面图" width="120">
<template #default="{ row }">
<el-image
v-if="row.coverImage"
:src="row.coverImage"
:preview-src-list="[row.coverImage]"
style="width: 80px; height: 80px"
/>
<span v-else>无</span>
</template>
</el-table-column>
<el-table-column label="操作" width="320">
<template #default="{ row }">
<div class="action-buttons">
<el-button size="small" @click="changeStatus(row, 0)" :disabled="row.status === 0">
草稿
</el-button>
<el-button
size="small"
type="success"
@click="changeStatus(row, 1)"
:disabled="row.status === 1"
>
发布
</el-button>
<el-button
size="small"
type="info"
@click="changeStatus(row, 2)"
:disabled="row.status === 2"
>
删除
</el-button>
<el-button size="small" type="primary" @click="goEdit(row)">
<el-icon><EditPen /></el-icon>
</el-button>
<el-button size="small" type="danger" @click="delPort(row)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog v-model="dialogShow" :title="dialogTitle" width="720px">
<el-form :model="port" label-width="100px">
<el-form-item label="标题">
<el-input v-model="port.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="作者ID">
<el-input v-model="port.authorId" placeholder="请输入作者ID" />
</el-form-item>
<el-form-item label="分类ID">
<el-input v-model="port.categoryId" placeholder="请输入分类ID" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="port.status" placeholder="请选择状态">
<el-option label="草稿" :value="0" />
<el-option label="已发布" :value="1" />
<el-option label="已删除" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="封面图 URL">
<el-input v-model="port.coverImage" placeholder="请输入封面图地址" />
</el-form-item>
<el-form-item label="内容">
<el-input v-model="port.content" type="textarea" :rows="5" placeholder="请输入文章内容" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="cancelEdit">取 消</el-button>
<el-button type="primary" @click="savePort">保 存</el-button>
</template>
</el-dialog>
<div class="container" style="margin-top: 10px; text-align: right">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
<style scoped>
.container {
margin: 12px 0;
}
.search-form {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.action-buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
align-items: center;
}
</style>
-
(3) 编写编辑器页面 (src/views/port/editor.vue)
- 这是写文章的界面。
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import Cherry from 'cherry-markdown'
import { getPortByIdApi, addPortApi, updatePortApi } from '@/api/port'
const route = useRoute()
const router = useRouter()
// 路由里拿 id:/port/editor/:id?
const idParam = route.params.id
const editingId = idParam ? Number(idParam) : null
// 表单:除了 content,其它字段在这里管理
const form = ref({
id: editingId as number | null,
title: '',
authorId: '',
categoryId: '',
status: 0,
coverImage: '',
})
let cherryInstance: any = null
onMounted(async () => {
// 初始化 Cherry Markdown 编辑器
cherryInstance = new Cherry({
id: 'markdown-container',
value: '',
// 这里可以根据你之前的配置补充工具栏、主题等
})
// 有 id:编辑模式 -> 加载文章详情
if (editingId) {
try {
const res = await getPortByIdApi(editingId)
if (res?.code === 1 && res.data) {
const data = res.data
form.value.id = data.id
form.value.title = data.title || ''
form.value.authorId = data.authorId != null ? String(data.authorId) : ''
form.value.categoryId = data.categoryId != null ? String(data.categoryId) : ''
form.value.status = data.status ?? 0
form.value.coverImage = data.coverImage || ''
// 把内容写入 Markdown 编辑器
cherryInstance.setValue(data.content || '')
} else {
ElMessage.error(res?.message || '加载文章失败')
}
} catch (e) {
console.error(e)
ElMessage.error('加载文章失败')
}
}
})
const save = async () => {
if (!cherryInstance) {
ElMessage.error('编辑器尚未初始化')
return
}
const content = cherryInstance.getValue() || ''
const payload: any = {
...form.value,
content,
}
if (!payload.title) {
ElMessage.error('请填写标题')
return
}
// 类型清洗:把字符串数字转成真正的数字
if (payload.authorId === '') payload.authorId = null
else payload.authorId = Number(payload.authorId)
if (payload.categoryId === '') payload.categoryId = null
else payload.categoryId = Number(payload.categoryId)
if (payload.status === '') payload.status = 0
try {
const api = payload.id ? updatePortApi : addPortApi
const res = await api(payload)
if (res?.code === 1) {
ElMessage.success(payload.id ? '更新成功' : '添加成功')
router.push('/port')
} else {
ElMessage.error(res?.message || '保存失败')
}
} catch (e) {
console.error(e)
ElMessage.error('请求出错')
}
}
</script>
<template>
<div class="editor-page">
<h2>{{ form.id ? '编辑文章' : '新建文章' }}</h2>
<el-form :model="form" label-width="100px" style="max-width: 800px">
<el-form-item label="标题">
<el-input v-model="form.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="作者ID">
<el-input v-model="form.authorId" placeholder="请输入作者ID" />
</el-form-item>
<el-form-item label="分类ID">
<el-input v-model="form.categoryId" placeholder="请输入分类ID" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" placeholder="请选择状态">
<el-option label="草稿" :value="0" />
<el-option label="已发布" :value="1" />
<el-option label="已删除" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="封面图 URL">
<el-input v-model="form.coverImage" placeholder="请输入封面图地址" />
</el-form-item>
</el-form>
<div id="markdown-container" style="height: 500px; margin-top: 16px"></div>
<div style="margin-top: 16px; text-align: right">
<el-button @click="router.back()">返 回</el-button>
<el-button type="primary" @click="save">保 存</el-button>
</div>
</div>
</template>
<style scoped>
.editor-page {
padding: 16px;
}
</style>
-
(4) 注册侧边栏菜单
- 修改 src/views/layout/index.vue。
<el-menu-item index="/port">
<el-icon><Document /></el-icon>
<span>文章管理</span>
</el-menu-item>
(记得引入 Document 图标)
-
(5) 注册路由
- 修改 src/router/index.ts。
import PortView from '@/views/port/index.vue'
import PortEditor from '@/views/port/editor.vue'
const routes = [
// ... 其他路由 ...
{
path: '/',
component: LayoutView, // 假设你之前叫 LayoutView
children: [
// ... user 路由 ...
{
path: '/port',
name: 'Port',
component: PortView,
},
{
path: '/port/editor/:id?', // :id? 表示 id 是可选的(新增时无id,编辑时有id)
name: 'PortEditor',
component: PortEditor,
},
],
},
]
2.3 用户端 UI (待续)
(下一版本我们将开始做手机端的界面,大家先把后台的这些功能消化好)