diff --git a/src/common/apis/ab98/index.ts b/src/common/apis/ab98/index.ts new file mode 100644 index 0000000..999be28 --- /dev/null +++ b/src/common/apis/ab98/index.ts @@ -0,0 +1,55 @@ +import { request } from '@/http/axios' +import { + GetTokenParams, + LoginData, + LogoutResponse, + SmsSendResponse, + TokenResponse, + VerifySmsParams, + WechatQrCodeParams +} from './type' + +/** 获取临时令牌 */ +export function getTokenApi(appName: string) { + return request<ApiResponseData<TokenResponse>>({ + url: '/api/wx/login/getToken', + method: 'get', + params: { appName } + }) +} + +/** 获取微信登录二维码 */ +export function getWechatQrCodeApi(token: string) { + return request<ApiResponseData<string>>({ + url: '/api/wx/login/wechat/qrcode', + method: 'get', + params: { token } + }) +} + +/** 发送短信验证码 */ +export function sendSmsApi(token: string, tel: string) { + return request<ApiResponseData<SmsSendResponse>>({ + url: '/api/wx/login/sendSms', + method: 'post', + params: { token, tel } + }) +} + +/** 验证短信验证码 */ +export function verifySmsApi(params: VerifySmsParams) { + return request<ApiResponseData<LoginData>>({ + url: '/api/wx/login/verifySms', + method: 'post', + params + }) +} + +/** 用户退出登录 */ +export function logoutApi(token: string) { + return request<ApiResponseData<LogoutResponse>>({ + url: '/api/wx/login/logout', + method: 'post', + params: { token } + }) +} \ No newline at end of file diff --git a/src/common/apis/ab98/type.ts b/src/common/apis/ab98/type.ts new file mode 100644 index 0000000..44d9025 --- /dev/null +++ b/src/common/apis/ab98/type.ts @@ -0,0 +1,59 @@ +/** 令牌响应 */ +export interface TokenResponse { + /** 认证令牌 */ + token: string +} + +/** 退出登录响应 */ +export interface LogoutResponse { + /** 是否成功 */ + success: boolean +} + +/** 短信发送响应 */ +export interface SmsSendResponse { + /** 发送状态 */ + success: boolean + /** 错误信息 */ + message?: string +} + +/** 登录数据 */ +export interface LoginData { + /** 用户头像 */ + face_img: string + /** 登录状态 */ + success: boolean + /** 用户性别 */ + sex: string + /** 用户姓名 */ + name: string + /** 用户ID */ + userid: string + /** 是否已注册 */ + registered: boolean + /** 联系电话 */ + tel: string +} + +/** 获取令牌参数 */ +export type GetTokenParams = { + /** 应用名称 */ + appName: string +} + +/** 微信二维码参数 */ +export type WechatQrCodeParams = { + /** 认证令牌 */ + token: string +} + +/** 短信验证参数 */ +export type VerifySmsParams = { + /** 认证令牌 */ + token: string + /** 手机号码 */ + tel: string + /** 验证码 */ + vcode: string +} \ No newline at end of file diff --git a/src/layout/components/Tabbar.vue b/src/layout/components/Tabbar.vue index 9a4b34f..310439a 100644 --- a/src/layout/components/Tabbar.vue +++ b/src/layout/components/Tabbar.vue @@ -3,7 +3,7 @@ const router = useRouter() const tabbarItemList = computed(() => { const routes = router.getRoutes() - return routes.filter(route => route.meta.layout?.tabbar?.showTabbar) + return routes.filter(route => route.meta.layout?.tabbar?.showTabbar && route.path !== '/cabinet') .map(route => ({ title: route.meta.title, icon: route.meta.layout?.tabbar?.icon, diff --git a/src/pages/login/Ab98Login.vue b/src/pages/login/Ab98Login.vue new file mode 100644 index 0000000..ed12a61 --- /dev/null +++ b/src/pages/login/Ab98Login.vue @@ -0,0 +1,168 @@ +<script setup> +import { ref } from 'vue' +import { useRouter } from 'vue-router' +import { verifySmsApi, sendSmsApi, getTokenApi } from '@/common/apis/ab98' +import { useAb98UserStore } from '@/pinia/stores/ab98-user' +import { showSuccessToast, showFailToast } from 'vant' + +const userStore = useAb98UserStore() +const router = useRouter() + +// 表单数据 +const form = ref({ + tel: '', + vcode: '' +}) + +// 验证规则 +const rules = { + tel: [ + { required: true, message: '请输入手机号码', trigger: 'blur' }, + { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' } + ], + vcode: [ + { required: true, message: '请输入验证码', trigger: 'blur' } + ] +} + +// 倒计时状态 +const countdown = ref(0) +const canSend = ref(true) + +// 发送验证码 +const handleSendSms = async () => { + if (!form.value.tel) { + showFailToast('请先输入手机号码') + return + } + + try { + const { data: tokenData } = await getTokenApi('shop-web') + if (!tokenData?.success) { + showFailToast('获取token失败') + return + } + userStore.setToken(tokenData.data.token); + + const { data } = await sendSmsApi(tokenData.data.token, form.value.tel) + if (data.success) { + showSuccessToast('验证码已发送') + startCountdown() + } else { + showFailToast(data.message || '发送失败') + } + } catch (error) { + showFailToast('发送验证码失败') + } +} + +// 开始倒计时 +const startCountdown = () => { + canSend.value = false + countdown.value = 60 + const timer = setInterval(() => { + if (countdown.value <= 0) { + clearInterval(timer) + canSend.value = true + return + } + countdown.value-- + }, 1000) +} + +// 提交登录 +const handleSubmit = async () => { + try { + const params = { + token: userStore.token, + tel: form.value.tel, + vcode: form.value.vcode + } + + const { data } = await verifySmsApi(params) + if (data.success) { + userStore.setUserInfo(data) + ElMessage.success('登录成功') + router.push('/') + } else { + ElMessage.error('验证码错误或已过期') + } + } catch (error) { + ElMessage.error('登录失败') + } +} +</script> + +<template> + <div class="login-container"> + <el-card class="login-box"> + <h2 class="title">手机验证码登录</h2> + + <el-form + :model="form" + :rules="rules" + label-width="80px" + label-position="top" + > + <el-form-item label="手机号码" prop="tel"> + <el-input v-model="form.tel" placeholder="请输入手机号码" /> + </el-form-item> + + <el-form-item label="验证码" prop="vcode"> + <div class="vcode-input"> + <el-input + v-model="form.vcode" + placeholder="请输入验证码" + style="width: 60%" + /> + <el-button + :disabled="!canSend" + @click="handleSendSms" + style="margin-left: 10px; width: 35%" + > + {{ countdown > 0 ? `${countdown}秒后重试` : '获取验证码' }} + </el-button> + </div> + </el-form-item> + + <el-form-item> + <el-button + type="primary" + @click="handleSubmit" + style="width: 100%" + > + 立即登录 + </el-button> + </el-form-item> + </el-form> + </el-card> + </div> +</template> + +<style scoped> +.login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background-color: #f5f7fa; +} + +.login-box { + width: 400px; + padding: 30px; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); +} + +.title { + text-align: center; + margin-bottom: 30px; + color: #303133; +} + +.vcode-input { + display: flex; + align-items: center; +} +</style> \ No newline at end of file diff --git a/src/pages/me/index.vue b/src/pages/me/index.vue index 61ece3a..c1e55da 100644 --- a/src/pages/me/index.vue +++ b/src/pages/me/index.vue @@ -1,17 +1,22 @@ <script setup lang="ts"> import { useRouter } from 'vue-router' import { useWxStore } from '@/pinia/stores/wx' -import { computed } from 'vue' +import { useAb98UserStore } from '@/pinia/stores/ab98-user' +import { storeToRefs } from 'pinia' import { publicPath } from "@/common/utils/path" const router = useRouter() const wxStore = useWxStore() -const balance = computed(() => wxStore.balance) +const ab98UserStore = useAb98UserStore() + +const { balance } = storeToRefs(wxStore) +const { name: userName, sex: userSex, face_img } = storeToRefs(ab98UserStore) + +const userAvatar = face_img.value ? face_img.value : `${publicPath}img/1.jpg` </script> <template> <div un-py-16px> - <!-- 用户信息区域 --> <van-cell-group class="user-card"> <van-cell :border="false"> <template #title> @@ -20,13 +25,12 @@ const balance = computed(() => wxStore.balance) round width="80" height="80" - :src="`${publicPath}img/1.jpg`" + :src="userAvatar" class="mr-4" /> <div> - <div class="text-lg font-bold mb-2">{{ '' }}</div> - <van-tag type="primary" class="mr-2">{{ 20 }}岁</van-tag> - <van-tag type="success">{{ '男' }}</van-tag> + <div class="text-lg font-bold mb-2">{{ userName }}</div> + <van-tag type="primary" class="mr-2">{{ userSex }}</van-tag> </div> </div> </template> diff --git a/src/pages/product/components/checkout.vue b/src/pages/product/components/checkout.vue index bc45ae4..b393938 100644 --- a/src/pages/product/components/checkout.vue +++ b/src/pages/product/components/checkout.vue @@ -155,7 +155,7 @@ async function handleSubmit() { <van-field label="支付方式" :model-value="selectedPayment" readonly> <template #input> <van-radio-group v-model="selectedPayment" direction="horizontal"> - <van-radio name="wechat"> + <van-radio name="wechat" v-if="!wxStore.corpid"> <van-icon name="wechat" class="method-icon" /> 微信支付 </van-radio> diff --git a/src/pinia/stores/ab98-user.ts b/src/pinia/stores/ab98-user.ts new file mode 100644 index 0000000..96a622b --- /dev/null +++ b/src/pinia/stores/ab98-user.ts @@ -0,0 +1,123 @@ +import { pinia } from "@/pinia" +import { LoginData } from "@/common/apis/ab98/type" + +// 本地存储键名常量 +const STORAGE_KEYS = { + FACE: 'ab98_face', + SEX: 'ab98_sex', + NAME: 'ab98_name', + USERID: 'ab98_userid', + REGISTERED: 'ab98_registered', + TEL: 'ab98_tel', + TOKEN: 'ab98_token' +} + +/** + * AB98用户信息存储 + * @description 管理AB98系统用户相关状态信息 + */ +export const useAb98UserStore = defineStore("ab98User", () => { + // 用户面部图像URL + const storedFace = localStorage.getItem(STORAGE_KEYS.FACE) + const face_img = ref<string>(storedFace ? atob(storedFace) : '') + // 用户性别(男/女) + const storedSex = localStorage.getItem(STORAGE_KEYS.SEX) + const sex = ref<string>(storedSex ? atob(storedSex) : '') + // 用户真实姓名 + const storedName = localStorage.getItem(STORAGE_KEYS.NAME) + const name = ref<string>(storedName ? atob(storedName) : '') + // AB98系统用户唯一标识 + const storedUserId = localStorage.getItem(STORAGE_KEYS.USERID) + const userid = ref<string>(storedUserId ? atob(storedUserId) : "") + // 是否已完成注册流程 + const registered = ref<boolean>(JSON.parse(localStorage.getItem(STORAGE_KEYS.REGISTERED) || "false")) + // 用户绑定手机号 + const storedTel = localStorage.getItem(STORAGE_KEYS.TEL) + const tel = ref<string>(storedTel ? atob(storedTel) : "") + // 用户认证令牌 + const storedToken = localStorage.getItem(STORAGE_KEYS.TOKEN) + const token = ref<string>(storedToken ? atob(storedToken) : "") + // 用户登录状态 + const isLogin = ref<boolean>(false); + isLogin.value = tel.value ? true : false; + + /** + * 更新用户基本信息 + * @param data - 登录接口返回的用户数据 + */ + const setUserInfo = (data: LoginData) => { + face_img.value = data.face_img + localStorage.setItem(STORAGE_KEYS.FACE, btoa(data.face_img)) + sex.value = data.sex + localStorage.setItem(STORAGE_KEYS.SEX, btoa(data.sex)) + name.value = data.name + localStorage.setItem(STORAGE_KEYS.NAME, btoa(data.name)) + userid.value = data.userid + localStorage.setItem(STORAGE_KEYS.USERID, btoa(data.userid)) + registered.value = data.registered + localStorage.setItem(STORAGE_KEYS.REGISTERED, JSON.stringify(data.registered)) + tel.value = data.tel + localStorage.setItem(STORAGE_KEYS.TEL, btoa(data.tel)) + } + + /** + * 清空用户敏感信息 + * @description 用于用户登出或会话过期时 + */ + const clearUserInfo = () => { + face_img.value = "" + localStorage.removeItem(STORAGE_KEYS.FACE) + sex.value = "" + localStorage.removeItem(STORAGE_KEYS.SEX) + name.value = "" + localStorage.removeItem(STORAGE_KEYS.NAME) + userid.value = "" + localStorage.removeItem(STORAGE_KEYS.USERID) + registered.value = false + localStorage.removeItem(STORAGE_KEYS.REGISTERED) + tel.value = "" + localStorage.removeItem(STORAGE_KEYS.TEL) + localStorage.removeItem(STORAGE_KEYS.TOKEN) + } + + /** + * 设置认证令牌 + * @param value - JWT格式的认证令牌 + */ + const setToken = (value: string) => { + localStorage.setItem(STORAGE_KEYS.TOKEN, btoa(value)) + token.value = value + } + + const setTel = (value: string) => { + localStorage.setItem(STORAGE_KEYS.TEL, btoa(value)) + tel.value = value + } + + const setIsLogin = (value: boolean) => { + isLogin.value = value; + } + + return { + face_img, + sex, + name, + userid, + registered, + tel, + token, + isLogin, + setUserInfo, + setToken, + setTel, + setIsLogin, + clearUserInfo + } +}) + +/** + * @description 在非setup上下文或SSR场景中使用store + */ +export function useAb98UserStoreOutside() { + return useAb98UserStore(pinia) +} \ No newline at end of file diff --git a/src/router/guard.ts b/src/router/guard.ts index 01a3ca1..4ca96fd 100644 --- a/src/router/guard.ts +++ b/src/router/guard.ts @@ -5,18 +5,26 @@ import { isWhiteList } from "@/router/whitelist" import { useTitle } from "@@/composables/useTitle" import { getToken } from "@@/utils/cache/cookies" import NProgress from "nprogress" +import { useAb98UserStore } from '@/pinia/stores/ab98-user' NProgress.configure({ showSpinner: false }) const { setTitle } = useTitle() -const LOGIN_PATH = "/login" +const LOGIN_PATH = "/ab98" export function registerNavigationGuard(router: Router) { // 全局前置守卫 router.beforeEach((to, _from) => { NProgress.start() + const userStore = useAb98UserStore() + if (!userStore.isLogin) { + // 如果在免登录的白名单中,则直接进入 + if (isWhiteList(to)) return true + // 其他没有访问权限的页面将被重定向到登录页面 + return LOGIN_PATH + } return true; // const userStore = useUserStore() // // 如果没有登录 diff --git a/src/router/index.ts b/src/router/index.ts index 62b9a4e..1acf8f1 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -86,7 +86,7 @@ export const routes: RouteRecordRaw[] = [ showLeftArrow: false }, tabbar: { - showTabbar: false, + showTabbar: true, icon: "home-o" } } @@ -147,6 +147,14 @@ export const routes: RouteRecordRaw[] = [ } } } + }, + { + path: "/ab98", + component: () => import("@/pages/login/Ab98Login.vue"), + name: "Ab98Login", + meta: { + title: "登录" + } } ] diff --git a/src/router/whitelist.ts b/src/router/whitelist.ts index 7811e19..5c7cf86 100644 --- a/src/router/whitelist.ts +++ b/src/router/whitelist.ts @@ -1,7 +1,7 @@ import type { RouteLocationNormalizedGeneric, RouteRecordNameGeneric } from "vue-router" /** 免登录白名单(匹配路由 path) */ -const whiteListByPath: string[] = ["/login"] +const whiteListByPath: string[] = ["/login", "/ab98"] /** 免登录白名单(匹配路由 name) */ const whiteListByName: RouteRecordNameGeneric[] = []