feat: 添加用户认证功能,优化登录和注册流程,集成表单验证和加载状态
This commit is contained in:
15
src/App.vue
15
src/App.vue
@@ -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>
|
||||
|
||||
12
src/composables/useNavigateToRedirect.ts
Normal file
12
src/composables/useNavigateToRedirect.ts
Normal 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);
|
||||
}
|
||||
41
src/main.ts
41
src/main.ts
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
50
src/views/auth/schema.ts
Normal 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位"),
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
|
||||
Reference in New Issue
Block a user