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

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>