Files
EchoChat/frontend/src/pages/auth/login.vue
bujinyuan 4d03215fe4 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
2026-03-02 11:31:24 +08:00

348 lines
7.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
登录页面
设计系统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>