feat: add google sign-in, utils, refactor imports
- Restructure API import paths from @/request/api/* to cleaner @/api/[module] format - Add three new utility classes: PhoneUtils for phone validation, ThrottleUtils and DebounceUtils for rate limiting - Implement official Google One Tap login flow, add the GIS client script to index.html - Clean up long inline template attributes in multiple Vue components for better readability - Fix constant import path and default object return values in booking components
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
/>
|
||||
<meta name="theme-color" content="#2D91FF" />
|
||||
<title>nianxx</title>
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
class="booking-footer border-box bg-white flex flex-items-center font-family-misans-vf"
|
||||
>
|
||||
<span
|
||||
class="amt font-size-20 font-bold color-FF3D60 line-height-28 flex flex-items-center mr-8"
|
||||
>
|
||||
<div class="booking-footer border-box bg-white flex flex-items-center font-family-misans-vf">
|
||||
<span class="amt font-size-20 font-bold color-FF3D60 line-height-28 flex flex-items-center mr-8">
|
||||
{{ totalAmt }}
|
||||
</span>
|
||||
<!-- <div class="flex flex-items-center" @click="emit('detailClick')">
|
||||
<span class="font-size-12 color-A3A3A3 mr-4">明细</span>
|
||||
<uni-icons type="up" size="16" color="#A3A3A3" />
|
||||
</div> -->
|
||||
<div
|
||||
class="btn border-box rounded-10 flex flex-items-center ml-auto pl-8"
|
||||
@click="handleBooking"
|
||||
>
|
||||
<img
|
||||
class="icon"
|
||||
src="https://oss.nianxx.cn/mp/static/version_101/common/btn.png"
|
||||
/>
|
||||
<div class="btn border-box rounded-10 flex flex-items-center ml-auto pl-8" @click="handleBooking">
|
||||
<img class="icon" src="https://oss.nianxx.cn/mp/static/version_101/common/btn.png" />
|
||||
<span class="font-size-16 font-500 color-white">立即支付</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,8 +16,8 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, defineProps, defineEmits, ref, onMounted, watch } from "vue";
|
||||
import { DebounceUtils } from "@/utils";
|
||||
import { preOrder } from "@/request/api/OrderApi";
|
||||
import { DebounceUtils } from "@/utils/DebounceUtils";
|
||||
import { preOrder } from "@/api/order";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -36,11 +26,11 @@ const props = defineProps({
|
||||
},
|
||||
orderData: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
default: () => { },
|
||||
},
|
||||
selectedDate: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
default: () => { },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -69,10 +69,11 @@ import UserSection from "./components/UserSection/index.vue";
|
||||
import RefundPopup from "@/components/RefundPopup/index.vue";
|
||||
import DetailPopup from "@/components/DetailPopup/index.vue";
|
||||
import FooterSection from "./components/FooterSection/index.vue";
|
||||
import { goodsDetail, orderPay } from "@/request/api/GoodsApi";
|
||||
import { goodsDetail, orderPay } from "@/api/goods";
|
||||
import { useSelectedDateStore } from "@/store";
|
||||
import { GOODS_TYPE } from "@/constant/type";
|
||||
import { ThrottleUtils, PhoneUtils } from "@/utils";
|
||||
import { GOODS_TYPE } from "@/constants/type";
|
||||
import { ThrottleUtils } from "@/utils/ThrottleUtils";
|
||||
import { PhoneUtils } from "@/utils/PhoneUtils";
|
||||
|
||||
const refundVisible = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { goodsDetail, commodityDailyPriceList } from "@/request/api/GoodsApi";
|
||||
import { goodsDetail, commodityDailyPriceList } from "@/api/goods";
|
||||
import { DateUtils } from "@/utils";
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import ImageSwiper from "@/components/ImageSwiper/index.vue";
|
||||
|
||||
@@ -1,56 +1,118 @@
|
||||
<template>
|
||||
<div class="h-screen flex flex-col items-center justify-center">
|
||||
<div class="w-[304px] mt-[20px]">
|
||||
<button class="w-full h-[44px] bg-[#0ccd58] text-white rounded-[10px] text-center" @click="loginWithGoogle">
|
||||
Continue with Google
|
||||
</button>
|
||||
</div>
|
||||
<div id="buttonDiv" class="w-[304px] mt-[20px]"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from "vue";
|
||||
import { oauthToken } from '@/api/login'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const generateState = () => {
|
||||
const array = new Uint32Array(4);
|
||||
const router = useRouter()
|
||||
|
||||
window.crypto.getRandomValues(array);
|
||||
|
||||
return Array.from(array, dec => dec.toString(16).padStart(8, '0')).join('');
|
||||
}
|
||||
|
||||
// 用Google登录
|
||||
const loginWithGoogle = () => {
|
||||
const client_id = '你的Google客户端ID';
|
||||
const redirect_uri = encodeURIComponent('http://10.10.3.1:5174/login');
|
||||
const scope = encodeURIComponent('openid email profile');
|
||||
const response_type = 'code'; // 推荐 code 模式(授权码模式,更安全);如果不需要后端参与,可填 'token' (隐式模式)
|
||||
const state = generateState();
|
||||
sessionStorage.setItem('oauth_state', state);
|
||||
|
||||
const authUrl = `https://google.com{client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`;
|
||||
|
||||
window.location.href = authUrl;
|
||||
type GoogleIdentityServices = {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize: (params: Record<string, unknown>) => void;
|
||||
renderButton: (el: Element, options: Record<string, unknown>) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 1. 从 URL 中获取谷歌传回的 state
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const returnedState = urlParams.get('state');
|
||||
declare global {
|
||||
interface Window {
|
||||
google?: GoogleIdentityServices;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 从本地缓存取出之前存的 state
|
||||
const originalState = sessionStorage.getItem('oauth_state');
|
||||
|
||||
// 3. 严格比对
|
||||
if (!returnedState || returnedState !== originalState) {
|
||||
// 清除缓存并拒绝后续登录逻辑
|
||||
sessionStorage.removeItem('oauth_state');
|
||||
return;
|
||||
function loadGoogleIdentityServices(): Promise<void> {
|
||||
if (window.google?.accounts?.id) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 4. 比对成功,清除使用过的 state,继续执行后续拿 code 换 token 的业务
|
||||
sessionStorage.removeItem('oauth_state');
|
||||
const code = urlParams.get('code');
|
||||
})
|
||||
return new Promise((resolve, reject) => {
|
||||
const src = "https://accounts.google.com/gsi/client";
|
||||
const existing = document.querySelector < HTMLScriptElement > (
|
||||
`script[src="${src}"]`,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
if (window.google?.accounts?.id) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
existing.addEventListener("load", () => resolve(), { once: true });
|
||||
existing.addEventListener(
|
||||
"error",
|
||||
() => reject(new Error("GIS load error")),
|
||||
{ once: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error("GIS load error"));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadGoogleIdentityServices()
|
||||
.then(() => {
|
||||
if (!window.google?.accounts?.id) {
|
||||
throw new Error("GIS not available");
|
||||
}
|
||||
|
||||
window.google.accounts.id.initialize({
|
||||
client_id:
|
||||
"237078935964-e2pioe5hlfbcd4gaam4m9sn859iihdb9.apps.googleusercontent.com",
|
||||
ux_mode: "popup",
|
||||
callback: (response: any) => {
|
||||
// 登录成功后,谷歌会直接把凭证传进这个函数
|
||||
console.log("谷歌登录成功,收到凭证:", response);
|
||||
|
||||
// 这个 credential 就是 JWT (ID Token)
|
||||
const idToken = response.credential;
|
||||
|
||||
if (idToken) {
|
||||
// 【后续业务】:你可以将这串 idToken 通过 axios 发送给你自己的后端接口
|
||||
// 或者是直接利用第三方平台(如 Firebase / Supabase)完成纯前端鉴权
|
||||
console.log("拿到的 ID Token 是:", idToken);
|
||||
oauthToken({ clientId: '6', openIdCode: [idToken], grant_type: 'google' })
|
||||
.then((res) => {
|
||||
console.log("获取到的 oauth token:", res)
|
||||
|
||||
router.go(-1)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
})
|
||||
}
|
||||
},
|
||||
context: "signin",
|
||||
});
|
||||
|
||||
const container = document.getElementById("buttonDiv");
|
||||
if (!container) {
|
||||
throw new Error("Missing #buttonDiv");
|
||||
}
|
||||
|
||||
window.google.accounts.id.renderButton(container, {
|
||||
theme: "outline",
|
||||
size: "large",
|
||||
width: 304,
|
||||
shape: "rectangular",
|
||||
logo_alignment: "center",
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
<template>
|
||||
<div
|
||||
class="footer bg-white border-box flex flex-items-center flex-justify-between p-12"
|
||||
>
|
||||
<button
|
||||
v-if="['1', '2'].includes(statusCode)"
|
||||
<div class="footer bg-white border-box flex flex-items-center flex-justify-between p-12">
|
||||
<button v-if="['1', '2'].includes(statusCode)"
|
||||
class="left border-none border-box bg-white rounded-10 flex flex-items-center flex-justify-center font-size-14 font-500 color-525866 mr-12"
|
||||
@click="emit('refund', orderData)"
|
||||
>
|
||||
@click="emit('refund', orderData)">
|
||||
申请退款
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'right border-none rounded-10 flex flex-full flex-items-center flex-justify-center font-size-14 font-500 bg-theme-color-500',
|
||||
{
|
||||
'bg-FF3D60': statusCode === '0',
|
||||
'color-white': ['1', '2', '3', '4', '5', '6'].includes(statusCode),
|
||||
},
|
||||
]"
|
||||
@click="handleButtonClick(orderData)"
|
||||
>
|
||||
<button :class="[
|
||||
'right border-none rounded-10 flex flex-full flex-items-center flex-justify-center font-size-14 font-500 bg-theme-color-500',
|
||||
{
|
||||
'bg-FF3D60': statusCode === '0',
|
||||
'color-white': ['1', '2', '3', '4', '5', '6'].includes(statusCode),
|
||||
},
|
||||
]" @click="handleButtonClick(orderData)">
|
||||
{{ buttonspan }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -26,7 +19,7 @@
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, computed } from "vue";
|
||||
import { orderPayNow } from "@/request/api/OrderApi";
|
||||
import { orderPayNow } from "@/api/order";
|
||||
import { DebounceUtils } from "@/utils";
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, getCurrentInstance } from "vue";
|
||||
import { userOrderDetail, orderRefund } from "@/request/api/OrderApi";
|
||||
import { userOrderDetail, orderRefund } from "@/api/order";
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import OrderQrcode from "./components/OrderQrcode/index.vue";
|
||||
import VoucherList from "./components/VoucherList/index.vue";
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
<template>
|
||||
<z-paging
|
||||
:bg-color="
|
||||
'linear-gradient(180deg, ' +
|
||||
$theme -
|
||||
color -
|
||||
100 +
|
||||
' 0%, #F5F7FA 100%) 0 86px / 100% 100px no-repeat'
|
||||
"
|
||||
ref="paging"
|
||||
v-model="dataList"
|
||||
use-virtual-list
|
||||
:force-close-inner-list="true"
|
||||
cell-height-mode="dynamic"
|
||||
safe-area-inset-bottom
|
||||
@query="queryList"
|
||||
>
|
||||
<z-paging :bg-color="'linear-gradient(180deg, ' +
|
||||
$theme -
|
||||
color -
|
||||
100 +
|
||||
' 0%, #F5F7FA 100%) 0 86px / 100% 100px no-repeat'
|
||||
" ref="paging" v-model="dataList" use-virtual-list :force-close-inner-list="true" cell-height-mode="dynamic"
|
||||
safe-area-inset-bottom @query="queryList">
|
||||
<template #top>
|
||||
<TopNavBar title="全部订单" />
|
||||
</template>
|
||||
@@ -23,12 +14,7 @@
|
||||
<CustomEmpty statusspan="您暂无订单" />
|
||||
</template>
|
||||
|
||||
<OrderCard
|
||||
v-for="(item, index) in dataList"
|
||||
:key="item.id || index"
|
||||
:orderData="item"
|
||||
@click="handleOrderClick"
|
||||
/>
|
||||
<OrderCard v-for="(item, index) in dataList" :key="item.id || index" :orderData="item" @click="handleOrderClick" />
|
||||
</z-paging>
|
||||
</template>
|
||||
|
||||
@@ -37,7 +23,7 @@ import { ref } from "vue";
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import CustomEmpty from "@/components/CustomEmpty/index.vue";
|
||||
import OrderCard from "./components/OrderCard/index.vue";
|
||||
import { userOrderList } from "@/request/api/OrderApi";
|
||||
import { userOrderList } from "@/api/order";
|
||||
|
||||
const dataList = ref([]);
|
||||
const paging = ref(null);
|
||||
|
||||
@@ -1,40 +1,26 @@
|
||||
<template>
|
||||
<div class="tab-container relative">
|
||||
<div class="tab-wrapper flex flex-items-center">
|
||||
<div
|
||||
v-for="(item, index) in tabList"
|
||||
:key="item.id"
|
||||
:class="[
|
||||
'tab-item flex flex-items-center flex-justify-center relative',
|
||||
activeIndex === index && 'tab-item-active',
|
||||
]"
|
||||
@click="handleTabClick(index)"
|
||||
>
|
||||
<div v-for="(item, index) in tabList" :key="item.id" :class="[
|
||||
'tab-item flex flex-items-center flex-justify-center relative',
|
||||
activeIndex === index && 'tab-item-active',
|
||||
]" @click="handleTabClick(index)">
|
||||
<div class="tab-item-inner flex flex-items-center">
|
||||
<uni-icons
|
||||
:class="['icon mr-4', activeIndex === index && 'icon-active']"
|
||||
fontFamily="znicons"
|
||||
size="20"
|
||||
color="opacity"
|
||||
>
|
||||
<uni-icons :class="['icon mr-4', activeIndex === index && 'icon-active']" fontFamily="znicons" size="20"
|
||||
color="opacity">
|
||||
{{ zniconsMap[item.iconCode] }}
|
||||
</uni-icons>
|
||||
|
||||
<span
|
||||
:class="[
|
||||
'font-size-16 font-500 color-525866 ',
|
||||
activeIndex === index && 'tab-span-active',
|
||||
]"
|
||||
>
|
||||
<span :class="[
|
||||
'font-size-16 font-500 color-525866 ',
|
||||
activeIndex === index && 'tab-span-active',
|
||||
]">
|
||||
{{ item.iconTitle }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 每项内的下划线指示器,通过类控制显示/隐藏 -->
|
||||
<div
|
||||
class="tab-item-indicator"
|
||||
:class="{ visible: activeIndex === index }"
|
||||
></div>
|
||||
<div class="tab-item-indicator" :class="{ visible: activeIndex === index }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,7 +29,7 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { zniconsMap } from "@/static/fonts/znicons";
|
||||
import { commodityTypePageList } from "@/request/api/GoodsApi";
|
||||
import { commodityTypePageList } from "@/api/goods";
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
|
||||
23
src/utils/DebounceUtils.ts
Normal file
23
src/utils/DebounceUtils.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 防抖工具类
|
||||
* 提供防抖功能,防止函数在短时间内被重复调用
|
||||
*/
|
||||
export class DebounceUtils {
|
||||
/**
|
||||
* 创建防抖函数
|
||||
* @param {Function} func - 要防抖的函数
|
||||
* @param {number} delay - 防抖延迟时间
|
||||
* @returns {Function} 防抖后的函数
|
||||
*/
|
||||
static createDebounce(func: Function, delay: number) {
|
||||
let timerId: number | null = null;
|
||||
|
||||
return function (...args: any[]) {
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
|
||||
timerId = setTimeout(() => func.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
}
|
||||
11
src/utils/PhoneUtils.ts
Normal file
11
src/utils/PhoneUtils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 验证手机号工具类
|
||||
* @param {string} phone - 手机号字符串
|
||||
* @returns {boolean} 是否为有效手机号
|
||||
*/
|
||||
export class PhoneUtils {
|
||||
static validatePhone(phone: string) {
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
return phoneRegex.test(phone);
|
||||
}
|
||||
}
|
||||
24
src/utils/ThrottleUtils.ts
Normal file
24
src/utils/ThrottleUtils.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 节流工具类
|
||||
* 提供节流功能,防止函数在短时间内被重复调用
|
||||
*/
|
||||
export class ThrottleUtils {
|
||||
/**
|
||||
* 创建节流函数
|
||||
* @param {Function} func - 要节流的函数
|
||||
* @param {number} delay - 节流延迟时间
|
||||
* @returns {Function} 节流后的函数
|
||||
*/
|
||||
static createThrottle(func: Function, delay: number) {
|
||||
let prev = Date.now();
|
||||
|
||||
return function (...args: any[]) {
|
||||
let now = Date.now();
|
||||
|
||||
if (now - prev >= delay) {
|
||||
func.apply(this, args);
|
||||
prev = now;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user