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

5
.gitignore vendored
View File

@@ -9,6 +9,7 @@ Thumbs.db
# --- IDE ---
.idea/
.vscode/
.cursor/
*.swp
*.swo
*~
@@ -53,3 +54,7 @@ build/
# --- 测试覆盖率 ---
coverage/
*.coverprofile
# --- Playwright 截图 ---
*.png
!design-system/**/*.png

View File

@@ -64,6 +64,12 @@ Usage notes:
<location>global</location>
</skill>
<skill>
<name>my-first-skill</name>
<description>Example skill demonstrating Anthropic SKILL.md format. Load when learning to create skills or testing the OpenSkills loader.</description>
<location>global</location>
</skill>
<skill>
<name>pdf</name>
<description>Use this skill whenever the user wants to do anything with PDF files. This includes reading or extracting text/tables from PDFs, combining or merging multiple PDFs into one, splitting PDFs apart, rotating pages, adding watermarks, creating new PDFs, filling PDF forms, encrypting/decrypting PDFs, extracting images, and OCR on scanned PDFs to make them searchable. If the user mentions a .pdf file or asks to produce one, use this skill.</description>

View File

@@ -51,17 +51,23 @@ mediasoup Worker (C++ SFU)
```
EchoChat/
├── frontend/ # 前台用户端 (uniapp)
├── admin/ # 后台管理端 (Vue 3 + Element Plus)
├── frontend/ # 前台用户端 (uni-app + Vue 3.4)
├── admin/ # 后台管理端 (Vue 3.5 + Element Plus)
├── backend/
│ └── go-service/ # Go 后端服务
├── media-server/ # mediasoup Node 媒体服务
├── deploy/ # 部署配置 (Docker / Nginx / K8s)
├── docs/ # 项目文档
│ ├── plans/ # 设计方案与实施计划
├── api/ # API 接口文档
│ └── architecture/ # 架构设计文档
├── scripts/ # 脚本工具
│ └── go-service/ # Go 后端服务 (Gin + GORM + Wire)
│ ├── app/ # 业务模块 (auth / admin)
│ ├── cmd/server/ # 服务入口
│ ├── config/ # 配置文件
├── pkg/ # 公共包 (db / logs / middleware / utils)
└── router/ # 路由聚合
├── media-server/ # mediasoup Node 媒体服务 (Phase 3)
├── deploy/ # 部署配置 (Docker Compose)
├── design-system/ # UI 设计系统 (ui-ux-pro-max 生成)
├── docs/ # 项目文档
│ ├── progress/ # 开发进度
│ ├── plans/ # 实施计划
│ ├── api/ # API 接口文档
│ └── architecture/ # 架构设计文档
└── README.md
```
@@ -71,20 +77,34 @@ EchoChat/
### 环境要求
- Go 1.22+
- Go 1.23+
- Node.js 18+
- Docker & Docker Compose
- PostgreSQL 17 (通过 Docker)
- Redis 7 (通过 Docker)
- PostgreSQL 17通过 Docker 自动启动)
- Redis 7通过 Docker 自动启动)
### 1. 启动基础设施
### 方式一Docker Compose 一键启动(推荐)
```bash
cd deploy
docker compose -f docker-compose.dev.yml up -d
```
### 2. 启动 Go 后端
启动后服务地址:
- Go 后端 API`http://localhost:8085`
- PostgreSQL`localhost:5432`
- Redis`localhost:6379`
### 方式二:分步手动启动(开发调试)
**1. 启动基础设施(数据库 + 缓存)**
```bash
cd deploy
docker compose -f docker-compose.dev.yml up -d postgres redis
```
**2. 启动 Go 后端**
```bash
cd backend/go-service
@@ -92,15 +112,19 @@ go mod tidy
go run cmd/server/main.go
```
### 3. 启动前台前端
后端启动在 `http://localhost:8085`,健康检查:`GET /health`
**3. 启动前台用户端uni-app H5**
```bash
cd frontend
npm install
npm install --legacy-peer-deps
npm run dev:h5
```
### 4. 启动管理端
前台 H5 启动在 `http://localhost:5173`(端口可能递增)
**4. 启动后台管理端Vue 3**
```bash
cd admin
@@ -108,17 +132,34 @@ npm install
npm run dev
```
管理端启动在 `http://localhost:3100`(自动代理 `/api` 到后端 8085
---
## MVP 功能规划(第一期)
### Phase 1 — 基础设施与用户认证 ✅
- [x] 设计方案与架构文档
- [ ] 用户注册/登录(邮箱+密码、用户名+密码
- [x] Docker Compose 开发环境PostgreSQL + Redis
- [x] Go 后端服务骨架Gin + GORM + Wire + Zap
- [x] 用户注册/登录 API用户名+邮箱+密码)
- [x] JWT Token 认证(有状态 JWT + Redis
- [x] RBAC 角色权限user / admin / super_admin
- [x] 前台 uni-app 骨架 + 登录/注册/TabBar/个人中心
- [x] 后台 Vue 3 管理端 + 登录/布局/用户管理
- [x] Go 服务 Dockerfile + Docker Compose 全栈部署
### Phase 2 — 即时通讯(待开始)
- [ ] 即时聊天(单聊 + 群聊,文字/图片/文件)
- [ ] 联系人/好友管理
- [ ] 多人音视频会议(即时会议 + 预约会议)
- [ ] 消息通知系统
- [ ] 后台管理端(用户管理、会议监控、系统配置)
### Phase 3 — 音视频会议(待开始)
- [ ] 多人音视频会议(即时会议 + 预约会议)
- [ ] 后台管理端会议监控
## 后续规划
@@ -135,6 +176,7 @@ npm run dev
| 文档 | 路径 | 说明 |
|------|------|------|
| 项目进度 | [docs/progress/CURRENT_STATUS.md](docs/progress/CURRENT_STATUS.md) | 当前开发进度与技术决策 |
| 整体设计方案 | [docs/plans/2026-02-27-echochat-system-design.md](docs/plans/2026-02-27-echochat-system-design.md) | 系统完整设计方案 |
| 第一阶段实施计划 | [docs/plans/2026-02-27-phase1-foundation-and-auth.md](docs/plans/2026-02-27-phase1-foundation-and-auth.md) | 基础设施+用户体系实施步骤 |
| 系统架构文档 | [docs/architecture/system-architecture.md](docs/architecture/system-architecture.md) | 架构分层与技术选型 |

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
}
}
}
})

View File

@@ -0,0 +1,16 @@
# 日志和临时文件
logs/
*.log
# IDE 配置
.idea/
.vscode/
*.swp
*.swo
# 测试文件
*_test.go
# Git
.git
.gitignore

View File

@@ -0,0 +1,48 @@
# ============================================================
# EchoChat Go 后端服务 Dockerfile
# 多阶段构建:编译阶段 → 运行阶段alpine 最小镜像)
# ============================================================
# --- 第一阶段:编译 ---
FROM golang:1.23-alpine AS builder
# 安装编译依赖CGO 需要 gcc
RUN apk add --no-cache gcc musl-dev
WORKDIR /build
# 先复制依赖文件利用 Docker 缓存层
COPY go.mod go.sum ./
RUN go mod download
# 复制全部源码并编译
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /build/echochat ./cmd/server
# --- 第二阶段:运行 ---
FROM alpine:3.19
# 安装运行时依赖(时区数据 + CA 证书)
RUN apk add --no-cache ca-certificates tzdata
# 设置时区为上海
ENV TZ=Asia/Shanghai
WORKDIR /app
# 从编译阶段复制二进制文件
COPY --from=builder /build/echochat .
# 复制配置文件目录
COPY --from=builder /build/config ./config
# 创建日志目录
RUN mkdir -p /app/logs
EXPOSE 8085
# 健康检查
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://localhost:8085/health || exit 1
CMD ["./echochat"]

View File

@@ -0,0 +1,202 @@
// Package controller 提供 admin 模块的 HTTP 请求处理
package controller
import (
"strconv"
"github.com/echochat/backend/app/admin/service"
"github.com/echochat/backend/app/dto"
"github.com/echochat/backend/pkg/logs"
"github.com/echochat/backend/pkg/middleware"
"github.com/echochat/backend/pkg/utils"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// UserManageController 管理端用户管理控制器
type UserManageController struct {
userManageService *service.UserManageService
}
// NewUserManageController 创建用户管理控制器实例
func NewUserManageController(svc *service.UserManageService) *UserManageController {
return &UserManageController{userManageService: svc}
}
// GetUserList 获取用户列表
// GET /api/v1/admin/users?page=1&page_size=10&keyword=xxx&status=1
func (ctl *UserManageController) GetUserList(c *gin.Context) {
funcName := "controller.user_manage_controller.GetUserList"
ctx := c.Request.Context()
var req dto.UserListRequest
if err := c.ShouldBindQuery(&req); err != nil {
logs.Warn(ctx, funcName, "参数校验失败", zap.Error(err))
utils.ResponseBadRequest(c, "参数错误: "+err.Error())
return
}
logs.Info(ctx, funcName, "获取用户列表",
zap.Int("page", req.Page),
zap.Int("page_size", req.PageSize),
zap.String("keyword", req.Keyword),
)
resp, err := ctl.userManageService.GetUserList(ctx, &req)
if err != nil {
logs.Error(ctx, funcName, "获取用户列表失败", zap.Error(err))
utils.ResponseError(c, "获取用户列表失败")
return
}
utils.ResponseOK(c, resp)
}
// GetUserDetail 获取用户详情
// GET /api/v1/admin/users/:id
func (ctl *UserManageController) GetUserDetail(c *gin.Context) {
funcName := "controller.user_manage_controller.GetUserDetail"
ctx := c.Request.Context()
userID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
utils.ResponseBadRequest(c, "无效的用户 ID")
return
}
logs.Info(ctx, funcName, "获取用户详情", zap.Int64("user_id", userID))
detail, err := ctl.userManageService.GetUserDetail(ctx, userID)
if err != nil {
if err == service.ErrUserNotFound {
utils.ResponseNotFound(c, "用户不存在")
return
}
logs.Error(ctx, funcName, "获取用户详情失败", zap.Error(err))
utils.ResponseError(c, "获取用户详情失败")
return
}
utils.ResponseOK(c, detail)
}
// UpdateUserStatus 更新用户状态(启用/禁用)
// PUT /api/v1/admin/users/:id/status
func (ctl *UserManageController) UpdateUserStatus(c *gin.Context) {
funcName := "controller.user_manage_controller.UpdateUserStatus"
ctx := c.Request.Context()
userID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
utils.ResponseBadRequest(c, "无效的用户 ID")
return
}
var req dto.UpdateUserStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
logs.Warn(ctx, funcName, "参数校验失败", zap.Error(err))
utils.ResponseBadRequest(c, "参数错误: "+err.Error())
return
}
adminUserID, ok := middleware.GetCurrentUserID(c)
if !ok {
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
return
}
logs.Info(ctx, funcName, "更新用户状态",
zap.Int64("target_user_id", userID),
zap.Int("status", req.Status),
zap.Int64("admin_user_id", adminUserID),
)
if err := ctl.userManageService.UpdateUserStatus(ctx, userID, req.Status, adminUserID); err != nil {
switch err {
case service.ErrUserNotFound:
utils.ResponseNotFound(c, "用户不存在")
case service.ErrCannotDisableSelf:
utils.ResponseBadRequest(c, "不能禁用自己的账号")
case service.ErrInvalidStatus:
utils.ResponseBadRequest(c, "无效的用户状态")
default:
logs.Error(ctx, funcName, "更新用户状态失败", zap.Error(err))
utils.ResponseError(c, "更新用户状态失败")
}
return
}
utils.ResponseOK(c, nil)
}
// AssignRole 分配角色
// PUT /api/v1/admin/users/:id/role
func (ctl *UserManageController) AssignRole(c *gin.Context) {
funcName := "controller.user_manage_controller.AssignRole"
ctx := c.Request.Context()
userID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
utils.ResponseBadRequest(c, "无效的用户 ID")
return
}
var req dto.AssignRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
logs.Warn(ctx, funcName, "参数校验失败", zap.Error(err))
utils.ResponseBadRequest(c, "参数错误: "+err.Error())
return
}
logs.Info(ctx, funcName, "分配角色",
zap.Int64("user_id", userID),
zap.String("role_code", req.RoleCode),
)
if err := ctl.userManageService.AssignUserRole(ctx, userID, req.RoleCode); err != nil {
switch err {
case service.ErrUserNotFound:
utils.ResponseNotFound(c, "用户不存在")
case service.ErrInvalidRole:
utils.ResponseBadRequest(c, "无效的角色代码")
default:
logs.Error(ctx, funcName, "分配角色失败", zap.Error(err))
utils.ResponseError(c, "分配角色失败")
}
return
}
utils.ResponseOK(c, nil)
}
// CreateUser 管理员手动创建用户
// POST /api/v1/admin/users
func (ctl *UserManageController) CreateUser(c *gin.Context) {
funcName := "controller.user_manage_controller.CreateUser"
ctx := c.Request.Context()
var req dto.AdminCreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
logs.Warn(ctx, funcName, "参数校验失败", zap.Error(err))
utils.ResponseBadRequest(c, "参数错误: "+err.Error())
return
}
logs.Info(ctx, funcName, "管理员创建用户",
zap.String("username", req.Username),
zap.String("email", logs.MaskEmail(req.Email)),
)
userInfo, err := ctl.userManageService.CreateUser(ctx, &req)
if err != nil {
if err == service.ErrUserExists {
utils.ResponseBadRequest(c, "用户名或邮箱已被注册")
return
}
logs.Error(ctx, funcName, "创建用户失败", zap.Error(err))
utils.ResponseError(c, "创建用户失败")
return
}
utils.ResponseOK(c, userInfo)
}

View File

@@ -0,0 +1,106 @@
// Package dao 提供 admin 模块的数据库访问操作
package dao
import (
"context"
"fmt"
"github.com/echochat/backend/app/auth/model"
"github.com/echochat/backend/app/dto"
"github.com/echochat/backend/pkg/logs"
"go.uber.org/zap"
"gorm.io/gorm"
)
// UserManageDAO 管理端用户数据访问对象
// 提供面向管理后台的用户查询操作(分页、搜索、筛选)
type UserManageDAO struct {
db *gorm.DB
}
// NewUserManageDAO 创建 UserManageDAO 实例
func NewUserManageDAO(db *gorm.DB) *UserManageDAO {
return &UserManageDAO{db: db}
}
// ListUsers 分页查询用户列表
// 支持按用户名/邮箱关键词搜索 + 按状态筛选
// SQL 执行两次查询COUNT 总数 + LIMIT/OFFSET 分页数据
func (d *UserManageDAO) ListUsers(ctx context.Context, req *dto.UserListRequest) ([]model.User, int64, error) {
funcName := "dao.user_manage_dao.ListUsers"
logs.Info(ctx, funcName, "查询用户列表",
zap.Int("page", req.Page),
zap.Int("page_size", req.PageSize),
zap.String("keyword", req.Keyword),
)
var users []model.User
var total int64
query := d.db.WithContext(ctx).Model(&model.User{})
// 关键词搜索:模糊匹配用户名或邮箱
if req.Keyword != "" {
like := fmt.Sprintf("%%%s%%", req.Keyword)
query = query.Where("username LIKE ? OR email LIKE ?", like, like)
}
// 状态筛选
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
// 先查询总数
if err := query.Count(&total).Error; err != nil {
logs.Error(ctx, funcName, "查询用户总数失败", zap.Error(err))
return nil, 0, err
}
// 分页查询数据,按创建时间倒序
offset := (req.Page - 1) * req.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&users).Error; err != nil {
logs.Error(ctx, funcName, "查询用户列表失败", zap.Error(err))
return nil, 0, err
}
logs.Info(ctx, funcName, "查询用户列表成功",
zap.Int64("total", total),
zap.Int("count", len(users)),
)
return users, total, nil
}
// CountUsers 统计用户总数(不含筛选条件)
func (d *UserManageDAO) CountUsers(ctx context.Context) (int64, error) {
funcName := "dao.user_manage_dao.CountUsers"
var count int64
err := d.db.WithContext(ctx).Model(&model.User{}).Count(&count).Error
if err != nil {
logs.Error(ctx, funcName, "统计用户总数失败", zap.Error(err))
}
return count, err
}
// UpdateUserStatus 更新用户账号状态
// 仅更新 status 字段,不触发其他字段的更新
func (d *UserManageDAO) UpdateUserStatus(ctx context.Context, userID int64, status int) error {
funcName := "dao.user_manage_dao.UpdateUserStatus"
logs.Info(ctx, funcName, "更新用户状态",
zap.Int64("user_id", userID),
zap.Int("status", status),
)
err := d.db.WithContext(ctx).
Model(&model.User{}).
Where("id = ?", userID).
Update("status", status).Error
if err != nil {
logs.Error(ctx, funcName, "更新用户状态失败",
zap.Int64("user_id", userID),
zap.Error(err),
)
}
return err
}

View File

@@ -0,0 +1,17 @@
// Package admin 提供管理后台功能模块
package admin
import (
"github.com/echochat/backend/app/admin/controller"
"github.com/echochat/backend/app/admin/dao"
"github.com/echochat/backend/app/admin/service"
"github.com/google/wire"
)
// AdminSet Admin 模块依赖注入 Provider Set
// 提供管理端的 DAO、Service 和 Controller 组件
var AdminSet = wire.NewSet(
dao.NewUserManageDAO,
service.NewUserManageService,
controller.NewUserManageController,
)

View File

@@ -0,0 +1,29 @@
// Package admin 提供管理后台功能模块
package admin
import (
"github.com/echochat/backend/app/admin/controller"
"github.com/echochat/backend/app/constants"
"github.com/echochat/backend/pkg/middleware"
"github.com/gin-gonic/gin"
)
// RegisterRoutes 注册 admin 模块的所有路由
// 所有管理端 API 需要 JWT + admin 角色双重中间件
func RegisterRoutes(
r *gin.Engine,
ctrl *controller.UserManageController,
jwtAuth gin.HandlerFunc,
) {
// 管理端路由组JWT 认证 + admin/super_admin 角色检查
adminGroup := r.Group("/api/v1/admin")
adminGroup.Use(jwtAuth, middleware.RequireRole(constants.RoleAdmin, constants.RoleSuperAdmin))
{
// 用户管理
adminGroup.GET("/users", ctrl.GetUserList)
adminGroup.GET("/users/:id", ctrl.GetUserDetail)
adminGroup.PUT("/users/:id/status", ctrl.UpdateUserStatus)
adminGroup.PUT("/users/:id/role", ctrl.AssignRole)
adminGroup.POST("/users", ctrl.CreateUser)
}
}

View File

@@ -0,0 +1,272 @@
// Package service 提供 admin 模块的核心业务逻辑
package service
import (
"context"
"errors"
authDAO "github.com/echochat/backend/app/auth/dao"
"github.com/echochat/backend/app/auth/model"
adminDAO "github.com/echochat/backend/app/admin/dao"
"github.com/echochat/backend/app/constants"
"github.com/echochat/backend/app/dto"
"github.com/echochat/backend/pkg/logs"
"github.com/echochat/backend/pkg/utils"
"go.uber.org/zap"
"gorm.io/gorm"
)
var (
ErrUserNotFound = errors.New("用户不存在")
ErrUserExists = errors.New("用户名或邮箱已被注册")
ErrInvalidStatus = errors.New("无效的用户状态")
ErrInvalidRole = errors.New("无效的角色代码")
ErrCannotDisableSelf = errors.New("不能禁用自己的账号")
)
// UserManageService 管理端用户管理服务
type UserManageService struct {
userManageDAO *adminDAO.UserManageDAO
userDAO *authDAO.UserDAO
roleDAO *authDAO.RoleDAO
}
// NewUserManageService 创建用户管理服务实例
func NewUserManageService(
userManageDAO *adminDAO.UserManageDAO,
userDAO *authDAO.UserDAO,
roleDAO *authDAO.RoleDAO,
) *UserManageService {
return &UserManageService{
userManageDAO: userManageDAO,
userDAO: userDAO,
roleDAO: roleDAO,
}
}
// GetUserList 获取用户列表(分页 + 搜索 + 筛选)
func (s *UserManageService) GetUserList(ctx context.Context, req *dto.UserListRequest) (*dto.UserListResponse, error) {
funcName := "service.user_manage_service.GetUserList"
logs.Info(ctx, funcName, "获取用户列表")
users, total, err := s.userManageDAO.ListUsers(ctx, req)
if err != nil {
return nil, err
}
list := make([]dto.AdminUserInfo, 0, len(users))
for _, user := range users {
roles, roleErr := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
if roleErr != nil {
logs.Warn(ctx, funcName, "获取用户角色失败,降级为空角色列表",
zap.Int64("user_id", user.ID), zap.Error(roleErr))
roles = []string{}
}
list = append(list, *s.buildAdminUserInfo(&user, roles))
}
return &dto.UserListResponse{
Total: total,
List: list,
}, nil
}
// GetUserDetail 获取单个用户详情(含角色信息)
func (s *UserManageService) GetUserDetail(ctx context.Context, userID int64) (*dto.AdminUserInfo, error) {
funcName := "service.user_manage_service.GetUserDetail"
logs.Info(ctx, funcName, "获取用户详情", zap.Int64("user_id", userID))
user, err := s.userDAO.FindByID(ctx, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
roles, roleErr := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
if roleErr != nil {
logs.Warn(ctx, funcName, "获取用户角色失败,降级为空角色列表",
zap.Int64("user_id", user.ID), zap.Error(roleErr))
roles = []string{}
}
return s.buildAdminUserInfo(user, roles), nil
}
// UpdateUserStatus 启用/禁用用户
// adminUserID 用于防止管理员禁用自己
func (s *UserManageService) UpdateUserStatus(ctx context.Context, userID int64, status int, adminUserID int64) error {
funcName := "service.user_manage_service.UpdateUserStatus"
logs.Info(ctx, funcName, "更新用户状态",
zap.Int64("target_user_id", userID),
zap.Int("status", status),
zap.Int64("admin_user_id", adminUserID),
)
if userID == adminUserID {
return ErrCannotDisableSelf
}
if status != constants.UserStatusActive && status != constants.UserStatusDisabled {
return ErrInvalidStatus
}
// 确认用户存在
_, err := s.userDAO.FindByID(ctx, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrUserNotFound
}
return err
}
if err := s.userManageDAO.UpdateUserStatus(ctx, userID, status); err != nil {
return err
}
logs.Info(ctx, funcName, "用户状态更新成功",
zap.Int64("user_id", userID),
zap.Int("new_status", status),
)
return nil
}
// AssignUserRole 分配角色给用户
func (s *UserManageService) AssignUserRole(ctx context.Context, userID int64, roleCode string) error {
funcName := "service.user_manage_service.AssignUserRole"
logs.Info(ctx, funcName, "分配角色",
zap.Int64("user_id", userID),
zap.String("role_code", roleCode),
)
// 验证用户存在
_, err := s.userDAO.FindByID(ctx, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrUserNotFound
}
return err
}
// 查找角色
role, err := s.roleDAO.FindByCode(ctx, roleCode)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrInvalidRole
}
return err
}
// 分配角色RoleDAO.AssignRole 内部会处理重复分配)
if err := s.roleDAO.AssignRole(ctx, userID, role.ID); err != nil {
return err
}
logs.Info(ctx, funcName, "角色分配成功",
zap.Int64("user_id", userID),
zap.String("role_code", roleCode),
)
return nil
}
// CreateUser 管理员手动创建用户
func (s *UserManageService) CreateUser(ctx context.Context, req *dto.AdminCreateUserRequest) (*dto.AdminUserInfo, error) {
funcName := "service.user_manage_service.CreateUser"
logs.Info(ctx, funcName, "管理员创建用户",
zap.String("username", req.Username),
zap.String("email", logs.MaskEmail(req.Email)),
)
existing, findErr := s.userDAO.FindByUsername(ctx, req.Username)
if findErr != nil && !errors.Is(findErr, gorm.ErrRecordNotFound) {
return nil, findErr
}
if existing != nil {
return nil, ErrUserExists
}
existing, findErr = s.userDAO.FindByEmail(ctx, req.Email)
if findErr != nil && !errors.Is(findErr, gorm.ErrRecordNotFound) {
return nil, findErr
}
if existing != nil {
return nil, ErrUserExists
}
// 加密密码
hashedPassword, err := utils.HashPassword(req.Password)
if err != nil {
return nil, err
}
nickname := req.Nickname
if nickname == "" {
nickname = req.Username
}
user := &model.User{
Username: req.Username,
Email: req.Email,
PasswordHash: hashedPassword,
Nickname: nickname,
Status: constants.UserStatusActive,
}
if err := s.userDAO.Create(ctx, user); err != nil {
return nil, err
}
roleCode := req.RoleCode
if roleCode == "" {
roleCode = constants.RoleUser
}
role, roleErr := s.roleDAO.FindByCode(ctx, roleCode)
if roleErr != nil {
logs.Warn(ctx, funcName, "角色查找失败,将跳过角色分配",
zap.String("role_code", roleCode), zap.Error(roleErr))
} else {
if assignErr := s.roleDAO.AssignRole(ctx, user.ID, role.ID); assignErr != nil {
logs.Warn(ctx, funcName, "角色分配失败",
zap.Int64("user_id", user.ID), zap.Error(assignErr))
}
}
roles, rolesErr := s.roleDAO.GetUserRoleCodes(ctx, user.ID)
if rolesErr != nil {
logs.Warn(ctx, funcName, "获取新建用户角色失败",
zap.Int64("user_id", user.ID), zap.Error(rolesErr))
roles = []string{}
}
logs.Info(ctx, funcName, "管理员创建用户成功", zap.Int64("user_id", user.ID))
return s.buildAdminUserInfo(user, roles), nil
}
// buildAdminUserInfo 从 model.User 构建 dto.AdminUserInfo
func (s *UserManageService) buildAdminUserInfo(user *model.User, roles []string) *dto.AdminUserInfo {
if roles == nil {
roles = []string{}
}
info := &dto.AdminUserInfo{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Nickname: user.Nickname,
Avatar: user.Avatar,
Gender: user.Gender,
Status: user.Status,
StatusText: constants.UserStatusMap[user.Status],
Roles: roles,
CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: user.UpdatedAt.Format("2006-01-02 15:04:05"),
}
if user.Phone != nil {
info.Phone = *user.Phone
}
if user.LastLoginAt != nil {
info.LastLoginAt = user.LastLoginAt.Format("2006-01-02 15:04:05")
}
if user.LastLoginIP != nil {
info.LastLoginIP = *user.LastLoginIP
}
return info
}

View File

@@ -0,0 +1,53 @@
// Package dto 定义数据传输对象
package dto
// UserListRequest 管理端用户列表查询请求参数
type UserListRequest struct {
Page int `form:"page" binding:"required,min=1"` // 页码,从 1 开始
PageSize int `form:"page_size" binding:"required,min=1,max=100"` // 每页数量1-100
Keyword string `form:"keyword"` // 搜索关键词(匹配用户名或邮箱)
Status *int `form:"status"` // 状态筛选1=正常, 2=禁用, 3=注销;为空则不筛选
}
// UserListResponse 管理端用户列表响应
type UserListResponse struct {
Total int64 `json:"total"` // 符合条件的用户总数
List []AdminUserInfo `json:"list"` // 当前页的用户列表
}
// AdminUserInfo 管理端用户信息(比 UserInfo 更详细,包含管理字段)
type AdminUserInfo struct {
ID int64 `json:"id"` // 用户 ID
Username string `json:"username"` // 用户名
Email string `json:"email"` // 邮箱
Nickname string `json:"nickname"` // 昵称
Avatar string `json:"avatar"` // 头像 URL
Gender int `json:"gender"` // 性别0=未知, 1=男, 2=女
Phone string `json:"phone,omitempty"` // 手机号
Status int `json:"status"` // 账号状态
StatusText string `json:"status_text"` // 状态中文描述
Roles []string `json:"roles"` // 角色代码列表
LastLoginAt string `json:"last_login_at,omitempty"` // 最后登录时间
LastLoginIP string `json:"last_login_ip,omitempty"` // 最后登录 IP
CreatedAt string `json:"created_at"` // 注册时间
UpdatedAt string `json:"updated_at"` // 更新时间
}
// UpdateUserStatusRequest 更新用户状态请求
type UpdateUserStatusRequest struct {
Status int `json:"status" binding:"required,oneof=1 2"` // 目标状态1=正常, 2=禁用
}
// AssignRoleRequest 分配角色请求
type AssignRoleRequest struct {
RoleCode string `json:"role_code" binding:"required"` // 角色代码user/admin/super_admin
}
// AdminCreateUserRequest 管理员手动创建用户请求
type AdminCreateUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"` // 用户名
Email string `json:"email" binding:"required,email"` // 邮箱
Password string `json:"password" binding:"required,min=6,max=50"` // 初始密码
Nickname string `json:"nickname" binding:"max=50"` // 昵称(选填)
RoleCode string `json:"role_code" binding:"omitempty"` // 初始角色(选填,默认 user
}

View File

@@ -3,7 +3,8 @@
package provider
import (
"github.com/echochat/backend/app/auth/controller"
adminController "github.com/echochat/backend/app/admin/controller"
authController "github.com/echochat/backend/app/auth/controller"
"github.com/echochat/backend/app/auth/service"
"github.com/echochat/backend/config"
"github.com/echochat/backend/pkg/db"
@@ -14,12 +15,13 @@ import (
// App 应用根容器,持有基础设施组件和各模块的 Controller/Service
type App struct {
Config *config.Config
DB *gorm.DB
Redis *redis.Client
AuthService *service.AuthService // Auth 认证服务
AuthController *controller.AuthController // 前台认证控制器
AdminAuthController *controller.AdminAuthController // 后台认证控制器
Config *config.Config
DB *gorm.DB
Redis *redis.Client
AuthService *service.AuthService // Auth 认证服务
AuthController *authController.AuthController // 前台认证控制器
AdminAuthController *authController.AdminAuthController // 后台认证控制器
UserManageController *adminController.UserManageController // 管理端用户管理控制器
}
// NewApp 创建应用实例
@@ -28,16 +30,18 @@ func NewApp(
gormDB *gorm.DB,
redisClient *redis.Client,
authService *service.AuthService,
authCtrl *controller.AuthController,
adminAuthCtrl *controller.AdminAuthController,
authCtrl *authController.AuthController,
adminAuthCtrl *authController.AdminAuthController,
userManageCtrl *adminController.UserManageController,
) *App {
return &App{
Config: cfg,
DB: gormDB,
Redis: redisClient,
AuthService: authService,
AuthController: authCtrl,
AdminAuthController: adminAuthCtrl,
Config: cfg,
DB: gormDB,
Redis: redisClient,
AuthService: authService,
AuthController: authCtrl,
AdminAuthController: adminAuthCtrl,
UserManageController: userManageCtrl,
}
}

View File

@@ -4,6 +4,7 @@
package provider
import (
"github.com/echochat/backend/app/admin"
"github.com/echochat/backend/app/auth"
"github.com/echochat/backend/config"
"github.com/google/wire"
@@ -14,6 +15,7 @@ func InitializeApp(cfg *config.Config) (*App, error) {
wire.Build(
InfraSet,
auth.AuthSet,
admin.AdminSet,
)
return nil, nil
}

View File

@@ -7,6 +7,9 @@
package provider
import (
adminController "github.com/echochat/backend/app/admin/controller"
adminDAO "github.com/echochat/backend/app/admin/dao"
adminService "github.com/echochat/backend/app/admin/service"
"github.com/echochat/backend/app/auth/controller"
"github.com/echochat/backend/app/auth/dao"
"github.com/echochat/backend/app/auth/service"
@@ -35,6 +38,9 @@ func InitializeApp(cfg *config.Config) (*App, error) {
authService := service.NewAuthService(userDAO, roleDAO, jwtConfig, tokenStore)
authController := controller.NewAuthController(authService)
adminAuthController := controller.NewAdminAuthController(authService)
app := NewApp(cfg, gormDB, client, authService, authController, adminAuthController)
userManageDAO := adminDAO.NewUserManageDAO(gormDB)
userManageService := adminService.NewUserManageService(userManageDAO, userDAO, roleDAO)
userManageController := adminController.NewUserManageController(userManageService)
app := NewApp(cfg, gormDB, client, authService, authController, adminAuthController, userManageController)
return app, nil
}

View File

@@ -20,8 +20,12 @@ import (
)
func main() {
// 1. 加载配置
cfg, err := config.Load("config", "config.dev")
// 1. 加载配置(通过 CONFIG_NAME 环境变量指定配置文件名,默认 config.dev
configName := os.Getenv("CONFIG_NAME")
if configName == "" {
configName = "config.dev"
}
cfg, err := config.Load("config", configName)
if err != nil {
fmt.Printf("加载配置失败: %v\n", err)
os.Exit(1)

View File

@@ -0,0 +1,42 @@
# ============================================================
# EchoChat Docker 容器内配置
# 与 config.dev.yaml 的区别:数据库和 Redis 地址使用 Docker 服务名
# ============================================================
server:
port: 8085
mode: debug
database:
host: postgres
port: 5432
user: echochat
password: echochat_dev_2026
dbname: echochat
sslmode: disable
max_idle_conns: 10
max_open_conns: 100
redis:
host: redis
port: 6379
password: ""
db: 0
jwt:
secret: echochat-dev-jwt-secret-2026-please-change-in-production
access_expire_min: 120
refresh_expire_day: 7
issuer: echochat
log:
level: debug
format: text
output_path: stdout
file:
enable: true
dir: logs
max_size: 50
max_backups: 10
max_age: 30
compress: false

View File

@@ -3,6 +3,7 @@ package middleware
import (
"net/http"
"runtime/debug"
"time"
"github.com/echochat/backend/pkg/logs"
"github.com/echochat/backend/pkg/utils"
@@ -28,6 +29,7 @@ func Recovery() gin.HandlerFunc {
Code: 500,
Message: "服务内部错误",
TraceID: logs.GetTraceID(ctx),
Time: time.Now().Format("2006-01-02 15:04:05"),
})
}
}()

View File

@@ -6,6 +6,7 @@ package router
import (
"time"
"github.com/echochat/backend/app/admin"
"github.com/echochat/backend/app/auth"
"github.com/echochat/backend/app/provider"
"github.com/echochat/backend/pkg/middleware"
@@ -30,10 +31,10 @@ func Setup(engine *gin.Engine, app *provider.App) {
// --- 各模块路由注册 ---
auth.RegisterRoutes(engine, app.AuthController, app.AdminAuthController, jwtAuth)
admin.RegisterRoutes(engine, app.UserManageController, jwtAuth)
// [未来] im.RegisterRoutes(engine, app.ImController, jwtAuth)
// [未来] meeting.RegisterRoutes(engine, app.MeetingController, jwtAuth)
// [未来] contact.RegisterRoutes(engine, app.ContactController, jwtAuth)
// [未来] notify.RegisterRoutes(engine, app.NotifyController, jwtAuth)
// [未来] admin.RegisterRoutes(engine, app.AdminController, jwtAuth)
}

View File

@@ -32,6 +32,22 @@ services:
timeout: 5s
retries: 5
go-service:
build:
context: ../backend/go-service
dockerfile: Dockerfile
container_name: echochat-go-service
environment:
CONFIG_NAME: config.docker
ports:
- "8085:8085"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
volumes:
pgdata:
redisdata:

View File

@@ -0,0 +1,202 @@
# Design System Master File
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
> If that file exists, its rules **override** this Master file.
> If not, strictly follow the rules below.
---
**Project:** EchoChat
**Generated:** 2026-03-02 10:27:26
**Category:** Analytics Dashboard
---
## Global Rules
### Color Palette
| Role | Hex | CSS Variable |
|------|-----|--------------|
| Primary | `#7C3AED` | `--color-primary` |
| Secondary | `#A78BFA` | `--color-secondary` |
| CTA/Accent | `#F97316` | `--color-cta` |
| Background | `#FAF5FF` | `--color-background` |
| Text | `#4C1D95` | `--color-text` |
**Color Notes:** Excitement purple + action orange
### Typography
- **Heading Font:** Fira Code
- **Body Font:** Fira Sans
- **Mood:** dashboard, data, analytics, code, technical, precise
- **Google Fonts:** [Fira Code + Fira Sans](https://fonts.google.com/share?selection.family=Fira+Code:wght@400;500;600;700|Fira+Sans:wght@300;400;500;600;700)
**CSS Import:**
```css
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap');
```
### Spacing Variables
| Token | Value | Usage |
|-------|-------|-------|
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
| `--space-md` | `16px` / `1rem` | Standard padding |
| `--space-lg` | `24px` / `1.5rem` | Section padding |
| `--space-xl` | `32px` / `2rem` | Large gaps |
| `--space-2xl` | `48px` / `3rem` | Section margins |
| `--space-3xl` | `64px` / `4rem` | Hero padding |
### Shadow Depths
| Level | Value | Usage |
|-------|-------|-------|
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
---
## Component Specs
### Buttons
```css
/* Primary Button */
.btn-primary {
background: #F97316;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Secondary Button */
.btn-secondary {
background: transparent;
color: #7C3AED;
border: 2px solid #7C3AED;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
```
### Cards
```css
.card {
background: #FAF5FF;
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow-md);
transition: all 200ms ease;
cursor: pointer;
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
```
### Inputs
```css
.input {
padding: 12px 16px;
border: 1px solid #E2E8F0;
border-radius: 8px;
font-size: 16px;
transition: border-color 200ms ease;
}
.input:focus {
border-color: #7C3AED;
outline: none;
box-shadow: 0 0 0 3px #7C3AED20;
}
```
### Modals
```css
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-xl);
max-width: 500px;
width: 90%;
}
```
---
## Style Guidelines
**Style:** Data-Dense Dashboard
**Keywords:** Multiple charts/widgets, data tables, KPI cards, minimal padding, grid layout, space-efficient, maximum data visibility
**Best For:** Business intelligence dashboards, financial analytics, enterprise reporting, operational dashboards, data warehousing
**Key Effects:** Hover tooltips, chart zoom on click, row highlighting on hover, smooth filter animations, data loading spinners
### Page Pattern
**Pattern Name:** Data-Dense + Drill-Down
- **CTA Placement:** Above fold
- **Section Order:** Hero > Features > CTA
---
## Anti-Patterns (Do NOT Use)
- ❌ Ornate design
- ❌ No filtering
### Additional Forbidden Patterns
-**Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
-**Missing cursor:pointer** — All clickable elements must have cursor:pointer
-**Layout-shifting hovers** — Avoid scale transforms that shift layout
-**Low contrast text** — Maintain 4.5:1 minimum contrast ratio
-**Instant state changes** — Always use transitions (150-300ms)
-**Invisible focus states** — Focus states must be visible for a11y
---
## Pre-Delivery Checklist
Before delivering any UI code, verify:
- [ ] No emojis used as icons (use SVG instead)
- [ ] All icons from consistent icon set (Heroicons/Lucide)
- [ ] `cursor-pointer` on all clickable elements
- [ ] Hover states with smooth transitions (150-300ms)
- [ ] Light mode: text contrast 4.5:1 minimum
- [ ] Focus states visible for keyboard navigation
- [ ] `prefers-reduced-motion` respected
- [ ] Responsive: 375px, 768px, 1024px, 1440px
- [ ] No content hidden behind fixed navbars
- [ ] No horizontal scroll on mobile

View File

@@ -0,0 +1,46 @@
# Admin Login Page Overrides
> **PROJECT:** EchoChat
> **Generated:** 2026-03-02 10:27:26
> **Page Type:** Dashboard / Data View
> ⚠️ **IMPORTANT:** Rules in this file **override** the Master file (`design-system/MASTER.md`).
> Only deviations from the Master are documented here. For all other rules, refer to the Master.
---
## Page-Specific Rules
### Layout Overrides
- **Max Width:** 1200px (standard)
- **Layout:** Full-width sections, centered content
### Spacing Overrides
- No overrides — use Master spacing
### Typography Overrides
- No overrides — use Master typography
### Color Overrides
- No overrides — use Master colors
### Component Overrides
- Avoid: Use arbitrary large z-index values
---
## Page-Specific Components
- No unique components for this page
---
## Recommendations
- Effects: Deal movement animations, metric updates, leaderboard ranking changes, gauge needle movements, status change highlights
- Layout: Define z-index scale system (10 20 30 50)

View File

@@ -0,0 +1,52 @@
# Admin User List Page Overrides
> **PROJECT:** EchoChat
> **Generated:** 2026-03-02 10:58:45
> **Page Type:** Dashboard / Data View
> ⚠️ **IMPORTANT:** Rules in this file **override** the Master file (`design-system/MASTER.md`).
> Only deviations from the Master are documented here. For all other rules, refer to the Master.
---
## Page-Specific Rules
### Layout Overrides
- **Max Width:** 1400px or full-width
- **Grid:** 12-column grid for data flexibility
- **Sections:** 1. Hero (product + aggregate rating), 2. Rating breakdown, 3. Individual reviews, 4. Buy/CTA
### Spacing Overrides
- **Content Density:** High — optimize for information display
### Typography Overrides
- No overrides — use Master typography
### Color Overrides
- **Strategy:** Trust colors. Star ratings gold. Verified badge green. Review sentiment colors.
### Component Overrides
- Avoid: Use for flat single-level sites
- Avoid: Ignore accessibility motion settings
- Avoid: Validate only on submit
---
## Page-Specific Components
- No unique components for this page
---
## Recommendations
- Effects: Hover tooltips, chart zoom on click, row highlighting on hover, smooth filter animations, data loading spinners
- Navigation: Use for sites with 3+ levels of depth
- Animation: Check prefers-reduced-motion media query
- Forms: Validate on blur for most fields
- CTA Placement: After reviews summary + Buy button alongside reviews

View File

@@ -0,0 +1,50 @@
# Login Page Overrides
> **PROJECT:** EchoChat
> **Generated:** 2026-03-02 09:58:55
> **Page Type:** Authentication
> ⚠️ **IMPORTANT:** Rules in this file **override** the Master file (`design-system/MASTER.md`).
> Only deviations from the Master are documented here. For all other rules, refer to the Master.
---
## Page-Specific Rules
### Layout Overrides
- **Max Width:** 800px (narrow, focused)
- **Layout:** Single column, centered
- **Sections:** 1. Hero (benefit headline), 2. Lead magnet preview (ebook cover, checklist, etc), 3. Form (minimal fields), 4. CTA submit
### Spacing Overrides
- **Content Density:** Low — focus on clarity
### Typography Overrides
- No overrides — use Master typography
### Color Overrides
- **Strategy:** Lead magnet: Professional design. Form: Clean white bg. Inputs: Light border #CCCCCC. CTA: Brand color
### Component Overrides
- Avoid: No feedback after submit
- Avoid: Placeholder-only inputs
---
## Page-Specific Components
- No unique components for this page
---
## Recommendations
- Effects: Hover states on CTA (color shift, slight scale), form field focus animations, loading spinner, success feedback
- Forms: Show loading then success/error state
- Accessibility: Use label with for attribute or wrap input
- CTA Placement: Form CTA: Submit button

View File

@@ -26,13 +26,41 @@
**查询参数:**
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| keyword | string | | 搜索关键词(匹配用户名/邮箱/昵称 |
| status | int | | 按状态筛选1=正常2=禁用3=注销 |
| role | string | | 按角色筛选user / admin / super_admin |
| page | int | 1 | 页码 |
| page_size | int | 20 | 每页数量 |
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| page | int | | - | 页码(从 1 开始 |
| page_size | int | | - | 每页数量1-100 |
| keyword | string | | 无 | 搜索关键词(模糊匹配用户名或邮箱) |
| status | int | 否 | 无 | 按状态筛选1=正常2=禁用3=注销 |
**成功响应:**
```json
{
"code": 0,
"message": "success",
"data": {
"total": 100,
"list": [
{
"id": 1,
"username": "zhangsan",
"email": "zhangsan@example.com",
"nickname": "张三",
"avatar": "",
"gender": 1,
"phone": "13800138000",
"status": 1,
"status_text": "正常",
"roles": ["user"],
"last_login_at": "2026-02-27 10:00:00",
"last_login_ip": "192.168.1.100",
"created_at": "2026-02-20 08:00:00",
"updated_at": "2026-02-27 10:00:00"
}
]
}
}
```
---
@@ -46,20 +74,22 @@
```json
{
"code": 0,
"message": "ok",
"message": "success",
"data": {
"id": 1,
"username": "zhangsan",
"email": "zhangsan@example.com",
"nickname": "张三",
"avatar": "https://...",
"avatar": "",
"gender": 1,
"phone": "13800138000",
"status": 1,
"status_text": "正常",
"roles": ["user"],
"last_login_at": "2026-02-27 10:00:00",
"last_login_ip": "192.168.1.100",
"created_at": "2026-02-20 08:00:00"
"created_at": "2026-02-20 08:00:00",
"updated_at": "2026-02-27 10:00:00"
}
}
```
@@ -86,7 +116,7 @@
`PUT /api/v1/admin/users/:id/role`
**权限:** super_admin
**权限:** admin / super_admin
**请求参数:**
@@ -102,10 +132,14 @@
**权限:** admin
**请求参数:** 同前台用户注册接口,额外支持:
**请求参数:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| username | string | 是 | 用户名3-50 字符 |
| email | string | 是 | 邮箱地址 |
| password | string | 是 | 初始密码6-50 字符 |
| nickname | string | 否 | 昵称(默认使用用户名) |
| role_code | string | 否 | 指定角色,默认为 user |
---

View File

@@ -283,10 +283,39 @@ services:
| **mediasoup** | 最高性能的开源 SFUC++ 实现 |
| **PostgreSQL 17** | 强一致性、JSONB 支持、性能优异 |
| **Redis 7** | 实时状态存储、发布订阅、高速缓存 |
| **uniapp (Vue 3)** | 一套代码多端运行H5/App/小程序) |
| **Element Plus** | Vue 3 生态最成熟的 PC 端组件库 |
| **uniapp (Vue 3.4)** | 一套代码多端运行H5/App/小程序)Vue 版本由 uni-app 框架锁定 |
| **Element Plus** | Vue 3 生态最成熟的 PC 端组件库,管理端使用 |
| **Docker Compose** | 轻量级容器编排,适合初期和开发环境 |
### 7.1 前端技术栈版本策略
系统包含两个独立前端项目,因框架约束,版本策略有所区别:
| 前端项目 | 目录 | 框架 | Vue 版本 | 状态管理 | 原因 |
|----------|------|------|---------|---------|------|
| 前台用户端 | `frontend/` | uni-app 3.0 | 3.4.21(框架锁定) | Pinia 2.x | uni-app 尚未适配 Vue 3.5Pinia 3.x 需要 Vue >= 3.5.11 |
| 后台管理端 | `admin/` | Vue 3 + Element Plus | 3.5+(不受限) | Pinia 3.x | 独立 Vue 3 项目,可使用最新版本 |
**npm 兼容性说明:** uni-app 的 peer dependency 链与 npm 7+ 的严格依赖解析存在冲突,`frontend/` 项目需在 `.npmrc` 中设置 `legacy-peer-deps=true`,这是 uni-app 社区的标准做法。
**版本升级策略:** 当 uni-app 正式适配 Vue 3.5+ 后,可统一将前台的 Pinia 升级至 3.xAPI 层面几乎无需改动Pinia 2.x 与 3.x 的 `defineStore` API 完全兼容)。
### 7.2 两端开发规范差异
前台用户端和后台管理端是**完全独立**的前端项目,开发规范、依赖版本、构建方式不需要强制统一:
| 维度 | 前台用户端 (`frontend/`) | 后台管理端 (`admin/`) |
|------|------------------------|---------------------|
| 框架 | uni-app 3.0Vue 3.4.21 | Vue 3.5+ + Vite |
| 状态管理 | Pinia 2.x`pinia-plugin-persistedstate@3` | Pinia 3.x最新版 |
| HTTP 客户端 | `uni.request` 封装 | Axios |
| 路由 | `pages.json` + `uni.navigateTo/switchTab` | Vue Router 4 |
| UI 组件 | 原生 uni-app 组件(`<view>`/`<text>`/`<input>` | Element Plus |
| 模块系统 | ES ModuleVite 要求) | ES ModuleVite 标准) |
| npm 配置 | 需要 `.npmrc` 设置 `legacy-peer-deps=true` | 标准 npm 配置 |
| 适配能力 | H5 / 小程序 / App / 桌面端 | 仅 PC 浏览器 |
| 设计系统 | ui-ux-pro-max 生成的设计系统 | ui-ux-pro-max + Element Plus 主题 |
---
## 八、日志系统与链路追踪设计

View File

@@ -6,7 +6,7 @@
**架构:** Go 单体模块化服务 + uniapp 前台 + Vue3 管理端 + PostgreSQL + RedisDocker Compose 编排。
**技术栈:** Go 1.23+ (Gin + GORM + Wire + zap) / Vue 3.4+ / uniapp 3.0 / Element Plus / PostgreSQL 17 / Redis 7 / Docker Compose
**技术栈:** Go 1.23+ (Gin + GORM + Wire + zap) / uniapp 3.0 (Vue 3.4 + Pinia 2.x) / Vue 3.5+ 管理端 (Pinia 3.x + Element Plus) / PostgreSQL 17 / Redis 7 / Docker Compose
**设计文档:** `docs/plans/2026-02-27-echochat-system-design.md`
@@ -30,11 +30,52 @@
7. **中间件**:说明中间件的作用、执行顺序依赖和副作用
**前端注释规范Vue/uniapp**
1. **组件注释**:每个 `.vue` 文件顶部说明组件用途和依赖
2. **API 函数**:每个 API 调用函数说明对应后端接口
1. **组件注释**:每个 `.vue` 文件顶部 `<!-- -->` 说明组件用途、功能清单、对应 API 和文档引用
2. **API 函数**:每个 API 调用函数说明对应后端接口路径和参数
3. **Store**state 字段、action 方法需要注释说明
4. **工具函数**:参数和返回值类型、用途说明
5. **复杂模板逻辑**`v-if`/`v-for` 等复杂条件需要注释说明
6. **CSS 注释**`<style>` 顶部声明设计系统来源和色板引用
### 前端目录结构与编码规范
**目录结构uni-app 前台):**
```
frontend/src/
├── api/ # API 接口模块,每个后端模块一个文件
│ └── auth.js # 对应 /api/v1/auth/*
├── pages/ # 页面组件uni-app 页面路由)
│ ├── auth/ # 认证页面login/register
│ ├── index/ # 启动页(登录状态判断 + 路由重定向)
│ ├── chat/ # 消息列表TabBar 页)
│ ├── contact/ # 联系人TabBar 页)
│ ├── meeting/ # 会议TabBar 页)
│ └── profile/ # 个人中心TabBar 页)
├── store/ # Pinia 状态管理
│ └── user.js # 用户认证状态
├── utils/ # 工具函数
│ ├── request.js # HTTP 请求封装(基于 uni.request
│ └── storage.js # 本地存储封装
├── services/ # 业务服务WebSocket 等)
│ └── websocket.js # WebSocket 管理(占位)
├── static/ # 静态资源(图片、图标)
├── main.js # 应用入口Pinia 初始化)
├── App.vue # 根组件
├── pages.json # 页面路由 + TabBar 配置
└── manifest.json # 应用配置
```
**编码规范:**
1. **模块化导入**:使用 `@/` 别名引用 `src/` 下的模块(如 `@/api/auth``@/store/user`
2. **命名规范**:文件名使用 kebab-case 或小写Vue 组件 name 使用 PascalCase
3. **导出方式**:统一使用 ES Module`export` / `export default`)。禁止使用 CommonJS `module.exports`/`require`Vite 构建环境不兼容 CJS
4. **状态管理**:所有全局状态通过 Pinia Store 管理,组件内部状态使用 `data()`
5. **API 调用**:页面组件不直接调用 `request.js`,必须通过 `api/` 层或 Store action
6. **表单校验**:使用 label + error feedback 模式(禁止 placeholder-only参照 ui-ux-pro-max UX 规则
7. **设计系统**:所有页面 UI 必须基于 `design-system/echochat/MASTER.md` 设计规范实现
8. **响应式单位**:使用 `rpx` 替代 `px`750rpx = 屏幕宽度),确保多端适配
9. **npm 配置**`frontend/.npmrc` 设置 `legacy-peer-deps=true`uni-app 生态需要)
### 日志规范
@@ -633,7 +674,17 @@ git commit -m "feat(auth): 用户注册/登录 APIController + 路由注册
**Step 1: 安装前端依赖**
Run: `cd frontend && npm install pinia @pinia/persist`
> **版本策略:** uni-app 锁定 Vue 3.4.21Pinia 3.x 需要 Vue >= 3.5.11,因此前台用户端必须使用 Pinia 2.x。后台管理端Task 9为独立 Vue 3 项目,不受此限制,可使用 Pinia 3.x。
>
> **npm 兼容性:** uni-app 的 peer dependency 链与 npm 7+ 的严格解析存在冲突,需在 `frontend/.npmrc` 中设置 `legacy-peer-deps=true`。
```bash
# 创建 .npmrc 固化 npm 解析策略
echo "legacy-peer-deps=true" > frontend/.npmrc
# 安装 Pinia 及持久化插件
cd frontend && npm install pinia@2.1.7 pinia-plugin-persistedstate@3
```
uniapp 内置了请求 APIuni.request不需要额外安装 axios。
@@ -796,6 +847,8 @@ Run: `cd /path/to/EchoChat && npm create vite@latest admin -- --template vue`
**Step 2: 安装核心依赖**
> **版本策略:** 管理端为独立 Vue 3 项目(非 uni-app可使用最新的 Vue 3.5+ 和 Pinia 3.x不受 uni-app 版本限制。
Run: `cd admin && npm install element-plus vue-router@4 pinia axios @element-plus/icons-vue`
**Step 3: 创建布局框架**

View File

@@ -0,0 +1,140 @@
# EchoChat 项目开发进度
> **最后更新**2026-03-02Phase 1 全部完成 — Task 11 完成后更新)
> **当前阶段**Phase 1 - 基础设施与用户认证
> **当前分支**`feature/phase1-foundation-and-auth`
> **实施计划**`docs/plans/2026-02-27-phase1-foundation-and-auth.md`
---
## 一、Task 完成状态
| Task | 描述 | 状态 | 备注 |
|------|------|------|------|
| Task 1 | Docker Compose 开发环境搭建 | ✅ 完成 | PostgreSQL 17 + Redis 7 |
| Task 2 | 数据库初始化脚本 | ✅ 完成 | users + user_roles + roles 表 |
| Task 3 | Go 后端服务骨架 | ✅ 完成 | Gin + GORM + Wire + Zap |
| Task 4 | Auth Service 层 | ✅ 完成 | 注册/登录/Token/Profile API |
| Task 5 | Auth Controller & Router | ✅ 完成 | JWT + Redis 有状态校验 |
| Task 6 | uniapp 前端骨架 | ✅ 完成 | request/storage/api/store/pages.json |
| Task 7 | uniapp 登录/注册页面 | ✅ 完成 | 基于 ui-ux-pro-max 设计系统 |
| Task 8 | 首页框架与 TabBar | ✅ 完成 | 自定义 TabBar + 路由分发 |
| Task 9 | Vue 3 管理端项目搭建 | ✅ 完成 | Element Plus + Pinia 3.x |
| Task 10 | 管理端用户管理模块 | ✅ 完成 | 后端 admin 模块 + 前端列表/详情页 |
| Task 11 | 端到端集成测试与文档 | ✅ 完成 | Dockerfile + Docker Compose + README + 全流程 API 验证 + Playwright 页面验证 + 代码审查 |
---
## 二、关键技术决策记录
### 后端Go
1. **框架组合**Gin + GORM + Wire + Zap + Viper
2. **JWT 策略**:有状态 JWTToken 存储在 Redis`echo:auth:token:{user_id}`
3. **密码加密**bcrypt
4. **数据库时间精度**`TIMESTAMP(0)` 精确到秒
5. **API 响应格式**:统一 `{ "code": 0, "message": "success", "data": ... }`
6. **常量命名**Go camelCase`UserStatusActive`),非大写下划线
7. **模块路由**:模块内自注册 + 中央 router 聚合
### 前台用户端frontend/
1. **框架**uni-app 3.0Vue 3.4.21 框架锁定)
2. **状态管理**Pinia 2.1.7 + pinia-plugin-persistedstate@3
3. **npm 配置**`.npmrc` 设置 `legacy-peer-deps=true`uni-app 兼容性)
4. **模块系统**ESM`export` / `import`),禁止 CommonJS
5. **响应式单位**`rpx`750rpx = 屏幕宽度)
6. **设计系统**ui-ux-pro-max 生成,持久化在 `design-system/echochat/`
7. **色板**Primary `#2563EB` / BG `#F8FAFC` / Text `#1E293B`
8. **开发端口**`npm run dev:h5` → localhost:5173+
### 后台管理端admin/
1. **框架**Vue 3.5+ + Vite 7.x独立项目不受 uni-app 限制)
2. **UI 组件**Element Plus中文语言包
3. **状态管理**Pinia 3.x最新版
4. **HTTP 客户端**Axios
5. **存储隔离**localStorage key 前缀 `admin_`
6. **主题色**CSS 变量覆盖 Element Plus → `--el-color-primary: #2563EB`
7. **开发端口**`npm run dev` → localhost:3100Vite proxy 代理后端)
---
## 三、目录结构概览
```
EchoChat/
├── backend/go-service/ # Go 后端服务
│ ├── app/
│ │ ├── admin/ # 管理端模块controller/service/dao/router/provider
│ │ ├── auth/ # 认证模块controller/service/dao/model/router
│ │ ├── constants/ # 常量role_code/user_status
│ │ ├── dto/ # 数据传输对象auth_dto + admin_dto
│ │ └── provider/ # Wire 依赖注入
│ ├── cmd/server/main.go # 入口
│ ├── config/ # 配置
│ ├── pkg/ # 公共包db/logs/middleware/utils
│ └── router/router.go # 中央路由聚合
├── frontend/ # 前台用户端uni-app
│ └── src/
│ ├── api/auth.js
│ ├── components/CustomTabBar.vue
│ ├── pages/{auth,chat,contact,meeting,profile,index}/
│ ├── store/user.js
│ └── utils/{request,storage}.js
├── admin/ # 后台管理端Vue 3 + Element Plus
│ └── src/
│ ├── api/auth.js
│ ├── router/index.js
│ ├── store/user.js
│ ├── utils/{request,storage}.js
│ └── views/{layout,login,dashboard,user}/
├── deploy/
│ ├── docker-compose.dev.yml
│ └── docker/postgres/init.sql
├── design-system/echochat/ # ui-ux-pro-max 生成的设计系统
│ ├── MASTER.md
│ └── pages/{login,admin-login}.md
└── docs/
├── api/ # API 接口文档
├── architecture/ # 系统架构文档
├── plans/ # 实施计划文档
└── progress/ # 进度文档(本文件)
```
---
## 四、开发流程规范
1. **工作流**:使用 superpowers 流程控制开发节奏
2. **前端设计****必须**使用 ui-ux-pro-max 技能包,禁止手动设计
3. **代码注释**所有公开函数、组件、Store 必须有详细注释
4. **文档同步**:代码变更后必须同步更新相关文档
5. **Git 分支**`feature/phase1-foundation-and-auth`
6. **验证方式**Playwright MCP 进行页面自动化验证
7. **代码审查**:每个 Task 完成后,使用 `code-reviewer` 子代理进行结构化代码审查,对照实施计划和编码标准检查
8. **完成验证**:使用 `verification-before-completion` 技能,在声称完成前必须运行验证命令并确认输出结果
---
## 五、已知问题与注意事项
1. ~~前端工具模块使用了 CommonJS已修复为 ESM~~(已解决)
2. uni-app 的 `tabBar.custom: true` 配合自定义 TabBar 组件使用
3. 管理端和前台的 localStorage key 通过前缀隔离(`admin_` vs `echo_`
4. Go 依赖版本需匹配 Go 1.23.12,不要随意升级 Go 工具链
---
## 六、Phase 1 完成总结
### Phase 1 阶段成果
- **11 个 Task 全部完成**,端到端验证通过
- **Go 后端**15 个 API 端点JWT 有状态认证RBAC 角色权限
- **前台 uni-app**:登录/注册/TabBar/个人中心
- **管理端 Vue 3**:登录/仪表盘/用户列表/用户详情
- **基础设施**Docker Compose 一键启动PostgreSQL + Redis + Go 服务)
- **代码审查**code-reviewer 审查通过,已修复 panic 风险、错误处理等问题
### 下一阶段Phase 2 - 即时通讯
- 待制定实施计划
- WebSocket 长连接 + 消息系统
- 联系人/好友管理
- 消息通知

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

8365
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,8 @@
"@dcloudio/uni-mp-weixin": "3.0.0-4080720251210001",
"@dcloudio/uni-mp-xhs": "3.0.0-4080720251210001",
"@dcloudio/uni-quickapp-webview": "3.0.0-4080720251210001",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.3",
"vue": "^3.4.21",
"vue-i18n": "^9.1.9"
},

115
frontend/src/api/auth.js Normal file
View File

@@ -0,0 +1,115 @@
/**
* 认证模块 API
*
* 对应后端路由:/api/v1/auth/*
* 接口文档见docs/api/frontend/auth.md
*
* 所有 API 函数返回 Promiseresolve 时返回完整响应体 { code, message, data }
*/
import { post, get, put } from '@/utils/request'
/**
* 用户注册
* POST /api/v1/auth/register
*
* @param {Object} data - 注册信息
* @param {string} data.username - 用户名3-50 字符,全局唯一)
* @param {string} data.email - 邮箱地址(全局唯一)
* @param {string} data.password - 登录密码6-50 字符)
* @param {string} [data.nickname] - 昵称(选填,默认与用户名相同)
* @returns {Promise<Object>} { token, refresh_token, expires_in, user }
*/
const register = (data) => {
return post('/api/v1/auth/register', data, { needAuth: false })
}
/**
* 用户登录
* POST /api/v1/auth/login
*
* @param {Object} data - 登录信息
* @param {string} data.account - 用户名或邮箱(自动识别)
* @param {string} data.password - 登录密码
* @returns {Promise<Object>} { token, refresh_token, expires_in, user }
*/
const login = (data) => {
return post('/api/v1/auth/login', data, { needAuth: false })
}
/**
* 退出登录
* POST /api/v1/auth/logout
*
* 服务端会从 Redis 中删除该用户的 Token使其立即失效
* 客户端需同步清除本地存储的 Token由 store 层处理)
*
* @returns {Promise<Object>}
*/
const logout = () => {
return post('/api/v1/auth/logout')
}
/**
* 刷新 Token
* POST /api/v1/auth/refresh-token
*
* 使用 Refresh Token 获取新的 Access Token 和 Refresh Token
* 新 Token 会覆盖 Redis 中的旧值(单设备登录)
*
* @param {string} refreshToken - Refresh Token
* @returns {Promise<Object>} { token, refresh_token, expires_in, user }
*/
const refreshToken = (refreshToken) => {
return post('/api/v1/auth/refresh-token', { refresh_token: refreshToken }, { needAuth: false })
}
/**
* 获取个人信息
* GET /api/v1/auth/profile
*
* 返回完整用户信息(含 phone、created_at 等额外字段)
*
* @returns {Promise<Object>} UserInfo 对象
*/
const getProfile = () => {
return get('/api/v1/auth/profile')
}
/**
* 更新个人信息
* PUT /api/v1/auth/profile
*
* @param {Object} data - 需要更新的字段(均为可选)
* @param {string} [data.nickname] - 新昵称
* @param {string} [data.avatar] - 新头像 URL
* @param {number} [data.gender] - 性别0=未知1=男2=女)
* @param {string} [data.phone] - 手机号
* @returns {Promise<Object>} 更新后的完整 UserInfo
*/
const updateProfile = (data) => {
return put('/api/v1/auth/profile', data)
}
/**
* 修改密码
* PUT /api/v1/auth/password
*
* @param {Object} data - 密码信息
* @param {string} data.old_password - 原密码
* @param {string} data.new_password - 新密码6-50 字符)
* @returns {Promise<Object>}
*/
const changePassword = (data) => {
return put('/api/v1/auth/password', data)
}
export default {
register,
login,
logout,
refreshToken,
getProfile,
updateProfile,
changePassword
}

View File

@@ -0,0 +1,127 @@
<!--
自定义 TabBar 组件
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / Text #1E293B / Muted #94A3B8
功能
- 底部导航栏4 Tab消息 / 联系人 / 会议 / 我的
- 选中状态高亮Primary
- 使用 Unicode 图标占位后续可替换为 SVG 图标组件
- 支持 switchTab 跳转
使用方式在每个 TabBar 页面中引入此组件并放在页面底部
-->
<template>
<view class="tab-bar">
<view
v-for="(item, index) in tabs"
:key="item.path"
class="tab-item"
:class="{ 'tab-active': currentIndex === index }"
@tap="switchTo(index)"
>
<!-- 图标区域使用 Unicode 字符占位后续替换为 SVG -->
<text class="tab-icon">{{ item.icon }}</text>
<text class="tab-label">{{ item.label }}</text>
</view>
</view>
</template>
<script>
/**
* 自定义 TabBar 组件
*
* Props:
* - current: 当前选中的 Tab 索引
*
* 跳转使用 uni.switchTab确保与 pages.json tabBar 配置的页面一致
*/
export default {
name: 'CustomTabBar',
props: {
/** 当前选中 Tab 的索引0-3 */
current: {
type: Number,
default: 0
}
},
data() {
return {
/** Tab 配置列表 */
tabs: [
{ path: '/pages/chat/index', label: '消息', icon: '💬' },
{ path: '/pages/contact/index', label: '联系人', icon: '👥' },
{ path: '/pages/meeting/index', label: '会议', icon: '🎥' },
{ path: '/pages/profile/index', label: '我的', icon: '👤' }
]
}
},
computed: {
currentIndex() {
return this.current
}
},
methods: {
/**
* 切换 Tab
* @param {number} index - 目标 Tab 索引
*/
switchTo(index) {
if (index === this.currentIndex) return
uni.switchTab({ url: this.tabs[index].path })
}
}
}
</script>
<style scoped>
/*
* 设计系统MASTER.md
* 选中色:#2563EBPrimary
* 未选中色:#94A3B8Muted
* 背景:#FFFFFF + 顶部分割线
* 高度:适配安全区域(底部 safe area
*/
.tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 110rpx;
background-color: #FFFFFF;
display: flex;
align-items: center;
justify-content: space-around;
border-top: 1rpx solid #E2E8F0;
padding-bottom: env(safe-area-inset-bottom, 0);
z-index: 999;
}
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
height: 100%;
transition: color 200ms ease;
}
.tab-icon {
font-size: 40rpx;
margin-bottom: 4rpx;
}
.tab-label {
font-size: 22rpx;
color: #94A3B8;
font-weight: 400;
}
/* 选中状态 */
.tab-active .tab-label {
color: #2563EB;
font-weight: 500;
}
</style>

View File

@@ -1,10 +1,27 @@
import {
createSSRApp
} from "vue";
import App from "./App.vue";
/**
* 应用入口文件
*
* 初始化 Vue 应用实例,集成:
* - Pinia 状态管理(含持久化插件)
*
* uni-app 要求导出 createApp 工厂函数,由框架负责调用
*/
import { createSSRApp } from "vue"
import * as Pinia from "pinia"
import piniaPluginPersistedstate from "pinia-plugin-persistedstate"
import App from "./App.vue"
export function createApp() {
const app = createSSRApp(App);
const app = createSSRApp(App)
// 创建 Pinia 实例并注册持久化插件
const pinia = Pinia.createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
return {
app,
};
// uni-app 要求返回 Pinia 实例,用于 SSR 场景
Pinia,
}
}

View File

@@ -1,16 +1,66 @@
{
"pages": [ //pages数组中第一项表示应用启动页参考https://uniapp.dcloud.io/collocation/pages
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "uni-app"
"navigationBarTitleText": "EchoChat",
"navigationStyle": "custom"
}
},
{
"path": "pages/auth/login",
"style": {
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/auth/register",
"style": {
"navigationBarTitleText": "注册"
}
},
{
"path": "pages/chat/index",
"style": {
"navigationBarTitleText": "消息"
}
},
{
"path": "pages/contact/index",
"style": {
"navigationBarTitleText": "联系人"
}
},
{
"path": "pages/meeting/index",
"style": {
"navigationBarTitleText": "会议"
}
},
{
"path": "pages/profile/index",
"style": {
"navigationBarTitleText": "我的"
}
}
],
"tabBar": {
"custom": true,
"color": "#94A3B8",
"selectedColor": "#2563EB",
"backgroundColor": "#FFFFFF",
"borderStyle": "white",
"list": [
{ "pagePath": "pages/chat/index", "text": "消息" },
{ "pagePath": "pages/contact/index", "text": "联系人" },
{ "pagePath": "pages/meeting/index", "text": "会议" },
{ "pagePath": "pages/profile/index", "text": "我的" }
]
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
"navigationBarTitleText": "EchoChat",
"navigationBarBackgroundColor": "#FFFFFF",
"backgroundColor": "#F8FAFC"
}
}

View File

@@ -0,0 +1,347 @@
<!--
登录页面
设计系统design-system/echochat/MASTER.md + pages/login.md
色板Primary #2563EB / CTA #F97316 / Background #F8FAFC / Text #1E293B
风格Clean Minimalism单列居中低内容密度
功能
- 账号用户名或邮箱+ 密码登录
- 表单验证label + error feedback不使用 placeholder-only
- Loading 状态反馈禁用按钮防止重复提交
- 登录成功跳转首页
- "没有账号?去注册" 链接
对应 APIPOST /api/v1/auth/login
接口文档docs/api/frontend/auth.md
-->
<template>
<view class="page-container">
<!-- 顶部品牌区域 -->
<view class="brand-section">
<view class="logo-box">
<text class="logo-letter">E</text>
</view>
<text class="brand-name">EchoChat</text>
<text class="brand-slogan">连接无限沟通无界</text>
</view>
<!-- 表单卡片 -->
<view class="form-card">
<text class="form-heading">登录</text>
<!-- 账号 -->
<view class="field-group">
<text class="field-label">账号</text>
<view class="input-box" :class="{ 'input-focused': focusState.account, 'input-error': errors.account }">
<input
class="input-control"
type="text"
placeholder="请输入用户名或邮箱"
v-model="form.account"
@focus="focusState.account = true"
@blur="onBlur('account')"
maxlength="50"
/>
</view>
<text v-if="errors.account" class="field-error">{{ errors.account }}</text>
</view>
<!-- 密码 -->
<view class="field-group">
<text class="field-label">密码</text>
<view class="input-box" :class="{ 'input-focused': focusState.password, 'input-error': errors.password }">
<input
class="input-control"
:type="passwordVisible ? 'text' : 'password'"
placeholder="请输入密码"
v-model="form.password"
@focus="focusState.password = true"
@blur="onBlur('password')"
maxlength="50"
@confirm="submit"
/>
<text class="pwd-toggle" @tap="passwordVisible = !passwordVisible">
{{ passwordVisible ? '隐藏' : '显示' }}
</text>
</view>
<text v-if="errors.password" class="field-error">{{ errors.password }}</text>
</view>
<!-- 登录按钮 -->
<button class="btn-primary" :class="{ 'btn-disabled': loading }" :disabled="loading" @tap="submit">
<text class="btn-label">{{ loading ? '登录中…' : '登录' }}</text>
</button>
<!-- 底部链接 -->
<view class="link-row">
<text class="link-hint">还没有账号</text>
<text class="link-action" @tap="goRegister">立即注册</text>
</view>
</view>
</view>
</template>
<script>
/**
* 登录页面逻辑
*
* 使用 useUserStore 处理登录
* 登录成功后 reLaunch 到首页
*/
import { useUserStore } from '@/store/user'
export default {
name: 'LoginPage',
data() {
return {
form: { account: '', password: '' },
passwordVisible: false,
loading: false,
focusState: { account: false, password: false },
errors: { account: '', password: '' }
}
},
methods: {
/** 失焦时触发对应字段校验 */
onBlur(field) {
this.focusState[field] = false
this.validate(field)
},
/**
* 单字段校验
* @param {string} field
* @returns {boolean}
*/
validate(field) {
if (field === 'account') {
if (!this.form.account.trim()) {
this.errors.account = '请输入用户名或邮箱'
return false
}
this.errors.account = ''
return true
}
if (field === 'password') {
if (!this.form.password) {
this.errors.password = '请输入密码'
return false
}
if (this.form.password.length < 6) {
this.errors.password = '密码至少 6 位'
return false
}
this.errors.password = ''
return true
}
return true
},
/** 提交登录 */
async submit() {
if (this.loading) return
const a = this.validate('account')
const p = this.validate('password')
if (!a || !p) return
this.loading = true
try {
const store = useUserStore()
await store.login({
account: this.form.account.trim(),
password: this.form.password
})
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => uni.reLaunch({ url: '/pages/index/index' }), 800)
} catch (e) {
console.error('登录失败:', e)
} finally {
this.loading = false
}
},
goRegister() {
uni.navigateTo({ url: '/pages/auth/register' })
}
}
}
</script>
<style scoped>
/*
* 设计系统来源design-system/echochat/MASTER.md + pages/login.md
* 色板Primary #2563EB / CTA #F97316 / BG #F8FAFC / Text #1E293B
* 输入框规范border #E2E8F0 / radius 8px(16rpx) / focus border #2563EB + shadow
* 按钮规范radius 8px(16rpx) / transition 200ms / cursor-pointer
* 间距规范:--space-md 16px(32rpx) / --space-lg 24px(48rpx)
*/
.page-container {
min-height: 100vh;
background-color: #F8FAFC;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 48rpx;
}
/* ---- 品牌区域 ---- */
.brand-section {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 120rpx;
margin-bottom: 64rpx;
}
.logo-box {
width: 112rpx;
height: 112rpx;
border-radius: 24rpx;
background-color: #2563EB;
display: flex;
align-items: center;
justify-content: center;
}
.logo-letter {
font-size: 52rpx;
font-weight: 700;
color: #FFFFFF;
}
.brand-name {
font-size: 44rpx;
font-weight: 700;
color: #1E293B;
margin-top: 24rpx;
letter-spacing: 2rpx;
}
.brand-slogan {
font-size: 26rpx;
color: #64748B;
margin-top: 8rpx;
}
/* ---- 表单卡片 ---- */
.form-card {
width: 100%;
max-width: 800rpx;
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 56rpx 40rpx 48rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.form-heading {
font-size: 40rpx;
font-weight: 600;
color: #1E293B;
margin-bottom: 48rpx;
}
/* ---- 输入字段 ---- */
.field-group {
margin-bottom: 36rpx;
}
.field-label {
display: block;
font-size: 26rpx;
font-weight: 500;
color: #475569;
margin-bottom: 12rpx;
}
.input-box {
display: flex;
align-items: center;
height: 96rpx;
background-color: #FFFFFF;
border: 2rpx solid #E2E8F0;
border-radius: 16rpx;
padding: 0 24rpx;
transition: border-color 200ms ease, box-shadow 200ms ease;
}
.input-box.input-focused {
border-color: #2563EB;
box-shadow: 0 0 0 6rpx rgba(37, 99, 235, 0.12);
}
.input-box.input-error {
border-color: #EF4444;
}
.input-control {
flex: 1;
font-size: 30rpx;
color: #1E293B;
}
.pwd-toggle {
font-size: 24rpx;
color: #2563EB;
font-weight: 500;
padding: 12rpx 0 12rpx 16rpx;
}
.field-error {
display: block;
font-size: 22rpx;
color: #EF4444;
margin-top: 8rpx;
padding-left: 4rpx;
}
/* ---- 按钮CTA 色 #F97316 ---- */
.btn-primary {
width: 100%;
height: 96rpx;
background-color: #2563EB;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
margin-top: 48rpx;
border: none;
transition: opacity 200ms ease;
}
.btn-primary::after {
border: none;
}
.btn-primary.btn-disabled {
opacity: 0.6;
}
.btn-label {
font-size: 32rpx;
font-weight: 600;
color: #FFFFFF;
letter-spacing: 2rpx;
}
/* ---- 底部链接 ---- */
.link-row {
display: flex;
justify-content: center;
align-items: center;
margin-top: 40rpx;
}
.link-hint {
font-size: 26rpx;
color: #94A3B8;
}
.link-action {
font-size: 26rpx;
color: #2563EB;
font-weight: 500;
margin-left: 8rpx;
}
</style>

View File

@@ -0,0 +1,427 @@
<!--
注册页面
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / CTA #F97316 / Background #F8FAFC / Text #1E293B
风格Clean Minimalism单列居中低内容密度
功能
- 用户名 + 邮箱 + 密码 + 确认密码 + 昵称选填
- 实时表单验证label + error feedback不使用 placeholder-only
- 注册成功自动登录并跳转首页
- "已有账号?去登录" 链接
- Loading 状态防止重复提交
对应 APIPOST /api/v1/auth/register
接口文档docs/api/frontend/auth.md
-->
<template>
<view class="page-container">
<!-- 顶部品牌区域 -->
<view class="brand-section">
<view class="logo-box">
<text class="logo-letter">E</text>
</view>
<text class="brand-name">创建账号</text>
<text class="brand-slogan">加入 EchoChat开启高效沟通</text>
</view>
<!-- 表单卡片 -->
<view class="form-card">
<!-- 用户名 -->
<view class="field-group">
<text class="field-label">用户名 <text class="required-mark">*</text></text>
<view class="input-box" :class="{ 'input-focused': focus.username, 'input-error': errors.username }">
<input
class="input-control"
type="text"
placeholder="3-50 个字符,全局唯一"
v-model="form.username"
@focus="focus.username = true"
@blur="onBlur('username')"
maxlength="50"
/>
</view>
<text v-if="errors.username" class="field-error">{{ errors.username }}</text>
</view>
<!-- 邮箱 -->
<view class="field-group">
<text class="field-label">邮箱 <text class="required-mark">*</text></text>
<view class="input-box" :class="{ 'input-focused': focus.email, 'input-error': errors.email }">
<input
class="input-control"
type="text"
placeholder="your@email.com"
v-model="form.email"
@focus="focus.email = true"
@blur="onBlur('email')"
maxlength="100"
/>
</view>
<text v-if="errors.email" class="field-error">{{ errors.email }}</text>
</view>
<!-- 密码 -->
<view class="field-group">
<text class="field-label">密码 <text class="required-mark">*</text></text>
<view class="input-box" :class="{ 'input-focused': focus.password, 'input-error': errors.password }">
<input
class="input-control"
:type="passwordVisible ? 'text' : 'password'"
placeholder="至少 6 个字符"
v-model="form.password"
@focus="focus.password = true"
@blur="onBlur('password')"
maxlength="50"
/>
<text class="pwd-toggle" @tap="passwordVisible = !passwordVisible">
{{ passwordVisible ? '隐藏' : '显示' }}
</text>
</view>
<text v-if="errors.password" class="field-error">{{ errors.password }}</text>
</view>
<!-- 确认密码 -->
<view class="field-group">
<text class="field-label">确认密码 <text class="required-mark">*</text></text>
<view class="input-box" :class="{ 'input-focused': focus.confirmPwd, 'input-error': errors.confirmPwd }">
<input
class="input-control"
:type="passwordVisible ? 'text' : 'password'"
placeholder="再次输入密码"
v-model="form.confirmPwd"
@focus="focus.confirmPwd = true"
@blur="onBlur('confirmPwd')"
maxlength="50"
/>
</view>
<text v-if="errors.confirmPwd" class="field-error">{{ errors.confirmPwd }}</text>
</view>
<!-- 昵称 -->
<view class="field-group">
<text class="field-label">昵称 <text class="optional-mark">(选填)</text></text>
<view class="input-box" :class="{ 'input-focused': focus.nickname }">
<input
class="input-control"
type="text"
placeholder="不填则默认使用用户名"
v-model="form.nickname"
@focus="focus.nickname = true"
@blur="focus.nickname = false"
maxlength="50"
@confirm="submit"
/>
</view>
</view>
<!-- 注册按钮 -->
<button class="btn-primary" :class="{ 'btn-disabled': loading }" :disabled="loading" @tap="submit">
<text class="btn-label">{{ loading ? '注册中…' : '注册' }}</text>
</button>
<!-- 底部链接 -->
<view class="link-row">
<text class="link-hint">已有账号</text>
<text class="link-action" @tap="goLogin">去登录</text>
</view>
</view>
<!-- 底部留白避免键盘弹起时遮挡 -->
<view class="bottom-spacer"></view>
</view>
</template>
<script>
/**
* 注册页面逻辑
*
* 使用 useUserStore 处理注册(注册成功自动登录)
* 注册成功后 reLaunch 到首页
*/
import { useUserStore } from '@/store/user'
/** 邮箱校验正则 */
const EMAIL_RE = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
export default {
name: 'RegisterPage',
data() {
return {
form: { username: '', email: '', password: '', confirmPwd: '', nickname: '' },
passwordVisible: false,
loading: false,
focus: { username: false, email: false, password: false, confirmPwd: false, nickname: false },
errors: { username: '', email: '', password: '', confirmPwd: '' }
}
},
methods: {
/** 失焦时触发字段校验 */
onBlur(field) {
this.focus[field] = false
this.validate(field)
},
/**
* 单字段校验
* @param {string} field
* @returns {boolean}
*/
validate(field) {
switch (field) {
case 'username':
if (!this.form.username.trim()) { this.errors.username = '请输入用户名'; return false }
if (this.form.username.trim().length < 3) { this.errors.username = '用户名至少 3 个字符'; return false }
this.errors.username = ''
return true
case 'email':
if (!this.form.email.trim()) { this.errors.email = '请输入邮箱'; return false }
if (!EMAIL_RE.test(this.form.email.trim())) { this.errors.email = '邮箱格式不正确'; return false }
this.errors.email = ''
return true
case 'password':
if (!this.form.password) { this.errors.password = '请输入密码'; return false }
if (this.form.password.length < 6) { this.errors.password = '密码至少 6 位'; return false }
this.errors.password = ''
if (this.form.confirmPwd) this.validate('confirmPwd')
return true
case 'confirmPwd':
if (!this.form.confirmPwd) { this.errors.confirmPwd = '请再次输入密码'; return false }
if (this.form.confirmPwd !== this.form.password) { this.errors.confirmPwd = '两次密码不一致'; return false }
this.errors.confirmPwd = ''
return true
default:
return true
}
},
/** 全字段校验 */
validateAll() {
const fields = ['username', 'email', 'password', 'confirmPwd']
let ok = true
fields.forEach(f => { if (!this.validate(f)) ok = false })
return ok
},
/** 提交注册 */
async submit() {
if (this.loading) return
if (!this.validateAll()) return
this.loading = true
try {
const store = useUserStore()
await store.register({
username: this.form.username.trim(),
email: this.form.email.trim(),
password: this.form.password,
nickname: this.form.nickname.trim() || undefined
})
uni.showToast({ title: '注册成功', icon: 'success' })
setTimeout(() => uni.reLaunch({ url: '/pages/index/index' }), 800)
} catch (e) {
console.error('注册失败:', e)
} finally {
this.loading = false
}
},
/** 返回登录页 */
goLogin() {
uni.navigateBack({
fail: () => uni.redirectTo({ url: '/pages/auth/login' })
})
}
}
}
</script>
<style scoped>
/*
* 设计系统来源design-system/echochat/MASTER.md
* 色板Primary #2563EB / CTA #F97316 / BG #F8FAFC / Text #1E293B
* 输入框规范border #E2E8F0 / radius 16rpx / focus border #2563EB + shadow
* 按钮规范radius 16rpx / transition 200ms
* 间距规范field-group mb 32rpx
*/
.page-container {
min-height: 100vh;
background-color: #F8FAFC;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 48rpx;
}
/* ---- 品牌区域 ---- */
.brand-section {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 80rpx;
margin-bottom: 48rpx;
}
.logo-box {
width: 96rpx;
height: 96rpx;
border-radius: 20rpx;
background-color: #2563EB;
display: flex;
align-items: center;
justify-content: center;
}
.logo-letter {
font-size: 44rpx;
font-weight: 700;
color: #FFFFFF;
}
.brand-name {
font-size: 40rpx;
font-weight: 700;
color: #1E293B;
margin-top: 20rpx;
}
.brand-slogan {
font-size: 24rpx;
color: #64748B;
margin-top: 6rpx;
}
/* ---- 表单卡片 ---- */
.form-card {
width: 100%;
max-width: 800rpx;
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 48rpx 40rpx 40rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
/* ---- 输入字段 ---- */
.field-group {
margin-bottom: 32rpx;
}
.field-label {
display: block;
font-size: 26rpx;
font-weight: 500;
color: #475569;
margin-bottom: 10rpx;
}
.required-mark {
color: #EF4444;
}
.optional-mark {
color: #94A3B8;
font-weight: 400;
font-size: 22rpx;
}
.input-box {
display: flex;
align-items: center;
height: 92rpx;
background-color: #FFFFFF;
border: 2rpx solid #E2E8F0;
border-radius: 16rpx;
padding: 0 24rpx;
transition: border-color 200ms ease, box-shadow 200ms ease;
}
.input-box.input-focused {
border-color: #2563EB;
box-shadow: 0 0 0 6rpx rgba(37, 99, 235, 0.12);
}
.input-box.input-error {
border-color: #EF4444;
}
.input-control {
flex: 1;
font-size: 28rpx;
color: #1E293B;
}
.pwd-toggle {
font-size: 24rpx;
color: #2563EB;
font-weight: 500;
padding: 12rpx 0 12rpx 16rpx;
}
.field-error {
display: block;
font-size: 22rpx;
color: #EF4444;
margin-top: 8rpx;
padding-left: 4rpx;
}
/* ---- 按钮 ---- */
.btn-primary {
width: 100%;
height: 96rpx;
background-color: #2563EB;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
margin-top: 40rpx;
border: none;
transition: opacity 200ms ease;
}
.btn-primary::after {
border: none;
}
.btn-primary.btn-disabled {
opacity: 0.6;
}
.btn-label {
font-size: 32rpx;
font-weight: 600;
color: #FFFFFF;
letter-spacing: 2rpx;
}
/* ---- 底部链接 ---- */
.link-row {
display: flex;
justify-content: center;
align-items: center;
margin-top: 36rpx;
}
.link-hint {
font-size: 26rpx;
color: #94A3B8;
}
.link-action {
font-size: 26rpx;
color: #2563EB;
font-weight: 500;
margin-left: 8rpx;
}
/* ---- 底部留白 ---- */
.bottom-spacer {
height: 80rpx;
}
</style>

View File

@@ -0,0 +1,62 @@
<!--
消息列表页面占位
设计系统design-system/echochat/MASTER.md
TabBar 页面Phase 2IM 模块中实现具体功能
当前功能
- 显示模块名称和开发中提示
- 集成自定义 TabBar 组件
-->
<template>
<view class="page-wrapper">
<view class="placeholder-content">
<text class="placeholder-icon">💬</text>
<text class="placeholder-title">消息</text>
<text class="placeholder-desc">即时通讯功能开发中</text>
</view>
<CustomTabBar :current="0" />
</view>
</template>
<script>
import CustomTabBar from '@/components/CustomTabBar.vue'
export default {
name: 'ChatIndex',
components: { CustomTabBar }
}
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
padding-bottom: 120rpx;
}
.placeholder-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 300rpx;
}
.placeholder-icon {
font-size: 80rpx;
margin-bottom: 24rpx;
}
.placeholder-title {
font-size: 36rpx;
font-weight: 600;
color: #1E293B;
margin-bottom: 12rpx;
}
.placeholder-desc {
font-size: 28rpx;
color: #94A3B8;
}
</style>

View File

@@ -0,0 +1,58 @@
<!--
联系人页面占位
设计系统design-system/echochat/MASTER.md
TabBar 页面Phase 2好友模块中实现具体功能
-->
<template>
<view class="page-wrapper">
<view class="placeholder-content">
<text class="placeholder-icon">👥</text>
<text class="placeholder-title">联系人</text>
<text class="placeholder-desc">好友管理功能开发中</text>
</view>
<CustomTabBar :current="1" />
</view>
</template>
<script>
import CustomTabBar from '@/components/CustomTabBar.vue'
export default {
name: 'ContactIndex',
components: { CustomTabBar }
}
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
padding-bottom: 120rpx;
}
.placeholder-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 300rpx;
}
.placeholder-icon {
font-size: 80rpx;
margin-bottom: 24rpx;
}
.placeholder-title {
font-size: 36rpx;
font-weight: 600;
color: #1E293B;
margin-bottom: 12rpx;
}
.placeholder-desc {
font-size: 28rpx;
color: #94A3B8;
}
</style>

View File

@@ -1,48 +1,52 @@
<!--
启动页 / 路由分发页
功能
- 判断用户登录状态检查本地 Token
- 已登录 跳转消息列表页TabBar 首页
- 未登录 跳转登录页
此页面不展示 UI仅作为路由分发入口
-->
<template>
<view class="content">
<image class="logo" src="/static/logo.png"></image>
<view class="text-area">
<text class="title">{{ title }}</text>
</view>
<view class="launch-page">
<text class="loading-text">加载中</text>
</view>
</template>
<script>
/**
* 启动页逻辑
*
* 在 onLoad 生命周期中检查登录状态并重定向
* 使用 reLaunch 确保清除导航栈
*/
import { useUserStore } from '@/store/user'
export default {
data() {
return {
title: 'Hello',
name: 'LaunchPage',
onLoad() {
const store = useUserStore()
if (store.isLoggedIn) {
uni.switchTab({ url: '/pages/chat/index' })
} else {
uni.reLaunch({ url: '/pages/auth/login' })
}
},
onLoad() {},
methods: {},
}
}
</script>
<style>
.content {
<style scoped>
.launch-page {
min-height: 100vh;
background-color: #F8FAFC;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.logo {
height: 200rpx;
width: 200rpx;
margin-top: 200rpx;
margin-left: auto;
margin-right: auto;
margin-bottom: 50rpx;
}
.text-area {
display: flex;
justify-content: center;
}
.title {
font-size: 36rpx;
color: #8f8f94;
.loading-text {
font-size: 28rpx;
color: #94A3B8;
}
</style>

View File

@@ -0,0 +1,58 @@
<!--
会议页面占位
设计系统design-system/echochat/MASTER.md
TabBar 页面Phase 3会议模块中实现具体功能
-->
<template>
<view class="page-wrapper">
<view class="placeholder-content">
<text class="placeholder-icon">🎥</text>
<text class="placeholder-title">会议</text>
<text class="placeholder-desc">视频会议功能开发中</text>
</view>
<CustomTabBar :current="2" />
</view>
</template>
<script>
import CustomTabBar from '@/components/CustomTabBar.vue'
export default {
name: 'MeetingIndex',
components: { CustomTabBar }
}
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
padding-bottom: 120rpx;
}
.placeholder-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 300rpx;
}
.placeholder-icon {
font-size: 80rpx;
margin-bottom: 24rpx;
}
.placeholder-title {
font-size: 36rpx;
font-weight: 600;
color: #1E293B;
margin-bottom: 12rpx;
}
.placeholder-desc {
font-size: 28rpx;
color: #94A3B8;
}
</style>

View File

@@ -0,0 +1,213 @@
<!--
个人中心页面
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / BG #F8FAFC / Text #1E293B / Muted #94A3B8
功能
- 显示用户头像昵称用户名
- 编辑资料入口跳转编辑页后续实现
- 修改密码入口跳转修改页后续实现
- 退出登录按钮
- 集成自定义 TabBar 组件
对应 APIGET /api/v1/auth/profilePOST /api/v1/auth/logout
-->
<template>
<view class="page-wrapper">
<!-- 用户信息头部 -->
<view class="profile-header">
<view class="avatar-box">
<text class="avatar-letter">{{ avatarLetter }}</text>
</view>
<text class="nickname">{{ userInfo.nickname || userInfo.username || '未登录' }}</text>
<text class="username" v-if="userInfo.username">@{{ userInfo.username }}</text>
</view>
<!-- 功能菜单 -->
<view class="menu-card">
<view class="menu-item" @tap="goEditProfile">
<text class="menu-label">编辑资料</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-divider"></view>
<view class="menu-item" @tap="goChangePassword">
<text class="menu-label">修改密码</text>
<text class="menu-arrow"></text>
</view>
</view>
<!-- 退出登录 -->
<view class="logout-section">
<button class="logout-btn" @tap="handleLogout">
<text class="logout-text">退出登录</text>
</button>
</view>
<CustomTabBar :current="3" />
</view>
</template>
<script>
/**
* 个人中心页面逻辑
*
* 从 useUserStore 获取用户信息
* 退出登录时调用 store.logout(),清除 Redis Token + 本地缓存
*/
import { useUserStore } from '@/store/user'
import CustomTabBar from '@/components/CustomTabBar.vue'
export default {
name: 'ProfileIndex',
components: { CustomTabBar },
computed: {
/** 从 Store 获取用户信息 */
userInfo() {
const store = useUserStore()
return store.userInfo || {}
},
/** 头像占位字母(取昵称或用户名首字符) */
avatarLetter() {
const name = this.userInfo.nickname || this.userInfo.username || '?'
return name.charAt(0).toUpperCase()
}
},
methods: {
/** 编辑资料(后续实现,当前提示开发中) */
goEditProfile() {
uni.showToast({ title: '功能开发中', icon: 'none' })
},
/** 修改密码(后续实现) */
goChangePassword() {
uni.showToast({ title: '功能开发中', icon: 'none' })
},
/** 退出登录 */
async handleLogout() {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: async (res) => {
if (!res.confirm) return
const store = useUserStore()
await store.logout()
uni.reLaunch({ url: '/pages/auth/login' })
}
})
}
}
}
</script>
<style scoped>
/*
* 设计系统MASTER.md
* 背景:#F8FAFC / 卡片:#FFFFFF / 文字:#1E293B / 辅助:#94A3B8
* 圆角24rpx卡片/ 按钮 16rpx
* 间距space-lg 48rpx / space-md 32rpx
*/
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
padding-bottom: 120rpx;
}
/* ---- 用户信息头部 ---- */
.profile-header {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 48rpx 48rpx;
background-color: #FFFFFF;
margin-bottom: 24rpx;
}
.avatar-box {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
background-color: #2563EB;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
}
.avatar-letter {
font-size: 52rpx;
font-weight: 700;
color: #FFFFFF;
}
.nickname {
font-size: 36rpx;
font-weight: 600;
color: #1E293B;
margin-bottom: 6rpx;
}
.username {
font-size: 26rpx;
color: #94A3B8;
}
/* ---- 功能菜单 ---- */
.menu-card {
background-color: #FFFFFF;
margin: 0 32rpx;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 36rpx 40rpx;
}
.menu-label {
font-size: 30rpx;
color: #1E293B;
}
.menu-arrow {
font-size: 32rpx;
color: #CBD5E1;
}
.menu-divider {
height: 1rpx;
background-color: #F1F5F9;
margin: 0 40rpx;
}
/* ---- 退出登录 ---- */
.logout-section {
padding: 48rpx 32rpx;
}
.logout-btn {
width: 100%;
height: 88rpx;
background-color: #FFFFFF;
border: 2rpx solid #EF4444;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
.logout-btn::after {
border: none;
}
.logout-text {
font-size: 30rpx;
color: #EF4444;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,27 @@
/**
* WebSocket 服务模块(占位)
*
* 后续阶段实现 IM 即时通讯和会议信令时使用。
* 职责:
* - 建立和管理 WebSocket 连接
* - 心跳保活机制
* - 断线自动重连
* - 消息收发与事件分发
*
* 对应架构设计见docs/architecture/system-architecture.md 第 4.1 节
* 日志规范见docs/architecture/system-architecture.md 第 8.7 节
*/
// TODO: Phase 2 - IM 模块开发时实现
// 预期功能:
// - connect(token) — 建立 WebSocket 连接,附带认证 Token
// - disconnect() — 主动断开连接
// - send(event, data) — 发送消息
// - on(event, callback) — 监听事件
// - off(event, callback) — 移除监听
// - 自动心跳30s 间隔)
// - 断线重连(指数退避,最大 5 次)
export default {
// 占位导出,后续实现时替换
}

217
frontend/src/store/user.js Normal file
View File

@@ -0,0 +1,217 @@
/**
* 用户状态管理 Store
*
* 管理用户认证状态和个人信息,提供:
* - 登录/注册/登出操作
* - Token 自动管理(存储 + 刷新)
* - 用户信息缓存与更新
* - 持久化存储(通过 pinia-plugin-persistedstateH5 用 localStorage小程序用 uni 存储)
*
* 对应后端 API/api/v1/auth/*
* 接口文档见docs/api/frontend/auth.md
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import authApi from '@/api/auth'
import {
saveToken,
getToken,
getRefreshToken,
removeToken,
saveUserInfo,
getUserInfo,
removeUserInfo,
clearAll
} from '@/utils/storage'
/**
* 用户 StoreComposition API 风格)
*
* state:
* - token: Access Token
* - userInfo: 用户信息对象
*
* getters:
* - isLoggedIn: 是否已登录
* - username: 用户名快捷访问
* - roles: 用户角色列表
*
* actions:
* - login: 用户登录
* - register: 用户注册
* - logout: 退出登录
* - getProfile: 获取/刷新用户信息
* - updateProfile: 更新个人信息
* - changePassword: 修改密码
* - refreshUserToken: 刷新 Token
*/
export const useUserStore = defineStore('user', () => {
// ==================== State ====================
/** Access Token用于 API 认证 */
const token = ref(getToken() || '')
/** 用户信息对象,与后端 dto.UserInfo 结构一致 */
const userInfo = ref(getUserInfo() || null)
// ==================== Getters ====================
/** 是否已登录Token 存在视为已登录) */
const isLoggedIn = computed(() => !!token.value)
/** 用户名快捷访问 */
const username = computed(() => userInfo.value?.username || '')
/** 用户角色列表 */
const roles = computed(() => userInfo.value?.roles || [])
// ==================== Actions ====================
/**
* 处理登录/注册成功后的通用逻辑
* 保存 Token 和用户信息到 Store 和本地存储
*
* @param {Object} data - 后端返回的登录响应 { token, refresh_token, expires_in, user }
*/
const _handleAuthSuccess = (data) => {
token.value = data.token
userInfo.value = data.user
saveToken(data.token, data.refresh_token, data.expires_in)
saveUserInfo(data.user)
}
/**
* 用户登录
*
* @param {Object} loginData - { account, password }
* @returns {Promise<Object>} 登录响应数据
*/
const login = async (loginData) => {
const res = await authApi.login(loginData)
_handleAuthSuccess(res.data)
return res.data
}
/**
* 用户注册(注册成功后自动登录)
*
* @param {Object} registerData - { username, email, password, nickname? }
* @returns {Promise<Object>} 注册响应数据
*/
const register = async (registerData) => {
const res = await authApi.register(registerData)
_handleAuthSuccess(res.data)
return res.data
}
/**
* 退出登录
* 1. 调用后端 API 从 Redis 删除 Token使 Token 立即失效)
* 2. 清除本地存储的 Token 和用户信息
* 3. 重置 Store 状态
*/
const logout = async () => {
try {
await authApi.logout()
} catch (e) {
// 即使后端请求失败,也要清除本地状态(避免用户被卡住)
console.warn('登出 API 调用失败,仍然清除本地状态', e)
}
token.value = ''
userInfo.value = null
clearAll()
}
/**
* 获取/刷新用户信息
* 从后端获取最新的用户资料并更新 Store 和本地缓存
*
* @returns {Promise<Object>} 用户信息
*/
const getProfile = async () => {
const res = await authApi.getProfile()
userInfo.value = res.data
saveUserInfo(res.data)
return res.data
}
/**
* 更新个人信息
*
* @param {Object} data - 需要更新的字段 { nickname?, avatar?, gender?, phone? }
* @returns {Promise<Object>} 更新后的用户信息
*/
const updateProfile = async (data) => {
const res = await authApi.updateProfile(data)
userInfo.value = res.data
saveUserInfo(res.data)
return res.data
}
/**
* 修改密码
*
* @param {Object} data - { old_password, new_password }
* @returns {Promise<Object>}
*/
const changePassword = async (data) => {
return await authApi.changePassword(data)
}
/**
* 刷新 Token
* 使用 Refresh Token 获取新的 Access Token
* 新 Token 会覆盖 Redis 中的旧值(实现单设备登录)
*
* @returns {Promise<boolean>} 刷新是否成功
*/
const refreshUserToken = async () => {
const refreshTokenValue = getRefreshToken()
if (!refreshTokenValue) return false
try {
const res = await authApi.refreshToken(refreshTokenValue)
_handleAuthSuccess(res.data)
return true
} catch (e) {
// Refresh Token 也过期或无效,需要重新登录
token.value = ''
userInfo.value = null
clearAll()
return false
}
}
return {
// state
token,
userInfo,
// getters
isLoggedIn,
username,
roles,
// actions
login,
register,
logout,
getProfile,
updateProfile,
changePassword,
refreshUserToken
}
}, {
/**
* Pinia 持久化配置
* H5 环境使用 localStorage小程序/App 使用 uni 存储
* 仅持久化 token 和 userInfo避免存储过多无关数据
*/
persist: {
key: 'echo-user-store',
storage: {
getItem: (key) => uni.getStorageSync(key),
setItem: (key, value) => uni.setStorageSync(key, value)
},
paths: ['token', 'userInfo']
}
})

View File

@@ -0,0 +1,173 @@
/**
* HTTP 请求封装模块
*
* 基于 uni.request 封装统一的请求方法,提供:
* - 基础 URL 配置(区分开发/生产环境)
* - 请求拦截:自动附加 Authorization Header
* - 响应拦截统一处理错误码401 自动跳转登录等)
* - Promise 化的请求接口
*
* 对应后端 API 规范见 docs/api/README.md
*/
import { getToken, removeToken } from './storage'
// 基础 URL 配置,根据环境自动切换
const BASE_URL = process.env.NODE_ENV === 'development'
? 'http://localhost:8085'
: '' // 生产环境由 Nginx 反向代理,使用相对路径
// 请求超时时间(毫秒)
const TIMEOUT = 15000
/**
* 统一请求方法
*
* @param {Object} options - 请求配置
* @param {string} options.url - 请求路径(不含 BASE_URL如 /api/v1/auth/login
* @param {string} [options.method='GET'] - HTTP 方法
* @param {Object} [options.data] - 请求体数据
* @param {Object} [options.params] - URL Query 参数
* @param {Object} [options.header] - 自定义请求头
* @param {boolean} [options.needAuth=true] - 是否需要自动附加 Token
* @returns {Promise<Object>} 响应中的 data 字段(已解包)
*/
const request = (options) => {
return new Promise((resolve, reject) => {
// 构建请求头
const header = {
'Content-Type': 'application/json',
...options.header
}
// 自动附加 Authorization Header默认需要认证
const needAuth = options.needAuth !== false
if (needAuth) {
const token = getToken()
if (token) {
header['Authorization'] = `Bearer ${token}`
}
}
uni.request({
url: `${BASE_URL}${options.url}`,
method: options.method || 'GET',
data: options.data,
header,
timeout: TIMEOUT,
success: (res) => {
const { statusCode, data } = res
// HTTP 状态码 2xx 视为成功
if (statusCode >= 200 && statusCode < 300) {
// 后端统一响应格式:{ code: 0, message: "success", data: ... }
if (data.code === 0) {
resolve(data)
} else {
// code 非 0 表示业务逻辑错误
uni.showToast({
title: data.message || '请求失败',
icon: 'none',
duration: 2000
})
reject(data)
}
} else if (statusCode === 401) {
// Token 过期或无效,清除本地 Token 并跳转登录页
removeToken()
uni.showToast({
title: '登录已过期,请重新登录',
icon: 'none',
duration: 2000
})
// 延迟跳转,让 Toast 有时间显示
setTimeout(() => {
uni.reLaunch({ url: '/pages/auth/login' })
}, 1500)
reject({ code: 401, message: '认证已失效' })
} else if (statusCode === 403) {
uni.showToast({
title: data.message || '没有访问权限',
icon: 'none',
duration: 2000
})
reject(data)
} else {
// 其他 HTTP 错误400/404/500 等)
uni.showToast({
title: data.message || `请求错误 (${statusCode})`,
icon: 'none',
duration: 2000
})
reject(data)
}
},
fail: (err) => {
// 网络错误或请求超时
uni.showToast({
title: '网络异常,请检查网络连接',
icon: 'none',
duration: 2000
})
reject({ code: -1, message: err.errMsg || '网络异常' })
}
})
})
}
/**
* GET 请求快捷方法
*
* @param {string} url - 请求路径
* @param {Object} [data] - Query 参数
* @param {Object} [options] - 额外配置
* @returns {Promise<Object>}
*/
const get = (url, data, options = {}) => {
return request({ url, method: 'GET', data, ...options })
}
/**
* POST 请求快捷方法
*
* @param {string} url - 请求路径
* @param {Object} [data] - 请求体数据
* @param {Object} [options] - 额外配置
* @returns {Promise<Object>}
*/
const post = (url, data, options = {}) => {
return request({ url, method: 'POST', data, ...options })
}
/**
* PUT 请求快捷方法
*
* @param {string} url - 请求路径
* @param {Object} [data] - 请求体数据
* @param {Object} [options] - 额外配置
* @returns {Promise<Object>}
*/
const put = (url, data, options = {}) => {
return request({ url, method: 'PUT', data, ...options })
}
/**
* DELETE 请求快捷方法
*
* @param {string} url - 请求路径
* @param {Object} [data] - 请求体数据
* @param {Object} [options] - 额外配置
* @returns {Promise<Object>}
*/
const del = (url, data, options = {}) => {
return request({ url, method: 'DELETE', data, ...options })
}
export {
request,
get,
post,
put,
del,
BASE_URL
}

View File

@@ -0,0 +1,140 @@
/**
* 本地存储工具模块
*
* 封装 uni.setStorageSync / uni.getStorageSync提供
* - Token 存取Access Token + Refresh Token
* - 用户信息缓存
* - 通用存储操作
*
* 存储方式:
* - H5 环境localStorage
* - 小程序/Appuni 内置存储
* - 均通过 uni.setStorageSync/getStorageSync 统一调用
*/
// 存储 Key 常量,集中管理避免硬编码
const STORAGE_KEYS = {
/** Access Token用于 API 认证 */
ACCESS_TOKEN: 'echo_access_token',
/** Refresh Token用于刷新 Access Token */
REFRESH_TOKEN: 'echo_refresh_token',
/** Access Token 过期时间戳(毫秒) */
TOKEN_EXPIRE_TIME: 'echo_token_expire_time',
/** 用户信息缓存 */
USER_INFO: 'echo_user_info'
}
// ==================== Token 操作 ====================
/**
* 保存 Token 信息(登录/注册成功后调用)
*
* @param {string} accessToken - Access Token
* @param {string} refreshToken - Refresh Token
* @param {number} expiresIn - Access Token 有效期(秒)
*/
const saveToken = (accessToken, refreshToken, expiresIn) => {
uni.setStorageSync(STORAGE_KEYS.ACCESS_TOKEN, accessToken)
uni.setStorageSync(STORAGE_KEYS.REFRESH_TOKEN, refreshToken)
// 计算并存储过期时间戳,用于前端主动判断是否需要刷新
const expireTime = Date.now() + expiresIn * 1000
uni.setStorageSync(STORAGE_KEYS.TOKEN_EXPIRE_TIME, expireTime)
}
/**
* 获取 Access Token
*
* @returns {string|null} Access Token不存在时返回 null
*/
const getToken = () => {
return uni.getStorageSync(STORAGE_KEYS.ACCESS_TOKEN) || null
}
/**
* 获取 Refresh Token
*
* @returns {string|null} Refresh Token不存在时返回 null
*/
const getRefreshToken = () => {
return uni.getStorageSync(STORAGE_KEYS.REFRESH_TOKEN) || null
}
/**
* 判断 Access Token 是否即将过期(提前 5 分钟视为过期)
* 用于在请求前主动触发 Token 刷新
*
* @returns {boolean} true 表示已过期或即将过期
*/
const isTokenExpired = () => {
const expireTime = uni.getStorageSync(STORAGE_KEYS.TOKEN_EXPIRE_TIME)
if (!expireTime) return true
// 提前 5 分钟300秒判定为过期给刷新操作留出时间窗口
return Date.now() >= expireTime - 300 * 1000
}
/**
* 清除所有 Token登出时调用
* 同时清除服务端 Redis 中的 Token通过 logout API这里只清除客户端缓存
*/
const removeToken = () => {
uni.removeStorageSync(STORAGE_KEYS.ACCESS_TOKEN)
uni.removeStorageSync(STORAGE_KEYS.REFRESH_TOKEN)
uni.removeStorageSync(STORAGE_KEYS.TOKEN_EXPIRE_TIME)
}
// ==================== 用户信息操作 ====================
/**
* 缓存用户信息
*
* @param {Object} userInfo - 用户信息对象(与 dto.UserInfo 结构一致)
*/
const saveUserInfo = (userInfo) => {
uni.setStorageSync(STORAGE_KEYS.USER_INFO, JSON.stringify(userInfo))
}
/**
* 获取缓存的用户信息
*
* @returns {Object|null} 用户信息对象,不存在时返回 null
*/
const getUserInfo = () => {
const data = uni.getStorageSync(STORAGE_KEYS.USER_INFO)
if (!data) return null
try {
return JSON.parse(data)
} catch (e) {
return null
}
}
/**
* 清除用户信息缓存
*/
const removeUserInfo = () => {
uni.removeStorageSync(STORAGE_KEYS.USER_INFO)
}
// ==================== 通用存储操作 ====================
/**
* 清除所有应用数据(重置应用状态)
* 谨慎使用,会清除所有本地存储
*/
const clearAll = () => {
removeToken()
removeUserInfo()
}
export {
STORAGE_KEYS,
saveToken,
getToken,
getRefreshToken,
isTokenExpired,
removeToken,
saveUserInfo,
getUserInfo,
removeUserInfo,
clearAll
}