西华头条系统开发文档 V1.2

1.5 搭建“前台”:Vue 3 (后台管理) 项目初始化

任务书要求: Vue, Element Plus, PC 端后台管理。

对你们的要求: 严格按步骤执行,搭建前端“毛坯房”。

给零基础同学的话:

现在我们来搭建“餐厅大堂”(后台管理系统的前端界面)。我们使用 npm create vue (它会启用 Vite,一个超快的“装修队”) 来一键生成毛坯房。

1. 创建项目 (命令行)

npm create vue@latest
  • 输入项目名:headline_admin_ui

  • 勾选: TypeScript , Router (路由,管理页面跳转), Vuex (状态管理,共享数据), ESLint (代码规范)。

  • ESLint: ESLint + Prettier (统一代码风格)。

  • (Vitest 是测试工具,可先不选)

2. 安装核心 UI 框架 (Tailwind + Inspira)

进入项目目录 cd headline_admin_ui,然后执行:

Bash

# 安装 Tailwind CSS 及其依赖
npm install -D tailwindcss@3 postcss autoprefixer

# 初始化 Tailwind 配置文件
npx tailwindcss init -p

# 安装 Inspira UI 及其依赖
npm install -D @inspira-ui/plugins clsx tailwind-merge class-variance-authority tailwindcss-animate
npm install @vueuse/core motion-v
  • tailwindcss:CSS的“瑞士军刀”,让我们能快速写样式。

  • @inspira-ui/plugins:我们选择的UI组件库之一。

3. 安装 Element Plus 和 Axios

npm install element-plus axios
  • element-plus:另一个强大的UI库,我们主要用它的表格、对话框等。

  • axios:老朋友,和后端通信的“点餐器”。

4. 配置文件

给零基础同学的话:

这一步是技术活,主要是配置 Tailwind 和 Inspira。零基础的同学: 暂时不需要理解每一行,直接复制粘贴,确保 tailwind.config.jsmain.csssrc/lib/utils.ts 这三个文件和下面的一致就行。

a. 修改 tailwind.config.js (替换全部内容)

import animate from "tailwindcss-animate";
import { setupInspiraUI } from "@inspira-ui/plugins";

export default {
  darkMode: "selector",
  safelist: ["dark"],
  prefix: "",
  content: ["./index.html","./public/**/*.html", "./src/**/*.{vue,js,ts,jsx,tsx}","./pages/**/*.{vue,js}" ],
  theme: {
    extend: {
      colors: {
        border: "hsl(var(--border))",
        input: "hsl(var(--input))",
        ring: "hsl(var(--ring))",
        background: "hsl(var(--background))",
        foreground: "hsl(var(--foreground))",
        primary: {
          DEFAULT: "hsl(var(--primary))",
          foreground: "hsl(var(--primary-foreground))",
        },
        secondary: {
          DEFAULT: "hsl(var(--secondary))",
          foreground: "hsl(var(--secondary-foreground))",
        },
        destructive: {
          DEFAULT: "hsl(var(--destructive))",
          foreground: "hsl(var(--destructive-foreground))",
        },
        muted: {
          DEFAULT: "hsl(var(--muted))",
          foreground: "hsl(var(--muted-foreground))",
        },
        accent: {
          DEFAULT: "hsl(var(--accent))",
          foreground: "hsl(var(--accent-foreground))",
        },
        popover: {
          DEFAULT: "hsl(var(--popover))",
          foreground: "hsl(var(--popover-foreground))",
        },
        card: {
          DEFAULT: "hsl(var(--card))",
          foreground: "hsl(var(--card-foreground))",
        },
      },
      borderRadius: {
        xl: "calc(var(--radius) + 4px)",
        lg: "var(--radius)",
        md: "calc(var(--radius) - 2px)",
        sm: "calc(var(--radius) - 4px)",
      },
    },
  },

  plugins: [animate, setupInspiraUI],
};

b. 修改 src/assets/main.css (替换全部内容)

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;

    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;

    --popover: 0 0% 100%;
    --popover-foreground: 222.2 84% 4.9%;

    --primary: 221.2 83.2% 53.3%;
    --primary-foreground: 210 40% 98%;

    --secondary: 210 40% 96.1%;
    --secondary-foreground: 222.2 47.4% 11.2%;

    --muted: 210 40% 96.1%;
    --muted-foreground: 215.4 16.3% 46.9%;

    --accent: 210 40% 96.1%;
    --accent-foreground: 222.2 47.4% 11.2%;

    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 210 40% 98%;

    --border: 214.3 31.8% 91.4%;
    --input: 214.3 31.8% 91.4%;
    --ring: 221.2 83.2% 53.3%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;

    --card: 222.2 84% 4.9%;
    --card-foreground: 210 40% 98%;

    --popover: 222.2 84% 4.9%;
    --popover-foreground: 210 40% 98%;

    --primary: 217.2 91.2% 59.8%;
    --primary-foreground: 222.2 47.4% 11.2%;

    --secondary: 217.2 32.6% 17.5%;
    --secondary-foreground: 210 40% 98%;

    --muted: 217.2 32.6% 17.5%;
    --muted-foreground: 215 20.2% 65.1%;

    --accent: 217.2 32.6% 17.5%;
    --accent-foreground: 210 40% 98%;

    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 210 40% 98%;

    --border: 217.2 32.6% 17.5%;
    --input: 217.2 32.6% 17.5%;
    --ring: 224.3 76.3% 48%;
  }
}

c. 新建 src/lib/utils.ts 文件 (粘贴以下内容)

import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

export type ObjectValues<T> = T[keyof T];

5. 在 main.ts 中引入 (替换 main.ts 内容)

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store' // 假设你创建了 store
import './assets/main.css' // 引入我们刚修改的 main.css

// 引入 Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

const app = createApp(App)
app.use(store)
app.use(router)
app.use(ElementPlus) // 全局注册
app.mount('#app')

6. 组织项目结构

src/ 目录下,我们约定:

  • views/: 存放“页面” (如 UserManagement.vue - 用户管理页)。

  • components/: 存放可复用的“小组件” (如 MyPagination.vue - 分页器)。

  • api/: 存放所有与后端交互的 axios 请求 (所有“点单”的地方)。

  • router/: 存放“路由地图”,告诉 Vue 哪个 URL 对应哪个页面。

  • store/: 存放 Vuex (全局“数据中心”)。

  • layout/: 存放后台管理的“整体装修” (如侧边栏、顶部导航)。


1.6 实践:跑通第一个 CRUD (用户管理)

目标: (Req 3, 4) 实现管理员对“用户”的增、删、改、查。

给零基础同学的话:数据流转之旅

我们的目标是:在前端页面上点击“刷新”按钮,从数据库拿到所有用户数据,并显示在表格中。

数据是这样旅行的:

  1. [前端-页面] UserManagement.vue (页面) 被打开时,它调用 fetchData 方法。

  2. [前端-API] fetchData 方法调用 api/user.js 里定义的 getUserList() 函数。

  3. [网络] getUserList() 触发 axios (点餐器),向后端的 /admin/user/list 地址发送一个 GET 请求 (点了一份“用户列表”)。

  4. [后端-Controller] UserController (服务员) 听到了 /admin/user/list 的请求,调用 userService.listUsers() 方法。

  5. [后端-Service] UserService (厨师) 接到任务,他调用 userMapper.listAll()

  6. [后端-Mapper] UserMapper (仓管) 执行 SELECT * FROM user SQL语句。

  7. [数据库] MySQL (仓库) 收到 SQL,返回所有用户数据给 Mapper。

  8. [返回] 数据原路返回:Mapper -> Service -> Controller

  9. [网络] Controller 把数据打包成 Result (统一格式),通过网络发回给前端 axios

  10. [前端-页面] UserManagement.vue 拿到了数据,更新 userList,表格显示数据。


1. 后端实现 (SpringBoot)

对你们的要求:

下面是后端“餐厅”里每个人的代码。按照 1.4 创建的包结构,把这些文件创建在对应位置。

代码不变,直接复制粘贴。

a. Entity (数据模型)

  • 文件: entity/User.java
package com.xhu.headline_server.entity;  
  
import lombok.Data;  
import lombok.NoArgsConstructor;  
import lombok.AllArgsConstructor;  
import lombok.Builder;  
  
// 用户类  
@Data  
@NoArgsConstructor  
@AllArgsConstructor  
@Builder  
  
public class User { // 类名建议大写 User 
    private int id;  
    private String userName;  
    private String password;  
    private String nickName;  
    private String avatarUrl;  
    private String phone;  
    private int role; // 0表示管理员 1表示员工 2表示普通用户  
    private String createTime;  
    private String updateTime;  
    private String deleted; // 注意:你这里用了 String,数据库是 TINYINT
}

b. Controller (服务员)

  • 文件: controller/admin/UserController.java
package com.xhu.headline_server.controller.admin;

import com.xhu.headline_server.entity.User;
import com.xhu.headline_server.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

/** * 用户管理后台接口  
 * 接口前缀:/admin/user  
 */
@RestController  
@RequestMapping("/admin/user")  
@Slf4j  
public class UserController {  
  
    @Autowired  
    private UserService userService;  
  
    /** * 新增用户  
     * POST /admin/user/add  
     */
    @PostMapping("/add")  
    public Map<String, Object> addUser(@RequestBody User user) {  
        Map<String, Object> res = new HashMap<>();  
        try {  
            userService.addUser(user);  
            res.put("code", 1);  
            res.put("message", "用户添加成功");  
            res.put("userName", user.getUserName());  
        } catch (Exception e) {  
            log.error("添加用户失败", e);  
            res.put("code", 0);  
            res.put("message", "用户添加失败");  
        }  
        return res;  
    }  
  
    /** * 删除用户(根据 id)  
     * POST /admin/user/delete  
     * 请求体示例:{"id": 1}  
     */
    @PostMapping("/delete")  
    public Map<String, Object> deleteUser(@RequestBody Map<String, Object> params) {  
        Map<String, Object> res = new HashMap<>();  
        Long id = getLongParam(params, "id", null);  
        if (id == null) {  
            res.put("code", 0);  
            res.put("message", "缺少用户 id");  
            return res;  
        }  
  
        boolean ok = userService.delUserById(id);  
        if (ok) {  
            res.put("code", 1);  
            res.put("message", "用户删除成功");  
        } else {  
            res.put("code", 0);  
            res.put("message", "用户不存在或删除失败");  
        }  
        return res;  
    }  
  
    /** * 更新用户信息  
     * POST /admin/user/update  
     * 约定请求体中必须包含 id  
     */
    @PostMapping("/update")  
    public Map<String, Object> updateUser(@RequestBody User user) {  
        Map<String, Object> res = new HashMap<>();  
        if (user == null || user.getId() == 0) { // 假设 id 是 int
            res.put("code", 0);  
            res.put("message", "缺少用户 id,无法更新");  
            return res;  
        }  
  
        try {  
            userService.saveUser(user);  
            res.put("code", 1);  
            res.put("message", "用户信息已更新");  
            res.put("userName", user.getUserName());  
        } catch (Exception e) {  
            log.error("更新用户失败, id={}", user.getId(), e);  
            res.put("code", 0);  
            res.put("message", "用户更新失败");  
        }  
        return res;  
    }  
  
    /** * 根据 id 查询用户  
     * POST /admin/user/get  
     * 请求体示例:{"id": 1}  
     */
    @PostMapping("/get")  
    public Map<String, Object> getUser(@RequestBody Map<String, Object> params) {  
        Map<String, Object> res = new HashMap<>();  
        Long id = getLongParam(params, "id", null);  
        if (id == null) {  
            res.put("code", 0);  
            res.put("message", "缺少用户 id");  
            return res;  
        }  
  
        User user = userService.getUserById(id);  
        if (Objects.isNull(user)) {  
            res.put("code", 0);  
            res.put("message", "用户不存在");  
        } else {  
            res.put("code", 1);  
            res.put("message", "用户查询成功");  
            res.put("user", user);  
        }  
        return res;  
    }  
  
    // ========== 工具方法:从 Map 中安全获取 Long 参数 ==========
    private Long getLongParam(Map<String, Object> params, String key, Long defaultVal) {  
        Object v = params == null ? null : params.get(key);  
        if (v == null) {  
            return defaultVal;  
        }  
        if (v instanceof Number) {  
            return ((Number) v).longValue();  
        }  
        try {  
            return Long.parseLong(v.toString());  
        } catch (Exception e) {  
            return defaultVal;  
        }  
    }  
}

c. Service (厨师接口)

  • 文件: service/UserService.java

package com.xhu.headline_server.service;

import com.xhu.headline_server.entity.User;
import org.springframework.stereotype.Service;

@Service  
public interface UserService {  
  
    User getUserById(Long id);  
  
    void saveUser(User user) ;  
  
    boolean delUserById(Long id);  
  
    void addUser(User user);  
}

d. Service Impl (厨师实现)

  • 文件: service/impl/UserServiceImpl.java (需要先建一个 impl 子包)
package com.xhu.headline_server.service.impl;

import com.xhu.headline_server.entity.User;
import com.xhu.headline_server.mapper.UserMapper;
import com.xhu.headline_server.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/** * 用户服务实现类  
 */  
@Service  
@Slf4j  
public class UserServiceImpl implements UserService {  
  
    @Autowired  
    private UserMapper userMapper;  
  
    /** * 根据 id 查询用户  
     */  
    @Override  
    public User getUserById(Long id) {  
        if (id == null) {  
            return null;  
        }  
        return userMapper.getUserById(id);  
    }  
  
    /** * 新增或更新用户  
     * 如果 id 为 null 则新增,否则更新  
     */  
    @Override  
    public void saveUser(User user) {  
        if (user == null) {  
            log.warn("saveUser 传入 user 为空");  
            return;  
        }  
        if (user.getId() == 0) { // 假设 id 是 int, 0 为“空”
            userMapper.addUser(user);  
        } else {  
            userMapper.updateUser(user);  
        }  
    }  
  
    /** * 根据 id 删除用户  
     */  
    @Override  
    public boolean delUserById(Long id) {  
        if (id == null) {  
            return false;  
        }  
        int affected = userMapper.delUserById(id);  
        return affected > 0;  
    }  
  
    /** * 新增  
     */  
    @Override  
    public void addUser(User user) {  
        if (user == null) {  
            log.warn("addUser 传入 user 为空");  
            return;  
        }  
        userMapper.addUser(user);  
    }  
}

e. Mapper (仓管接口)

  • 文件: mapper/UserMapper.java
Java
package com.xhu.headline_server.mapper;

import com.xhu.headline_server.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper  
public interface UserMapper {  
  
    /** * 根据 id 查询用户  
     */  
    User getUserById(@Param("id") Long id);  
  
    /** * 新增用户  
     */  
    int addUser(User user);  
  
    /** * 更新用户  
     */  
    int updateUser(User user);  
  
    /** * 根据 id 删除用户  
     */  
    int delUserById(@Param("id") Long id);  
}

f. Mapper XML (仓管SQL)

  • 文件: resources/mapper/UserMapper.xml (你需要先建一个 mapper 文件夹)
<?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.UserMapper">  
  
    <select id="getUserById" parameterType="long" resultType="com.xhu.headline_server.entity.User">  
        SELECT  
        id,  
        user_name   AS userName,  
        password,  
        nick_name   AS nickName,  
        avatar_url  AS avatarUrl,  
        phone,  
        role,  
        create_time AS createTime,  
        update_time AS updateTime,  
        deleted  
        FROM `user`  
        WHERE id = #{id}  
        AND deleted = 0  
    </select>  
  
    <insert id="addUser" parameterType="com.xhu.headline_server.entity.User" useGeneratedKeys="true" keyProperty="id">  
        INSERT INTO `user` (  
        user_name,  
        password,  
        nick_name,  
        avatar_url,  
        phone,  
        role,  
        create_time,  
        update_time,  
        deleted  
        ) VALUES (  
        #{userName},  
        #{password},  
        #{nickName},  
        #{avatarUrl},  
        #{phone},  
        #{role},  
        NOW(),  
        NOW(),  
        0  
        )  
    </insert>  
  
    <update id="updateUser" parameterType="com.xhu.headline_server.entity.User">  
        UPDATE `user`  
        SET  
        user_name   = #{userName},  
        password    = #{password},  
        nick_name   = #{nickName},  
        avatar_url  = #{avatarUrl},  
        phone       = #{phone},  
        role        = #{role},  
        update_time = NOW()  
        WHERE id = #{id}  
        AND deleted = 0  
    </update>  
  
    <delete id="delUserById" parameterType="long">  
        DELETE FROM `user`  
        WHERE id = #{id}  
    </delete>  
  
</mapper>

2. 前端实现 (Vue)

对你们的要求:

接下来是“餐厅大堂”的装修。同样,在 1.5 创建的前端项目里,找到对应文件,能理解即可

a. 清理主视图

  • 文件: src/App.vue (替换全部内容)
<template>
  <router-view />
</template>

<script setup lang="ts">  
</script>

<style scoped>
</style>

b. 搭建基础布局 (Layout)

  • 文件: src/views/layout/index.vue (新建 layout 文件夹和 index.vue 文件)
<template>
  <el-container class="layout-container-demo" style="min-height: 100vh">
    <el-aside width="220px" class="aside">
      <el-scrollbar>
        <el-menu :default-active="active" @select="go" class="el-menu-vertical-demo">
          <el-menu-item index="/user">
            <el-icon><IconMenu /></el-icon>
            <span>用户管理</span>
          </el-menu-item>
        </el-menu>

        <el-menu :default-active="active" @select="go" class="el-menu-vertical-demo">
          <el-menu-item index="/login">
            <el-icon><IconMenu /></el-icon>
            <span>登录管理</span>
          </el-menu-item>
        </el-menu>
      </el-scrollbar>
    </el-aside>

    <el-container>
      <el-header class="app-header">
        <div class="title">头条后台管理系统</div>
        <div class="user">当前用户</div>
      </el-header>

      <el-main class="app-main">
        <router-view />
      </el-main>
    </el-container>
  </el-container>
</template>

<script lang="ts">
export default { name: 'AppLayout' }
</script>

<script setup lang="ts">
import { ref, watch } from 'vue'
import {
  // useRoute 用于获取当前路由信息
  // useRouter 用于编程式导航
  useRoute,
  useRouter,
  // 下面的类型仅用于 TypeScript 提示
  type RouteLocationAsPathGeneric,
  type RouteLocationAsRelativeGeneric,
} from 'vue-router'

// 从 element-plus icons 中引入图标组件
// Message 用作“图书管理”图标示例,Menu 重命名为 IconMenu 用作“用户管理”图标
import { Menu as IconMenu } from '@element-plus/icons-vue'

/*
  获取路由实例与路由状态:
  - route: 当前路由对象,包含 path、params、query 等
  - router: 用于跳转页面(router.push)
*/
const route = useRoute()
const router = useRouter()

/*
  active 保存当前菜单应被高亮的索引值(这里使用 path,例如 "/book")。
  使用 ref 包装是因为 active 会在模板中响应式使用。
*/
const active = ref(route.path)

/*
  监听路由 path 的变化:
  当路由发生改变时,把 active 更新为最新的 path,
  这样菜单的高亮状态会自动跟随路由切换。
*/
watch(
  () => route.path,
  (p) => {
    active.value = p
  },
)

/*
  go 函数:菜单点击时触发的回调。
  参数 index 是菜单项绑定的 index(我们此处把 index 设为路径字符串,如 "/book")。
  - 如果目标路径存在并且与当前路由不同,就使用 router.push 跳转。
  - 参数类型中包含两种路由位置的类型,便于 TypeScript 检查。
*/
const go = (index: string | RouteLocationAsRelativeGeneric | RouteLocationAsPathGeneric) => {
  if (index && route.path !== index) router.push(index as string) // 强制转为 string
}
</script>

<style scoped>
/* 布局容器:水平排列,撑满高度 */
.layout-container-demo {
  display: flex;
  min-height: 100vh;
}

/* 头部样式:居中、左右布局、渐变背景 */
.app-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 64px;
  padding: 0 16px;
  background: linear-gradient(90deg, #00547d, #00aaa0);
  color: #fff;
}

/* 主内容区:内边距与高度计算,保证内容不被头部遮挡 */
.app-main {
  padding: 16px;
  box-sizing: border-box;
  min-height: calc(100vh - 64px);
}

/* 侧边栏背景与分割线 */
.aside {
  background: #f7f9fb;
  border-right: 1px solid #e6e6e6;
}

/* 响应式:窄屏时隐藏侧边栏(可结合汉堡菜单实现更复杂行为) */
@media (max-width: 768px) {
  .aside {
    display: none;
  }
}
</style>

c. 配置路由 (Router)

  • 文件: src/router/index.ts (替换全部内容)
import { createRouter, createWebHistory } from 'vue-router'
import LayoutView from '@/views/layout/index.vue'
import UserView from '@/views/user/index.vue'
import LoginView from '@/views/Login/index.vue' // 假设你创建了 Login 目录和 index.vue

const routes = [
  {
    path: '/',
    component: LayoutView,
    redirect: '/user', // 根访问自动跳到 /user
    children: [
      { path: 'user', name: 'user', component: UserView },
      // 其他需要认证的页面按需加入 children
    ],
  },
  // 登录页单独放在根路由(不走 Layout)
  { path: '/login', name: 'login', component: LoginView },
]

// 创建路由实例
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
})

export default router

d. 编写“用户管理”页面

  • 文件: src/views/user/index.vue (新建 user 文件夹和 index.vue 文件)
<template>
  <h2>用户管理</h2>

  <div class="container">
    <el-form :inline="true" :model="searchUserForm" class="inline-form">
      <el-form-item v-for="field in formSchema" :key="field.prop" :label="field.label">
        <component
          v-if="field.type === 'input'"
          :is="'el-input'"
          v-model="searchUserForm[field.prop]"
          :placeholder="field.placeholder"
        />
      </el-form-item>

      <el-form-item>
        <el-button type="success" @click="addUser"
          ><el-icon><EditPen /></el-icon> 添加</el-button
        >
        <el-button type="primary" @click="search">查询</el-button>
        <el-button @click="clear">清空</el-button>
      </el-form-item>
    </el-form>
  </div>

  <div class="container" style="margin-top: 10px">
    <el-table :data="userList" border style="width: 100%">
      <el-table-column prop="id" label="ID" width="120" />
      <el-table-column prop="userName" label="用户名" width="120" />
      <el-table-column prop="password" label="密码" width="120" />
      <el-table-column prop="nickName" label="昵称" width="120" />
      <el-table-column prop="role" label="分类" width="120" />
      <el-table-column prop="phone" label="电话" width="120" />
      <el-table-column prop="createTime" label="创建时间" width="180" />
      <el-table-column prop="updateTime" label="更新时间" width="180" />

      <el-table-column label="头像" width="80">
        <template #default="{ row }">
          <div class="avatar-cell">
            <a v-if="row.avatarUrl" :href="row.avatarUrl" target="_blank" rel="noopener noreferrer">
              <img
                :src="row.avatarUrl || DEFAULT_AVATAR"
                @error="onImageError"
                alt="avatar"
                class="avatar-img"
              />
            </a>
            <img v-else :src="DEFAULT_AVATAR" alt="default avatar" class="avatar-img" />
          </div>
        </template>
      </el-table-column>

      <el-table-column label="操作" width="120">
        <template #default="{ row }">
          <div class="action-buttons">
            <el-button size="small" type="primary" @click="updateUser(row)">
              <el-icon><EditPen /></el-icon>
            </el-button>
            <el-button size="small" type="danger" @click="delUser(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="user" label-width="100px">
      <el-row :gutter="20">
        <el-col :span="12">
          <el-form-item label="用户名">
            <el-input v-model="user.userName" placeholder="请输入用户名" />
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="密码">
            <el-input v-model="user.password" placeholder="请输入密码" type="password" />
          </el-form-item>
        </el-col>
      </el-row>

      <el-row :gutter="20">
        <el-col :span="12">
          <el-form-item label="昵称">
            <el-input v-model="user.nickName" placeholder="请输入昵称" />
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="分类">
            <el-select v-model="user.role" placeholder="请选择分类">
              <el-option :label="'0 - 管理员'" :value="0" />
              <el-option :label="'1 - 员工'" :value="1" />
              <el-option :label="'2 - 用户'" :value="2" />
            </el-select>
            <div class="hint">提示:0 为管理员,1 为员工,2 为用户</div>
          </el-form-item>
        </el-col>
      </el-row>

      <el-row :gutter="20">
        <el-col :span="12">
          <el-form-item label="电话">
            <el-input v-model="user.phone" placeholder="请输入电话" />
          </el-form-item>
        </el-col>
      </el-row>

      <el-row :gutter="20">
        <el-col :span="12">
          <el-form-item label="头像">
            <el-upload
              class="image-uploader"
              action="/api/admin/user/avatar"
              name="image"
              :show-file-list="false"
              :on-success="uploadSuccess"
              :on-error="uploadError"
              :before-upload="beforeUpload"
              :headers="{ Accept: 'application/json' }"
              accept="image/*"
            >
              <el-button size="small" type="primary">点击上传头像</el-button>
            </el-upload>

            <div style="margin-top: 6px">
              <div v-if="user.avatarUrl">
                <a :href="user.avatarUrl" target="_blank">{{ user.avatarUrl }}</a>
                <div style="margin-top: 6px">
                  <img
                    :src="user.avatarUrl"
                    @error="onImageError"
                    alt="avatar preview"
                    class="avatar-preview"
                  />
                </div>
              </div>
              <div v-else>尚未上传</div>
            </div>
          </el-form-item>
        </el-col>
      </el-row>
    </el-form>

    <template #footer>
      <el-button @click="cancelEdit">取 消</el-button>
      <el-button type="primary" @click="saveUser">确 定</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>

  <div class="space-y-6 p-8 dark:bg-black">
    <FileUpload class="rounded-lg border border-dashed border-neutral-200 dark:border-neutral-800">
      <FileUploadGrid />
    </FileUpload>
  </div>
</template>

<script lang="ts">
export default { name: 'UserView' }
</script>

<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { EditPen, Delete } from '@element-plus/icons-vue'
// 假设你把 api/user 和 api/curd 放在了 @/api/ 目录下
import { queryPageApi, deleteUserApi, addUserApi, updateUserApi } from '@/api/user' 
import { curd } from '@/api/curd'
// 假设 FileUpload 来自 Inspira UI
import { FileUpload, FileUploadGrid } from '@inspira-ui/vue' 

// 初始用户对象
const initialUser = {
  id: null,
  userName: '',
  password: '',
  nickName: '',
  role: '',
  phone: '',
  createTime: '',
  updateTime: '',
  avatarUrl: '',
}

// 搜索 / 表单 字段配置
const formSchemaConfig = [
  { prop: 'userName', label: '用户名', type: 'input', placeholder: '请输入用户名' },
  { prop: 'role', label: '分类', type: 'input', placeholder: '请输入分类' },
  { prop: 'phone', label: '手机号', type: 'input', placeholder: '请输入手机号' },
]

// 使用通用 curd.js
const {
  item,
  searchForm,
  formSchema,
  list,
  currentPage,
  pageSize,
  total,
  dialogShow,
  dialogTitle,
  search,
  addItem,
  updateItem,
  saveItem,
  deleteItem,
  cancel,
  handleSizeChange,
  handleCurrentChange,
} = curd(
  { queryPage: queryPageApi, add: addUserApi, update: updateUserApi, del: deleteUserApi },
  {
    initialItem: initialUser,
    formSchema: formSchemaConfig,
    title: '用户管理',
    labels: {
      userName: '用户名',
      role: '分类',
      phone: '手机号',
      avatarUrl: '头像',
      actions: '操作',
    },
  },
)

// 直接使用 curd 返回的 item 等
const user = item
const searchUserForm = searchForm
const userList = list

// 默认头像
const DEFAULT_AVATAR = 'http://yumoni.top/upload/Transparent_Akkarin_Transparentized.png'

// 图片加载失败时把图片替换为默认
const onImageError = (e: Event) => {
  const img = e.target as HTMLImageElement
  if (img && DEFAULT_AVATAR) img.src = DEFAULT_AVATAR
}
// 清空搜索
const clear = () => {
  formSchema.forEach((f: any) => {
    searchUserForm[f.prop] = ''
  })
  currentPage.value = 1
  search()
}

// 删除
const delUser = async (row: unknown) => {
  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('请求出错')
  }
}

// 新增 / 编辑 / 保存 / 取消
const addUser = addItem
const updateUser = updateItem
const saveUser = async () => {
  const payload = JSON.parse(JSON.stringify(user))
  if (!payload.userName) {
    ElMessage.error('请填写用户名')
    return
  }
  Object.assign(user, payload)
  try {
    const res = await saveItem()
    if (res && (res.code || res.message)) {
      ElMessage.success(res.message || (user.id ? '更新成功' : '添加成功'))
      cancel()
      search()
    } else {
      ElMessage.error(res?.message || '保存失败')
    }
  } catch (err) {
    console.error(err)
    ElMessage.error('请求出错')
  }
}
const cancelEdit = cancel

// 上传回调与校验(新增)
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
const beforeUpload = (file: File) => {
  if (file.size > MAX_FILE_SIZE) {
    ElMessage.error('上传文件大小不能超过 5 MB')
    return false
  }
  return true
}

const uploadSuccess = (Response: unknown) => {
  let data: any = Response
  if (typeof Response === 'string') {
    try {
      data = JSON.parse(Response)
    } catch (e) {
      console.error('解析上传响应失败', e)
      ElMessage.error('上传失败,服务器返回数据格式错误')
      return
    }
  }
  // 尝试解析常见位置
  const url = data?.url || data?.data?.url || data?.avatarUrl || data?.data?.avatarUrl || ''
  if (url) {
    user.avatarUrl = url
    ElMessage.success('上传成功')
  } else {
    ElMessage.warning('上传完成,但后端未返回图片链接')
    console.warn('upload response parsed but no url:', data)
  }
}
const uploadError = () => {
  console.error('上传失败')
  ElMessage.error('上传失败')
}
</script>

<style scoped>
.container {
  margin: 12px 0;
}
.inline-form {
  gap: 12px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
}

/* 操作按钮区域 */
.action-buttons {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
  align-items: center;
}
/* avatar 列单元格样式 */
.avatar-img {
  width: 40px;
  height: 40px;
  border-radius: 6px;
  object-fit: cover;
  display: inline-block;
}

/* 编辑对话框内的预览 */
.avatar-preview {
  width: 80px;
  height: 80px;
  border-radius: 6px;
  object-fit: cover;
  margin-top: 6px;
}

/* dialog 中分类提示样式 */
.hint {
  font-size: 12px;
  color: #909399;
  margin-top: 6px;
}
</style>

e. 编写 API 请求

  • 文件: src/api/user.js (新建 api 文件夹和 user.js 文件)
export async function queryPageApi(userName = '', role = '', phone = '', page = 1, size = 10) {
  // 注意:你的 UserController 没有实现分页和模糊查询,
  // 这里暂时用 /get 模拟,或者你需要修改后端
  // 暂时修改为调用 /get (如果 id 存在) 或 /list (假设你有一个 /list 接口)
  // 你的后端代码里没有 /list 接口,你需要加上
  //
  // **为了让代码跑通,我先假设你后端加了 /admin/user/list 接口**
  const res = await fetch('/api/admin/user/list', { // 假设你后端有 /list
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userName, role, phone, page, size }),
  })
  return res.ok ? await res.json() : { code: 0, data: { rows: [], total: 0 } }
}

export async function deleteUserApi(userOrId) {
  const payload = typeof userOrId === 'object' ? userOrId : { id: userOrId }
  const res = await fetch('/api/admin/user/delete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  })
  return res.ok ? await res.json() : { code: 0, message: '删除失败' }
}

export async function addUserApi(user) {
  const body = { ...user }
  delete body.id
  
  const res = await fetch('/api/admin/user/add', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
  if (res.ok) {
    return await res.json()
  } else {
    const txt = await res.text().catch(() => '')
    return { code: 0, message: txt || `服务器错误 ${res.status}` }
  }
}

export async function updateUserApi(user) {
  const body = { ...user }
  const res = await fetch('/api/admin/user/update', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
  if (res.ok) {
    return await res.json()
  } else {
    const txt = await res.text().catch(() => '')
    return { code: 0, message: txt || `服务器错误 ${res.status}` }
  }
}

f. 编写通用 CURD 逻辑

  • 文件: src/api/curd.js

给零基础同学的话:

curd.js 是一个通用的逻辑抽离,它帮我们处理了大部分增删改查的重复代码(比如翻页、搜索、打开弹窗等)。初学者可以先不用深究,知道 user/index.vue 在调用它就行。


import { reactive, ref, onMounted } from 'vue'

type Api = {
  queryPage?: (...args: any[]) => Promise<any>
  add?: (body: any) => Promise<any>
  update?: (body: any) => Promise<any>
  del?: (payload: any) => Promise<any>
}

type FormSchemaItem = { prop: string; label?: string; type?: string; placeholder?: string }

export function curd(api: Api = {}, options: any = {}) {
  const idField: string = options.idField || 'id'
  const initialItem = options.initialItem || {}
  const mapToServer = options.mapToServer || ((p: any) => p)
  const formSchema: FormSchemaItem[] = options.formSchema || []

  // 对话框数据
  const item = reactive({ ...initialItem }) as any
  const dialogShow = ref(false)
  const dialogTitle = ref('新增')
  const searchForm = reactive(
    formSchema.reduce((acc: any, cur: FormSchemaItem) => {
      acc[cur.prop] = ''
      return acc
    }, {}),
  ) as any

  // 列表与分页
  const list = ref<any[]>([])
  const currentPage = ref(1)
  const pageSize = ref(10)
  const total = ref(0)

  // 列宽
  const colWidths = reactive(options.colWidths || {}) as Record<string, string>

  // 测量文本宽度
  function textWidth(text: string | number | null | undefined, font?: string) {
    const can = document.createElement('canvas').getContext('2d')!
    can.font =
      font ||
      `${getComputedStyle(document.body).fontSize} ${getComputedStyle(document.body).fontFamily}`
    return can.measureText((text ?? '') as string).width
  }

  // 计算列宽函数
  function columnWidth(
    rows: any[] = [],
    labels: Record<string, string> = {},
    padding = 32,
    minWidthParam: Record<string, number> = {},
    maxWidthParam: Record<string, number> = {},
  ) {
    const font = `${getComputedStyle(document.body).fontSize} ${getComputedStyle(document.body).fontFamily}`
    Object.keys(labels).forEach((prop) => {
      let localMax = textWidth(labels[prop], font)
      for (const r of rows) {
        const txt = r && r[prop] != null ? String(r[prop]) : ''
        const w = textWidth(txt, font)
        if (w > localMax) localMax = w
      }
      const final = Math.ceil(
        Math.min(
          maxWidthParam[prop] || 300,
          Math.max(minWidthParam[prop] || 50, localMax + padding),
        ),
      )
      colWidths[prop] = `${final}px`
    })
  }

  // 清空搜索
  const clear = () => {
    formSchema.forEach((f) => {
      ;(searchForm as any)[f.prop] = ''
    })
    currentPage.value = 1
    search()
  }

  // 查询
  const search = async () => {
    const args = [...Object.values(searchForm), currentPage.value, pageSize.value]
    console.debug('curd.search args:', args)
    try {
      const res = await api.queryPage?.(...args)
      console.debug('curd.search response:', res)
      if (res && res.code) {
        list.value = res.data.rows || []
        total.value = res.data.total || 0
        columnWidth(
          list.value,
          options.labels || {},
          options.padding,
          options.minWidth,
          options.maxWidth,
        )
      } else {
        list.value = []
        total.value = 0
        columnWidth([], options.labels || {}, options.padding, options.minWidth, options.maxWidth)
      }
    } catch (err) {
      console.error('curd.search network/error:', err)
      list.value = []
      total.value = 0
      columnWidth([], options.labels || {}, options.padding, options.minWidth, options.maxWidth)
    }
  }

  // 新增
  const addItem = () => {
    dialogTitle.value = `新增${options.title || ''}`
    Object.assign(item, JSON.parse(JSON.stringify(initialItem)))
    dialogShow.value = true
  }

  // 编辑
  const updateItem = (row: any) => {
    dialogTitle.value = `编辑${options.title || ''}`
    Object.assign(item, { ...row })
    dialogShow.value = true
  }

  // 保存
  const saveItem = async () => {
    const payload = JSON.parse(JSON.stringify(item))
    const body = mapToServer(payload)
    try {
      let res
      if ((item as any)[idField]) res = await api.update?.(body)
      else {
        delete (body as any)[idField]
        res = await api.add?.(body)
      }
      return res
    } catch (e) {
      console.error('curd.saveItem error:', e)
      throw e
    }
  }

  // 删除
  const deleteItem = async (row: any) => {
    const payload = { id: row[idField] ?? row.id ?? row._id }
    return api.del?.(payload)
  }

  // 分页处理
  const handleSizeChange = (val: number) => {
    pageSize.value = val
    currentPage.value = 1
    search()
  }
  const handleCurrentChange = (val: number) => {
    currentPage.value = val
    search()
  }

  // 钩子函数
  onMounted(() => {
    if (typeof api.queryPage === 'function') search()
    else console.warn('curd: api.queryPage is not a function, skip initial search')
  })

  return {
    // 参数
    item,
    dialogShow,
    dialogTitle,
    searchForm,
    formSchema,
    list,
    currentPage,
    pageSize,
    total,
    colWidths,
    // 函数
    clear,
    search,
    addItem,
    updateItem,
    saveItem,
    deleteItem,
    cancel: () => (dialogShow.value = false),
    handleSizeChange,
    handleCurrentChange,
    columnWidth,
    textWidth,
  } as any
}

export const useCrud = curd

第二阶段:用户端与身份认证体系

(待完成)

想温柔的对待这个世界