第二阶段:用户端与身份认证 & 内容管理升级

目标: 在这一阶段,我们要给系统加上“全局配置”显得更专业,并实现最核心的功能——写文章。我们将引入一个很酷的 Markdown 编辑器,让发布新闻变得像写博客一样简单。

2.1 课程全局信息 (作业要求)

任务书要求: 把课程设计开发基本信息通过上下文读取出来,放置到用户登录首页的底部。

对你们的要求: 理解 Vue 的 provide (提供) 和 inject (注入) 机制。

给零基础同学的话:

想象一下,爷爷(main.ts)有一笔传家宝(全局配置),他通过 provide 把它存进了家族信托。

孙子(Login/index.vue)不管隔了多少代,都可以直接通过 inject 拿到这笔钱,而不需要通过爸爸、叔叔一层层传递。

这样做的好处是:以后改版权年份,只需要改一个文件,全站都变了。

前端实现

  1. 新建全局配置文件
  • 在 src/lib 目录下新建 siteConfig.js。
// src/lib/siteConfig.js
export default {
  teamName: 'XHU Headline 团队', // 团队名称
  copyright: 'Copyright © 2025 XHU Headline' // 版权信息
}
  1. 在 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')
  1. 在登录页展示

修改 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)

对你们的要求:

代码量比较大,请务必看清包路径,不要建错文件。

  1. 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;  
    }  
}
  1. 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();  
}
  1. 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;  
    }  
}
  1. 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);  
}
  1. 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 一样,左边写源码,右边实时预览。

  1. 安装编辑器依赖
  • 在前端项目目录下运行:
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 (待续)

(下一版本我们将开始做手机端的界面,大家先把后台的这些功能消化好)

想温柔的对待这个世界