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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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