西华头条系统开发文档 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.js、main.css、src/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) 实现管理员对“用户”的增、删、改、查。
给零基础同学的话:数据流转之旅
我们的目标是:在前端页面上点击“刷新”按钮,从数据库拿到所有用户数据,并显示在表格中。
数据是这样旅行的:
[前端-页面]
UserManagement.vue(页面) 被打开时,它调用fetchData方法。[前端-API]
fetchData方法调用api/user.js里定义的getUserList()函数。[网络]
getUserList()触发axios(点餐器),向后端的/admin/user/list地址发送一个GET请求 (点了一份“用户列表”)。[后端-Controller]
UserController(服务员) 听到了/admin/user/list的请求,调用userService.listUsers()方法。[后端-Service]
UserService(厨师) 接到任务,他调用userMapper.listAll()。[后端-Mapper]
UserMapper(仓管) 执行SELECT * FROM userSQL语句。[数据库] MySQL (仓库) 收到 SQL,返回所有用户数据给 Mapper。
[返回] 数据原路返回:
Mapper->Service->Controller。[网络] Controller 把数据打包成
Result(统一格式),通过网络发回给前端axios。[前端-页面]
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
第二阶段:用户端与身份认证体系
(待完成)