重构登录以及增加兑换功能
This commit is contained in:
parent
97b4b215fb
commit
0ee332fcb6
@ -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
|
||||
|
||||
@ -1 +1,2 @@
|
||||
VITE_API_BASE_URL=https://token.opencomputing.cn
|
||||
VITE_PLATFORM_NAME=yuanjing
|
||||
|
||||
@ -1 +1,2 @@
|
||||
VITE_API_BASE_URL=https://tokentest.opencomputing.cn
|
||||
VITE_PLATFORM_NAME=yuanjing
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@ -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
46
src/apis/auth.ts
Normal 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
15
src/apis/fund.ts
Normal 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');
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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')
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -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
71
src/types/auth.ts
Normal 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
39
src/types/fund.ts
Normal 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;
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
583
src/views/Profile/Profile.vue
Normal file
583
src/views/Profile/Profile.vue
Normal 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
244
src/views/Redeem/Redeem.vue
Normal 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>
|
||||
@ -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
9
src/vite-env.d.ts
vendored
@ -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>
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user