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:
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