feat: Phase 1 完成 — 基础设施 + 用户认证 + 管理端 + 端到端验证

Phase 1 (基础设施与用户认证) 全部 11 个 Task 开发完成:

后端 (Go):
- Auth 模块: 注册/登录/JWT(有状态)/Profile/密码修改
- Admin 模块: 用户列表/详情/禁用/启用/角色分配/创建用户
- 中间件: JWT认证 + RBAC角色权限 + 请求日志 + CORS + Panic恢复
- Dockerfile 多阶段构建 + Docker Compose 全栈部署

前台 (uni-app):
- 登录/注册页面 + 自定义 TabBar + 个人中心
- 请求封装 + 状态管理 (Pinia)

管理端 (Vue 3 + Element Plus):
- 登录/仪表盘/用户列表/用户详情
- Axios 封装 + 路由守卫 + Pinia 状态管理

验证:
- 全流程 API 端到端测试通过
- Playwright 页面自动化验证通过
- code-reviewer 代码审查通过

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-03-02 11:31:24 +08:00
parent e9bd4dd204
commit 4d03215fe4
63 changed files with 15908 additions and 102 deletions

13
admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EchoChat 管理后台</title>
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1952
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
admin/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "echochat-admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@vitejs/plugin-vue": "^6.0.4",
"axios": "^1.13.6",
"element-plus": "^2.13.3",
"pinia": "^3.0.4",
"vite": "^7.3.1",
"vue": "^3.5.29",
"vue-router": "^4.6.4"
}
}

10
admin/src/App.vue Normal file
View File

@@ -0,0 +1,10 @@
<!--
管理后台根组件
仅提供 router-view 出口
-->
<template>
<router-view />
</template>
<script setup>
</script>

35
admin/src/api/auth.js Normal file
View File

@@ -0,0 +1,35 @@
/**
* 管理后台认证 API
*
* 对应后端路由:/api/v1/auth/*
* 管理员登录使用与普通用户相同的登录接口,
* 权限区分由 JWT 中的 role 字段和 RequireRole 中间件控制
*/
import request from '@/utils/request'
/**
* 管理员登录
* POST /api/v1/auth/login
*
* @param {Object} data - { account, password }
* @returns {Promise<Object>} { token, refresh_token, expires_in, user }
*/
export const login = (data) => {
return request.post('/api/v1/auth/login', data)
}
/**
* 管理员登出
* POST /api/v1/auth/logout
*/
export const logout = () => {
return request.post('/api/v1/auth/logout')
}
/**
* 获取管理员个人信息
* GET /api/v1/auth/profile
*/
export const getProfile = () => {
return request.get('/api/v1/auth/profile')
}

89
admin/src/api/user.js Normal file
View File

@@ -0,0 +1,89 @@
/**
* 用户管理 API 模块
*
* 对应后端路由:/api/v1/admin/users
* 所有接口需要 JWT + admin 角色权限
*
* 接口列表:
* - GET /api/v1/admin/users 用户列表(分页 + 搜索 + 筛选)
* - GET /api/v1/admin/users/:id 用户详情
* - PUT /api/v1/admin/users/:id/status 更新用户状态
* - PUT /api/v1/admin/users/:id/role 分配角色
* - POST /api/v1/admin/users 管理员创建用户
*/
import request from '@/utils/request'
/**
* 获取用户列表
* @param {Object} params - 查询参数
* @param {number} params.page - 页码(从 1 开始)
* @param {number} params.page_size - 每页数量
* @param {string} [params.keyword] - 搜索关键词(用户名/邮箱)
* @param {number} [params.status] - 状态筛选1=正常, 2=禁用
* @returns {Promise<{data: {total: number, list: Array}}>}
*/
export function getUserList(params) {
return request({
url: '/api/v1/admin/users',
method: 'get',
params
})
}
/**
* 获取用户详情
* @param {number} id - 用户 ID
* @returns {Promise<{data: Object}>}
*/
export function getUserDetail(id) {
return request({
url: `/api/v1/admin/users/${id}`,
method: 'get'
})
}
/**
* 更新用户状态
* @param {number} id - 用户 ID
* @param {number} status - 目标状态1=正常, 2=禁用
* @returns {Promise}
*/
export function updateUserStatus(id, status) {
return request({
url: `/api/v1/admin/users/${id}/status`,
method: 'put',
data: { status }
})
}
/**
* 分配角色
* @param {number} id - 用户 ID
* @param {string} roleCode - 角色代码user/admin/super_admin
* @returns {Promise}
*/
export function assignUserRole(id, roleCode) {
return request({
url: `/api/v1/admin/users/${id}/role`,
method: 'put',
data: { role_code: roleCode }
})
}
/**
* 管理员创建用户
* @param {Object} data - 用户信息
* @param {string} data.username - 用户名
* @param {string} data.email - 邮箱
* @param {string} data.password - 初始密码
* @param {string} [data.nickname] - 昵称
* @param {string} [data.role_code] - 初始角色
* @returns {Promise<{data: Object}>}
*/
export function createUser(data) {
return request({
url: '/api/v1/admin/users',
method: 'post',
data
})
}

31
admin/src/main.js Normal file
View File

@@ -0,0 +1,31 @@
/**
* 管理后台入口文件
*
* 初始化 Vue 3 应用,注册全局插件:
* - Element PlusUI 组件库,含中文语言包)
* - Vue Router路由管理
* - Pinia状态管理
* - 全局样式
*/
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import './styles/global.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

85
admin/src/router/index.js Normal file
View File

@@ -0,0 +1,85 @@
/**
* 管理后台路由配置
*
* 路由结构:
* - /login → 管理员登录页(无需认证)
* - / → 后台主布局(需要认证 + admin 角色)
* - /dashboard → 仪表盘
* - /user/list → 用户管理列表
* - /user/detail/:id → 用户详情
*
* 路由守卫:
* - 未登录 → 重定向到 /login
* - 已登录访问 /login → 重定向到 /dashboard
*/
import { createRouter, createWebHashHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '管理员登录', requiresAuth: false }
},
{
path: '/',
component: () => import('@/views/layout/index.vue'),
redirect: '/dashboard',
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '仪表盘' }
},
{
path: 'user/list',
name: 'UserList',
component: () => import('@/views/user/list.vue'),
meta: { title: '用户列表' }
},
{
path: 'user/detail/:id',
name: 'UserDetail',
component: () => import('@/views/user/detail.vue'),
meta: { title: '用户详情' }
}
]
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
/**
* 全局前置守卫
* 检查登录状态,控制页面访问权限
*/
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('admin_token')
document.title = to.meta.title
? `${to.meta.title} - EchoChat 管理后台`
: 'EchoChat 管理后台'
if (to.meta.requiresAuth === false) {
if (token && to.path === '/login') {
next('/dashboard')
} else {
next()
}
return
}
if (!token) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
next()
})
export default router

71
admin/src/store/user.js Normal file
View File

@@ -0,0 +1,71 @@
/**
* 管理后台用户 Store
*
* 管理管理员认证状态、个人信息
* 使用 Pinia 3.x + Composition API 风格
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login as loginApi, logout as logoutApi, getProfile as getProfileApi } from '@/api/auth'
import { saveToken, getToken, removeToken, saveUserInfo, getUserInfo, removeUserInfo, clearAll } from '@/utils/storage'
export const useUserStore = defineStore('admin-user', () => {
/** Access Token */
const token = ref(getToken() || '')
/** 管理员信息 */
const userInfo = ref(getUserInfo() || null)
/** 是否已登录 */
const isLoggedIn = computed(() => !!token.value)
/** 管理员用户名 */
const username = computed(() => userInfo.value?.username || '')
/** 角色列表 */
const roles = computed(() => userInfo.value?.roles || [])
/**
* 管理员登录
* @param {Object} loginData - { account, password }
*/
const login = async (loginData) => {
const res = await loginApi(loginData)
token.value = res.data.token
userInfo.value = res.data.user
saveToken(res.data.token, res.data.refresh_token)
saveUserInfo(res.data.user)
return res.data
}
/** 退出登录 */
const logout = async () => {
try {
await logoutApi()
} catch {
// 即使后端请求失败也清除本地状态
}
token.value = ''
userInfo.value = null
clearAll()
}
/** 获取/刷新管理员信息 */
const getProfile = async () => {
const res = await getProfileApi()
userInfo.value = res.data
saveUserInfo(res.data)
return res.data
}
return {
token,
userInfo,
isLoggedIn,
username,
roles,
login,
logout,
getProfile
}
})

View File

@@ -0,0 +1,43 @@
/**
* 管理后台全局样式
*
* 设计系统来源design-system/echochat/MASTER.md管理端适配
* 色板Primary #2563EB / Background #F8FAFC / Text #1E293B
*
* 注意:管理端使用 Element Plus 组件库,
* 通过 CSS 变量覆盖 Element Plus 默认主题色以匹配设计系统
*/
:root {
/* 与前台统一品牌色,管理端基于 Element Plus 主题 */
--el-color-primary: #2563EB;
--el-color-primary-light-3: #60A5FA;
--el-color-primary-light-5: #93C5FD;
--el-color-primary-light-7: #BFDBFE;
--el-color-primary-light-8: #DBEAFE;
--el-color-primary-light-9: #EFF6FF;
--el-color-primary-dark-2: #1D4ED8;
--el-color-danger: #EF4444;
--el-color-success: #22C55E;
--el-color-warning: #F59E0B;
--el-color-info: #64748B;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
color: #1E293B;
background-color: #F0F2F5;
-webkit-font-smoothing: antialiased;
}
a {
text-decoration: none;
color: inherit;
}

View File

@@ -0,0 +1,70 @@
/**
* HTTP 请求封装(管理后台)
*
* 基于 Axios 封装,提供:
* - 基础 URL 配置(开发环境通过 Vite proxy 代理)
* - 请求拦截:自动附加 Authorization Header
* - 响应拦截统一处理错误码401 跳转登录等)
*
* 对应后端 API 规范见 docs/api/README.md
*/
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
/** Axios 实例 */
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '',
timeout: 15000,
headers: { 'Content-Type': 'application/json' }
})
/**
* 请求拦截器
* 自动从 localStorage 读取 Token 并附加到 Authorization Header
*/
service.interceptors.request.use(
(config) => {
const token = localStorage.getItem('admin_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
/**
* 响应拦截器
* 统一处理后端 { code, message, data } 响应格式
*/
service.interceptors.response.use(
(response) => {
const { data } = response
if (data.code === 0) {
return data
}
ElMessage.error(data.message || '请求失败')
return Promise.reject(data)
},
(error) => {
if (error.response) {
const { status, data } = error.response
if (status === 401) {
ElMessage.error('登录已过期,请重新登录')
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_user')
router.push('/login')
} else if (status === 403) {
ElMessage.error(data?.message || '没有访问权限')
} else {
ElMessage.error(data?.message || `请求错误 (${status})`)
}
} else {
ElMessage.error('网络异常,请检查网络连接')
}
return Promise.reject(error)
}
)
export default service

View File

@@ -0,0 +1,54 @@
/**
* 本地存储工具(管理后台)
*
* 管理端使用 localStorageKey 前缀 admin_ 与前台隔离
*/
const KEYS = {
TOKEN: 'admin_token',
REFRESH_TOKEN: 'admin_refresh_token',
USER_INFO: 'admin_user'
}
/** 保存 Token */
export const saveToken = (token, refreshToken) => {
localStorage.setItem(KEYS.TOKEN, token)
if (refreshToken) localStorage.setItem(KEYS.REFRESH_TOKEN, refreshToken)
}
/** 获取 Access Token */
export const getToken = () => localStorage.getItem(KEYS.TOKEN)
/** 获取 Refresh Token */
export const getRefreshToken = () => localStorage.getItem(KEYS.REFRESH_TOKEN)
/** 清除 Token */
export const removeToken = () => {
localStorage.removeItem(KEYS.TOKEN)
localStorage.removeItem(KEYS.REFRESH_TOKEN)
}
/** 保存用户信息 */
export const saveUserInfo = (user) => {
localStorage.setItem(KEYS.USER_INFO, JSON.stringify(user))
}
/** 获取用户信息 */
export const getUserInfo = () => {
try {
return JSON.parse(localStorage.getItem(KEYS.USER_INFO))
} catch {
return null
}
}
/** 清除用户信息 */
export const removeUserInfo = () => {
localStorage.removeItem(KEYS.USER_INFO)
}
/** 清除所有管理端缓存 */
export const clearAll = () => {
removeToken()
removeUserInfo()
}

View File

@@ -0,0 +1,98 @@
<!--
仪表盘页面占位
设计系统design-system/echochat/MASTER.md
当前功能
- 显示 4 个统计卡片占位总用户数在线用户今日会议系统消息
- 后续 Phase 完善时接入真实数据 API
-->
<template>
<div class="dashboard-page">
<h2 class="page-title">仪表盘</h2>
<!-- 统计卡片 -->
<el-row :gutter="20" class="stat-row">
<el-col :xs="24" :sm="12" :lg="6" v-for="item in stats" :key="item.label">
<el-card shadow="hover" class="stat-card">
<div class="stat-header">
<el-icon :size="28" :color="item.color"><component :is="item.icon" /></el-icon>
</div>
<div class="stat-value">{{ item.value }}</div>
<div class="stat-label">{{ item.label }}</div>
</el-card>
</el-col>
</el-row>
<!-- 开发提示 -->
<el-card class="dev-notice">
<template #header>
<span>开发进度</span>
</template>
<p>Phase 1: 基础设施与用户认证模块 <el-tag type="warning">进行中</el-tag></p>
<p style="margin-top: 12px; color: #94A3B8; font-size: 14px;">
更多数据面板将在后续阶段中完善IM 统计会议统计系统监控等
</p>
</el-card>
</div>
</template>
<script setup>
/**
* 仪表盘数据
* 当前使用占位数据,后续接入统计 API
*/
import { shallowRef, markRaw } from 'vue'
import { UserFilled, Connection, VideoCameraFilled, ChatDotRound } from '@element-plus/icons-vue'
/** 使用 shallowRef + markRaw 避免 Vue 对组件对象做深层响应式处理 */
const stats = shallowRef([
{ label: '总用户数', value: '--', icon: markRaw(UserFilled), color: '#2563EB' },
{ label: '在线用户', value: '--', icon: markRaw(Connection), color: '#22C55E' },
{ label: '今日会议', value: '--', icon: markRaw(VideoCameraFilled), color: '#F59E0B' },
{ label: '系统消息', value: '--', icon: markRaw(ChatDotRound), color: '#8B5CF6' }
])
</script>
<style scoped>
.dashboard-page {
max-width: 1400px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #1E293B;
margin-bottom: 20px;
}
.stat-row {
margin-bottom: 24px;
}
.stat-card {
text-align: center;
padding: 8px 0;
margin-bottom: 12px;
}
.stat-header {
margin-bottom: 12px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #1E293B;
}
.stat-label {
font-size: 14px;
color: #94A3B8;
margin-top: 4px;
}
.dev-notice {
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,234 @@
<!--
管理后台主布局组件
设计系统design-system/echochat/MASTER.md管理端适配
结构
- 左侧固定侧边栏可折叠logo + 导航菜单
- 右侧主区域顶部栏面包屑 + 管理员信息+ 内容区
使用 Element Plus Container 布局组件
-->
<template>
<el-container class="layout-container">
<!-- 左侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '220px'" class="layout-aside">
<div class="logo-section">
<div class="logo-icon">E</div>
<span v-show="!isCollapse" class="logo-text">EchoChat</span>
</div>
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
router
background-color="#1E293B"
text-color="#94A3B8"
active-text-color="#FFFFFF"
class="aside-menu"
>
<el-menu-item index="/dashboard">
<el-icon><DataAnalysis /></el-icon>
<template #title>仪表盘</template>
</el-menu-item>
<el-sub-menu index="user-manage">
<template #title>
<el-icon><User /></el-icon>
<span>用户管理</span>
</template>
<el-menu-item index="/user/list">用户列表</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
<!-- 右侧主区域 -->
<el-container class="layout-main">
<!-- 顶部栏 -->
<el-header class="layout-header">
<div class="header-left">
<el-icon class="collapse-btn" @click="isCollapse = !isCollapse">
<Expand v-if="isCollapse" />
<Fold v-else />
</el-icon>
</div>
<div class="header-right">
<el-dropdown trigger="click" @command="handleCommand">
<span class="admin-info">
<el-avatar :size="32" class="admin-avatar">
{{ avatarLetter }}
</el-avatar>
<span class="admin-name">{{ username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 内容区 -->
<el-main class="layout-content">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
/**
* 管理后台布局逻辑
*
* 侧边栏折叠状态
* 管理员信息从 Store 获取
* 退出登录后清除状态并跳转
*/
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
/** 侧边栏折叠状态 */
const isCollapse = ref(false)
/** 当前激活的菜单项(跟随路由) */
const activeMenu = computed(() => route.path)
/** 管理员用户名 */
const username = computed(() => userStore.username || '管理员')
/** 头像占位字母 */
const avatarLetter = computed(() => {
const name = userStore.userInfo?.nickname || userStore.username || '?'
return name.charAt(0).toUpperCase()
})
/**
* 下拉菜单命令处理
* @param {string} command
*/
const handleCommand = async (command) => {
if (command === 'logout') {
await userStore.logout()
router.push('/login')
}
}
</script>
<style scoped>
/*
* 管理端布局样式
* 左侧导航:深色 #1E293B
* 顶部栏:白色 + 底部阴影
* 内容区:灰色背景 #F0F2F5
*/
.layout-container {
min-height: 100vh;
}
/* ---- 侧边栏 ---- */
.layout-aside {
background-color: #1E293B;
transition: width 0.3s ease;
overflow: hidden;
}
.logo-section {
display: flex;
align-items: center;
justify-content: center;
height: 60px;
padding: 0 16px;
border-bottom: 1px solid #334155;
}
.logo-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background-color: #2563EB;
color: #FFFFFF;
font-size: 18px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.logo-text {
font-size: 16px;
font-weight: 600;
color: #FFFFFF;
margin-left: 10px;
white-space: nowrap;
}
.aside-menu {
border-right: none !important;
}
/* ---- 顶部栏 ---- */
.layout-header {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #FFFFFF;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
padding: 0 20px;
height: 60px;
}
.header-left {
display: flex;
align-items: center;
}
.collapse-btn {
font-size: 20px;
cursor: pointer;
color: #64748B;
transition: color 200ms;
}
.collapse-btn:hover {
color: #2563EB;
}
.header-right {
display: flex;
align-items: center;
}
.admin-info {
display: flex;
align-items: center;
cursor: pointer;
gap: 8px;
}
.admin-avatar {
background-color: #2563EB;
color: #FFFFFF;
font-weight: 600;
}
.admin-name {
font-size: 14px;
color: #475569;
}
/* ---- 内容区 ---- */
.layout-content {
background-color: #F0F2F5;
padding: 20px;
min-height: calc(100vh - 60px);
}
</style>

View File

@@ -0,0 +1,203 @@
<!--
管理员登录页
设计系统design-system/echochat/MASTER.md + pages/admin-login.md
色板Primary #2563EB / Background #F0F2F5 / Text #1E293B
功能
- 管理员账号 + 密码登录
- 使用 Element Plus 表单组件 + 校验
- Loading 状态防重复提交
- 登录成功跳转仪表盘
对应 APIPOST /api/v1/auth/login
-->
<template>
<div class="login-page">
<div class="login-card">
<!-- 品牌区域 -->
<div class="brand-area">
<div class="logo-icon">E</div>
<h1 class="brand-title">EchoChat 管理后台</h1>
<p class="brand-desc">管理员专用请使用管理员账号登录</p>
</div>
<!-- 登录表单 -->
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large">
<el-form-item label="账号" prop="account">
<el-input
v-model="form.account"
placeholder="请输入管理员账号"
:prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
class="login-btn"
@click="handleLogin"
>
{{ loading ? '登录中…' : '登 录' }}
</el-button>
</el-form-item>
</el-form>
</div>
<div class="login-footer">
<p>EchoChat &copy; 2026 · 实时音视频通讯平台</p>
</div>
</div>
</template>
<script setup>
/**
* 管理员登录逻辑
*
* 使用 Element Plus 表单校验 + useUserStore 登录
* 登录成功后跳转到 redirect 参数指定的页面,默认仪表盘
*/
import { ref, shallowRef } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const formRef = ref(null)
const loading = ref(false)
const form = ref({
account: '',
password: ''
})
/** 表单校验规则 */
const rules = {
account: [{ required: true, message: '请输入管理员账号', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 位', trigger: 'blur' }
]
}
/** 提交登录 */
const handleLogin = async () => {
if (loading.value) return
try {
await formRef.value.validate()
} catch {
return
}
loading.value = true
try {
await userStore.login({
account: form.value.account.trim(),
password: form.value.password
})
ElMessage.success('登录成功')
const redirect = route.query.redirect || '/dashboard'
router.push(redirect)
} catch (err) {
console.error('管理员登录失败:', err)
} finally {
loading.value = false
}
}
</script>
<style scoped>
/*
* 管理员登录页样式
* 居中卡片布局,背景灰色
* 品牌色:#2563EB
*/
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #EFF6FF 0%, #F0F2F5 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-card {
width: 100%;
max-width: 420px;
background: #FFFFFF;
border-radius: 12px;
padding: 40px 36px 32px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
/* ---- 品牌区域 ---- */
.brand-area {
text-align: center;
margin-bottom: 32px;
}
.logo-icon {
width: 48px;
height: 48px;
border-radius: 10px;
background-color: #2563EB;
color: #FFFFFF;
font-size: 24px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.brand-title {
font-size: 22px;
font-weight: 600;
color: #1E293B;
margin: 0 0 8px;
}
.brand-desc {
font-size: 14px;
color: #94A3B8;
margin: 0;
}
/* ---- 登录按钮 ---- */
.login-btn {
width: 100%;
height: 44px;
font-size: 16px;
letter-spacing: 2px;
}
/* ---- 底部 ---- */
.login-footer {
margin-top: 32px;
text-align: center;
}
.login-footer p {
font-size: 13px;
color: #94A3B8;
}
</style>

View File

@@ -0,0 +1,409 @@
<!--
管理端用户详情页面
设计系统design-system/echochat/pages/admin-user-list.md
功能
- 基本信息展示ID用户名邮箱昵称性别手机状态注册/更新时间
- 角色管理查看当前角色 + 分配新角色
- 状态切换启用/禁用
- 会议记录 Tab占位后续完善
对应后端 API
- GET /api/v1/admin/users/:id
- PUT /api/v1/admin/users/:id/status
- PUT /api/v1/admin/users/:id/role
-->
<template>
<div class="user-detail-page" v-loading="loading">
<!-- 返回按钮 + 页面标题 -->
<div class="page-header">
<el-button text @click="goBack">
<el-icon><ArrowLeft /></el-icon>
返回列表
</el-button>
<h2 class="page-title">用户详情</h2>
</div>
<template v-if="userInfo">
<!-- Tab 切换 -->
<el-tabs v-model="activeTab" class="detail-tabs">
<!-- 基本信息 Tab -->
<el-tab-pane label="基本信息" name="info">
<el-card shadow="never" class="info-card">
<!-- 用户头部区域 -->
<div class="user-header">
<el-avatar :size="64" class="user-avatar">
{{ avatarLetter }}
</el-avatar>
<div class="user-header-info">
<h3 class="user-display-name">{{ userInfo.nickname || userInfo.username }}</h3>
<span class="user-id">ID: {{ userInfo.id }}</span>
<el-tag :type="getStatusType(userInfo.status)" size="small">
{{ userInfo.status_text }}
</el-tag>
</div>
<div class="user-actions">
<el-button
v-if="userInfo.status === 1"
type="danger"
@click="handleToggleStatus(2)"
>
禁用账号
</el-button>
<el-button
v-else-if="userInfo.status === 2"
type="success"
@click="handleToggleStatus(1)"
>
启用账号
</el-button>
</div>
</div>
<!-- 详细信息表格 -->
<el-descriptions :column="2" border class="info-descriptions">
<el-descriptions-item label="用户名">{{ userInfo.username }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ userInfo.email }}</el-descriptions-item>
<el-descriptions-item label="昵称">{{ userInfo.nickname || '-' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ getGenderText(userInfo.gender) }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ userInfo.phone || '未绑定' }}</el-descriptions-item>
<el-descriptions-item label="最后登录 IP">{{ userInfo.last_login_ip || '-' }}</el-descriptions-item>
<el-descriptions-item label="最后登录时间">{{ userInfo.last_login_at || '从未登录' }}</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ userInfo.created_at }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ userInfo.updated_at }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-tab-pane>
<!-- 角色管理 Tab -->
<el-tab-pane label="角色管理" name="roles">
<el-card shadow="never" class="role-card">
<div class="role-section">
<h4 class="section-title">当前角色</h4>
<div class="current-roles">
<el-tag
v-for="role in userInfo.roles"
:key="role"
:type="getRoleTagType(role)"
size="large"
class="role-tag"
>
{{ getRoleName(role) }}
</el-tag>
<span v-if="!userInfo.roles || userInfo.roles.length === 0" class="no-role">
暂未分配角色
</span>
</div>
</div>
<el-divider />
<div class="role-section">
<h4 class="section-title">分配角色</h4>
<div class="assign-role-form">
<el-select v-model="selectedRole" placeholder="选择角色" style="width: 200px">
<el-option label="普通用户" value="user" />
<el-option label="管理员" value="admin" />
<el-option label="超级管理员" value="super_admin" />
</el-select>
<el-button
type="primary"
:loading="roleLoading"
:disabled="!selectedRole"
@click="handleAssignRole"
>
分配角色
</el-button>
</div>
<p class="role-hint">
分配角色将为该用户添加所选角色权限已有角色不会受影响
</p>
</div>
</el-card>
</el-tab-pane>
<!-- 会议记录 Tab占位 -->
<el-tab-pane label="会议记录" name="meetings">
<el-card shadow="never">
<el-empty description="会议记录功能将在后续版本中实现" />
</el-card>
</el-tab-pane>
</el-tabs>
</template>
<!-- 用户不存在 -->
<el-empty v-else-if="!loading" description="用户不存在" />
</div>
</template>
<script setup>
/**
* 用户详情页面逻辑
*
* 数据流:
* 1. 从路由参数获取 userId
* 2. 调用 API 获取用户详情
* 3. 支持状态切换和角色分配操作
*/
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getUserDetail, updateUserStatus, assignUserRole } from '@/api/user'
const route = useRoute()
const router = useRouter()
/** 加载状态 */
const loading = ref(false)
/** 用户详情数据 */
const userInfo = ref(null)
/** 当前 Tab */
const activeTab = ref('info')
/** 角色分配选中值 */
const selectedRole = ref('')
/** 角色分配加载状态 */
const roleLoading = ref(false)
/** 路由参数中的用户 ID */
const userId = computed(() => Number(route.params.id))
/** 头像占位字母 */
const avatarLetter = computed(() => {
if (!userInfo.value) return '?'
const name = userInfo.value.nickname || userInfo.value.username || '?'
return name.charAt(0).toUpperCase()
})
/**
* 获取状态 Tag 类型
* @param {number} status
*/
const getStatusType = (status) => {
const map = { 1: 'success', 2: 'danger', 3: 'info' }
return map[status] || 'info'
}
/**
* 获取性别文本
* @param {number} gender
*/
const getGenderText = (gender) => {
const map = { 0: '未知', 1: '男', 2: '女' }
return map[gender] || '未知'
}
/**
* 获取角色 Tag 类型
* @param {string} role
*/
const getRoleTagType = (role) => {
const map = { super_admin: 'danger', admin: 'warning', user: '' }
return map[role] || 'info'
}
/**
* 获取角色中文名称
* @param {string} role
*/
const getRoleName = (role) => {
const map = { super_admin: '超级管理员', admin: '管理员', user: '普通用户' }
return map[role] || role
}
/**
* 获取用户详情
*/
const fetchUserDetail = async () => {
loading.value = true
try {
const res = await getUserDetail(userId.value)
userInfo.value = res.data
} catch (err) {
console.error('获取用户详情失败:', err)
userInfo.value = null
} finally {
loading.value = false
}
}
/** 返回列表 */
const goBack = () => {
router.push('/user/list')
}
/**
* 切换用户状态
* @param {number} newStatus
*/
const handleToggleStatus = async (newStatus) => {
const action = newStatus === 2 ? '禁用' : '启用'
try {
await ElMessageBox.confirm(
`确定要${action}该用户吗?`,
`${action}确认`,
{ type: 'warning' }
)
await updateUserStatus(userId.value, newStatus)
ElMessage.success(`${action}成功`)
fetchUserDetail()
} catch (err) {
if (err !== 'cancel') {
console.error(`${action}失败:`, err)
}
}
}
/**
* 分配角色
*/
const handleAssignRole = async () => {
if (!selectedRole.value) return
roleLoading.value = true
try {
await assignUserRole(userId.value, selectedRole.value)
ElMessage.success('角色分配成功')
selectedRole.value = ''
fetchUserDetail()
} catch (err) {
console.error('角色分配失败:', err)
} finally {
roleLoading.value = false
}
}
onMounted(() => {
fetchUserDetail()
})
</script>
<style scoped>
/*
* 用户详情页样式
* 设计系统Data-Dense Dashboard
* 清晰的信息层次、分区卡片、操作按钮明确
*/
.user-detail-page {
padding: 0;
}
/* ---- 页面头部 ---- */
.page-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #1E293B;
margin: 0;
}
/* ---- Tabs ---- */
.detail-tabs {
margin-top: 8px;
}
/* ---- 用户头部区域 ---- */
.user-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #E2E8F0;
}
.user-avatar {
background-color: #2563EB;
color: #FFFFFF;
font-size: 24px;
font-weight: 600;
flex-shrink: 0;
}
.user-header-info {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
}
.user-display-name {
font-size: 18px;
font-weight: 600;
color: #1E293B;
margin: 0;
}
.user-id {
font-size: 13px;
color: #94A3B8;
}
.user-actions {
flex-shrink: 0;
}
/* ---- 信息描述 ---- */
.info-descriptions {
margin-top: 8px;
}
.info-card {
margin-bottom: 0;
}
/* ---- 角色管理 ---- */
.role-card {
margin-bottom: 0;
}
.role-section {
margin-bottom: 8px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #1E293B;
margin: 0 0 12px 0;
}
.current-roles {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.role-tag {
font-size: 14px;
}
.no-role {
color: #94A3B8;
font-size: 14px;
}
.assign-role-form {
display: flex;
align-items: center;
gap: 12px;
}
.role-hint {
font-size: 12px;
color: #94A3B8;
margin-top: 8px;
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,485 @@
<!--
管理端用户列表页面
设计系统design-system/echochat/pages/admin-user-list.md
覆盖 MASTER.md Data-Dense Dashboard 风格
功能
- 搜索框用户名/邮箱关键词搜索
- 状态筛选下拉
- 用户数据表格Element Plus Table
- 分页组件
- 操作按钮查看详情启用/禁用创建用户
对应后端 APIGET /api/v1/admin/users
-->
<template>
<div class="user-list-page">
<!-- 页面标题 + 操作按钮 -->
<div class="page-header">
<h2 class="page-title">用户管理</h2>
<el-button type="primary" @click="showCreateDialog = true">
<el-icon><Plus /></el-icon>
创建用户
</el-button>
</div>
<!-- 搜索和筛选区 -->
<el-card class="filter-card" shadow="never">
<el-form :inline="true" class="filter-form" @submit.prevent="handleSearch">
<el-form-item label="关键词">
<el-input
v-model="searchKeyword"
placeholder="搜索用户名或邮箱"
clearable
style="width: 240px"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="searchStatus"
placeholder="全部状态"
clearable
style="width: 140px"
@change="handleSearch"
>
<el-option label="正常" :value="1" />
<el-option label="禁用" :value="2" />
<el-option label="注销" :value="3" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 用户数据表格 -->
<el-card class="table-card" shadow="never">
<el-table
v-loading="loading"
:data="userList"
stripe
border
style="width: 100%"
row-class-name="table-row"
@row-click="handleRowClick"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="username" label="用户名" min-width="120">
<template #default="{ row }">
<span class="username-cell">{{ row.username }}</span>
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="180" />
<el-table-column prop="nickname" label="昵称" min-width="100" />
<el-table-column prop="roles" label="角色" min-width="120">
<template #default="{ row }">
<el-tag
v-for="role in row.roles"
:key="role"
:type="getRoleTagType(role)"
size="small"
class="role-tag"
>
{{ getRoleName(role) }}
</el-tag>
<span v-if="!row.roles || row.roles.length === 0" class="no-role">未分配</span>
</template>
</el-table-column>
<el-table-column prop="status_text" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ row.status_text }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="last_login_at" label="最后登录" min-width="160">
<template #default="{ row }">
{{ row.last_login_at || '从未登录' }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="注册时间" min-width="160" />
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click.stop="goDetail(row.id)">
详情
</el-button>
<el-button
v-if="row.status === 1"
type="danger"
link
size="small"
@click.stop="handleToggleStatus(row, 2)"
>
禁用
</el-button>
<el-button
v-else-if="row.status === 2"
type="success"
link
size="small"
@click.stop="handleToggleStatus(row, 1)"
>
启用
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="fetchUserList"
@current-change="fetchUserList"
/>
</div>
</el-card>
<!-- 创建用户对话框 -->
<el-dialog
v-model="showCreateDialog"
title="创建用户"
width="500px"
:close-on-click-modal="false"
>
<el-form
ref="createFormRef"
:model="createForm"
:rules="createRules"
label-width="80px"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="createForm.username" placeholder="3-50 个字符" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="createForm.email" placeholder="输入邮箱地址" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="createForm.password" type="password" placeholder="至少 6 个字符" show-password />
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="createForm.nickname" placeholder="选填,默认使用用户名" />
</el-form-item>
<el-form-item label="角色" prop="role_code">
<el-select v-model="createForm.role_code" placeholder="选择角色" style="width: 100%">
<el-option label="普通用户" value="user" />
<el-option label="管理员" value="admin" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="handleCreateUser">确认创建</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
/**
* 用户列表页面逻辑
*
* 数据流:
* 1. 组件挂载时调用 fetchUserList 加载用户列表
* 2. 搜索/筛选/分页变化时重新加载
* 3. 操作(禁用/启用/创建)成功后刷新列表
*/
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getUserList, updateUserStatus, createUser } from '@/api/user'
const router = useRouter()
/** 加载状态 */
const loading = ref(false)
/** 用户列表数据 */
const userList = ref([])
/** 搜索关键词 */
const searchKeyword = ref('')
/** 状态筛选 */
const searchStatus = ref(null)
/** 分页状态 */
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
/** 创建用户对话框显示状态 */
const showCreateDialog = ref(false)
/** 创建用户表单引用 */
const createFormRef = ref(null)
/** 创建用户加载状态 */
const createLoading = ref(false)
/** 创建用户表单数据 */
const createForm = reactive({
username: '',
email: '',
password: '',
nickname: '',
role_code: 'user'
})
/** 创建用户表单校验规则 */
const createRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 50, message: '用户名长度为 3-50 个字符', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 50, message: '密码长度为 6-50 个字符', trigger: 'blur' }
]
}
/**
* 获取角色 Tag 类型Element Plus tag type
* @param {string} role - 角色代码
*/
const getRoleTagType = (role) => {
const map = { super_admin: 'danger', admin: 'warning', user: '' }
return map[role] || 'info'
}
/**
* 获取角色中文名称
* @param {string} role - 角色代码
*/
const getRoleName = (role) => {
const map = { super_admin: '超级管理员', admin: '管理员', user: '普通用户' }
return map[role] || role
}
/**
* 获取状态 Tag 类型
* @param {number} status - 状态码
*/
const getStatusType = (status) => {
const map = { 1: 'success', 2: 'danger', 3: 'info' }
return map[status] || 'info'
}
/**
* 获取用户列表
* 根据当前搜索、筛选、分页条件查询
*/
const fetchUserList = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize
}
if (searchKeyword.value) {
params.keyword = searchKeyword.value
}
if (searchStatus.value !== null && searchStatus.value !== undefined) {
params.status = searchStatus.value
}
const res = await getUserList(params)
userList.value = res.data?.list || []
pagination.total = res.data?.total || 0
} catch (err) {
console.error('获取用户列表失败:', err)
} finally {
loading.value = false
}
}
/** 搜索按钮点击 */
const handleSearch = () => {
pagination.page = 1
fetchUserList()
}
/** 重置搜索条件 */
const handleReset = () => {
searchKeyword.value = ''
searchStatus.value = null
pagination.page = 1
fetchUserList()
}
/** 点击行跳转详情 */
const handleRowClick = (row) => {
router.push(`/user/detail/${row.id}`)
}
/** 跳转用户详情页 */
const goDetail = (id) => {
router.push(`/user/detail/${id}`)
}
/**
* 切换用户状态(启用/禁用)
* @param {Object} row - 用户行数据
* @param {number} newStatus - 目标状态
*/
const handleToggleStatus = async (row, newStatus) => {
const action = newStatus === 2 ? '禁用' : '启用'
try {
await ElMessageBox.confirm(
`确定要${action}用户「${row.username}」吗?`,
`${action}确认`,
{ type: 'warning' }
)
await updateUserStatus(row.id, newStatus)
ElMessage.success(`${action}成功`)
fetchUserList()
} catch (err) {
if (err !== 'cancel') {
console.error(`${action}用户失败:`, err)
}
}
}
/**
* 创建用户提交
*/
const handleCreateUser = async () => {
if (!createFormRef.value) return
try {
await createFormRef.value.validate()
} catch {
return
}
createLoading.value = true
try {
await createUser(createForm)
ElMessage.success('用户创建成功')
showCreateDialog.value = false
resetCreateForm()
fetchUserList()
} catch (err) {
console.error('创建用户失败:', err)
} finally {
createLoading.value = false
}
}
/** 重置创建表单 */
const resetCreateForm = () => {
createForm.username = ''
createForm.email = ''
createForm.password = ''
createForm.nickname = ''
createForm.role_code = 'user'
}
onMounted(() => {
fetchUserList()
})
</script>
<style scoped>
/*
* 用户列表页样式
* 设计系统Data-Dense Dashboard 风格
* 高信息密度、行悬停高亮、清晰的数据层次
*/
.user-list-page {
padding: 0;
}
/* ---- 页面头部 ---- */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #1E293B;
margin: 0;
}
/* ---- 筛选区 ---- */
.filter-card {
margin-bottom: 16px;
}
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0;
}
/* ---- 表格区 ---- */
.table-card {
margin-bottom: 16px;
}
.username-cell {
font-weight: 500;
color: #2563EB;
cursor: pointer;
}
.role-tag {
margin-right: 4px;
}
.no-role {
color: #94A3B8;
font-size: 12px;
}
:deep(.table-row) {
cursor: pointer;
transition: background-color 200ms ease;
}
:deep(.table-row:hover) {
background-color: #EFF6FF !important;
}
/* ---- 分页 ---- */
.pagination-wrapper {
display: flex;
justify-content: flex-end;
padding-top: 16px;
}
</style>

21
admin/vite.config.js Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3100,
proxy: {
'/api': {
target: 'http://localhost:8085',
changeOrigin: true
}
}
}
})