重构登录以及增加兑换功能
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_API_BASE_URL=https://token.opencomputing.cn
|
||||||
|
VITE_PLATFORM_NAME=yuanjing
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
VITE_API_BASE_URL=https://tokentest.opencomputing.cn
|
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"
|
"vue-waterfall-plugin-next": "^3.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.9.2",
|
"@types/node": "^25.4.0",
|
||||||
"@types/swiper": "^6.0.0",
|
"@types/swiper": "^6.0.0",
|
||||||
"@vitejs/plugin-vue": "^5.2.3",
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
"@vue/tsconfig": "^0.7.0",
|
"@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 {
|
.dropdown-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
.item-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.user-id {
|
&.user-id {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
.item-label {
|
.item-label {
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
@ -350,15 +358,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.logout {
|
&.logout {
|
||||||
gap: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
.item-icon {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
|
|
||||||
|
|||||||
@ -89,7 +89,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.captcha-wrapper {
|
.captcha-wrapper {
|
||||||
margin-top: 16px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
@ -144,40 +143,79 @@
|
|||||||
padding: 14px 0;
|
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 {
|
.form-bottom {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-top: 20%;
|
margin-top: 24px;
|
||||||
}
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
|
||||||
/* 账号选择弹窗样式 */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -12,7 +12,8 @@
|
|||||||
<!-- 登录后显示:充值 + 余额 一体化模块 -->
|
<!-- 登录后显示:充值 + 余额 一体化模块 -->
|
||||||
<template v-if="loginStore.isLoggedIn">
|
<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">
|
<div class="balance-main">
|
||||||
<span class="balance-icon-wrap">
|
<span class="balance-icon-wrap">
|
||||||
<i class="iconfont icon-yue"></i>
|
<i class="iconfont icon-yue"></i>
|
||||||
@ -25,8 +26,8 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="combo-divider"></span>
|
<span class="combo-divider" v-show="false"></span>
|
||||||
<span class="recharge-text">充值</span>
|
<span class="recharge-text" v-show="false">充值</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 用户信息 -->
|
<!-- 用户信息 -->
|
||||||
@ -44,7 +45,11 @@
|
|||||||
<div v-if="userDropdownVisible" class="user-dropdown">
|
<div v-if="userDropdownVisible" class="user-dropdown">
|
||||||
<div class="dropdown-item user-id">
|
<div class="dropdown-item user-id">
|
||||||
<span class="item-label">用户名:</span>
|
<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>
|
||||||
<!-- <div class="dropdown-item user-id">
|
<!-- <div class="dropdown-item user-id">
|
||||||
<span class="item-label">用户ID:</span>
|
<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 Recharge from '@/components/Recharge/Recharge.vue';
|
||||||
import { useLoginStore } from '@/store/LoginStore';
|
import { useLoginStore } from '@/store/LoginStore';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { balance } from '@/apis/home';
|
import { getFundBalance } from '@/apis/fund';
|
||||||
import type { BalanceResult } from '@/types/home';
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const loginStore = useLoginStore();
|
const loginStore = useLoginStore();
|
||||||
@ -96,6 +100,25 @@ const balances = ref(0);
|
|||||||
// const userInitial = computed(() => userDisplayName.value.trim().charAt(0).toUpperCase() || 'U');
|
// const userInitial = computed(() => userDisplayName.value.trim().charAt(0).toUpperCase() || 'U');
|
||||||
const formattedBalance = computed(() => Number(balances.value || 0).toLocaleString('zh-CN'));
|
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 = () => {
|
const toggleUserDropdown = () => {
|
||||||
userDropdownVisible.value = !userDropdownVisible.value;
|
userDropdownVisible.value = !userDropdownVisible.value;
|
||||||
@ -113,9 +136,12 @@ const handleLogout = () => {
|
|||||||
// ==============================================
|
// ==============================================
|
||||||
const getBalance = async () => {
|
const getBalance = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await balance<BalanceResult>();
|
const res = await getFundBalance();
|
||||||
if (res.status === 'ok') {
|
if (res.code === 0 && res.data) {
|
||||||
balances.value = res.data[0].balance;
|
// 现金余额 + 赠送余额
|
||||||
|
const cash = Number(res.data.cash_balance || 0);
|
||||||
|
const gift = Number(res.data.gift_balance || 0);
|
||||||
|
balances.value = cash + gift;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('获取余额失败:', err);
|
console.error('获取余额失败:', err);
|
||||||
|
|||||||
@ -84,6 +84,12 @@
|
|||||||
<span class="menu-text">令牌管理</span>
|
<span class="menu-text">令牌管理</span>
|
||||||
</div>
|
</div>
|
||||||
</el-menu-item>
|
</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>
|
</el-menu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -146,6 +152,9 @@ const handleSelect = (index: string) => {
|
|||||||
case 'apikey':
|
case 'apikey':
|
||||||
router.push('/apikey');
|
router.push('/apikey');
|
||||||
break;
|
break;
|
||||||
|
case 'redeem':
|
||||||
|
router.push('/redeem');
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -178,6 +187,8 @@ const updateActiveIndex = () => {
|
|||||||
activeIndex.value = 'material-square';
|
activeIndex.value = 'material-square';
|
||||||
} else if (path.includes('/apikey')) {
|
} else if (path.includes('/apikey')) {
|
||||||
activeIndex.value = 'apikey';
|
activeIndex.value = 'apikey';
|
||||||
|
} else if (path.includes('/redeem')) {
|
||||||
|
activeIndex.value = 'redeem';
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
activeIndex.value = 'index';
|
activeIndex.value = 'index';
|
||||||
|
|||||||
@ -121,6 +121,18 @@ const router = createRouter({
|
|||||||
path: '/apikey',
|
path: '/apikey',
|
||||||
name: 'apikey',
|
name: 'apikey',
|
||||||
component: () => import('@/views/APiKey/apikey.vue')
|
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 { defineStore } from 'pinia';
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
import type { LoginResult, LoginParams, LoginUser } from '@/types/login';
|
import type { LoginResult, LoginParams, LoginUser } from '@/types/login';
|
||||||
|
import type { LoginVO, UserInfoVO } from '@/types/auth';
|
||||||
import { LoginApi } from '@/apis/login';
|
import { LoginApi } from '@/apis/login';
|
||||||
import { openAccount } from '@/apis/home';
|
import { openAccount } from '@/apis/home';
|
||||||
import { clearApiBaseURL } from '@/utlis/request';
|
import { clearApiBaseURL, setAccessToken, clearAccessToken } from '@/utlis/request';
|
||||||
|
|
||||||
export const useLoginStore = defineStore(
|
export const useLoginStore = defineStore(
|
||||||
'loginStore',
|
'loginStore',
|
||||||
() => {
|
() => {
|
||||||
// 登录状态
|
// 登录状态
|
||||||
const isLoggedIn = ref(false);
|
const isLoggedIn = ref(false);
|
||||||
// 当前用户信息
|
// 当前用户信息(旧 rbac 结构)
|
||||||
const userInfo = ref<{ user: LoginUser } | null>(null);
|
const userInfo = ref<{ user: LoginUser } | null>(null);
|
||||||
// 多账号列表
|
// 当前用户信息(v1 新结构,登录后 + getUserInfo 完整字段)
|
||||||
const accountList = ref<LoginUser[]>([]);
|
const userInfoV1 = ref<(LoginVO & Partial<UserInfoVO>) | null>(null);
|
||||||
// 是否显示账号选择弹窗
|
|
||||||
const showAccountSelect = ref(false);
|
|
||||||
// 登录时间戳(用于精确计算剩余时间,可选)
|
// 登录时间戳(用于精确计算剩余时间,可选)
|
||||||
const loginTimestamp = ref<number | null>(null);
|
const loginTimestamp = ref<number | null>(null);
|
||||||
// 过期前的当前页面路径
|
// 过期前的当前页面路径
|
||||||
@ -77,20 +76,6 @@ export const useLoginStore = defineStore(
|
|||||||
return res;
|
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 || '登录失败');
|
ElMessage.error(res.message || '登录失败');
|
||||||
return res;
|
return res;
|
||||||
@ -99,13 +84,25 @@ export const useLoginStore = defineStore(
|
|||||||
// 登录(首次)
|
// 登录(首次)
|
||||||
const Login = (params: LoginParams) => doLogin(params);
|
const Login = (params: LoginParams) => doLogin(params);
|
||||||
|
|
||||||
// 选择账号后登录(携带 selected_id)
|
// v1 登录成功后的统一处理(密码登录 / 短信登录 / 注册后自动登录 都用这个)
|
||||||
const loginWithSelectedAccount = async (
|
const loginWithV1 = (vo: LoginVO) => {
|
||||||
selectedId: string,
|
if (vo.access_token) {
|
||||||
params: Omit<LoginParams, 'selected_id'>
|
setAccessToken(vo.access_token);
|
||||||
) => {
|
}
|
||||||
showAccountSelect.value = false;
|
isLoggedIn.value = true;
|
||||||
return doLogin({ ...params, selected_id: selectedId });
|
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();
|
openAccounts();
|
||||||
isLoggedIn.value = true;
|
isLoggedIn.value = true;
|
||||||
userInfo.value = nextUserInfo;
|
userInfo.value = nextUserInfo;
|
||||||
accountList.value = [];
|
|
||||||
showAccountSelect.value = false;
|
|
||||||
loginTimestamp.value = Date.now();
|
loginTimestamp.value = Date.now();
|
||||||
startAutoLogoutTimer();
|
startAutoLogoutTimer();
|
||||||
|
|
||||||
@ -144,8 +139,6 @@ export const useLoginStore = defineStore(
|
|||||||
cellphone: user?.cellphone,
|
cellphone: user?.cellphone,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
accountList.value = [];
|
|
||||||
showAccountSelect.value = false;
|
|
||||||
loginTimestamp.value = Date.now();
|
loginTimestamp.value = Date.now();
|
||||||
startAutoLogoutTimer();
|
startAutoLogoutTimer();
|
||||||
|
|
||||||
@ -160,10 +153,10 @@ export const useLoginStore = defineStore(
|
|||||||
clearAutoLogoutTimer();
|
clearAutoLogoutTimer();
|
||||||
isLoggedIn.value = false;
|
isLoggedIn.value = false;
|
||||||
userInfo.value = null;
|
userInfo.value = null;
|
||||||
accountList.value = [];
|
userInfoV1.value = null;
|
||||||
showAccountSelect.value = false;
|
|
||||||
loginTimestamp.value = null;
|
loginTimestamp.value = null;
|
||||||
clearApiBaseURL();
|
clearApiBaseURL();
|
||||||
|
clearAccessToken();
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
ElMessage.error('身份验证过期请重新登录');
|
ElMessage.error('身份验证过期请重新登录');
|
||||||
};
|
};
|
||||||
@ -172,10 +165,10 @@ export const useLoginStore = defineStore(
|
|||||||
clearAutoLogoutTimer();
|
clearAutoLogoutTimer();
|
||||||
isLoggedIn.value = false;
|
isLoggedIn.value = false;
|
||||||
userInfo.value = null;
|
userInfo.value = null;
|
||||||
accountList.value = [];
|
userInfoV1.value = null;
|
||||||
showAccountSelect.value = false;
|
|
||||||
loginTimestamp.value = null;
|
loginTimestamp.value = null;
|
||||||
clearApiBaseURL();
|
clearApiBaseURL();
|
||||||
|
clearAccessToken();
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -193,11 +186,11 @@ export const useLoginStore = defineStore(
|
|||||||
return {
|
return {
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
userInfo,
|
userInfo,
|
||||||
accountList,
|
userInfoV1,
|
||||||
showAccountSelect,
|
|
||||||
redirectPath,
|
redirectPath,
|
||||||
Login,
|
Login,
|
||||||
loginWithSelectedAccount,
|
loginWithV1,
|
||||||
|
updateUserInfoV1,
|
||||||
syncLogin,
|
syncLogin,
|
||||||
syncCookieLogin,
|
syncCookieLogin,
|
||||||
logout,
|
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,14 +1,28 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { ElMessage } from 'element-plus'
|
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 getDefaultApiBaseURL = () => {
|
||||||
const envUrl = import.meta.env.VITE_API_BASE_URL
|
const envUrl = import.meta.env.VITE_API_BASE_URL
|
||||||
|
|
||||||
// 开发环境如果没有配置,使用本地地址
|
// 开发环境:使用相对路径,让 vite proxy 转发请求,避免跨域
|
||||||
if (import.meta.env.DEV && !envUrl) {
|
if (import.meta.env.DEV) {
|
||||||
console.warn('⚠️ 未配置 VITE_API_BASE_URL,使用默认本地地址')
|
return ''
|
||||||
return 'http://localhost:5173' // Vite 默认端口
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return envUrl || 'https://token.opencomputing.cn'
|
return envUrl || 'https://token.opencomputing.cn'
|
||||||
@ -47,6 +61,9 @@ export const getApiBaseURL = () => {
|
|||||||
const shouldIncludeCredentials = (url?: string, baseURL?: string) => {
|
const shouldIncludeCredentials = (url?: string, baseURL?: string) => {
|
||||||
if (!url) return true
|
if (!url) return true
|
||||||
|
|
||||||
|
// 开发环境走代理,同源请求,不需要跨域凭证
|
||||||
|
if (import.meta.env.DEV) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiBaseURL = getApiBaseURL()
|
const apiBaseURL = getApiBaseURL()
|
||||||
const apiOrigin = new URL(apiBaseURL).origin
|
const apiOrigin = new URL(apiBaseURL).origin
|
||||||
@ -63,6 +80,13 @@ axios.interceptors.request.use(
|
|||||||
const apiBaseURL = getApiBaseURL()
|
const apiBaseURL = getApiBaseURL()
|
||||||
config.baseURL = apiBaseURL
|
config.baseURL = apiBaseURL
|
||||||
config.withCredentials = shouldIncludeCredentials(config.url, config.baseURL)
|
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
|
return config
|
||||||
},
|
},
|
||||||
function (error) {
|
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,245 +15,702 @@
|
|||||||
|
|
||||||
<!-- 玻璃卡片表单 -->
|
<!-- 玻璃卡片表单 -->
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
<div class="form-top">
|
<!-- ===== 登录视图 ===== -->
|
||||||
<h2 class="form-title">欢迎来到元境</h2>
|
<template v-if="view === 'login'">
|
||||||
<span class="form-title"> 元启万象,境由心生 </span>
|
<div class="form-top">
|
||||||
</div>
|
<h2 class="form-title">欢迎来到元境</h2>
|
||||||
<el-form
|
<span class="form-title"> 元启万象,境由心生 </span>
|
||||||
ref="formRef"
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- 验证码 + 获取按钮 -->
|
<!-- 登录方式切换 -->
|
||||||
<el-form-item prop="captcha">
|
<div class="login-tabs">
|
||||||
<div class="captcha-wrapper">
|
<button
|
||||||
<el-input
|
class="login-tab"
|
||||||
v-model="form.captcha"
|
:class="{ active: loginType === 'password' }"
|
||||||
placeholder="请输入验证码"
|
@click="loginType = 'password'"
|
||||||
: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"
|
|
||||||
>
|
>
|
||||||
登 录 / 注 册
|
账密登录
|
||||||
</el-button>
|
</button>
|
||||||
</el-form-item>
|
<button
|
||||||
</el-form>
|
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 class="form-bottom">
|
||||||
登录即表示您同意遵守《用户协议》和《隐私政策》
|
登录即表示您同意遵守《用户协议》和《隐私政策》
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, computed, onUnmounted } from 'vue';
|
import { reactive, ref, computed, onUnmounted } from 'vue';
|
||||||
import type { FormInstance, FormRules } from 'element-plus';
|
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 { ElMessage } from 'element-plus';
|
||||||
import { Code } from '@/apis/login';
|
|
||||||
import type { CodeResult, LoginParams, CaptchaParams } from '@/types/login';
|
|
||||||
import { useLoginStore } from '@/store/LoginStore';
|
import { useLoginStore } from '@/store/LoginStore';
|
||||||
import { debounce } from '@/utlis/debounce';
|
import { debounce } from '@/utlis/debounce';
|
||||||
|
import {
|
||||||
|
authLogin,
|
||||||
|
authSendSmsCode,
|
||||||
|
authSmsLogin,
|
||||||
|
authSmsResetPassword,
|
||||||
|
createUser,
|
||||||
|
} from '@/apis/auth';
|
||||||
|
import { getUserInfo } from '@/apis/auth';
|
||||||
|
|
||||||
// 登录Store
|
|
||||||
const loginStore = useLoginStore();
|
const loginStore = useLoginStore();
|
||||||
|
|
||||||
// 表单数据类型
|
// ============ 视图与登录方式切换 ============
|
||||||
interface LoginForm {
|
// view: login | register | reset
|
||||||
cellphone: string;
|
const view = ref<'login' | 'register' | 'reset'>('login');
|
||||||
captcha: string;
|
// 登录方式:password | sms
|
||||||
}
|
const loginType = ref<'password' | 'sms'>('password');
|
||||||
|
|
||||||
// 表单数据
|
// 切换视图时重置表单状态
|
||||||
const form = reactive<LoginForm>({
|
const switchView = (v: 'login' | 'register' | 'reset') => {
|
||||||
cellphone: '',
|
view.value = v;
|
||||||
captcha: '',
|
// 切换视图时清空倒计时
|
||||||
});
|
resetCountdown();
|
||||||
|
};
|
||||||
|
|
||||||
// 表单实例引用
|
// ============ 通用状态 ============
|
||||||
const formRef = ref<FormInstance>();
|
|
||||||
// 加载状态
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
// 登录参数(用于保存key)
|
|
||||||
const loginParams = ref<LoginParams>({
|
|
||||||
cellphone: '',
|
|
||||||
key: '',
|
|
||||||
sms_code: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 验证规则
|
// ============ 验证码倒计时(login / register / reset 共用) ============
|
||||||
const rules = reactive<FormRules<LoginForm>>({
|
const countdown = ref(0);
|
||||||
cellphone: [
|
let countdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
{ 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' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// 验证码倒计时
|
const canSendSms = computed(() => countdown.value === 0);
|
||||||
const countdown = ref(60);
|
|
||||||
const countingDown = ref(false);
|
|
||||||
let timer: number | null = null;
|
|
||||||
|
|
||||||
// 手机号是否有效
|
const startCountdown = () => {
|
||||||
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;
|
|
||||||
countdown.value = 60;
|
countdown.value = 60;
|
||||||
timer = setInterval(() => {
|
countdownTimer = setInterval(() => {
|
||||||
countdown.value--;
|
countdown.value--;
|
||||||
if (countdown.value <= 0) {
|
if (countdown.value <= 0 && countdownTimer) {
|
||||||
clearInterval(timer!);
|
clearInterval(countdownTimer);
|
||||||
timer = null;
|
countdownTimer = null;
|
||||||
countingDown.value = false;
|
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGetCaptcha = debounce(requestCaptcha, 500);
|
const resetCountdown = () => {
|
||||||
|
if (countdownTimer) {
|
||||||
// 提交表单(登录)
|
clearInterval(countdownTimer);
|
||||||
const doSubmitForm = async () => {
|
countdownTimer = null;
|
||||||
if (!formRef.value) return;
|
}
|
||||||
if (loading.value) return;
|
countdown.value = 0;
|
||||||
|
|
||||||
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 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;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
await loginStore.loginWithSelectedAccount(selectedId, loginParams.value);
|
const vo = await passwordLoginApi({
|
||||||
// 登录成功后会跳转,无需额外关闭弹窗(store 里已经关闭)
|
username: loginForm.username,
|
||||||
} catch (error) {
|
password: loginForm.password,
|
||||||
console.error('选择账号登录失败:', error);
|
});
|
||||||
ElMessage.error('登录失败,请稍后重试');
|
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 {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectAccount = debounce(handleSelectAccount, 500);
|
const handleLogin = debounce(doLogin, 500);
|
||||||
|
|
||||||
// 弹窗关闭时的回调(如果用户直接关闭,清空账号列表,避免下次残留)
|
/** 短信登录 */
|
||||||
const handleDialogClose = () => {
|
const doSmsLogin = async () => {
|
||||||
loginStore.accountList = [];
|
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(() => {
|
onUnmounted(() => {
|
||||||
if (timer) clearInterval(timer);
|
resetCountdown();
|
||||||
handleGetCaptcha.cancel();
|
handleSendSms.cancel();
|
||||||
submitForm.cancel();
|
handleLogin.cancel();
|
||||||
selectAccount.cancel();
|
handleSmsLogin.cancel();
|
||||||
|
handleRegister.cancel();
|
||||||
|
handleResetPassword.cancel();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
9
src/vite-env.d.ts
vendored
9
src/vite-env.d.ts
vendored
@ -1,5 +1,14 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <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' {
|
declare module '*.vue' {
|
||||||
import type { DefineComponent } from 'vue'
|
import type { DefineComponent } from 'vue'
|
||||||
const component: DefineComponent<{}, {}, any>
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: 'localhost',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
// 配置代理解决跨域问题
|
// 配置代理解决跨域问题
|
||||||
proxy: {
|
proxy: {
|
||||||
@ -31,8 +31,17 @@ export default defineConfig(({ mode }) => {
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/token/, ''),
|
rewrite: (path) => path.replace(/^\/token/, ''),
|
||||||
|
|
||||||
}
|
},
|
||||||
|
// 新增:元境 /v1 接口代理(auth/user 等)
|
||||||
|
'/v1': {
|
||||||
|
target: apiBaseURL,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
// 兼容旧的 /rbac 接口(图片/视频生成)
|
||||||
|
'/rbac': {
|
||||||
|
target: apiBaseURL,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// optimizeDeps: {
|
// optimizeDeps: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user