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

3
auto-imports.d.ts vendored
View File

@@ -27,6 +27,7 @@ declare global {
const createReusableTemplate: typeof import('@vueuse/core').createReusableTemplate
const createSharedComposable: typeof import('@vueuse/core').createSharedComposable
const createTemplatePromise: typeof import('@vueuse/core').createTemplatePromise
const createUUID: typeof import('./src/utils/helper').createUUID
const createUnrefFn: typeof import('@vueuse/core').createUnrefFn
const customRef: typeof import('vue').customRef
const debouncedRef: typeof import('@vueuse/core').debouncedRef
@@ -216,6 +217,7 @@ declare global {
const useMouseInElement: typeof import('@vueuse/core').useMouseInElement
const useMousePressed: typeof import('@vueuse/core').useMousePressed
const useMutationObserver: typeof import('@vueuse/core').useMutationObserver
const useNavigateToRedirect: typeof import('./src/composables/useNavigateToRedirect').useNavigateToRedirect
const useNavigatorLanguage: typeof import('@vueuse/core').useNavigatorLanguage
const useNetwork: typeof import('@vueuse/core').useNetwork
const useNow: typeof import('@vueuse/core').useNow
@@ -533,6 +535,7 @@ declare module 'vue' {
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>
readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>
readonly useNavigateToRedirect: UnwrapRef<typeof import('./src/composables/useNavigateToRedirect')['useNavigateToRedirect']>
readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>
readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>

2
components.d.ts vendored
View File

@@ -25,6 +25,7 @@ declare module 'vue' {
IonLabel: typeof import('@ionic/vue')['IonLabel']
IonPage: typeof import('@ionic/vue')['IonPage']
IonRouterOutlet: typeof import('@ionic/vue')['IonRouterOutlet']
IonSpinner: typeof import('@ionic/vue')['IonSpinner']
IonTabBar: typeof import('@ionic/vue')['IonTabBar']
IonTabButton: typeof import('@ionic/vue')['IonTabButton']
IonTabs: typeof import('@ionic/vue')['IonTabs']
@@ -51,6 +52,7 @@ declare global {
const IonLabel: typeof import('@ionic/vue')['IonLabel']
const IonPage: typeof import('@ionic/vue')['IonPage']
const IonRouterOutlet: typeof import('@ionic/vue')['IonRouterOutlet']
const IonSpinner: typeof import('@ionic/vue')['IonSpinner']
const IonTabBar: typeof import('@ionic/vue')['IonTabBar']
const IonTabButton: typeof import('@ionic/vue')['IonTabButton']
const IonTabs: typeof import('@ionic/vue')['IonTabs']

View File

@@ -32,6 +32,7 @@
"@capacitor/status-bar": "catalog:",
"@capp/eden": "catalog:",
"@elysiajs/eden": "catalog:",
"@faker-js/faker": "catalog:",
"@ionic/vue": "catalog:",
"@ionic/vue-router": "catalog:",
"@tailwindcss/vite": "catalog:",

12
pnpm-lock.yaml generated
View File

@@ -60,6 +60,9 @@ catalogs:
'@elysiajs/eden':
specifier: ^1.4.6
version: 1.4.6
'@faker-js/faker':
specifier: ^10.2.0
version: 10.2.0
'@iconify-json/bx':
specifier: ^1.2.2
version: 1.2.2
@@ -299,6 +302,9 @@ importers:
'@elysiajs/eden':
specifier: 'catalog:'
version: 1.4.6(elysia@1.4.22(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3))
'@faker-js/faker':
specifier: 'catalog:'
version: 10.2.0
'@ionic/vue':
specifier: 'catalog:'
version: 8.7.17(@stencil/core@4.41.1)(vue-router@4.6.4(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3))
@@ -1632,6 +1638,10 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@faker-js/faker@10.2.0':
resolution: {integrity: sha512-rTXwAsIxpCqzUnZvrxVh3L0QA0NzToqWBLAhV+zDV3MIIwiQhAZHMdPCIaj5n/yADu/tyk12wIPgL6YHGXJP+g==}
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -7190,6 +7200,8 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@faker-js/faker@10.2.0': {}
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':

View File

@@ -21,6 +21,7 @@ catalog:
'@capp/eden': http://192.168.1.2:9538/api/capp-eden-0.0.2.tgz
'@cloudflare/workers-types': ^4.20260113.0
'@elysiajs/eden': ^1.4.6
'@faker-js/faker': ^10.2.0
'@iconify-json/bx': ^1.2.2
'@iconify-json/circle-flags': ^1.2.10
'@iconify-json/cryptocurrency-color': ^1.2.4

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,9 +65,12 @@ 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)
const app = createApp(App)
.use(IonicVue, {
backButtonText: "返回",
mode: "ios",
@@ -79,6 +83,7 @@ const app = createApp(App)
.use(router)
.use(i18n);
router.isReady().then(() => {
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>
<!-- 登录按钮 -->