feat(login): add phone and Google login page with i18n

Add complete login flow with phone verification and country picker.
Add login-related translations for en-US, zh-CN and th-TH locales.
Add country calling codes constants and selection utility function.
Register Vant UI component type definitions for TypeScript.
Update development environment authentication token.
This commit is contained in:
duanshuwen
2026-06-01 21:57:12 +08:00
parent b30158b7d9
commit a8ae315e9f
7 changed files with 391 additions and 20 deletions

View File

@@ -14,6 +14,6 @@ VITE_SOCKET_BASE_URL = "wss://onefeel.brother7.cn/ingress/agent/ws/chat"
VITE_CLIENT_ID = "6"
# Token
VITE_TOKEN = "eyJraWQiOiJiMTVhZTk0Mi03MjI5LTMyOWUtODA1Yi0wNjFlNmRjYTE1MDQiLCJhbGciOiJSUzI1NiJ9.eyJ0ZW5hbnRfaWQiOjEsInN1YiI6Im94T3NGN2lqTkxvbEFIdkhDZDYtek1acE5kNWsiLCJjbGllbnRJZCI6ImN1c3RvbSIsImlzcyI6Imh0dHBzOi8vcGlnNGNsb3VkLmNvbSIsImNsaWVudF9pZCI6ImN1c3RvbSIsImF1dGhvcml0aWVzIjpbXSwiYXVkIjoiY3VzdG9tIiwibGljZW5zZSI6Imh0dHBzOi8vcGlnNGNsb3VkLmNvbSIsIndlY2hhdF9vcGVuaWQiOiJveE9zRjdpak5Mb2xBSHZIQ2Q2LXpNWnBOZDVrIiwibmJmIjoxNzgwMjk1MzQ0LCJ1c2VyX2lkIjoiMjAwNTEwMjg0NDQ2OTM4NzI2NSIsInNjb3BlIjpbInNlcnZlciJdLCJleHAiOjE3ODAzMDUzNDQsImlhdCI6MTc4MDI5NTM0NCwianRpIjoiZDgwMmZiZTktMjgxMi00MWU1LWE5MTQtMjBlMTdhZWJiYzA5IiwidXNlcm5hbWUiOiJveE9zRjdpak5Mb2xBSHZIQ2Q2LXpNWnBOZDVrIn0.HXA_IaLKX4M4AnBRHJvk9UbegccWTBWsJ9871TAeAX3SowYBhwyKJMxYhYfFt79WX30_UEa8MNmI49djlDM2ODuaS0Hr-rMsih-QJOasCvAc3AKv8dF8dlxIN0Yqb0visQs9tIaKjBCFDi9B78Ns24kmVCdEW4M90XrqmEIMvXzAyMdXSPL0FWO2IDLTt4EqWc_-wYvizCD4-VbImK2YnlzCqxQW-bvAamws9_psoV_AHflx7esDaOXOv_EAsvqlPAyh0Nf7Mey1wOK4_e-aJOHC1833xWajsK4dBi8-pVw5bA8NYsZowN36wJztDj76QUU51kkR6c_abyoS8EzZCg"
VITE_TOKEN = "eyJraWQiOiJiMTVhZTk0Mi03MjI5LTMyOWUtODA1Yi0wNjFlNmRjYTE1MDQiLCJhbGciOiJSUzI1NiJ9.eyJ0ZW5hbnRfaWQiOjEsInN1YiI6Im94T3NGN2lqTkxvbEFIdkhDZDYtek1acE5kNWsiLCJjbGllbnRJZCI6ImN1c3RvbSIsImlzcyI6Imh0dHBzOi8vcGlnNGNsb3VkLmNvbSIsImNsaWVudF9pZCI6ImN1c3RvbSIsImF1dGhvcml0aWVzIjpbXSwiYXVkIjoiY3VzdG9tIiwibGljZW5zZSI6Imh0dHBzOi8vcGlnNGNsb3VkLmNvbSIsIndlY2hhdF9vcGVuaWQiOiJveE9zRjdpak5Mb2xBSHZIQ2Q2LXpNWnBOZDVrIiwibmJmIjoxNzgwMzE2Mjc3LCJ1c2VyX2lkIjoiMjAwNTEwMjg0NDQ2OTM4NzI2NSIsInNjb3BlIjpbInNlcnZlciJdLCJleHAiOjE3ODAzMjYyNzcsImlhdCI6MTc4MDMxNjI3NywianRpIjoiNmJlNTg2ZjctYzE1YS00MGQwLWIzYTQtNzI4NWJjZDg2OGI0IiwidXNlcm5hbWUiOiJveE9zRjdpak5Mb2xBSHZIQ2Q2LXpNWnBOZDVrIn0.TsNAOgmULtZV7VrLVWFRrN6XziQe0-Uy8ujNMgZDOMu_jicBE-gIpR61FwlCZhHlLrpEounpV24yu1Sr6h0rpfpKQCJWX3VK9XiV6A-CpP1ASCSo08Si0cLmXh8YIh87WZ1BhbqxpvT51AeF2TVdktc297K--Zo41WhbA_QT9E2gi_0ZPOFTPlZlsv9qhnyLfy7R6J-V6u0GxoPQ1zog2py91zEHrtAv9F_RrLAurEtgeycBIMIYHsBxhqBKjkpSy5lmeuNRLHTBfVxNFNcqLaZqkf-xn8Sb-RtEWOeHeXEVhUtKIs94JsHnZn_jf4kQhmYPJOCdbWuGSeqd3VOj8Q"

14
components.d.ts vendored
View File

@@ -47,15 +47,22 @@ declare module 'vue' {
TagsGroup: typeof import('./src/components/TagsGroup/index.vue')['default']
TopNavBar: typeof import('./src/components/TopNavBar/index.vue')['default']
UseDateRange: typeof import('./src/components/UseDateRange/index.vue')['default']
VanButton: typeof import('vant/es')['Button']
VanCell: typeof import('vant/es')['Cell']
VanCheckbox: typeof import('vant/es')['Checkbox']
VanDivider: typeof import('vant/es')['Divider']
VanField: typeof import('vant/es')['Field']
VanIcon: typeof import('vant/es')['Icon']
VanIcons: typeof import('vant/es')['Icons']
VanList: typeof import('vant/es')['List']
VanPopup: typeof import('vant/es')['Popup']
VanPullRefresh: typeof import('vant/es')['PullRefresh']
VanSearch: typeof import('vant/es')['Search']
VanSwipe: typeof import('vant/es')['Swipe']
VanSwipeItem: typeof import('vant/es')['SwipeItem']
VanSwiperItem: typeof import('vant/es')['SwiperItem']
VanTab: typeof import('vant/es')['Tab']
VanTabs: typeof import('vant/es')['Tabs']
ZnIcon: typeof import('./src/components/ZnIcon/index.vue')['default']
}
}
@@ -97,14 +104,21 @@ declare global {
const TagsGroup: typeof import('./src/components/TagsGroup/index.vue')['default']
const TopNavBar: typeof import('./src/components/TopNavBar/index.vue')['default']
const UseDateRange: typeof import('./src/components/UseDateRange/index.vue')['default']
const VanButton: typeof import('vant/es')['Button']
const VanCell: typeof import('vant/es')['Cell']
const VanCheckbox: typeof import('vant/es')['Checkbox']
const VanDivider: typeof import('vant/es')['Divider']
const VanField: typeof import('vant/es')['Field']
const VanIcon: typeof import('vant/es')['Icon']
const VanIcons: typeof import('vant/es')['Icons']
const VanList: typeof import('vant/es')['List']
const VanPopup: typeof import('vant/es')['Popup']
const VanPullRefresh: typeof import('vant/es')['PullRefresh']
const VanSearch: typeof import('vant/es')['Search']
const VanSwipe: typeof import('vant/es')['Swipe']
const VanSwipeItem: typeof import('vant/es')['SwipeItem']
const VanSwiperItem: typeof import('vant/es')['SwiperItem']
const VanTab: typeof import('vant/es')['Tab']
const VanTabs: typeof import('vant/es')['Tabs']
const ZnIcon: typeof import('./src/components/ZnIcon/index.vue')['default']
}

View File

@@ -0,0 +1,93 @@
export type CountryCallingCode = {
name: string;
iso2: string;
dialCode: string;
};
export const COUNTRY_CALLING_CODES: CountryCallingCode[] = [
{ name: "China", iso2: "CN", dialCode: "+86" },
{ name: "Hong Kong", iso2: "HK", dialCode: "+852" },
{ name: "Macao", iso2: "MO", dialCode: "+853" },
{ name: "Taiwan", iso2: "TW", dialCode: "+886" },
{ name: "Thailand", iso2: "TH", dialCode: "+66" },
{ name: "Singapore", iso2: "SG", dialCode: "+65" },
{ name: "Malaysia", iso2: "MY", dialCode: "+60" },
{ name: "Indonesia", iso2: "ID", dialCode: "+62" },
{ name: "Vietnam", iso2: "VN", dialCode: "+84" },
{ name: "Philippines", iso2: "PH", dialCode: "+63" },
{ name: "Japan", iso2: "JP", dialCode: "+81" },
{ name: "Korea, Republic of", iso2: "KR", dialCode: "+82" },
{ name: "India", iso2: "IN", dialCode: "+91" },
{ name: "Pakistan", iso2: "PK", dialCode: "+92" },
{ name: "Bangladesh", iso2: "BD", dialCode: "+880" },
{ name: "Sri Lanka", iso2: "LK", dialCode: "+94" },
{ name: "Cambodia", iso2: "KH", dialCode: "+855" },
{ name: "Laos", iso2: "LA", dialCode: "+856" },
{ name: "Myanmar", iso2: "MM", dialCode: "+95" },
{ name: "Australia", iso2: "AU", dialCode: "+61" },
{ name: "New Zealand", iso2: "NZ", dialCode: "+64" },
{ name: "United States", iso2: "US", dialCode: "+1" },
{ name: "Canada", iso2: "CA", dialCode: "+1" },
{ name: "Mexico", iso2: "MX", dialCode: "+52" },
{ name: "Brazil", iso2: "BR", dialCode: "+55" },
{ name: "Argentina", iso2: "AR", dialCode: "+54" },
{ name: "Chile", iso2: "CL", dialCode: "+56" },
{ name: "Colombia", iso2: "CO", dialCode: "+57" },
{ name: "Peru", iso2: "PE", dialCode: "+51" },
{ name: "Ecuador", iso2: "EC", dialCode: "+593" },
{ name: "Bolivia", iso2: "BO", dialCode: "+591" },
{ name: "Venezuela", iso2: "VE", dialCode: "+58" },
{ name: "Uruguay", iso2: "UY", dialCode: "+598" },
{ name: "Paraguay", iso2: "PY", dialCode: "+595" },
{ name: "United Kingdom", iso2: "GB", dialCode: "+44" },
{ name: "Ireland", iso2: "IE", dialCode: "+353" },
{ name: "France", iso2: "FR", dialCode: "+33" },
{ name: "Germany", iso2: "DE", dialCode: "+49" },
{ name: "Italy", iso2: "IT", dialCode: "+39" },
{ name: "Spain", iso2: "ES", dialCode: "+34" },
{ name: "Portugal", iso2: "PT", dialCode: "+351" },
{ name: "Netherlands", iso2: "NL", dialCode: "+31" },
{ name: "Belgium", iso2: "BE", dialCode: "+32" },
{ name: "Switzerland", iso2: "CH", dialCode: "+41" },
{ name: "Austria", iso2: "AT", dialCode: "+43" },
{ name: "Sweden", iso2: "SE", dialCode: "+46" },
{ name: "Norway", iso2: "NO", dialCode: "+47" },
{ name: "Denmark", iso2: "DK", dialCode: "+45" },
{ name: "Finland", iso2: "FI", dialCode: "+358" },
{ name: "Poland", iso2: "PL", dialCode: "+48" },
{ name: "Czech Republic", iso2: "CZ", dialCode: "+420" },
{ name: "Hungary", iso2: "HU", dialCode: "+36" },
{ name: "Greece", iso2: "GR", dialCode: "+30" },
{ name: "Romania", iso2: "RO", dialCode: "+40" },
{ name: "Bulgaria", iso2: "BG", dialCode: "+359" },
{ name: "Russia", iso2: "RU", dialCode: "+7" },
{ name: "Ukraine", iso2: "UA", dialCode: "+380" },
{ name: "Turkey", iso2: "TR", dialCode: "+90" },
{ name: "Israel", iso2: "IL", dialCode: "+972" },
{ name: "Saudi Arabia", iso2: "SA", dialCode: "+966" },
{ name: "United Arab Emirates", iso2: "AE", dialCode: "+971" },
{ name: "Qatar", iso2: "QA", dialCode: "+974" },
{ name: "Kuwait", iso2: "KW", dialCode: "+965" },
{ name: "Bahrain", iso2: "BH", dialCode: "+973" },
{ name: "Oman", iso2: "OM", dialCode: "+968" },
{ name: "Jordan", iso2: "JO", dialCode: "+962" },
{ name: "Lebanon", iso2: "LB", dialCode: "+961" },
{ name: "Iraq", iso2: "IQ", dialCode: "+964" },
{ name: "Iran", iso2: "IR", dialCode: "+98" },
{ name: "Egypt", iso2: "EG", dialCode: "+20" },
{ name: "Morocco", iso2: "MA", dialCode: "+212" },
{ name: "Algeria", iso2: "DZ", dialCode: "+213" },
{ name: "Tunisia", iso2: "TN", dialCode: "+216" },
{ name: "South Africa", iso2: "ZA", dialCode: "+27" },
{ name: "Nigeria", iso2: "NG", dialCode: "+234" },
{ name: "Kenya", iso2: "KE", dialCode: "+254" },
{ name: "Ghana", iso2: "GH", dialCode: "+233" },
{ name: "Ethiopia", iso2: "ET", dialCode: "+251" },
{ name: "Tanzania", iso2: "TZ", dialCode: "+255" },
];
export function findCountryCallingCode(iso2: string): CountryCallingCode | null {
const normalized = iso2.trim().toUpperCase();
return COUNTRY_CALLING_CODES.find((item) => item.iso2 === normalized) ?? null;
}

View File

@@ -7,6 +7,34 @@ export default {
errors: {
network: "Network error. Please try again later.",
},
login: {
method: {
google: "Sign in with Google",
phone: "Phone + Code",
},
fields: {
country: "Country/Code",
phone: "Phone",
code: "Code",
},
placeholders: {
selectCountry: "Select country code",
phone: "Enter phone number",
code: "Enter verification code",
},
dividersText: "Or",
actions: {
sendCode: "Send code",
login: "Sign in",
},
errors: {
missingPhone: "Please enter your phone number",
missingCode: "Please enter the verification code",
},
tips: {
smsApiMissing: "SMS sending API is not connected",
},
},
state: {
empty: "No data",
loading: "Loading...",

View File

@@ -7,6 +7,34 @@ export default {
errors: {
network: "เครือข่ายผิดพลาด โปรดลองอีกครั้งในภายหลัง",
},
login: {
method: {
google: "เข้าสู่ระบบด้วย Google",
phone: "โทรศัพท์ + รหัส",
},
fields: {
country: "ประเทศ/รหัส",
phone: "โทรศัพท์",
code: "รหัสยืนยัน",
},
placeholders: {
selectCountry: "เลือกรหัสประเทศ",
phone: "กรอกหมายเลขโทรศัพท์",
code: "กรอกรหัสยืนยัน",
},
dividersText: "หรือ",
actions: {
sendCode: "ส่งรหัส",
login: "เข้าสู่ระบบ",
},
errors: {
missingPhone: "กรอกหมายเลขโทรศัพท์",
missingCode: "กรอกรหัสยืนยัน",
},
tips: {
smsApiMissing: "ยังไม่ได้เชื่อมต่อ API สำหรับส่ง SMS",
},
},
state: {
empty: "ไม่มีข้อมูล",
loading: "กำลังโหลด...",

View File

@@ -7,6 +7,34 @@ export default {
errors: {
network: "网络异常,请稍后重试",
},
login: {
method: {
google: "谷歌账号登录",
phone: "手机号验证码登录",
},
fields: {
country: "国家/区号",
phone: "手机号",
code: "验证码",
},
placeholders: {
selectCountry: "请选择国家/区号",
phone: "请输入手机号",
code: "请输入验证码",
},
dividersText: "或",
actions: {
sendCode: "获取验证码",
login: "登录",
},
errors: {
missingPhone: "请输入手机号",
missingCode: "请输入验证码",
},
tips: {
smsApiMissing: "验证码发送接口未接入",
},
},
state: {
empty: "暂无数据",
loading: "加载中...",

View File

@@ -1,15 +1,176 @@
<template>
<div class="h-screen flex flex-col items-center justify-center">
<div id="buttonDiv" class="w-[304px] mt-[20px]"></div>
<div class="h-[100dvh] flex flex-col items-center justify-center px-4 overflow-hidden bg-white">
<div class="px-[12px]">
<section>
<van-cell :title="t('common.login.fields.country')" :value="selectedCountryDisplay" is-link
@click="countryPopupVisible = true" />
<van-field v-model="phone" type="tel" clearable :label="t('common.login.fields.phone')"
:placeholder="t('common.login.placeholders.phone')" autocomplete="tel" />
<van-field v-model="code" type="digit" clearable maxlength="8" :label="t('common.login.fields.code')"
:placeholder="t('common.login.placeholders.code')" autocomplete="one-time-code">
<template #button>
<van-button class="rounded-full" size="small" @click="handleSendCode">
{{ t('common.login.actions.sendCode') }}
</van-button>
</template>
</van-field>
<div class="mt-4">
<van-button class="login-main-btn" type="primary" block :loading="phoneSubmitting"
:disabled="!canSubmitPhoneLogin" @click="handlePhoneLogin">
{{ t('common.login.actions.login') }}
</van-button>
</div>
</section>
<van-divider>{{ t('common.login.dividersText') }}</van-divider>
<div class="flex flex-col items-center justify-center">
<div ref="googleButtonEl" id="buttonDiv" class="google-login-btn"></div>
</div>
</div>
</div>
<van-popup v-model:show="countryPopupVisible" round position="bottom" class="h-[70vh]">
<div class="h-full flex flex-col">
<div class="p-3">
<van-search v-model="countrySearch" :placeholder="t('common.login.placeholders.selectCountry')" shape="round" />
</div>
<div class="flex-1 overflow-y-auto scrollbar-none [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
<van-cell v-for="item in filteredCountries" :key="`${item.iso2}-${item.dialCode}`" :title="item.name"
:label="item.iso2" :value="item.dialCode" @click="handleSelectCountry(item)" />
</div>
</div>
</van-popup>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { oauthToken } from '@/api/login'
import { useRouter } from 'vue-router'
import { computed, nextTick, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { oauthToken } from "@/api/login";
import { COUNTRY_CALLING_CODES, findCountryCallingCode } from "@/constants/countryCallingCodes";
import { getCurrentLocale } from "@/i18n";
import { useRouter } from "vue-router";
const router = useRouter()
const router = useRouter();
const { t } = useI18n();
const googleButtonEl = ref<HTMLElement | null>(null);
const googleInited = ref(false);
const googleButtonRendered = ref(false);
const countryPopupVisible = ref(false);
const countrySearch = ref("");
const selectedCountry = ref(
findCountryCallingCode(
(() => {
const locale = getCurrentLocale();
if (locale === "th-TH") return "TH";
if (locale === "en-US") return "US";
return "CN";
})(),
) ?? COUNTRY_CALLING_CODES[0],
);
const phone = ref("");
const code = ref("");
const phoneSubmitting = ref(false);
const selectedCountryDisplay = computed(() => {
return `${selectedCountry.value.name} (${selectedCountry.value.dialCode})`;
});
const filteredCountries = computed(() => {
const q = countrySearch.value.trim().toLowerCase();
if (!q) {
return COUNTRY_CALLING_CODES;
}
return COUNTRY_CALLING_CODES.filter((item) => {
const haystack = `${item.name} ${item.iso2} ${item.dialCode}`.toLowerCase();
return haystack.includes(q);
});
});
const canSubmitPhoneLogin = computed(() => {
const phoneDigits = phone.value.replace(/\D/g, "");
return (
phoneDigits.length >= 6 &&
code.value.trim().length >= 4 &&
!phoneSubmitting.value
);
});
function handleSelectCountry(item: { name: string; iso2: string; dialCode: string }) {
selectedCountry.value = item;
countryPopupVisible.value = false;
countrySearch.value = "";
}
function handleSendCode() {
showToast(t("common.login.tips.smsApiMissing"));
}
async function waitForGoogleButtonContainer(): Promise<HTMLElement> {
for (let i = 0; i < 30; i += 1) {
await nextTick();
const el = googleButtonEl.value ?? document.getElementById("buttonDiv");
if (el) {
return el;
}
await new Promise<void>((resolve) => setTimeout(resolve, 50));
}
throw new Error("Missing #buttonDiv");
}
async function ensureRenderGoogleButton(): Promise<void> {
if (!googleInited.value || googleButtonRendered.value) {
return;
}
if (!window.google?.accounts?.id) {
return;
}
const container = await waitForGoogleButtonContainer();
window.google.accounts.id.renderButton(container, {
theme: "outline",
size: "large",
width: 350,
shape: "rectangular",
logo_alignment: "center",
});
googleButtonRendered.value = true;
}
async function handlePhoneLogin() {
const phoneDigits = phone.value.replace(/\D/g, "");
const codeValue = code.value.trim();
if (!phoneDigits) {
showToast(t("common.login.errors.missingPhone"));
return;
}
if (codeValue.length < 4) {
showToast(t("common.login.errors.missingCode"));
return;
}
phoneSubmitting.value = true;
try {
await oauthToken({
clientId: import.meta.env.VITE_CLIENT_ID,
openIdCode: [`${selectedCountry.value.dialCode}${phoneDigits}`, codeValue],
grant_type: "sms",
});
router.go(-1);
} catch (e: unknown) {
console.error(e);
showToast(t("common.errors.network"));
} finally {
phoneSubmitting.value = false;
}
}
type GoogleIdentityServices = {
accounts: {
@@ -97,22 +258,41 @@ onMounted(() => {
},
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",
});
googleInited.value = true;
return ensureRenderGoogleButton();
})
.catch((e) => {
console.error(e);
});
});
</script>
<style scoped>
:deep(.van-button) {
border-radius: 50px !important;
}
.login-main-btn {
height: 44px;
}
:deep(#buttonDiv),
:deep(#buttonDiv > div),
:deep(#buttonDiv > div > div),
:deep(#buttonDiv iframe) {
border-radius: 50px !important;
height: 44px !important;
}
:deep([class~="nsm7Bb-HzV7m-LgbsSe"]) {
height: 44px !important;
border-radius: 50px !important;
overflow: visible !important;
}
:deep([class*="nsm7Bb-HzV7m-LgbsSe-MJoBVe"]) {
height: 44px !important;
border-radius: 50px !important;
overflow: hidden !important;
}
</style>