feat: 添加用户认证功能,优化登录和注册流程,集成表单验证和加载状态
This commit is contained in:
3
auto-imports.d.ts
vendored
3
auto-imports.d.ts
vendored
@@ -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
2
components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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
12
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
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);
|
||||
}
|
||||
11
src/main.ts
11
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,7 +65,10 @@ if (import.meta.env.DEV) {
|
||||
console.log("VConsole is enabled in development mode.");
|
||||
}
|
||||
|
||||
authClient.getSession().then((session) => {
|
||||
const pinia = createPinia();
|
||||
const userStore = useUserStore(pinia);
|
||||
userStore.setToken(session.data?.session.token || "");
|
||||
|
||||
const app = createApp(App)
|
||||
.use(IonicVue, {
|
||||
@@ -82,3 +86,4 @@ const app = createApp(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