重构登录以及增加兑换功能

This commit is contained in:
WangHui 2026-10-15 17:59:34 +08:00
parent 97b4b215fb
commit 0ee332fcb6
20 changed files with 1877 additions and 298 deletions

View File

@ -1 +1,2 @@
VITE_API_BASE_URL=https://token.opencomputing.cn
VITE_API_BASE_URL=http://192.168.31.127:5004
VITE_PLATFORM_NAME=yuanjing

View File

@ -1 +1,2 @@
VITE_API_BASE_URL=https://token.opencomputing.cn
VITE_PLATFORM_NAME=yuanjing

View File

@ -1 +1,2 @@
VITE_API_BASE_URL=https://tokentest.opencomputing.cn
VITE_PLATFORM_NAME=yuanjing

2
package-lock.json generated
View File

@ -23,7 +23,7 @@
"vue-waterfall-plugin-next": "^3.0.1"
},
"devDependencies": {
"@types/node": "^25.9.2",
"@types/node": "^25.4.0",
"@types/swiper": "^6.0.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.0",

46
src/apis/auth.ts Normal file
View File

@ -0,0 +1,46 @@
import { get, post, PUT } from '@/utlis/request';
import type {
RVO,
LoginBO,
LoginVO,
SmsSendCodeBO,
SmsLoginBO,
SmsResetPasswordBO,
UserInfoVO,
UserCreateBO,
UserUpdateBO,
} from '@/types/auth';
// ===== 认证 =====
/** 用户名密码登录 */
export const authLogin = (data: LoginBO) =>
post<RVO<LoginVO>, LoginBO>('/v1/auth/login', data);
/** 发送手机号验证码 */
export const authSendSmsCode = (data: SmsSendCodeBO) =>
post<RVO<null>, SmsSendCodeBO>('/v1/auth/sms/send_code', data);
/** 手机号验证码登录 */
export const authSmsLogin = (data: SmsLoginBO) =>
post<RVO<LoginVO>, SmsLoginBO>('/v1/auth/sms/login', data);
/** 手机号验证码重置密码 */
export const authSmsResetPassword = (data: SmsResetPasswordBO) =>
post<RVO<null>, SmsResetPasswordBO>('/v1/auth/sms/reset_password', data);
/** 退出登录 */
export const authLogout = () => get<RVO<null>, undefined>('/v1/auth/logout');
// ===== 用户 =====
/** 获取当前登录用户信息 */
export const getUserInfo = () => get<RVO<UserInfoVO>, undefined>('/v1/user/get_info');
/** 创建用户(注册) */
export const createUser = (data: UserCreateBO) =>
post<RVO<string>, UserCreateBO>('/v1/user/security/create', data);
/** 更新用户信息 */
export const updateUser = (data: UserUpdateBO) =>
PUT<RVO<null>, UserUpdateBO>('/v1/user/update', data);

15
src/apis/fund.ts Normal file
View File

@ -0,0 +1,15 @@
import { get, post } from '@/utlis/request';
import type { RVO } from '@/types/auth';
import type { ActivateCodeBO, ActivateResultVO, FundAccountVO } from '@/types/fund';
// ===== 兑换码 =====
/** 激活兑换码 */
export const activateRedeemCode = (data: ActivateCodeBO) =>
post<RVO<ActivateResultVO>, ActivateCodeBO>('/v1/redeem-code/activate', data);
// ===== 资金账户 =====
/** 查询资金账户余额 */
export const getFundBalance = () =>
get<RVO<FundAccountVO>, undefined>('/v1/fund/balance');

View File

@ -325,18 +325,26 @@
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
font-size: 14px;
color: #fff;
transition: all 0.2s ease;
cursor: pointer;
.item-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
color: rgba(255, 255, 255, 0.8);
}
&:hover {
background: rgba(255, 255, 255, 0.05);
}
&.user-id {
justify-content: space-between;
font-size: 14px;
.item-label {
color: rgba(255, 255, 255, 0.7);
@ -350,15 +358,6 @@
}
&.logout {
gap: 8px;
font-size: 14px;
.item-icon {
width: 16px;
height: 16px;
color: rgba(255, 255, 255, 0.8);
}
&:hover {
color: #ff6b6b;

View File

@ -89,7 +89,6 @@
}
.captcha-wrapper {
margin-top: 16px;
display: flex;
gap: 12px;
@ -144,40 +143,79 @@
padding: 14px 0;
}
/* 登录方式 Tab 切换 */
.login-tabs {
display: flex;
gap: 8px;
margin-bottom: 22px;
padding: 4px;
background: rgba(255, 255, 255, 0.12);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.18);
}
.login-tab {
flex: 1;
padding: 8px 0;
background: transparent;
border: none;
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
cursor: pointer;
transition: all 0.25s ease;
letter-spacing: 1px;
&:hover {
color: #fff;
}
&.active {
background: rgba(255, 255, 255, 0.25);
color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
}
/* 账密登录额外行:忘记密码链接 */
.login-extra {
display: flex;
justify-content: space-between;
align-items: center;
margin: -6px 0 4px;
font-size: 13px;
&--right {
justify-content: flex-end;
}
}
/* 链接按钮 */
.link-btn {
color: #fff;
cursor: pointer;
font-size: 13px;
text-decoration: none;
transition: all 0.2s;
border-bottom: 1px solid transparent;
&:hover {
color: #fff;
border-bottom-color: rgba(255, 255, 255, 0.6);
}
}
/* 切换模式(注册 / 返回登录) */
.login-switch-mode {
text-align: center;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
margin-top: 16px;
}
.form-bottom {
text-align: center;
font-size: 12px;
margin-top: 20%;
}
/* 账号选择弹窗样式 */
.account-list {
.account-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.account-info {
.account-name {
font-size: 16px;
font-weight: 500;
margin-bottom: 4px;
}
.account-phone {
font-size: 14px;
color: #909399;
}
}
&:hover {
background-color: #f5f7fa;
}
}
margin-top: 24px;
color: rgba(255, 255, 255, 0.7);
}

View File

@ -12,7 +12,8 @@
<!-- 登录后显示:充值 + 余额 一体化模块 -->
<template v-if="loginStore.isLoggedIn">
<!-- 一体化:余额 + 充值按钮 -->
<div class="balance-recharge-combo" @click="showRechargeModal = true">
<div class="balance-recharge-combo">
<!-- @click="showRechargeModal = true" -->
<div class="balance-main">
<span class="balance-icon-wrap">
<i class="iconfont icon-yue"></i>
@ -25,8 +26,8 @@
</span>
</div>
</div>
<span class="combo-divider"></span>
<span class="recharge-text">充值</span>
<span class="combo-divider" v-show="false"></span>
<span class="recharge-text" v-show="false">充值</span>
</div>
<!-- 用户信息 -->
@ -44,7 +45,11 @@
<div v-if="userDropdownVisible" class="user-dropdown">
<div class="dropdown-item user-id">
<span class="item-label">用户名:</span>
<span class="item-value">{{ loginStore.userInfo?.user?.name || loginStore.userInfo?.user?.username }}</span>
<span class="item-value">{{ displayName }}</span>
</div>
<div class="dropdown-item user-id">
<span class="item-label">手机号:</span>
<span class="item-value">{{ phoneDisplay }}</span>
</div>
<!-- <div class="dropdown-item user-id">
<span class="item-label">用户ID:</span>
@ -80,8 +85,7 @@ import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
import Recharge from '@/components/Recharge/Recharge.vue';
import { useLoginStore } from '@/store/LoginStore';
import { useRouter } from 'vue-router';
import { balance } from '@/apis/home';
import type { BalanceResult } from '@/types/home';
import { getFundBalance } from '@/apis/fund';
const router = useRouter();
const loginStore = useLoginStore();
@ -96,6 +100,25 @@ const balances = ref(0);
// const userInitial = computed(() => userDisplayName.value.trim().charAt(0).toUpperCase() || 'U');
const formattedBalance = computed(() => Number(balances.value || 0).toLocaleString('zh-CN'));
// 显示名:优先 v1 昵称 -> v1 用户名 -> 旧 userInfo 兜底
const displayName = computed(() => {
const v1 = loginStore.userInfoV1;
if (v1?.nickname) return v1.nickname;
if (v1?.username) return v1.username;
const legacy = loginStore.userInfo?.user;
return legacy?.name || legacy?.nick_name || legacy?.username || '用户';
});
// 手机号:优先 v1 phone -> 旧 userInfo 兜底,隐藏中间四位
const phoneDisplay = computed(() => {
const v1 = loginStore.userInfoV1;
const legacy = loginStore.userInfo?.user as any;
const phone = v1?.phone || legacy?.mobile || legacy?.cellphone || '';
if (!phone) return '-';
// 脱敏:保留前3后4,中间用 **** 替代
return phone.replace(/^(\d{3})\d+(\d{4})$/, '$1****$2');
});
// 切换下拉菜单
const toggleUserDropdown = () => {
userDropdownVisible.value = !userDropdownVisible.value;
@ -113,9 +136,12 @@ const handleLogout = () => {
// ==============================================
const getBalance = async () => {
try {
const res = await balance<BalanceResult>();
if (res.status === 'ok') {
balances.value = res.data[0].balance;
const res = await getFundBalance();
if (res.code === 0 && res.data) {
// 现金余额 + 赠送余额
const cash = Number(res.data.cash_balance || 0);
const gift = Number(res.data.gift_balance || 0);
balances.value = cash + gift;
}
} catch (err) {
console.error('获取余额失败:', err);

View File

@ -84,6 +84,12 @@
<span class="menu-text">令牌管理</span>
</div>
</el-menu-item>
<el-menu-item index="redeem">
<div class="menu-item-content">
<i class="iconfont icon-yue"></i>
<span class="menu-text">兑换码</span>
</div>
</el-menu-item>
</el-menu>
</div>
@ -146,6 +152,9 @@ const handleSelect = (index: string) => {
case 'apikey':
router.push('/apikey');
break;
case 'redeem':
router.push('/redeem');
break;
default:
break;
}
@ -178,6 +187,8 @@ const updateActiveIndex = () => {
activeIndex.value = 'material-square';
} else if (path.includes('/apikey')) {
activeIndex.value = 'apikey';
} else if (path.includes('/redeem')) {
activeIndex.value = 'redeem';
}
else {
activeIndex.value = 'index';

View File

@ -121,6 +121,18 @@ const router = createRouter({
path: '/apikey',
name: 'apikey',
component: () => import('@/views/APiKey/apikey.vue')
},
// 个人信息
{
path: '/profile',
name: 'profile',
component: () => import('@/views/Profile/Profile.vue')
},
// 兑换码
{
path: '/redeem',
name: 'redeem',
component: () => import('@/views/Redeem/Redeem.vue')
}
]
},

View File

@ -3,21 +3,20 @@ import { ElMessage } from 'element-plus';
import { defineStore } from 'pinia';
import router from '@/router';
import type { LoginResult, LoginParams, LoginUser } from '@/types/login';
import type { LoginVO, UserInfoVO } from '@/types/auth';
import { LoginApi } from '@/apis/login';
import { openAccount } from '@/apis/home';
import { clearApiBaseURL } from '@/utlis/request';
import { clearApiBaseURL, setAccessToken, clearAccessToken } from '@/utlis/request';
export const useLoginStore = defineStore(
'loginStore',
() => {
// 登录状态
const isLoggedIn = ref(false);
// 当前用户信息
// 当前用户信息(旧 rbac 结构)
const userInfo = ref<{ user: LoginUser } | null>(null);
// 多账号列表
const accountList = ref<LoginUser[]>([]);
// 是否显示账号选择弹窗
const showAccountSelect = ref(false);
// 当前用户信息(v1 新结构,登录后 + getUserInfo 完整字段)
const userInfoV1 = ref<(LoginVO & Partial<UserInfoVO>) | null>(null);
// 登录时间戳(用于精确计算剩余时间,可选)
const loginTimestamp = ref<number | null>(null);
// 过期前的当前页面路径
@ -77,20 +76,6 @@ export const useLoginStore = defineStore(
return res;
}
if (res.status === 'choose') {
// 需要选择账号
let users: LoginUser[] = [];
const data = res.data;
if (Array.isArray(data)) {
users = data;
} else if (data && 'users' in data) {
users = (data as { users: LoginUser[] }).users;
}
accountList.value = users;
showAccountSelect.value = true;
return res;
}
// 其他失败情况
ElMessage.error(res.message || '登录失败');
return res;
@ -99,13 +84,25 @@ export const useLoginStore = defineStore(
// 登录(首次)
const Login = (params: LoginParams) => doLogin(params);
// 选择账号后登录(携带 selected_id)
const loginWithSelectedAccount = async (
selectedId: string,
params: Omit<LoginParams, 'selected_id'>
) => {
showAccountSelect.value = false;
return doLogin({ ...params, selected_id: selectedId });
// v1 登录成功后的统一处理(密码登录 / 短信登录 / 注册后自动登录 都用这个)
const loginWithV1 = (vo: LoginVO) => {
if (vo.access_token) {
setAccessToken(vo.access_token);
}
isLoggedIn.value = true;
userInfoV1.value = { ...vo };
loginTimestamp.value = Date.now();
startAutoLogoutTimer();
const path = redirectPath.value || '/';
redirectPath.value = null;
router.push(path);
};
// 更新当前用户资料(Profile 保存后调用,同步 store)
const updateUserInfoV1 = (patch: Partial<UserInfoVO>) => {
if (!userInfoV1.value) return;
userInfoV1.value = { ...userInfoV1.value, ...patch };
};
// 第三方同步用户成功后,直接写入登录态
@ -115,8 +112,6 @@ export const useLoginStore = defineStore(
openAccounts();
isLoggedIn.value = true;
userInfo.value = nextUserInfo;
accountList.value = [];
showAccountSelect.value = false;
loginTimestamp.value = Date.now();
startAutoLogoutTimer();
@ -144,8 +139,6 @@ export const useLoginStore = defineStore(
cellphone: user?.cellphone,
},
};
accountList.value = [];
showAccountSelect.value = false;
loginTimestamp.value = Date.now();
startAutoLogoutTimer();
@ -160,10 +153,10 @@ export const useLoginStore = defineStore(
clearAutoLogoutTimer();
isLoggedIn.value = false;
userInfo.value = null;
accountList.value = [];
showAccountSelect.value = false;
userInfoV1.value = null;
loginTimestamp.value = null;
clearApiBaseURL();
clearAccessToken();
router.push('/login');
ElMessage.error('身份验证过期请重新登录');
};
@ -172,10 +165,10 @@ export const useLoginStore = defineStore(
clearAutoLogoutTimer();
isLoggedIn.value = false;
userInfo.value = null;
accountList.value = [];
showAccountSelect.value = false;
userInfoV1.value = null;
loginTimestamp.value = null;
clearApiBaseURL();
clearAccessToken();
router.push('/login');
};
@ -193,11 +186,11 @@ export const useLoginStore = defineStore(
return {
isLoggedIn,
userInfo,
accountList,
showAccountSelect,
userInfoV1,
redirectPath,
Login,
loginWithSelectedAccount,
loginWithV1,
updateUserInfoV1,
syncLogin,
syncCookieLogin,
logout,

71
src/types/auth.ts Normal file
View File

@ -0,0 +1,71 @@
// 元境 /v1 接口通用响应包装
export interface RVO<T = null> {
code: number;
message: string;
data: T;
}
// ===== 认证 =====
export interface LoginBO {
username: string;
password: string;
}
export interface SmsSendCodeBO {
phone: string;
scene: 'login' | 'register' | 'reset_password';
}
export interface SmsLoginBO {
phone: string;
code: string;
}
export interface SmsResetPasswordBO {
phone: string;
code: string;
new_password: string;
}
// 登录 / 短信登录 返回
export interface LoginVO {
username?: string | null;
nickname?: string | null;
avatar?: string | null;
is_new?: boolean | null;
access_token?: string | null;
access_token_expire_at?: number | null;
}
// ===== 用户 =====
export interface UserInfoVO {
username?: string | null;
nickname?: string | null;
phone?: string | null;
avatar?: string | null;
bio?: string | null;
is_new?: boolean | null;
status?: number | null;
expires_at?: string | null;
source?: string | null;
src_user_id?: string | null;
department?: string | null;
job_title?: string | null;
}
export interface UserCreateBO {
phone: string;
platform_name?: string;
username?: string | null;
password?: string | null;
sms_code?: string | null;
}
export interface UserUpdateBO {
user_id?: number | null;
username?: string | null;
phone?: string | null;
password?: string | null;
}

39
src/types/fund.ts Normal file
View File

@ -0,0 +1,39 @@
// 资金账户 / 兑换码 类型定义
// ===== 兑换码 =====
/** 激活兑换码请求体 */
export interface ActivateCodeBO {
/** 16位兑换码 */
code: string;
}
/** 激活兑换码返回数据 */
export interface ActivateResultVO {
/** 面值 */
face_value: number | null;
/** 币种 */
currency: string | null;
/** 激活时间 */
activated_at: string | null;
}
// ===== 资金账户 =====
/** 资金账户信息 */
export interface FundAccountVO {
/** 资金账户ID */
fund_account_id: string | null;
/** 账户ID */
account_id: string | null;
/** 现金余额 */
cash_balance: string | null;
/** 赠送余额 */
gift_balance: string | null;
/** 欠费金额 */
debt_amount: string | null;
/** 账户状态 */
status: number | null;
/** 乐观锁版本号 */
version: number | null;
}

View File

@ -1,16 +1,30 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
// Token 存取(access_token 持久化到 localStorage)
const TOKEN_STORAGE_KEY = 'yuanjing_access_token'
export const setAccessToken = (token: string) => {
localStorage.setItem(TOKEN_STORAGE_KEY, token)
}
export const getAccessToken = (): string | null => {
return localStorage.getItem(TOKEN_STORAGE_KEY)
}
export const clearAccessToken = () => {
localStorage.removeItem(TOKEN_STORAGE_KEY)
}
// 获取环境变量,添加默认值
const getDefaultApiBaseURL = () => {
const envUrl = import.meta.env.VITE_API_BASE_URL
// 开发环境如果没有配置,使用本地地址
if (import.meta.env.DEV && !envUrl) {
console.warn('⚠️ 未配置 VITE_API_BASE_URL,使用默认本地地址')
return 'http://localhost:5173' // Vite 默认端口
// 开发环境:使用相对路径,让 vite proxy 转发请求,避免跨域
if (import.meta.env.DEV) {
return ''
}
return envUrl || 'https://token.opencomputing.cn'
}
@ -47,6 +61,9 @@ export const getApiBaseURL = () => {
const shouldIncludeCredentials = (url?: string, baseURL?: string) => {
if (!url) return true
// 开发环境走代理,同源请求,不需要跨域凭证
if (import.meta.env.DEV) return false
try {
const apiBaseURL = getApiBaseURL()
const apiOrigin = new URL(apiBaseURL).origin
@ -63,6 +80,13 @@ axios.interceptors.request.use(
const apiBaseURL = getApiBaseURL()
config.baseURL = apiBaseURL
config.withCredentials = shouldIncludeCredentials(config.url, config.baseURL)
// 携带 access_token(如果存在)
const token = getAccessToken()
if (token) {
config.headers = config.headers || {}
;(config.headers as Record<string, string>).Authorization = `Bearer ${token}`
}
return config
},
function (error) {

View File

@ -0,0 +1,583 @@
<template>
<div class="profile-page">
<LoginPrompt
v-if="!isLoggedIn"
class="login-fullscreen"
title="请先登录"
description="登录后可查看和修改你的个人信息。"
full-height
/>
<div v-else class="profile-container">
<div class="page-head">
<div>
<div class="page-title">个人信息</div>
<div class="page-desc">查看和修改你的账号资料</div>
</div>
</div>
<div class="profile-grid">
<!-- 左侧:头像卡片 -->
<div class="profile-card avatar-card">
<div class="avatar-wrapper">
<el-avatar :size="96" :src="profileForm.avatar" class="avatar">
{{ avatarFallback }}
</el-avatar>
<button type="button" class="avatar-upload-btn" @click="handleAvatarClick">
<i class="iconfont icon-image-edit"></i>
<span>更换头像</span>
</button>
<input
ref="avatarInputRef"
type="file"
accept="image/*"
style="display: none"
@change="handleAvatarChange"
/>
</div>
<div class="avatar-meta">
<div class="meta-name">{{ profileForm.nickname || profileForm.username || '未设置' }}</div>
<div class="meta-uid">ID: {{ profileForm.userId || '-' }}</div>
</div>
</div>
<!-- 右侧:资料表单 -->
<div class="profile-card form-card-block">
<div class="card-title">基础资料</div>
<el-form
ref="profileFormRef"
:model="profileForm"
:rules="profileRules"
label-width="80px"
class="profile-form"
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="profileForm.username"
placeholder="请输入用户名"
maxlength="20"
disabled
/>
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input
v-model="profileForm.nickname"
placeholder="请输入昵称"
maxlength="20"
clearable
/>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="profileForm.phone"
placeholder="请输入手机号"
maxlength="11"
disabled
>
<template #append>
<el-button link type="primary" @click="handleChangePhone">换绑</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="profileForm.email"
placeholder="请输入邮箱(选填)"
clearable
/>
</el-form-item>
<el-form-item label="个人简介" prop="bio">
<el-input
v-model="profileForm.bio"
type="textarea"
:rows="3"
maxlength="100"
show-word-limit
placeholder="介绍一下自己吧(选填)"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="saving"
@click="handleSave"
>
保存修改
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- 修改密码卡片 -->
<div v-if="false" class="profile-card password-card">
<div class="card-title">修改密码</div>
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-width="80px"
class="profile-form"
>
<el-form-item label="原密码" prop="oldPassword">
<el-input
v-model="passwordForm.oldPassword"
type="password"
placeholder="请输入原密码"
show-password
clearable
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="passwordForm.newPassword"
type="password"
placeholder="请输入新密码(至少6位)"
show-password
clearable
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
placeholder="请再次输入新密码"
show-password
clearable
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="changingPassword"
@click="handleChangePassword"
>
修改密码
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import type { FormInstance, FormRules } from 'element-plus';
import { ElMessage } from 'element-plus';
import LoginPrompt from '@/components/LoginPrompt/LoginPrompt.vue';
import { useLoginStore } from '@/store/LoginStore';
import { debounce } from '@/utlis/debounce';
import { getUserInfo } from '@/apis/auth';
import type { UserInfoVO } from '@/types/auth';
const loginStore = useLoginStore();
const { isLoggedIn } = storeToRefs(loginStore);
// ============ 个人信息表单 ============
interface ProfileForm {
userId: string;
username: string;
nickname: string;
phone: string;
email: string;
avatar: string;
bio: string;
}
const profileFormRef = ref<FormInstance>();
const saving = ref(false);
const profileForm = reactive<ProfileForm>({
userId: '',
username: '',
nickname: '',
phone: '',
email: '',
avatar: '',
bio: '',
});
// 保存原始数据用于「重置」
let originalProfile: ProfileForm | null = null;
const profileRules: FormRules = {
nickname: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
{ min: 2, max: 20, message: '昵称长度 2-20 位', trigger: 'blur' },
],
email: [
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
],
};
const avatarFallback = computed(() => {
const name = profileForm.nickname || profileForm.username || 'U';
return name.charAt(0).toUpperCase();
});
// ============ 修改密码表单 ============
const passwordFormRef = ref<FormInstance>();
const changingPassword = ref(false);
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: '',
});
const passwordRules: FormRules = {
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{
validator: (_rule: any, value: string, callback: any) => {
if (value !== passwordForm.newPassword) {
callback(new Error('两次密码不一致'));
} else {
callback();
}
},
trigger: 'blur',
},
],
};
// ============ 接口调用 ============
/** 从 /v1/user/get_info 拉取真实资料 */
const fetchProfileApi = async (): Promise<Partial<ProfileForm>> => {
const res = await getUserInfo();
if (res.code !== 0 || !res.data) throw new Error(res.message || '获取用户信息失败');
const d: UserInfoVO = res.data;
return {
userId: d.src_user_id || '',
username: d.username || '',
nickname: d.nickname || '',
phone: d.phone || '',
// 邮箱/头像/简介 v1 接口未提供邮箱;头像和简介由 get_info 返回
email: '',
avatar: d.avatar || '',
bio: d.bio || '',
};
};
/**
* 保存个人信息
* 注:后端 PUT /v1/user/update 当前仅接受 username/phone/password/user_id
* 不支持 nickname/avatar/bio 等字段。如需修改这些,需后端补接口。
*/
const saveProfileApi = async (data: ProfileForm) => {
// 当前后端只能用 username 作为可修改字段,这里暂不提交 username(避免误改登录账号)
// 如果后端补充了 nickname/avatar/bio 字段,解除下面注释并按实际字段传参
// const res = await updateUser({
// user_id: Number(data.userId) || undefined,
// username: data.username,
// phone: data.phone,
// });
// if (res.code !== 0) throw new Error(res.message || '保存失败');
console.warn('[TODO] saveProfileApi: 后端 /v1/user/update 当前不支持 nickname/avatar/bio,保存逻辑待后端补接口', data);
await new Promise((r) => setTimeout(r, 300));
};
/** TODO: 上传头像
* 后端暂未提供头像上传接口,当前只做本地 base64 预览
*/
const uploadAvatarApi = async (_file: File): Promise<string> => {
console.warn('[TODO] uploadAvatarApi 未接入真实接口', _file.name);
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target?.result as string);
reader.readAsDataURL(_file);
});
};
/** TODO: 修改密码
* 后端暂未提供独立的「修改密码」接口。
* 可选方案:调用 /v1/auth/sms/reset_password 配合手机验证码重置
*/
const changePasswordApi = async (_data: {
oldPassword: string;
newPassword: string;
}) => {
console.warn('[TODO] changePasswordApi 未接入真实接口(后端无修改密码接口)');
await new Promise((r) => setTimeout(r, 500));
};
// ============ 事件处理 ============
const loadProfile = async () => {
try {
const data = await fetchProfileApi();
Object.assign(profileForm, data);
originalProfile = { ...profileForm };
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '加载失败');
}
};
const doSave = async () => {
if (!profileFormRef.value || saving.value) return;
const valid = await profileFormRef.value.validate().catch(() => false);
if (!valid) return;
saving.value = true;
try {
await saveProfileApi({ ...profileForm });
originalProfile = { ...profileForm };
// 同步到 store(即便后端暂时不支持,也保持 UI 一致)
loginStore.updateUserInfoV1({
nickname: profileForm.nickname,
avatar: profileForm.avatar,
bio: profileForm.bio,
});
ElMessage.success('保存成功');
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '保存失败');
} finally {
saving.value = false;
}
};
const handleSave = debounce(doSave, 500);
const handleReset = () => {
if (originalProfile) {
Object.assign(profileForm, originalProfile);
}
profileFormRef.value?.clearValidate();
};
const avatarInputRef = ref<HTMLInputElement>();
const handleAvatarClick = () => {
avatarInputRef.value?.click();
};
const handleAvatarChange = async (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
ElMessage.warning('请选择图片文件');
return;
}
if (file.size > 5 * 1024 * 1024) {
ElMessage.warning('图片大小不能超过 5MB');
return;
}
try {
const url = await uploadAvatarApi(file);
profileForm.avatar = url;
ElMessage.success('头像已更新(保存后生效)');
} catch (err) {
ElMessage.error('上传失败');
} finally {
target.value = '';
}
};
const handleChangePhone = () => {
ElMessage.info('TODO: 打开换绑手机号弹窗(需要短信验证)');
// TODO: 实现换绑手机号流程,通常需要:
// 1. 输入新手机号
// 2. 发送验证码到新手机号
// 3. 提交换绑
};
const doChangePassword = async () => {
if (!passwordFormRef.value || changingPassword.value) return;
const valid = await passwordFormRef.value.validate().catch(() => false);
if (!valid) return;
changingPassword.value = true;
try {
await changePasswordApi({
oldPassword: passwordForm.oldPassword,
newPassword: passwordForm.newPassword,
});
ElMessage.success('密码修改成功,请重新登录');
passwordForm.oldPassword = '';
passwordForm.newPassword = '';
passwordForm.confirmPassword = '';
// TODO: 可选:强制登出
// loginStore.logout();
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '修改失败');
} finally {
changingPassword.value = false;
}
};
const handleChangePassword = debounce(doChangePassword, 500);
onMounted(() => {
if (isLoggedIn.value) {
loadProfile();
}
});
</script>
<style scoped lang="less">
.profile-page {
width: 100%;
min-height: 100%;
padding: 24px;
color: #fff;
}
.login-fullscreen {
min-height: 60vh;
}
.page-head {
margin-bottom: 24px;
}
.page-title {
font-size: 24px;
font-weight: 500;
letter-spacing: 1px;
}
.page-desc {
margin-top: 6px;
font-size: 13px;
color: rgba(255, 255, 255, 0.55);
}
.profile-grid {
display: grid;
grid-template-columns: 280px 1fr;
gap: 20px;
margin-bottom: 20px;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
}
.profile-card {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 24px;
backdrop-filter: blur(10px);
}
.card-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 20px;
letter-spacing: 0.5px;
}
/* 头像卡片 */
.avatar-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.avatar-wrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.avatar {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
font-size: 32px;
font-weight: 500;
}
.avatar-upload-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: rgba(255, 255, 255, 0.85);
font-size: 12px;
cursor: pointer;
transition: all 0.25s;
&:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
}
.avatar-meta {
margin-top: 16px;
}
.meta-name {
font-size: 16px;
font-weight: 500;
}
.meta-uid {
margin-top: 4px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
/* 表单卡片 */
.profile-form {
:deep(.el-form-item__label) {
color: rgba(255, 255, 255, 0.75);
}
:deep(.el-input__wrapper),
:deep(.el-textarea__inner) {
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12) inset;
border-radius: 8px;
color: #fff;
&:hover {
background: rgba(255, 255, 255, 0.09);
}
&.is-focus {
background: rgba(255, 255, 255, 0.12);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset;
}
}
:deep(.el-input__inner),
:deep(.el-textarea__inner) {
color: #fff;
&::placeholder {
color: rgba(255, 255, 255, 0.4);
}
}
:deep(.el-input.is-disabled .el-input__wrapper) {
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.5);
}
:deep(.el-input.is-disabled .el-input__inner) {
color: rgba(255, 255, 255, 0.5);
-webkit-text-fill-color: rgba(255, 255, 255, 0.5);
}
}
.password-card {
max-width: 600px;
}
</style>

244
src/views/Redeem/Redeem.vue Normal file
View File

@ -0,0 +1,244 @@
<template>
<div class="redeem-page">
<LoginPrompt
v-if="!isLoggedIn"
class="login-fullscreen"
title="请先登录"
description="登录后可使用兑换码兑换权益。"
full-height
/>
<div v-else class="redeem-container">
<div class="page-head">
<div>
<div class="page-title">兑换码</div>
<div class="page-desc">输入兑换码,领取会员、积分或其他权益</div>
</div>
</div>
<!-- 兑换卡片 -->
<div class="redeem-card">
<div class="card-title">兑换权益</div>
<el-form
ref="redeemFormRef"
:model="redeemForm"
:rules="redeemRules"
class="redeem-form"
@submit.prevent="handleRedeem"
>
<el-form-item prop="code">
<el-input
v-model="redeemForm.code"
placeholder="请输入兑换码(例如:XXXX-XXXX-XXXX-XXXX)"
size="large"
maxlength="32"
clearable
class="code-input"
@keyup.enter="handleRedeem"
>
<template #prefix>
<i class="iconfont icon-yue"></i>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="redeem-btn"
:loading="redeeming"
:disabled="!redeemForm.code.trim()"
@click="handleRedeem"
>
立即兑换
</el-button>
</el-form-item>
</el-form>
<div class="tips">
<div class="tips-title">兑换说明:</div>
<ul class="tips-list">
<li>兑换码区分大小写,请准确输入。</li>
<li>每个兑换码仅可使用一次,兑换成功后立即生效。</li>
<li>如有疑问,请联系客服。</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { storeToRefs } from 'pinia';
import type { FormInstance, FormRules } from 'element-plus';
import { ElMessage } from 'element-plus';
import LoginPrompt from '@/components/LoginPrompt/LoginPrompt.vue';
import { useLoginStore } from '@/store/LoginStore';
import { debounce } from '@/utlis/debounce';
import { activateRedeemCode } from '@/apis/fund';
import type { ActivateResultVO } from '@/types/fund';
const loginStore = useLoginStore();
const { isLoggedIn } = storeToRefs(loginStore);
// ============ 兑换表单 ============
const redeemFormRef = ref<FormInstance>();
const redeeming = ref(false);
const redeemForm = reactive({
code: '',
});
const redeemRules: FormRules = {
code: [
{ required: true, message: '请输入兑换码', trigger: 'blur' },
{ min: 4, message: '兑换码长度至少 4 位', trigger: 'blur' },
],
};
// ============ 接口调用 ============
/** 兑换码兑换 — 调用 POST /v1/redeem-code/activate */
const redeemApi = async (code: string): Promise<ActivateResultVO> => {
const res = await activateRedeemCode({ code });
if (res.code !== 0) {
throw new Error(res.message || '兑换失败');
}
return res.data;
};
// ============ 事件处理 ============
const doRedeem = async () => {
if (!redeemFormRef.value || redeeming.value) return;
const valid = await redeemFormRef.value.validate().catch(() => false);
if (!valid) return;
redeeming.value = true;
try {
const result = await redeemApi(redeemForm.code.trim());
const faceValue = result.face_value != null ? `面值 ${result.face_value}` : '';
const currency = result.currency || '';
const reward = [faceValue, currency].filter(Boolean).join(' ') || '兑换成功';
ElMessage.success(`兑换成功:${reward}`);
redeemForm.code = '';
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '兑换失败,请检查兑换码是否正确');
} finally {
redeeming.value = false;
}
};
const handleRedeem = debounce(doRedeem, 500);
</script>
<style scoped lang="less">
.redeem-page {
width: 100%;
min-height: 100%;
padding: 24px;
color: #fff;
}
.login-fullscreen {
min-height: 60vh;
}
.page-head {
margin-bottom: 24px;
}
.page-title {
font-size: 24px;
font-weight: 500;
letter-spacing: 1px;
}
.page-desc {
margin-top: 6px;
font-size: 13px;
color: rgba(255, 255, 255, 0.55);
}
.redeem-container {
max-width: 880px;
}
.redeem-card {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 28px;
backdrop-filter: blur(10px);
margin-bottom: 20px;
}
.card-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 20px;
letter-spacing: 0.5px;
display: flex;
align-items: center;
justify-content: space-between;
}
.redeem-form {
:deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12) inset;
border-radius: 10px;
&:hover {
background: rgba(255, 255, 255, 0.09);
}
&.is-focus {
background: rgba(255, 255, 255, 0.12);
box-shadow: 0 0 0 1px rgba(0, 255, 204, 0.4) inset;
}
}
:deep(.el-input__inner) {
color: #fff;
letter-spacing: 1px;
&::placeholder {
color: rgba(255, 255, 255, 0.4);
letter-spacing: 0;
}
}
:deep(.el-input__prefix) {
color: rgba(255, 255, 255, 0.6);
}
}
.redeem-btn {
width: 100%;
letter-spacing: 2px;
}
.tips {
margin-top: 8px;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.04);
border-radius: 10px;
border-left: 3px solid rgba(0, 255, 204, 0.5);
}
.tips-title {
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
margin-bottom: 6px;
}
.tips-list {
margin: 0;
padding-left: 18px;
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.8;
}
</style>

View File

@ -15,248 +15,705 @@
<!-- 玻璃卡片表单 -->
<div class="form-card">
<div class="form-top">
<h2 class="form-title">欢迎来到元境</h2>
<span class="form-title"> 元启万象,境由心生 </span>
</div>
<el-form
ref="formRef"
style="max-width: 400px"
:model="form"
:rules="rules"
label-width="0px"
class="login-form"
@submit.prevent="submitForm"
>
<!-- 手机号 -->
<el-form-item prop="cellphone">
<el-input
v-model="form.cellphone"
placeholder="请输入手机号"
:prefix-icon="Iphone"
size="large"
@keyup.enter="submitForm"
/>
</el-form-item>
<!-- ===== 登录视图 ===== -->
<template v-if="view === 'login'">
<div class="form-top">
<h2 class="form-title">欢迎来到元境</h2>
<span class="form-title"> 元启万象,境由心生 </span>
</div>
<!-- 验证码 + 获取按钮 -->
<el-form-item prop="captcha">
<div class="captcha-wrapper">
<el-input
v-model="form.captcha"
placeholder="请输入验证码"
:prefix-icon="Key"
size="large"
class="captcha-input"
@keyup.enter="submitForm"
/>
<el-button
:disabled="countingDown || !validPhone"
class="captcha-btn glass-btn"
@click="handleGetCaptcha"
>
{{ countingDown ? `${countdown}秒后重试` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
<!-- 操作按钮 -->
<el-form-item>
<el-button
type="primary"
size="large"
class="submit-btn glass-btn"
@click="submitForm"
:loading="loading"
<!-- 登录方式切换 -->
<div class="login-tabs">
<button
class="login-tab"
:class="{ active: loginType === 'password' }"
@click="loginType = 'password'"
>
登 录 / 注 册
</el-button>
</el-form-item>
</el-form>
账密登录
</button>
<button
class="login-tab"
:class="{ active: loginType === 'sms' }"
@click="loginType = 'sms'"
>
短信登录
</button>
</div>
<!-- 账密登录表单 -->
<el-form
v-show="loginType === 'password'"
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
label-width="0px"
class="login-form"
@submit.prevent="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入用户名"
:prefix-icon="User"
size="large"
clearable
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
size="large"
show-password
clearable
@keyup.enter="handleLogin"
/>
</el-form-item>
<div class="login-extra login-extra--right">
<a class="link-btn" @click="switchView('reset')">忘记密码?</a>
</div>
<el-form-item>
<el-button
type="primary"
size="large"
class="submit-btn glass-btn"
:loading="loading"
@click="handleLogin"
>
登 录
</el-button>
</el-form-item>
</el-form>
<!-- 短信登录表单 -->
<el-form
v-show="loginType === 'sms'"
ref="smsFormRef"
:model="smsForm"
:rules="smsRules"
label-width="0px"
class="login-form"
@submit.prevent="handleSmsLogin"
>
<el-form-item prop="phone">
<el-input
v-model="smsForm.phone"
placeholder="请输入手机号"
:prefix-icon="Iphone"
size="large"
maxlength="11"
clearable
@keyup.enter="handleSmsLogin"
/>
</el-form-item>
<el-form-item prop="smsCode">
<div class="captcha-wrapper">
<el-input
v-model="smsForm.smsCode"
placeholder="请输入验证码"
:prefix-icon="Key"
size="large"
class="captcha-input"
maxlength="6"
@keyup.enter="handleSmsLogin"
/>
<el-button
:disabled="!canSendSms || !validPhone(smsForm.phone)"
class="captcha-btn glass-btn"
@click="handleSendSms('login', smsForm.phone)"
>
{{ countdown > 0 ? `${countdown}秒后重试` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="submit-btn glass-btn"
:loading="loading"
@click="handleSmsLogin"
>
登 录
</el-button>
</el-form-item>
</el-form>
<div class="login-switch-mode">
还没有账号?<a class="link-btn" @click="switchView('register')">立即注册</a>
</div>
</template>
<!-- ===== 注册视图 ===== -->
<template v-else-if="view === 'register'">
<div class="form-top">
<h2 class="form-title">创建账号</h2>
<span class="form-title"> 手机号注册,快速开通 </span>
</div>
<el-form
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
label-width="0px"
class="login-form"
@submit.prevent="handleRegister"
>
<el-form-item prop="username">
<el-input
v-model="registerForm.username"
placeholder="请输入用户名"
:prefix-icon="User"
size="large"
clearable
@keyup.enter="handleRegister"
/>
</el-form-item>
<el-form-item prop="phone">
<el-input
v-model="registerForm.phone"
placeholder="请输入手机号"
:prefix-icon="Iphone"
size="large"
maxlength="11"
clearable
@keyup.enter="handleRegister"
/>
</el-form-item>
<el-form-item prop="smsCode">
<div class="captcha-wrapper">
<el-input
v-model="registerForm.smsCode"
placeholder="请输入验证码"
:prefix-icon="Key"
size="large"
class="captcha-input"
maxlength="6"
@keyup.enter="handleRegister"
/>
<el-button
:disabled="!canSendSms || !validPhone(registerForm.phone)"
class="captcha-btn glass-btn"
@click="handleSendSms('register', registerForm.phone)"
>
{{ countdown > 0 ? `${countdown}秒后重试` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
type="password"
placeholder="设置密码(至少6位)"
:prefix-icon="Lock"
size="large"
show-password
clearable
@keyup.enter="handleRegister"
/>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
type="password"
placeholder="确认密码"
:prefix-icon="Lock"
size="large"
show-password
clearable
@keyup.enter="handleRegister"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="submit-btn glass-btn"
:loading="loading"
@click="handleRegister"
>
注 册
</el-button>
</el-form-item>
</el-form>
<div class="login-switch-mode">
已有账号?<a class="link-btn" @click="switchView('login')">返回登录</a>
</div>
</template>
<!-- ===== 忘记密码视图 ===== -->
<template v-else>
<div class="form-top">
<h2 class="form-title">重置密码</h2>
<span class="form-title"> 通过手机验证码设置新密码 </span>
</div>
<el-form
ref="resetFormRef"
:model="resetForm"
:rules="resetRules"
label-width="0px"
class="login-form"
@submit.prevent="handleResetPassword"
>
<el-form-item prop="phone">
<el-input
v-model="resetForm.phone"
placeholder="请输入手机号"
:prefix-icon="Iphone"
size="large"
maxlength="11"
clearable
@keyup.enter="handleResetPassword"
/>
</el-form-item>
<el-form-item prop="smsCode">
<div class="captcha-wrapper">
<el-input
v-model="resetForm.smsCode"
placeholder="请输入验证码"
:prefix-icon="Key"
size="large"
class="captcha-input"
maxlength="6"
@keyup.enter="handleResetPassword"
/>
<el-button
:disabled="!canSendSms || !validPhone(resetForm.phone)"
class="captcha-btn glass-btn"
@click="handleSendSms('reset', resetForm.phone)"
>
{{ countdown > 0 ? `${countdown}秒后重试` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
<el-form-item prop="newPassword">
<el-input
v-model="resetForm.newPassword"
type="password"
placeholder="新密码(至少6位)"
:prefix-icon="Lock"
size="large"
show-password
clearable
@keyup.enter="handleResetPassword"
/>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="resetForm.confirmPassword"
type="password"
placeholder="确认新密码"
:prefix-icon="Lock"
size="large"
show-password
clearable
@keyup.enter="handleResetPassword"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
class="submit-btn glass-btn"
:loading="loading"
@click="handleResetPassword"
>
确认重置
</el-button>
</el-form-item>
</el-form>
<div class="login-switch-mode">
想起来了?<a class="link-btn" @click="switchView('login')">返回登录</a>
</div>
</template>
<div class="form-bottom">
登录即表示您同意遵守《用户协议》和《隐私政策》
</div>
</div>
<!-- 账号选择弹窗 -->
<el-dialog
v-model="loginStore.showAccountSelect"
title="选择账号"
width="400px"
:close-on-click-modal="false"
@close="handleDialogClose"
>
<div class="account-list">
<div
v-for="account in loginStore.accountList"
:key="account.id"
class="account-item"
@click="selectAccount(account.id)"
>
<div class="account-info">
<div class="account-name">{{ account.username }}</div>
<!-- 显示手机号,优先使用 mobile 字段,其次 orgid -->
<div class="account-phone">{{ account.mobile || account.orgid }}</div>
</div>
<el-button type="primary" size="small">选择</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onUnmounted } from 'vue';
import type { FormInstance, FormRules } from 'element-plus';
import { Iphone, Key } from '@element-plus/icons-vue';
import { User, Lock, Iphone, Key } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { Code } from '@/apis/login';
import type { CodeResult, LoginParams, CaptchaParams } from '@/types/login';
import { useLoginStore } from '@/store/LoginStore';
import { debounce } from '@/utlis/debounce';
import {
authLogin,
authSendSmsCode,
authSmsLogin,
authSmsResetPassword,
createUser,
} from '@/apis/auth';
import { getUserInfo } from '@/apis/auth';
// 登录Store
const loginStore = useLoginStore();
// 表单数据类型
interface LoginForm {
cellphone: string;
captcha: string;
}
// ============ 视图与登录方式切换 ============
// view: login | register | reset
const view = ref<'login' | 'register' | 'reset'>('login');
// 登录方式:password | sms
const loginType = ref<'password' | 'sms'>('password');
// 表单数据
const form = reactive<LoginForm>({
cellphone: '',
captcha: '',
});
// 切换视图时重置表单状态
const switchView = (v: 'login' | 'register' | 'reset') => {
view.value = v;
// 切换视图时清空倒计时
resetCountdown();
};
// 表单实例引用
const formRef = ref<FormInstance>();
// 加载状态
// ============ 通用状态 ============
const loading = ref(false);
// 登录参数(用于保存key)
const loginParams = ref<LoginParams>({
cellphone: '',
key: '',
sms_code: '',
});
// 验证规则
const rules = reactive<FormRules<LoginForm>>({
cellphone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' },
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ len: 6, message: '验证码应为6位数字', trigger: 'blur' },
],
});
// ============ 验证码倒计时(login / register / reset 共用) ============
const countdown = ref(0);
let countdownTimer: ReturnType<typeof setInterval> | null = null;
// 验证码倒计时
const countdown = ref(60);
const countingDown = ref(false);
let timer: number | null = null;
const canSendSms = computed(() => countdown.value === 0);
// 手机号是否有效
const validPhone = computed(() => {
return /^1[3-9]\d{9}$/.test(form.cellphone);
});
// 获取验证码
const requestCaptcha = async () => {
if (countingDown.value) return;
try {
const res = await Code<CodeResult, CaptchaParams>({ cellphone: form.cellphone });
if (res.status === 'ok') {
ElMessage.success('发送成功');
// 保存key
loginParams.value.key = res.data?.key || '';
} else {
ElMessage.error('发送失败');
}
} catch (error) {
console.error('发送验证码失败:', error);
ElMessage.error('发送失败,请稍后重试');
}
// 开始倒计时
countingDown.value = true;
const startCountdown = () => {
countdown.value = 60;
timer = setInterval(() => {
countdownTimer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
clearInterval(timer!);
timer = null;
countingDown.value = false;
if (countdown.value <= 0 && countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}, 1000);
};
const handleGetCaptcha = debounce(requestCaptcha, 500);
// 提交表单(登录)
const doSubmitForm = async () => {
if (!formRef.value) return;
if (loading.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
// 准备登录参数
loginParams.value.cellphone = form.cellphone;
loginParams.value.sms_code = form.captcha;
// 调用登录接口(通过store)
await loginStore.Login(loginParams.value);
} catch (error) {
console.error('登录失败:', error);
ElMessage.error('登录失败,请稍后重试');
} finally {
loading.value = false;
}
} else {
ElMessage.error('请正确填写表单');
}
});
const resetCountdown = () => {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
countdown.value = 0;
};
const submitForm = debounce(doSubmitForm, 500);
// 手机号校验辅助
const validPhone = (phone: string) => /^1[3-9]\d{9}$/.test(phone);
// ============ 表单引用 ============
const loginFormRef = ref<FormInstance>();
const smsFormRef = ref<FormInstance>();
const registerFormRef = ref<FormInstance>();
const resetFormRef = ref<FormInstance>();
// ============ 账密登录 ============
const loginForm = reactive({
username: '',
password: '',
});
const loginRules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
};
// ============ 短信登录 ============
const smsForm = reactive({
phone: '',
smsCode: '',
});
const smsRules: FormRules = {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' },
],
smsCode: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ len: 6, message: '验证码为6位数字', trigger: 'blur' },
],
};
// ============ 注册 ============
const registerForm = reactive({
username: '',
phone: '',
smsCode: '',
password: '',
confirmPassword: '',
});
const registerRules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 20, message: '用户名2-20位', trigger: 'blur' },
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' },
],
smsCode: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ len: 6, message: '验证码为6位数字', trigger: 'blur' },
],
password: [
{ required: true, message: '请设置密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{
validator: (_rule: any, value: string, callback: any) => {
if (value !== registerForm.password) {
callback(new Error('两次密码不一致'));
} else {
callback();
}
},
trigger: 'blur',
},
],
};
// ============ 忘记密码 ============
const resetForm = reactive({
phone: '',
smsCode: '',
newPassword: '',
confirmPassword: '',
});
const resetRules: FormRules = {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' },
],
smsCode: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ len: 6, message: '验证码为6位数字', trigger: 'blur' },
],
newPassword: [
{ required: true, message: '请设置新密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{
validator: (_rule: any, value: string, callback: any) => {
if (value !== resetForm.newPassword) {
callback(new Error('两次密码不一致'));
} else {
callback();
}
},
trigger: 'blur',
},
],
};
// ============ 接口调用 ============
/** 发送短信验证码 */
const sendSmsCodeApi = async (
phone: string,
scene: 'login' | 'register' | 'reset'
): Promise<void> => {
const sceneMap = { login: 'login', register: 'register', reset: 'reset_password' } as const;
const res = await authSendSmsCode({ phone, scene: sceneMap[scene] });
if (res.code !== 0) throw new Error(res.message || '发送失败');
};
/** 用户名密码登录 */
const passwordLoginApi = async (data: { username: string; password: string }) => {
const res = await authLogin(data);
if (res.code !== 0 || !res.data) throw new Error(res.message || '登录失败');
return res.data;
};
/** 手机号验证码登录 */
const smsLoginApi = async (data: { phone: string; smsCode: string }) => {
const res = await authSmsLogin({ phone: data.phone, code: data.smsCode });
if (res.code !== 0 || !res.data) throw new Error(res.message || '登录失败');
return res.data;
};
/** 注册(创建用户) */
const registerApi = async (data: {
username: string;
phone: string;
smsCode: string;
password: string;
}) => {
const res = await createUser({
phone: data.phone,
username: data.username,
password: data.password,
sms_code: data.smsCode,
platform_name: import.meta.env.VITE_PLATFORM_NAME,
});
if (res.code !== 0) throw new Error(res.message || '注册失败');
return res;
};
/** 重置密码 */
const resetPasswordApi = async (data: {
phone: string;
smsCode: string;
newPassword: string;
}) => {
const res = await authSmsResetPassword({
phone: data.phone,
code: data.smsCode,
new_password: data.newPassword,
});
if (res.code !== 0) throw new Error(res.message || '重置失败');
};
// ============ 事件处理 ============
/** 发送验证码(通用) */
const doSendSms = async (scene: 'login' | 'register' | 'reset', phone: string) => {
if (!phone) {
ElMessage.warning('请先输入手机号');
return;
}
if (!validPhone(phone)) {
ElMessage.warning('手机号格式不正确');
return;
}
try {
await sendSmsCodeApi(phone, scene);
ElMessage.success('验证码已发送');
startCountdown();
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '发送失败');
}
};
const handleSendSms = debounce(doSendSms, 500);
/** 账密登录 */
const doLogin = async () => {
if (!loginFormRef.value || loading.value) return;
const valid = await loginFormRef.value.validate().catch(() => false);
if (!valid) return;
// 选择账号
const handleSelectAccount = async (selectedId: string) => {
// 避免重复点击时多次请求
if (loading.value) return;
loading.value = true;
try {
await loginStore.loginWithSelectedAccount(selectedId, loginParams.value);
// 登录成功后会跳转,无需额外关闭弹窗(store 里已经关闭)
} catch (error) {
console.error('选择账号登录失败:', error);
ElMessage.error('登录失败,请稍后重试');
const vo = await passwordLoginApi({
username: loginForm.username,
password: loginForm.password,
});
loginStore.loginWithV1(vo);
ElMessage.success('登录成功');
// 拉取完整用户资料(不阻塞跳转)
getUserInfo().then((res) => {
if (res.code === 0 && res.data) loginStore.updateUserInfoV1(res.data);
}).catch(() => {});
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '登录失败,请稍后重试');
} finally {
loading.value = false;
}
};
const selectAccount = debounce(handleSelectAccount, 500);
const handleLogin = debounce(doLogin, 500);
// 弹窗关闭时的回调(如果用户直接关闭,清空账号列表,避免下次残留)
const handleDialogClose = () => {
loginStore.accountList = [];
/** 短信登录 */
const doSmsLogin = async () => {
if (!smsFormRef.value || loading.value) return;
const valid = await smsFormRef.value.validate().catch(() => false);
if (!valid) return;
loading.value = true;
try {
const vo = await smsLoginApi({ phone: smsForm.phone, smsCode: smsForm.smsCode });
loginStore.loginWithV1(vo);
ElMessage.success('登录成功');
getUserInfo().then((res) => {
if (res.code === 0 && res.data) loginStore.updateUserInfoV1(res.data);
}).catch(() => {});
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '登录失败,请稍后重试');
} finally {
loading.value = false;
}
};
// 组件卸载时清除定时器
const handleSmsLogin = debounce(doSmsLogin, 500);
/** 注册 */
const doRegister = async () => {
if (!registerFormRef.value || loading.value) return;
const valid = await registerFormRef.value.validate().catch(() => false);
if (!valid) return;
loading.value = true;
try {
await registerApi({
username: registerForm.username,
phone: registerForm.phone,
smsCode: registerForm.smsCode,
password: registerForm.password,
});
ElMessage.success('注册成功,请登录');
// 注册成功后切回登录页,并预填用户名
loginForm.username = registerForm.username;
loginForm.password = '';
loginType.value = 'password';
switchView('login');
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '注册失败,请稍后重试');
} finally {
loading.value = false;
}
};
const handleRegister = debounce(doRegister, 500);
/** 重置密码 */
const doResetPassword = async () => {
if (!resetFormRef.value || loading.value) return;
const valid = await resetFormRef.value.validate().catch(() => false);
if (!valid) return;
loading.value = true;
try {
await resetPasswordApi({
phone: resetForm.phone,
smsCode: resetForm.smsCode,
newPassword: resetForm.newPassword,
});
ElMessage.success('密码重置成功,请使用新密码登录');
// 清空密码字段,切回登录
loginForm.password = '';
loginType.value = 'password';
switchView('login');
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '重置失败,请稍后重试');
} finally {
loading.value = false;
}
};
const handleResetPassword = debounce(doResetPassword, 500);
// 组件卸载时清理
onUnmounted(() => {
if (timer) clearInterval(timer);
handleGetCaptcha.cancel();
submitForm.cancel();
selectAccount.cancel();
resetCountdown();
handleSendSms.cancel();
handleLogin.cancel();
handleSmsLogin.cancel();
handleRegister.cancel();
handleResetPassword.cancel();
});
</script>
<style scoped lang="less">
@import url('../../assets/less/Login/Login.less');
</style>
</style>

9
src/vite-env.d.ts vendored
View File

@ -1,5 +1,14 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_PLATFORM_NAME: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>

View File

@ -17,7 +17,7 @@ export default defineConfig(({ mode }) => {
}
},
server: {
host: 'localhost',
host: '0.0.0.0',
port: 5173,
// 配置代理解决跨域问题
proxy: {
@ -30,9 +30,18 @@ export default defineConfig(({ mode }) => {
target: apiBaseURL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/token/, ''),
}
},
// 新增:元境 /v1 接口代理(auth/user 等)
'/v1': {
target: apiBaseURL,
changeOrigin: true,
},
// 兼容旧的 /rbac 接口(图片/视频生成)
'/rbac': {
target: apiBaseURL,
changeOrigin: true,
},
}
},
// optimizeDeps: {