登录是用户进入应用的第一道门,但设计一个体验好、可维护、多场景适用 的登录流程并不简单。这篇文章以心动恋聊小程序为例,展示如何和 AI 对话,设计一套完整的登录系统------从微信授权到手机号绑定,从弹窗组件到全局状态管理。
系列专栏 :【AI 编程实战:TRAE SOLO 全栈开发指南】
本篇主题:登录流程设计 - 多场景、多步骤的优雅实现
实战项目:心动恋聊 - AI 恋爱聊天助手
一、开篇:登录流程的复杂性
1.1 看似简单,实则复杂
很多人觉得登录就是"点击按钮 → 调用接口 → 跳转首页",但实际业务中的登录流程要复杂得多:
txt
用户点击登录
↓
微信静默登录(获取 openid)
↓
检查是否已注册?
├── 是 → 直接登录成功
└── 否 → 需要手机号授权
↓
用户授权手机号
↓
是否新用户?
├── 是 → 完善资料(性别、年龄)
└── 否 → 登录成功
还有更多场景:
- 任意页面触发登录(不只是登录页)
- 登录成功后执行回调(如继续之前的操作)
- 登录弹窗而非登录页(不打断用户浏览)
- 登录状态全局同步
1.2 我需要什么
我需要一套完整的登录系统:
- 微信一键登录 + 手机号授权
- 新用户引导完善资料
- 全局登录弹窗(任意页面可触发)
- 登录成功回调机制
- 状态全局同步
二、整体架构设计
2.1 我的需求
txt
我:需要设计一套登录系统,要求:
1. 微信小程序环境
2. 支持微信登录 + 手机号授权
3. 新用户要引导填性别和年龄
4. 任意页面都能触发登录弹窗
5. 登录成功后能执行回调
2.2 AI 分析并给出架构
txt
AI:这是一个比较复杂的登录系统,我来设计整体架构。
📐 架构设计:
┌─────────────────────────────────────────────────┐
│ 业务页面 │
│ (首页、我的、详情页...任意需要登录的地方) │
└───────────────────────┬─────────────────────────┘
│ 调用
▼
┌─────────────────────────────────────────────────┐
│ loginModalStore │
│ (全局状态:控制弹窗显示、管理回调队列) │
└───────────────────────┬─────────────────────────┘
│ 驱动
▼
┌─────────────────────────────────────────────────┐
│ LoginModalRoot │
│ (全局组件:挂载在 App 层,监听 Store 状态) │
└───────────────────────┬─────────────────────────┘
│ 包含
▼
┌─────────────────────────────────────────────────┐
│ LoginModal │
│ (登录弹窗:多步骤流程 login→gender→age) │
└───────────────────────┬─────────────────────────┘
│ 调用
▼
┌─────────────────────────────────────────────────┐
│ userStore │
│ (用户状态:登录方法、用户信息、持久化) │
└─────────────────────────────────────────────────┘
🗂️ 文件结构:
src/
├── store/
│ ├── user.ts # 用户状态 + 登录方法
│ └── loginModal.ts # 登录弹窗状态
├── components/
│ └── auth/
│ └── LoginModalRoot.vue # 全局登录弹窗容器
└── pages/
└── my/
└── components/
└── LoginModal.vue # 登录弹窗主体
这样设计的好处:
1. 任意页面调用 loginModalStore.open() 即可触发登录
2. 登录逻辑集中在 LoginModal,不分散到各页面
3. 支持回调机制,登录成功后执行特定操作
txt
我:这个架构看起来不错,开始实现吧
三、登录弹窗状态管理
3.1 设计 loginModalStore
txt
AI:先实现登录弹窗的状态管理。
核心功能:
1. 控制弹窗显示/隐藏
2. 管理登录成功回调队列
3. 触发登录流程或资料完善流程
typescript
// src/store/loginModal.ts
import { defineStore } from 'pinia';
import type { UserInfo } from 'shared-types';
type LoginSuccessHandler = (userInfo?: UserInfo) => void | Promise<void>;
interface LoginModalState {
visible: boolean;
shouldStart: boolean; // 是否触发登录流程
shouldStartProfileCompletion: boolean; // 是否触发资料完善流程
queue: LoginSuccessHandler[]; // 回调队列
}
export const useLoginModalStore = defineStore('loginModal', {
state: (): LoginModalState => ({
visible: false,
shouldStart: false,
shouldStartProfileCompletion: false,
queue: [],
}),
actions: {
/**
* 打开登录弹窗
* @param options.onSuccess - 登录成功后的回调
* @param options.startProfileCompletion - 是否直接进入资料完善
*/
open(options?: { onSuccess?: LoginSuccessHandler; startProfileCompletion?: boolean }) {
// 将回调加入队列
if (options?.onSuccess) {
this.queue.push(options.onSuccess);
}
this.visible = true;
if (options?.startProfileCompletion) {
this.shouldStartProfileCompletion = true;
} else {
this.shouldStart = true;
}
},
/**
* 登录流程已启动(由 LoginModalRoot 调用)
*/
markStarted() {
this.shouldStart = false;
},
/**
* 资料完善流程已启动
*/
markProfileCompletionStarted() {
this.shouldStartProfileCompletion = false;
},
/**
* 登录成功,执行所有回调
*/
async resolveSuccess(userInfo?: UserInfo) {
const callbacks = [...this.queue];
this.queue = [];
// 依次执行回调
for (const callback of callbacks) {
await callback(userInfo);
}
this.visible = false;
this.shouldStart = false;
this.shouldStartProfileCompletion = false;
},
/**
* 关闭弹窗
*/
close() {
this.visible = false;
this.shouldStart = false;
this.shouldStartProfileCompletion = false;
this.queue = [];
},
},
});
txt
AI:使用方式:
// 任意页面触发登录
import { useLoginModalStore } from '@/store/loginModal'
const loginModalStore = useLoginModalStore()
// 场景1:简单触发登录
loginModalStore.open()
// 场景2:登录成功后执行操作
loginModalStore.open({
onSuccess: (userInfo) => {
console.log('登录成功:', userInfo)
// 继续之前的操作,如发送消息、购买会员等
}
})
// 场景3:直接进入资料完善(用户已登录但未填资料)
loginModalStore.open({ startProfileCompletion: true })
3.2 我追问回调队列的作用
txt
我:为什么要用队列?直接存一个回调不行吗?
txt
AI:队列是为了处理"连续触发"的场景。
🎯 场景示例:
用户在页面 A 点击"发送消息"(需要登录)
→ 弹出登录框
→ 用户没登录,又点击了"收藏"(也需要登录)
→ 这时有两个回调等待执行
如果只存一个回调,第二个会覆盖第一个。
用队列就能保证两个操作都能在登录后执行。
当然,如果你的业务不需要这种场景,用单个回调也可以。
四、全局登录弹窗容器
4.1 LoginModalRoot 设计
txt
我:loginModalStore 有了,但弹窗组件在哪里渲染?
每个页面都引入一遍吗?
txt
AI:不用。创建一个全局容器组件,挂载在 App 层。
设计思路:
1. LoginModalRoot 放在 App.vue 或 Layout 中
2. 它监听 loginModalStore 的状态
3. 状态变化时,调用 LoginModal 的方法
vue
<!-- src/components/auth/LoginModalRoot.vue -->
<template>
<LoginModal ref="loginModalRef" v-model="modalVisible" @login-success="handleLoginSuccess" />
</template>
<script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue';
import type { UserInfo } from 'shared-types';
import LoginModal from '@/pages/my/components/LoginModal.vue';
import { useLoginModalStore } from '@/store/loginModal';
const loginModalStore = useLoginModalStore();
const loginModalRef = ref<InstanceType<typeof LoginModal> | null>(null);
// 双向绑定弹窗显示状态
const modalVisible = computed({
get: () => loginModalStore.visible,
set: (value: boolean) => {
loginModalStore.setVisible(value);
},
});
// 登录成功处理
const handleLoginSuccess = async (userInfo?: UserInfo) => {
await loginModalStore.resolveSuccess(userInfo);
};
// 监听"开始登录"信号
watch(
() => loginModalStore.shouldStart,
async (shouldStart) => {
if (!shouldStart) return;
await nextTick();
loginModalRef.value?.startLogin();
loginModalStore.markStarted();
},
);
// 监听"开始资料完善"信号
watch(
() => loginModalStore.shouldStartProfileCompletion,
async (shouldStart) => {
if (!shouldStart) return;
await nextTick();
loginModalRef.value?.startProfileCompletion();
loginModalStore.markProfileCompletionStarted();
},
);
</script>
4.2 挂载到全局
txt
AI:把 LoginModalRoot 挂载到 Layout 或 App 层:
vue
<!-- src/layouts/default.vue -->
<template>
<slot />
<!-- 全局登录弹窗 -->
<LoginModalRoot />
</template>
<script setup lang="ts">
import LoginModalRoot from '@/components/auth/LoginModalRoot.vue';
</script>
txt
AI:这样所有使用 default 布局的页面都能触发登录弹窗,
不需要每个页面单独引入。
五、多步骤登录弹窗
5.1 登录流程设计
txt
我:现在要实现 LoginModal 本身了。
流程是:登录 → 性别选择 → 年龄选择 → 完成
txt
AI:我来设计多步骤的状态机:
📋 步骤状态:
- login: 登录界面(微信授权 + 协议勾选)
- gender: 性别选择(新用户)
- age: 年龄选择(新用户)
📋 流程逻辑:
1. 用户点击登录 → 调用微信登录
2. 检查返回结果:
- 有 token + user_id → 老用户,直接成功
- 无 token → 需要手机号授权
- needsRegistration=true → 新用户,进入资料完善
vue
<!-- src/pages/my/components/LoginModal.vue -->
<template>
<u-popup
v-model="isVisible"
mode="bottom"
border-radius="24"
:safe-area-inset-bottom="true"
:closeable="true"
@close="handleClose"
>
<view class="login-modal-content">
<!-- 步骤1:登录界面 -->
<view v-if="currentStep === 'login'" class="login-step">
<view class="pt-4.5 pb-10">
<text class="block text-center text-lg font-bold">欢迎登录</text>
</view>
<!-- 登录按钮 -->
<view class="px-9 pb-4">
<XButton
text="手机号快捷登录"
:loading="isLoading"
:open-type="needPhoneAuth ? 'getPhoneNumber' : undefined"
@getphonenumber="handlePhoneNumber"
@click="handleLoginClick"
/>
</view>
<!-- 协议勾选 -->
<view class="px-9 pb-20">
<view class="flex items-center justify-center" @click="toggleAgreement">
<view
class="w-5 h-5 rounded-full border flex items-center justify-center"
:class="isAgreed ? 'bg-primary border-primary' : 'border-gray-400'"
>
<u-icon v-if="isAgreed" name="checkmark" size="20" color="#fff" />
</view>
<text class="ml-2 text-sm">
勾选同意
<text class="text-primary" @click.stop="openAgreement('user')">《用户协议》</text>
和
<text class="text-primary" @click.stop="openAgreement('privacy')">《隐私政策》</text>
</text>
</view>
</view>
</view>
<!-- 步骤2:性别选择 -->
<view v-else-if="currentStep === 'gender'" class="gender-step">
<view class="pt-4 pb-10">
<text class="block text-center text-lg font-bold">选择你的性别</text>
<text class="block text-center text-sm text-gray-500 mt-2">更精准匹配回复话术</text>
</view>
<view class="flex justify-center gap-8 pb-20">
<view
v-for="gender in genderOptions"
:key="gender.value"
class="flex flex-col items-center"
@click="selectGender(gender.value)"
>
<image :src="gender.icon" class="w-32 h-32" />
<text class="mt-2">{{ gender.label }}</text>
<view
v-if="selectedGender === gender.value"
class="w-5 h-5 rounded-full bg-primary mt-2"
/>
</view>
</view>
</view>
<!-- 步骤3:年龄选择 -->
<view v-else class="age-step">
<view class="pt-4 pb-10">
<text class="block text-center text-lg font-bold">选择你的年龄段</text>
</view>
<view class="flex flex-wrap justify-center gap-4 pb-20">
<view
v-for="age in ageOptions"
:key="age"
class="px-6 py-3 rounded-full"
:class="selectedAge === age ? 'bg-primary text-white' : 'bg-gray-100'"
@click="selectAge(age)"
>
{{ age }}
</view>
</view>
</view>
</view>
</u-popup>
</template>
5.2 登录逻辑实现
typescript
// LoginModal.vue <script setup>
import { ref, computed } from 'vue';
import { useUserStore } from '@/store/user';
import { toast } from '@/utils/toast';
import { GenderEnum, AgeGroupEnum } from 'shared-types';
import { requestWechatLoginCode } from '@/utils/wechat';
const userStore = useUserStore();
// 当前步骤
const currentStep = ref<'login' | 'gender' | 'age'>('login');
// 状态
const isAgreed = ref(false);
const isLoading = ref(false);
const needPhoneAuth = ref(false);
const selectedGender = ref('');
const selectedAge = ref('');
// 性别和年龄选项
const genderOptions = [
{ value: 'male', label: '男', icon: '/static/images/male.png' },
{ value: 'female', label: '女', icon: '/static/images/female.png' },
];
const ageOptions = ['00后', '05后', '90后', '80后', '70后'];
/**
* 处理登录按钮点击
*/
const handleLoginClick = async () => {
if (!isAgreed.value) {
toast.warning('请勾选同意用户协议');
return;
}
// 如果需要手机号授权,由 open-type 处理
if (needPhoneAuth.value) return;
await performWechatLogin();
};
/**
* 执行微信登录
*/
const performWechatLogin = async () => {
isLoading.value = true;
try {
// 1. 获取微信 code
const loginCode = await requestWechatLoginCode();
// 2. 调用 Store 登录方法
const result = await userStore.wechatLogin({ code: loginCode });
// 3. 判断结果
if (result.token && result.user_id) {
// 已有账号
if (result.needsRegistration) {
// 新用户,需要完善资料
currentStep.value = 'gender';
} else {
// 老用户,直接成功
completeLogin();
}
} else {
// 需要手机号授权
needPhoneAuth.value = true;
}
} catch (error) {
console.error('微信登录失败:', error);
toast.error('登录失败,请重试');
} finally {
isLoading.value = false;
}
};
/**
* 处理手机号授权
*/
const handlePhoneNumber = async (event: any) => {
const { code, errMsg } = event.detail || {};
if (!code) {
if (errMsg?.includes('user deny')) {
toast.info('已取消手机号授权');
}
return;
}
isLoading.value = true;
try {
const loginCode = await requestWechatLoginCode();
const result = await userStore.phoneLogin({
code,
login_code: loginCode,
});
if (result.needsRegistration) {
currentStep.value = 'gender';
} else {
completeLogin();
}
} catch (error) {
toast.error('验证手机号失败');
} finally {
isLoading.value = false;
}
};
/**
* 选择性别
*/
const selectGender = (gender: string) => {
selectedGender.value = gender;
// 延迟跳转,让用户看到选择效果
setTimeout(() => {
currentStep.value = 'age';
}, 500);
};
/**
* 选择年龄
*/
const selectAge = async (age: string) => {
selectedAge.value = age;
// 提交资料
setTimeout(async () => {
await submitProfile();
}, 300);
};
/**
* 提交用户资料
*/
const submitProfile = async () => {
const genderValue = selectedGender.value === 'male' ? GenderEnum.MALE : GenderEnum.FEMALE;
const ageMapping: Record<string, number> = {
'00后': AgeGroupEnum.POST_00,
'05后': AgeGroupEnum.POST_05,
'90后': AgeGroupEnum.POST_90,
'80后': AgeGroupEnum.POST_80,
'70后': AgeGroupEnum.POST_70,
};
await userStore.updateUserInfo(
{
gender: genderValue,
age_group: ageMapping[selectedAge.value],
},
'登录成功',
);
completeLogin();
};
/**
* 完成登录
*/
const completeLogin = () => {
emit('login-success', userStore.userInfo);
handleClose();
};
/**
* 关闭弹窗
*/
const handleClose = () => {
isVisible.value = false;
// 重置状态
setTimeout(() => {
currentStep.value = 'login';
isAgreed.value = false;
selectedGender.value = '';
selectedAge.value = '';
needPhoneAuth.value = false;
}, 300);
};
// 暴露方法给父组件
defineExpose({
startLogin: () => {
resetState();
isVisible.value = true;
},
startProfileCompletion: () => {
currentStep.value = 'gender';
isVisible.value = true;
},
});
六、业务页面集成
6.1 触发登录的场景
txt
我:登录弹窗做好了,业务页面怎么用?
txt
AI:业务页面只需要调用 loginModalStore.open():
typescript
// 场景1:需要登录才能操作的按钮
const handleSendMessage = () => {
if (!userStore.isLoggedIn) {
loginModalStore.open({
onSuccess: () => {
// 登录成功后,继续发送消息
doSendMessage();
},
});
return;
}
doSendMessage();
};
// 场景2:封装成通用的登录检查函数
const requireLogin = (callback: () => void) => {
if (userStore.isLoggedIn) {
callback();
return;
}
loginModalStore.open({
onSuccess: () => callback(),
});
};
// 使用
const handleBuyVip = () => {
requireLogin(() => {
uni.navigateTo({ url: '/pages/vip/index' });
});
};
const handleCollect = () => {
requireLogin(() => {
doCollect();
});
};
6.2 我追问:能不能更简洁
txt
我:每次都要写 if (!isLoggedIn) 很繁琐,能不能更简洁?
txt
AI:可以封装成 Hook:
typescript
// src/hooks/useRequireLogin.ts
import { useUserStore } from '@/store/user';
import { useLoginModalStore } from '@/store/loginModal';
export function useRequireLogin() {
const userStore = useUserStore();
const loginModalStore = useLoginModalStore();
/**
* 需要登录才能执行的操作
*/
const requireLogin = (callback: () => void | Promise<void>) => {
if (userStore.isLoggedIn) {
callback();
return;
}
loginModalStore.open({
onSuccess: async () => {
await callback();
},
});
};
/**
* 创建需要登录的处理函数
*/
const withLogin = <T extends (...args: any[]) => any>(fn: T): T => {
return ((...args: Parameters<T>) => {
requireLogin(() => fn(...args));
}) as T;
};
return {
requireLogin,
withLogin,
isLoggedIn: computed(() => userStore.isLoggedIn),
};
}
typescript
// 页面中使用
const { requireLogin, withLogin, isLoggedIn } = useRequireLogin();
// 方式1:包装函数
const handleSendMessage = () => {
requireLogin(() => {
doSendMessage();
});
};
// 方式2:直接创建带登录检查的函数
const handleBuyVip = withLogin(() => {
uni.navigateTo({ url: '/pages/vip/index' });
});
const handleCollect = withLogin(() => {
doCollect();
});
七、登录状态判断优化
7.1 预检微信登录状态
txt
我:每次打开登录弹窗都要等用户点击,
能不能提前知道需不需要手机号授权?
txt
AI:可以在打开弹窗时"预检"微信登录状态:
typescript
// LoginModal.vue
const determineLoginStrategy = async () => {
if (!isMpWeixin) {
needPhoneAuth.value = false;
return;
}
try {
// 静默获取 code,尝试登录
const loginCode = await requestWechatLoginCode();
const response = await loginByWechat({
code: loginCode,
source: clientSource,
channel: clientChannel,
});
// 如果能直接登录,不需要手机号
if (response.code === 200 && response.data?.token) {
needPhoneAuth.value = false;
} else {
needPhoneAuth.value = true;
}
} catch (error) {
needPhoneAuth.value = true;
}
};
// 打开弹窗时调用
const startLogin = () => {
resetState();
isVisible.value = true;
// 预检登录状态
void determineLoginStrategy();
};
txt
AI:这样的好处:
1. 老用户:按钮显示"微信登录",点击直接成功
2. 新用户:按钮显示"手机号快捷登录",需要授权
用户体验更顺畅,不用点两次。
八、核心经验:登录系统设计要点
8.1 架构设计原则
| 原则 | 说明 |
|---|---|
| 状态集中 | loginModalStore 统一管理弹窗状态和回调 |
| 组件分离 | LoginModalRoot 负责桥接,LoginModal 负责 UI 逻辑 |
| 全局可用 | 挂载在 Layout 层,任意页面可触发 |
| 回调机制 | 支持登录成功后执行特定操作 |
8.2 流程设计要点
typescript
// ✅ 推荐:多步骤用状态机
const currentStep = ref<'login' | 'gender' | 'age'>('login');
// ❌ 不推荐:多个 boolean 控制
const showLogin = ref(true);
const showGender = ref(false);
const showAge = ref(false);
typescript
// ✅ 推荐:预检登录状态
const startLogin = () => {
void determineLoginStrategy(); // 提前判断需要哪种登录
};
// ❌ 不推荐:用户点击才判断
const handleClick = () => {
// 点击后才知道需要手机号,体验差
};
8.3 错误处理
typescript
// 区分不同的错误场景
const showWechatLoginError = (error: any) => {
if (error?.code === -8) {
toast.error('未安装微信客户端');
return;
}
toast.error('登录失败,请重试');
};
// 手机号授权取消 vs 失败
const handlePhoneNumber = (event: any) => {
const { code, errMsg } = event.detail;
if (!code) {
if (errMsg?.includes('user deny')) {
toast.info('已取消授权'); // 用户主动取消,不是错误
} else {
toast.error('获取手机号失败'); // 真正的错误
}
return;
}
};
九、总结:登录系统的完整实现
9.1 文件清单
| 文件 | 职责 |
|---|---|
store/loginModal.ts |
弹窗状态 + 回调队列 |
store/user.ts |
用户状态 + 登录方法 |
components/auth/LoginModalRoot.vue |
全局弹窗容器 |
pages/my/components/LoginModal.vue |
登录弹窗 UI + 逻辑 |
hooks/useRequireLogin.ts |
登录检查 Hook |
9.2 关键收获
- 架构先行:先设计整体架构,再实现细节
- 状态集中:用 Store 管理弹窗状态和回调
- 多步骤流程:用状态机管理,避免多个 boolean
- 体验优化:预检登录状态,减少用户等待
- 错误区分:用户取消 vs 系统错误,提示不同
9.3 下一篇预告
《【AI 编程实战】第 8 篇:组件封装的艺术 - 从业务代码到可复用组件》
下一篇展示如何设计通用组件:
- 从业务代码中提取组件
- Props 和 Events 设计
- 组件的扩展性和灵活性
登录系统不只是"调用接口",而是用户体验、状态管理、错误处理的综合考验。 通过和 AI 对话,逐步理清每个环节,最终形成完整的解决方案。
如果这篇文章对你有帮助,请点赞、收藏、转发!