feat: 添加用户认证功能,优化登录和注册流程,集成表单验证和加载状态

This commit is contained in:
2026-01-17 21:18:51 +07:00
parent 7ec2522fa0
commit 51719cd229
12 changed files with 252 additions and 73 deletions

View File

@@ -1,4 +1,19 @@
<script setup lang="ts">
import { App as CapacitorApp } from "@capacitor/app";
const userStore = useUserStore();
const { isAuthenticated } = storeToRefs(userStore);
onMounted(() => {
if (!isAuthenticated.value)
return;
userStore.updateProfile();
CapacitorApp.addListener("appStateChange", ({ isActive }) => {
if (isActive) {
userStore.updateProfile();
}
});
});
</script>
<template>

View File

@@ -0,0 +1,12 @@
import type { LocationQueryValue } from "vue-router";
import { router } from "@/router";
export function useNavigateToRedirect(redirect: LocationQueryValue): void;
export function useNavigateToRedirect(redirect: LocationQueryValue[], index: number): void;
export function useNavigateToRedirect(redirect: LocationQueryValue | LocationQueryValue[], index?: number) {
const _redirect = Array.isArray(redirect) ? redirect[index || 0] as string : redirect as string;
const path = decodeURIComponent(_redirect || "/");
router.replace(path);
}

View File

@@ -4,22 +4,22 @@ import VConsole from "vconsole";
import { useRegisterSW } from "virtual:pwa-register/vue";
import { createApp } from "vue";
import App from "./App.vue";
import { authClient } from "./auth";
import { i18n } from "./locales";
import { router } from "./router";
import { router } from "./router";
/* Core CSS required for Ionic components to work properly */
import "@ionic/vue/css/core.css";
/* Basic CSS for apps built with Ionic */
import "@ionic/vue/css/normalize.css";
import "@ionic/vue/css/structure.css";
import "@ionic/vue/css/structure.css";
import "@ionic/vue/css/typography.css";
/* Optional CSS utils that can be commented out */
import "@ionic/vue/css/padding.css";
import "@ionic/vue/css/float-elements.css";
import "@ionic/vue/css/text-alignment.css";
import "@ionic/vue/css/text-transformation.css";
import "@ionic/vue/css/flex-utils.css";
/**
* Ionic Dark Mode
@@ -28,6 +28,7 @@ import "@ionic/vue/css/flex-utils.css";
* https://ionicframework.com/docs/theming/dark-mode
*/
import "@ionic/vue/css/flex-utils.css";
import "@ionic/vue/css/display.css";
// import "@ionic/vue/css/palettes/dark.system.css";
// import "@ionic/vue/css/palettes/dark.always.css";
@@ -64,21 +65,25 @@ if (import.meta.env.DEV) {
console.log("VConsole is enabled in development mode.");
}
const pinia = createPinia();
authClient.getSession().then((session) => {
const pinia = createPinia();
const userStore = useUserStore(pinia);
userStore.setToken(session.data?.session.token || "");
const app = createApp(App)
.use(IonicVue, {
backButtonText: "返回",
mode: "ios",
statusTap: true,
swipeBackEnabled: true,
// rippleEffect: true,
// animated: false,
})
.use(pinia)
.use(router)
.use(i18n);
const app = createApp(App)
.use(IonicVue, {
backButtonText: "返回",
mode: "ios",
statusTap: true,
swipeBackEnabled: true,
// rippleEffect: true,
// animated: false,
})
.use(pinia)
.use(router)
.use(i18n);
router.isReady().then(() => {
app.mount("#app");
router.isReady().then(() => {
app.mount("#app");
});
});

View File

@@ -1,15 +1,24 @@
import type { Router } from "vue-router";
import type { RouteLocation, Router } from "vue-router";
import authRoutes from "./auth";
function isAuthRoute(route: RouteLocation) {
return authRoutes.some(r => r.path === route.path);
}
export function createRouterGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore();
// if (to.meta.requiresAuth && !userStore.isAuthenticated) {
// if (from.path === "/auth/login") {
// return next("/");
// }
// const redirect = encodeURIComponent(to.fullPath);
// return next({ path: "/auth/login", query: { redirect } });
// }
if (to.meta.requiresAuth && !userStore.isAuthenticated) {
if (isAuthRoute(from)) {
return next("/");
}
const redirect = encodeURIComponent(to.fullPath);
return next({ path: "/auth/login", query: { redirect } });
}
if (isAuthRoute(to) && userStore.isAuthenticated) {
return next("/");
}
next();
});
}

View File

@@ -1,23 +1,65 @@
<script lang='ts' setup>
import { toastController } from "@ionic/vue";
import { safeClient } from "@/api";
import { authClient } from "@/auth";
import { LoginSchema } from "./schema";
const route = useRoute();
const router = useRouter();
const phoneNumber = ref("");
const password = ref("");
const form = ref({
phoneNumber: "",
password: "",
});
const agreed = ref(false);
const showPassword = ref(false);
const isLoading = ref(false);
function handleLogin() {
if (!phoneNumber.value || !password.value) {
// TODO: 显示提示信息
return;
}
async function showToast(message: string, color: "success" | "danger" | "warning" = "danger") {
const toast = await toastController.create({
message,
duration: 2000,
position: "top",
color,
});
await toast.present();
}
async function handleLogin() {
if (!agreed.value) {
// TODO: 提示需要同意条款
await showToast("请先阅读并同意服务条款和隐私政策", "warning");
return;
}
// TODO: 实现登录逻辑
console.log("登录", { phoneNumber: phoneNumber.value, password: password.value });
const result = LoginSchema.safeParse({ ...form.value });
if (!result.success) {
const first = result.error.issues[0];
await showToast(first.message);
return;
}
isLoading.value = true;
try {
const { data } = await safeClient(authClient.signIn.username({
username: form.value.phoneNumber,
password: form.value.password,
}));
if (!data.value?.token) {
toastController.create({
message: "登录失败,请检查手机号或密码",
duration: 2000,
color: "danger",
}).then(toast => toast.present());
}
else {
const userStore = useUserStore();
userStore.setToken(data.value.token);
await userStore.updateProfile();
await showToast("登录成功!", "success");
router.push(route.query.redirect as string || "/");
}
}
finally {
isLoading.value = false;
}
}
function handleSignup() {
@@ -71,7 +113,7 @@ function goToTerms(type: "service" | "privacy") {
<!-- 手机号输入 -->
<ion-item lines="none" class="input-item">
<ion-input
v-model="phoneNumber"
v-model="form.phoneNumber"
type="tel"
placeholder="请输入手机号"
class="custom-input"
@@ -82,7 +124,7 @@ function goToTerms(type: "service" | "privacy") {
<!-- 密码输入 -->
<ion-item lines="none" class="input-item">
<ion-input
v-model="password"
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
class="custom-input"
@@ -110,9 +152,11 @@ function goToTerms(type: "service" | "privacy") {
<ion-button
expand="block"
class="login-button mt-2"
:disabled="isLoading"
@click="handleLogin"
>
登录
<ion-spinner v-if="isLoading" name="crescent" class="mr-2" />
{{ isLoading ? '登录中...' : '登录' }}
</ion-button>
<!-- 注册按钮 -->

50
src/views/auth/schema.ts Normal file
View File

@@ -0,0 +1,50 @@
import zod from "zod";
export const SignupSchema = zod.object({
phoneNumber: zod
.string()
.min(1, "请输入手机号")
.regex(/^1[3-9]\d{9}$/, "请输入正确的手机号码"),
password: zod
.string()
.min(6, "密码至少6位")
.max(20, "密码最多20位")
.regex(/^(?=.*[a-z])(?=.*\d).+$/i, "密码必须包含字母和数字"),
confirmPassword: zod
.string()
.min(1, "请确认密码"),
realName: zod
.string()
.min(2, "请输入真实姓名")
.max(10, "姓名长度不能超过10个字符"),
idCard: zod
.string()
.min(1, "请输入身份证号码")
.regex(
/(^\d{15}$)|(^\d{18}$)|(^\d{17}([\dX])$)/i,
"请输入正确的身份证号码",
),
inviteCode: zod
.string()
.optional(),
}).refine(data => data.password === data.confirmPassword, {
message: "两次输入的密码不一致",
path: ["confirmPassword"],
});
export const LoginSchema = zod.object({
phoneNumber: zod
.string()
.min(1, "请输入手机号")
.regex(/^1[3-9]\d{9}$/, "请输入正确的手机号码"),
password: zod
.string()
.min(6, "密码至少6位")
.max(20, "密码最多20位"),
});

View File

@@ -1,7 +1,13 @@
<script lang='ts' setup>
import { faker } from "@faker-js/faker";
import { loadingController, toastController } from "@ionic/vue";
import { ref } from "vue";
import { useRouter } from "vue-router";
import { safeClient } from "@/api";
import { authClient } from "@/auth";
import { SignupSchema } from "./schema";
const route = useRoute();
const router = useRouter();
const formData = ref({
@@ -15,45 +21,62 @@ const formData = ref({
const agreed = ref(false);
const showPassword = ref(false);
const showConfirmPassword = ref(false);
const isLoading = ref(false);
function handleSignup() {
// 验证手机号
if (!formData.value.phoneNumber) {
// TODO: 显示提示信息
return;
}
// 验证密码
if (!formData.value.password) {
// TODO: 提示输入密码
return;
}
// 验证确认密码
if (formData.value.password !== formData.value.confirmPassword) {
// TODO: 提示密码不一致
return;
}
// 验证姓名
if (!formData.value.realName) {
// TODO: 提示输入姓名
return;
}
// 验证身份证
if (!formData.value.idCard) {
// TODO: 提示输入身份证
return;
}
async function showToast(message: string, color: "success" | "danger" | "warning" = "danger") {
const toast = await toastController.create({
message,
duration: 2000,
position: "top",
color,
});
await toast.present();
}
async function handleSignup() {
// 检查是否同意协议
if (!agreed.value) {
// TODO: 提示需要同意条款
await showToast("请先阅读并同意服务条款和隐私政策", "warning");
return;
}
// TODO: 实现注册逻辑
console.log("注册", formData.value);
const result = SignupSchema.safeParse(formData.value);
if (!result.success) {
const first = result.error.issues[0];
await showToast(first.message);
return;
}
isLoading.value = true;
try {
const email = faker.internet.email();
const { data } = await safeClient(authClient.signUp.email({
email,
name: formData.value.realName,
username: formData.value.phoneNumber,
password: formData.value.password,
idCard: formData.value.idCard,
inviteCode: formData.value.inviteCode || undefined,
}));
if (!data.value?.token) {
toastController.create({
message: "注册成功,但未收到令牌,请联系管理员",
duration: 2000,
color: "danger",
}).then(toast => toast.present());
}
else {
await showToast("注册成功!", "success");
const userStore = useUserStore();
userStore.setToken(data.value.token);
await userStore.updateProfile();
useNavigateToRedirect(route.query.redirect as string);
}
}
finally {
isLoading.value = false;
}
}
function handleLogin() {
@@ -193,9 +216,11 @@ function goToTerms(type: "service" | "privacy") {
<ion-button
expand="block"
class="signup-button mt-2"
:disabled="isLoading"
@click="handleSignup"
>
注册
<ion-spinner v-if="isLoading" name="crescent" class="mr-2" />
{{ isLoading ? '注册中...' : '注册' }}
</ion-button>
<!-- 登录按钮 -->