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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,6 +9,7 @@ Thumbs.db
|
||||
# --- IDE ---
|
||||
.idea/
|
||||
.vscode/
|
||||
.cursor/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@@ -53,3 +54,7 @@ build/
|
||||
# --- 测试覆盖率 ---
|
||||
coverage/
|
||||
*.coverprofile
|
||||
|
||||
# --- Playwright 截图 ---
|
||||
*.png
|
||||
!design-system/**/*.png
|
||||
|
||||
@@ -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>
|
||||
|
||||
84
README.md
84
README.md
@@ -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
13
admin/index.html
Normal 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
1952
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
admin/package.json
Normal file
21
admin/package.json
Normal 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
10
admin/src/App.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<!--
|
||||
管理后台根组件
|
||||
仅提供 router-view 出口
|
||||
-->
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
35
admin/src/api/auth.js
Normal file
35
admin/src/api/auth.js
Normal 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
89
admin/src/api/user.js
Normal 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
31
admin/src/main.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 管理后台入口文件
|
||||
*
|
||||
* 初始化 Vue 3 应用,注册全局插件:
|
||||
* - Element Plus(UI 组件库,含中文语言包)
|
||||
* - 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
85
admin/src/router/index.js
Normal 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
71
admin/src/store/user.js
Normal 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
|
||||
}
|
||||
})
|
||||
43
admin/src/styles/global.css
Normal file
43
admin/src/styles/global.css
Normal 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;
|
||||
}
|
||||
70
admin/src/utils/request.js
Normal file
70
admin/src/utils/request.js
Normal 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
|
||||
54
admin/src/utils/storage.js
Normal file
54
admin/src/utils/storage.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 本地存储工具(管理后台)
|
||||
*
|
||||
* 管理端使用 localStorage,Key 前缀 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()
|
||||
}
|
||||
98
admin/src/views/dashboard/index.vue
Normal file
98
admin/src/views/dashboard/index.vue
Normal 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>
|
||||
234
admin/src/views/layout/index.vue
Normal file
234
admin/src/views/layout/index.vue
Normal 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>
|
||||
203
admin/src/views/login/index.vue
Normal file
203
admin/src/views/login/index.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<!--
|
||||
管理员登录页
|
||||
|
||||
设计系统:design-system/echochat/MASTER.md + pages/admin-login.md
|
||||
色板:Primary #2563EB / Background #F0F2F5 / Text #1E293B
|
||||
|
||||
功能:
|
||||
- 管理员账号 + 密码登录
|
||||
- 使用 Element Plus 表单组件 + 校验
|
||||
- Loading 状态防重复提交
|
||||
- 登录成功跳转仪表盘
|
||||
|
||||
对应 API:POST /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 © 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>
|
||||
409
admin/src/views/user/detail.vue
Normal file
409
admin/src/views/user/detail.vue
Normal 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>
|
||||
485
admin/src/views/user/list.vue
Normal file
485
admin/src/views/user/list.vue
Normal file
@@ -0,0 +1,485 @@
|
||||
<!--
|
||||
管理端用户列表页面
|
||||
|
||||
设计系统:design-system/echochat/pages/admin-user-list.md
|
||||
覆盖 MASTER.md 的 Data-Dense Dashboard 风格
|
||||
|
||||
功能:
|
||||
- 搜索框(用户名/邮箱关键词搜索)
|
||||
- 状态筛选下拉
|
||||
- 用户数据表格(Element Plus Table)
|
||||
- 分页组件
|
||||
- 操作按钮(查看详情、启用/禁用、创建用户)
|
||||
|
||||
对应后端 API:GET /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
21
admin/vite.config.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
16
backend/go-service/.dockerignore
Normal file
16
backend/go-service/.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
||||
# 日志和临时文件
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# IDE 配置
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# 测试文件
|
||||
*_test.go
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
48
backend/go-service/Dockerfile
Normal file
48
backend/go-service/Dockerfile
Normal 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"]
|
||||
@@ -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)
|
||||
}
|
||||
106
backend/go-service/app/admin/dao/user_manage_dao.go
Normal file
106
backend/go-service/app/admin/dao/user_manage_dao.go
Normal 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
|
||||
}
|
||||
17
backend/go-service/app/admin/provider.go
Normal file
17
backend/go-service/app/admin/provider.go
Normal 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,
|
||||
)
|
||||
29
backend/go-service/app/admin/router.go
Normal file
29
backend/go-service/app/admin/router.go
Normal 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)
|
||||
}
|
||||
}
|
||||
272
backend/go-service/app/admin/service/user_manage_service.go
Normal file
272
backend/go-service/app/admin/service/user_manage_service.go
Normal 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
|
||||
}
|
||||
53
backend/go-service/app/dto/admin_dto.go
Normal file
53
backend/go-service/app/dto/admin_dto.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
42
backend/go-service/config/config.docker.yaml
Normal file
42
backend/go-service/config/config.docker.yaml
Normal 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
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
202
design-system/echochat/MASTER.md
Normal file
202
design-system/echochat/MASTER.md
Normal 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
|
||||
46
design-system/echochat/pages/admin-login.md
Normal file
46
design-system/echochat/pages/admin-login.md
Normal 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)
|
||||
52
design-system/echochat/pages/admin-user-list.md
Normal file
52
design-system/echochat/pages/admin-user-list.md
Normal 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
|
||||
50
design-system/echochat/pages/login.md
Normal file
50
design-system/echochat/pages/login.md
Normal 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
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -283,10 +283,39 @@ services:
|
||||
| **mediasoup** | 最高性能的开源 SFU,C++ 实现 |
|
||||
| **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.5,Pinia 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.x,API 层面几乎无需改动(Pinia 2.x 与 3.x 的 `defineStore` API 完全兼容)。
|
||||
|
||||
### 7.2 两端开发规范差异
|
||||
|
||||
前台用户端和后台管理端是**完全独立**的前端项目,开发规范、依赖版本、构建方式不需要强制统一:
|
||||
|
||||
| 维度 | 前台用户端 (`frontend/`) | 后台管理端 (`admin/`) |
|
||||
|------|------------------------|---------------------|
|
||||
| 框架 | uni-app 3.0(Vue 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 Module(Vite 要求) | ES Module(Vite 标准) |
|
||||
| npm 配置 | 需要 `.npmrc` 设置 `legacy-peer-deps=true` | 标准 npm 配置 |
|
||||
| 适配能力 | H5 / 小程序 / App / 桌面端 | 仅 PC 浏览器 |
|
||||
| 设计系统 | ui-ux-pro-max 生成的设计系统 | ui-ux-pro-max + Element Plus 主题 |
|
||||
|
||||
---
|
||||
|
||||
## 八、日志系统与链路追踪设计
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
**架构:** Go 单体模块化服务 + uniapp 前台 + Vue3 管理端 + PostgreSQL + Redis,Docker 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): 用户注册/登录 API(Controller + 路由注册
|
||||
|
||||
**Step 1: 安装前端依赖**
|
||||
|
||||
Run: `cd frontend && npm install pinia @pinia/persist`
|
||||
> **版本策略:** uni-app 锁定 Vue 3.4.21,Pinia 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 内置了请求 API(uni.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: 创建布局框架**
|
||||
|
||||
140
docs/progress/CURRENT_STATUS.md
Normal file
140
docs/progress/CURRENT_STATUS.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# EchoChat 项目开发进度
|
||||
|
||||
> **最后更新**:2026-03-02(Phase 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 策略**:有状态 JWT,Token 存储在 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.0(Vue 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:3100(Vite 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
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
legacy-peer-deps=true
|
||||
8365
frontend/package-lock.json
generated
Normal file
8365
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
115
frontend/src/api/auth.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 认证模块 API
|
||||
*
|
||||
* 对应后端路由:/api/v1/auth/*
|
||||
* 接口文档见:docs/api/frontend/auth.md
|
||||
*
|
||||
* 所有 API 函数返回 Promise,resolve 时返回完整响应体 { 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
|
||||
}
|
||||
127
frontend/src/components/CustomTabBar.vue
Normal file
127
frontend/src/components/CustomTabBar.vue
Normal 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
|
||||
* 选中色:#2563EB(Primary)
|
||||
* 未选中色:#94A3B8(Muted)
|
||||
* 背景:#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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
347
frontend/src/pages/auth/login.vue
Normal file
347
frontend/src/pages/auth/login.vue
Normal 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 状态反馈,禁用按钮防止重复提交
|
||||
- 登录成功跳转首页
|
||||
- "没有账号?去注册" 链接
|
||||
|
||||
对应 API:POST /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>
|
||||
427
frontend/src/pages/auth/register.vue
Normal file
427
frontend/src/pages/auth/register.vue
Normal 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 状态防止重复提交
|
||||
|
||||
对应 API:POST /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>
|
||||
62
frontend/src/pages/chat/index.vue
Normal file
62
frontend/src/pages/chat/index.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<!--
|
||||
消息列表页面(占位)
|
||||
|
||||
设计系统:design-system/echochat/MASTER.md
|
||||
TabBar 页面,Phase 2(IM 模块)中实现具体功能
|
||||
|
||||
当前功能:
|
||||
- 显示模块名称和开发中提示
|
||||
- 集成自定义 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>
|
||||
58
frontend/src/pages/contact/index.vue
Normal file
58
frontend/src/pages/contact/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
58
frontend/src/pages/meeting/index.vue
Normal file
58
frontend/src/pages/meeting/index.vue
Normal 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>
|
||||
213
frontend/src/pages/profile/index.vue
Normal file
213
frontend/src/pages/profile/index.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<!--
|
||||
个人中心页面
|
||||
|
||||
设计系统:design-system/echochat/MASTER.md
|
||||
色板:Primary #2563EB / BG #F8FAFC / Text #1E293B / Muted #94A3B8
|
||||
|
||||
功能:
|
||||
- 显示用户头像、昵称、用户名
|
||||
- 编辑资料入口(跳转编辑页,后续实现)
|
||||
- 修改密码入口(跳转修改页,后续实现)
|
||||
- 退出登录按钮
|
||||
- 集成自定义 TabBar 组件
|
||||
|
||||
对应 API:GET /api/v1/auth/profile、POST /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>
|
||||
27
frontend/src/services/websocket.js
Normal file
27
frontend/src/services/websocket.js
Normal 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
217
frontend/src/store/user.js
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 用户状态管理 Store
|
||||
*
|
||||
* 管理用户认证状态和个人信息,提供:
|
||||
* - 登录/注册/登出操作
|
||||
* - Token 自动管理(存储 + 刷新)
|
||||
* - 用户信息缓存与更新
|
||||
* - 持久化存储(通过 pinia-plugin-persistedstate,H5 用 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'
|
||||
|
||||
/**
|
||||
* 用户 Store(Composition 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']
|
||||
}
|
||||
})
|
||||
173
frontend/src/utils/request.js
Normal file
173
frontend/src/utils/request.js
Normal 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
|
||||
}
|
||||
140
frontend/src/utils/storage.js
Normal file
140
frontend/src/utils/storage.js
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 本地存储工具模块
|
||||
*
|
||||
* 封装 uni.setStorageSync / uni.getStorageSync,提供:
|
||||
* - Token 存取(Access Token + Refresh Token)
|
||||
* - 用户信息缓存
|
||||
* - 通用存储操作
|
||||
*
|
||||
* 存储方式:
|
||||
* - H5 环境:localStorage
|
||||
* - 小程序/App:uni 内置存储
|
||||
* - 均通过 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
|
||||
}
|
||||
Reference in New Issue
Block a user