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:
DEV_DSW
2026-05-27 18:51:17 +08:00
parent 5cdef2b692
commit e2891f5793
12 changed files with 209 additions and 132 deletions

View File

@@ -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>

View File

@@ -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: {

View File

@@ -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);

View File

@@ -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";

View File

@@ -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');
function loadGoogleIdentityServices(): Promise<void> {
if (window.google?.accounts?.id) {
return Promise.resolve();
}
// 3. 严格比对
if (!returnedState || returnedState !== originalState) {
// 清除缓存并拒绝后续登录逻辑
sessionStorage.removeItem('oauth_state');
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;
}
// 4. 比对成功,清除使用过的 state继续执行后续拿 code 换 token 的业务
sessionStorage.removeItem('oauth_state');
const code = urlParams.get('code');
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>

View File

@@ -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="[
<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)"
>
]" @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({

View File

@@ -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";

View File

@@ -1,20 +1,11 @@
<template>
<z-paging
:bg-color="
'linear-gradient(180deg, ' +
<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"
>
" 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);

View File

@@ -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="[
<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)"
>
]" @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="[
<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({

View 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
View 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);
}
}

View 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;
}
};
}
}