hookehuyr

refactor(auth): 重构鉴权架构,分离微信授权和用户登录

## 核心变更

### 架构调整
- 移除 sessionid 前端管理逻辑,改由后端自动处理(cookie)
- 移除接口白名单机制,所有接口直接发送
- 简化 401 处理,统一跳转登录页
- 分离微信授权(openid)和用户登录两个独立概念

### 文件变更

**新增文件:**
- src/utils/openid.js - 微信授权管理(wx.login、miniProgramAuthAPI)
- src/stores/user.js - 用户状态管理(Pinia)
- docs/specs/2026-02-02-auth-refactoring.md - 鉴权重构规划文档

**修改文件:**
- src/app.js - 启动时检查登录状态,移除旧授权逻辑
- src/utils/request.js - 简化拦截器,移除白名单和 sessionid
- src/pages/login/index.vue - 使用新的登录 API(uuid、password)
- src/app.config.js - 移除 pages/auth/index 引用
- src/pages/mine/index.vue - 适配新的鉴权逻辑
- src/utils/config.js - 配置调整
- src/api/user.js - API 文档更新
- .eslintrc.cjs - ESLint 配置调整
- .claude/settings.local.json - Claude 设置更新

**删除文件:**
- src/utils/authRedirect.js - 移除旧的授权重定向逻辑
- src/pages/auth/* - 移除旧的授权页面

## 新的鉴权流程

1. 小程序启动 → 确保 openid 已授权(wx.login)
2. 如果 miniProgramAuthAPI 返回 user → 自动登录
3. 如果未登录 → 不跳转,允许用户浏览小程序
4. 用户操作触发接口返回 401 → 跳转登录页

## 优势

- ✅ 简化前端逻辑,不需要维护白名单
- ✅ sessionid 由后端统一管理,更安全
- ✅ 用户体验更好,启动时不强制登录
- ✅ 代码更清晰,职责分离

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
......@@ -60,5 +60,9 @@
"Bash(do if [ -d \"/Users/huyirui/program/itomix/git/manulife-weapp/docs/$dir\" ])",
"Bash(then echo \"=== $dir/ ===\" ls -1 /Users/huyirui/program/itomix/git/manulife-weapp/docs/$dir/*.md)"
]
}
},
"enabledMcpjsonServers": [
"chrome-devtools"
],
"enableAllProjectMcpServers": true
}
......
......@@ -7,7 +7,6 @@ module.exports = {
globals: {
definePageConfig: 'readonly',
getCurrentPages: 'readonly',
ENABLE_AUTH_MODE: 'readonly',
wx: 'readonly'
},
extends: ['taro'],
......
# 鉴权功能重构规划
## 📋 文档信息
- **创建日期**: 2026-02-02
- **最后更新**: 2026-02-02
- **作者**: Claude Code
- **状态**: 规划阶段
---
## 🎯 需求概述
重构项目的鉴权功能,采用**简化设计**
### 核心原则
1. **sessionid 只存储和传递**:前端存储 sessionid 并在请求中发送给后端,但前端不依赖它来判断是否登录
2. **401 统一跳转登录页**:所有接口返回 401 时,跳转到登录页
3. **不需要白名单**:后端自己决定哪些接口需要鉴权,返回 401 即可
4. **启动时检查状态**:通过 `loginStatusAPI` 检查 openid 和登录状态
### 核心概念
| 概念 | 说明 | 状态字段 |
|------|------|----------|
| **微信授权(openid)** | 后端通过 `miniProgramAuthAPI` 授权获取用户的 openid | `loginStatusAPI.data.is_openid` |
| **用户登录** | 用户通过 `loginAPI` 输入账号密码后绑定 openid | `loginStatusAPI.data.is_login` |
| **401 响应** | 表示**用户未登录**,跳转到登录页 | 后端接口响应 |
**关键理解**
- sessionid 只是传递给后端的凭证,前端不依赖它做判断
- 所有请求直接发送,401 统一处理
- 不需要白名单机制
---
## 📊 API 数据结构
### 1. `miniProgramAuthAPI` - 小程序授权
**接口地址**`/srv/?a=openid`
**功能**:小程序授权,获取 openid 并尝试自动登录
**请求方式**`POST`
**请求参数**
```javascript
{
code: string // wx.login 获取的 code
}
```
**响应结构**
```javascript
{
code: 1,
msg: "success",
data: {
user: {
id: integer, // 用户ID(可能为空)
avatar_url: string, // 头像
name: string // 姓名
} || null // 如果为空,表示需要调用登录接口
}
}
```
**登录流程设计**(来自注释):
- 先小程序授权
- 如果返回 **用户为空**,则需要调用登录接口(`loginAPI`
- 如果返回 **用户非空**,则不需要调用登录接口,授权接口内部按照 OpenID 绑定的账号,自动登录
### 2. `loginAPI` - 账号密码登录
**接口地址**`/srv/?a=user&t=login`
**功能**:登录并绑定 openid
**请求方式**`POST`
**请求参数**
```javascript
{
uuid: string, // 账号(手机号或其他)
password: string // 密码
}
```
**响应结构**
```javascript
{
code: 1,
msg: "success",
data: any // 没有返回值,只有 code 和 msg
}
```
### 3. `loginStatusAPI` - 查询登录状态
**接口地址**`/srv/?a=user&t=login_status`
**功能**:查询用户的微信授权状态和登录状态
**请求方式**`GET`
**响应结构**
```javascript
{
code: 1,
msg: "success",
data: {
is_login: boolean, // true=已登录,false=未登录
is_openid: boolean // true=已授权(有 openid),false=未授权
}
}
```
**示例响应**
```javascript
// 场景 1:已授权 + 已登录
{
code: 1,
msg: "success",
data: {
is_login: true,
is_openid: true
}
}
// 场景 2:已授权 + 未登录
{
code: 1,
msg: "success",
data: {
is_login: false,
is_openid: true
}
}
// 场景 3:未授权
{
code: 1,
msg: "success",
data: {
is_login: false,
is_openid: false
}
}
```
### 4. `getProfileAPI` - 获取个人信息
**接口地址**`/srv/?a=user&t=get_profile`
**功能**:获取当前登录用户的个人信息
**请求方式**`GET`
**响应结构**
```javascript
{
code: 1,
msg: "success",
data: {
user: {
id: integer,
avatar_url: string,
name: string
}
}
}
```
### 5. `logoutAPI` - 退出登录
**接口地址**`/srv/?a=user&t=logout`
**功能**:退出登录并解绑 openid
**请求方式**`POST`
**响应结构**
```javascript
{
code: 1,
msg: "success",
data: any
}
```
---
## 🏗️ 鉴权架构设计
### 简化的鉴权模型
```
┌─────────────────────────────────────────────────────────────┐
│ 小程序启动 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 调用 loginStatusAPI │
│ 检查 is_openid + is_login │
└─────────────────────────────────────────────────────────────┘
┌───────────────┴───────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ is_openid │ │ is_login │
│ = false │ │ = false │
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ 调用 wx.login │ │ 跳转到登录页 │
│ 获取 code │ │ (/pages/login) │
│ 调用 │ │ │
│ miniProgramAuthAPI│ │ │
└─────────────────┘ └─────────────────┘
┌─────────────────┐
│ 返回 user? │
└─────────────────┘
│ │
有值 │ │ 为空
│ └──────────→ 跳转到登录页
正常进入小程序
```
### 请求处理流程
```
所有接口请求
├─→ 添加 sessionid 到请求头(如果有)
├─→ 发送请求
└─→ 响应处理
├─→ 200 → 正常处理
├─→ 401 → 清除 sessionid → 跳转登录页
└─→ 其他错误 → 显示错误提示
```
---
## 🔄 鉴权流程设计
### 场景 1:小程序启动
```mermaid
flowchart TD
A[小程序启动] --> B[调用 loginStatusAPI]
B --> C{检查响应}
C -->|网络错误| D[显示错误提示]
C -->|成功| E{is_openid?}
E -->|false| F[调用 wx.login 获取 code]
F --> G[调用 miniProgramAuthAPI]
G --> H{返回 user?}
H -->|为空| I[跳转到登录页]
H -->|有值| J[正常进入小程序]
E -->|true| K{is_login?}
K -->|false| I
K -->|true| L[正常进入小程序]
```
**代码流程**
```javascript
// 1. 检查状态
const { is_openid, is_login } = await loginStatusAPI()
// 2. 处理未授权
if (!is_openid) {
const { code } = await Taro.login()
const res = await miniProgramAuthAPI({ code })
if (res.data.user) {
// 已自动登录
return res.data.user
} else {
// 需要登录
Taro.navigateTo({ url: '/pages/login/index' })
return
}
}
// 3. 处理未登录
if (!is_login) {
Taro.navigateTo({ url: '/pages/login/index' })
}
```
### 场景 2:用户登录
```mermaid
flowchart TD
A[用户在登录页] --> B[输入账号密码]
B --> C[调用 loginAPI]
C --> D{登录结果}
D -->|成功| E[保存 sessionid]
E --> F[保存用户信息]
F --> G[返回上一页或首页]
D -->|失败| H[显示错误提示]
```
### 场景 3:接口请求 401
```mermaid
flowchart TD
A[发起接口请求] --> B[添加 sessionid 到请求头]
B --> C[发送请求]
C --> D{响应状态}
D -->|200| E[正常处理]
D -->|401| F[清除 sessionid]
F --> G[跳转到登录页]
D -->|其他错误| H[显示错误提示]
```
**关键点**
- 401 只表示**用户未登录**,不表示 openid 未授权
- 401 时清除 sessionid,跳转到登录页
- **不在 401 时重新调用 wx.login 或 miniProgramAuthAPI**
---
## 📁 文件结构规划
### 核心文件
```
src/
├── utils/
│ ├── openid.js # 新增:微信授权(openid)管理
│ ├── request.js # 修改:HTTP 请求拦截器(移除白名单和 sessionid)
│ └── authRedirect.js # 删除:完全替换为新逻辑
├── api/
│ ├── user.js # 已存在:用户相关 API
│ └── wechat.js # 已存在:微信授权 API
├── pages/
│ ├── auth/
│ │ └── index.vue # 删除:不再需要单独的授权页
│ └── login/
│ └── index.vue # 保留:用户登录页(账号密码登录)
├── stores/
│ └── user.js # 新增:用户状态管理(Pinia)
└── app.js # 修改:启动时检查登录状态
```
### 文件职责
| 文件 | 职责 | 状态 |
|------|------|------|
| `utils/openid.js` | 微信授权逻辑(wx.login、miniProgramAuthAPI) | 新增 |
| `utils/request.js` | HTTP 拦截器(401 处理,移除白名单和 sessionid) | 修改 |
| `utils/authRedirect.js` | 旧授权逻辑 | 删除 |
| `api/user.js` | 用户相关 API | 已存在 |
| `api/wechat.js` | 微信授权 API | 已存在 |
| `stores/user.js` | 用户信息状态管理 | 新增 |
| `app.js` | 启动时检查登录状态 | 修改 |
---
## 🔧 实现细节
### 1. 微信授权管理 (`utils/openid.js`)
```javascript
import Taro from '@tarojs/taro'
import { miniProgramAuthAPI } from '@/api/wechat'
import { loginStatusAPI } from '@/api/user'
/**
* 小程序授权
* @description 调用 wx.login 获取 code,由后端授权获取 openid
* @returns {Promise<{user: Object|null}>} 返回用户信息(如果已自动登录)
*/
export async function miniProgramAuth() {
try {
// 1. 调用 wx.login 获取 code
const { code } = await Taro.login()
if (!code) {
throw new Error('获取微信 code 失败')
}
// 2. 调用后端授权接口
const res = await miniProgramAuthAPI({ code })
if (res.code === 1) {
return res.data.user || null
} else {
throw new Error(res.msg || '小程序授权失败')
}
} catch (err) {
console.error('小程序授权失败:', err)
throw err
}
}
/**
* 检查 openid 状态
* @description 调用 loginStatusAPI 检查 is_openid
* @returns {Promise<boolean>} 是否已授权
*/
export async function checkOpenidStatus() {
try {
const res = await loginStatusAPI()
if (res.code === 1) {
return res.data.is_openid
} else {
return false
}
} catch (err) {
console.error('检查 openid 状态失败:', err)
return false
}
}
/**
* 确保 openid 已授权并尝试自动登录
* @description 如果未授权,则调用 wx.login 授权
* @returns {Promise<{user: Object|null}>} 返回用户信息(如果已自动登录)
*/
export async function ensureOpenidAuthorized() {
const isOpenid = await checkOpenidStatus()
if (!isOpenid) {
// 未授权,调用 wx.login 授权
return await miniProgramAuth()
}
// 已授权,返回 null(需要检查登录状态)
return null
}
```
### 2. sessionid 管理 (`utils/session.js`)
```javascript
const SESSION_KEY = 'sessionid'
/**
* 保存 sessionid
* @param {string} sessionid
*/
export function setSessionId(sessionid) {
if (!sessionid) {
console.warn('sessionid 为空,无法保存')
return
}
localStorage.setItem(SESSION_KEY, sessionid)
}
/**
* 获取 sessionid
* @returns {string|null}
*/
export function getSessionId() {
return localStorage.getItem(SESSION_KEY)
}
/**
* 清除 sessionid
*/
export function clearSessionId() {
localStorage.removeItem(SESSION_KEY)
}
/**
* 检查是否有 sessionid
* @description 注意:这只是检查本地是否有 sessionid,不代表用户已登录
* @returns {boolean}
*/
export function hasSessionId() {
return !!getSessionId()
}
```
### 3. 请求拦截器 (`utils/request.js`)
```javascript
// 请求拦截器
instance.interceptors.request.use((config) => {
// 后端通过 cookie 自动处理 sessionid
// 前端不需要添加任何 sessionid 相关的逻辑
return config
})
// 响应拦截器
instance.interceptors.response.use(
(response) => {
// 正常响应
return response
},
(error) => {
// 错误响应
if (error.response?.status === 401) {
// 401 表示用户未登录
// 跳转到登录页
Taro.navigateTo({
url: '/pages/login/index'
})
}
return Promise.reject(error)
}
)
```
**关键点**
- **不需要白名单**:所有接口直接发送
- **不处理 sessionid**:后端通过 cookie 自动处理
- **401 统一处理**:跳转登录页
### 4. 用户状态管理 (`stores/user.js`)
```javascript
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { loginStatusAPI, loginAPI, getProfileAPI, logoutAPI } from '@/api/user'
import { ensureOpenidAuthorized } from '@/utils/openid'
export const useUserStore = defineStore('user', () => {
// 状态
const userInfo = ref(null) // 用户信息
const isOpenid = ref(false) // 是否已授权(openid)
const isLoggedIn = ref(false) // 是否已登录
const loading = ref(false) // 加载状态
/**
* 检查登录状态
* @description 检查 openid 和登录状态,处理相应的逻辑
*/
async function checkLoginStatus() {
loading.value = true
try {
// 1. 确保 openid 已授权并尝试自动登录
const user = await ensureOpenidAuthorized()
if (user) {
// miniProgramAuthAPI 返回了用户信息,说明已自动登录
userInfo.value = user
isOpenid.value = true
isLoggedIn.value = true
return
}
// 2. 查询登录状态
const res = await loginStatusAPI()
if (res.code === 1) {
isOpenid.value = res.data.is_openid
isLoggedIn.value = res.data.is_login
// 3. 如果已登录,获取用户信息
if (isLoggedIn.value) {
await fetchUserInfo()
} else {
// 未登录,跳转到登录页
Taro.navigateTo({
url: '/pages/login/index'
})
}
} else {
throw new Error(res.msg || '查询登录状态失败')
}
} catch (err) {
console.error('检查登录状态失败:', err)
throw err
} finally {
loading.value = false
}
}
/**
* 获取用户信息
*/
async function fetchUserInfo() {
try {
const res = await getProfileAPI()
if (res.code === 1) {
userInfo.value = res.data.user
} else {
throw new Error(res.msg || '获取用户信息失败')
}
} catch (err) {
console.error('获取用户信息失败:', err)
throw err
}
}
/**
* 用户登录
* @param {Object} loginData 登录数据
* @param {string} loginData.uuid 账号
* @param {string} loginData.password 密码
*/
async function login(loginData) {
loading.value = true
try {
const res = await loginAPI(loginData)
if (res.code === 1) {
// 登录成功,获取用户信息
await fetchUserInfo()
isLoggedIn.value = true
return { success: true }
} else {
throw new Error(res.msg || '登录失败')
}
} catch (err) {
console.error('登录失败:', err)
return { success: false, message: err.message }
} finally {
loading.value = false
}
}
/**
* 用户登出
*/
async function logout() {
try {
// 调用登出接口
await logoutAPI()
// 清除本地状态
userInfo.value = null
isOpenid.value = false
isLoggedIn.value = false
} catch (err) {
console.error('登出失败:', err)
}
}
return {
// 状态
userInfo,
isOpenid,
isLoggedIn,
loading,
// 方法
checkLoginStatus,
fetchUserInfo,
login,
logout
}
})
```
### 5. 应用启动逻辑 (`app.js`)
```javascript
import { useUserStore } from '@/stores/user'
function App(props) {
const userStore = useUserStore()
useLaunch(() => {
console.log('小程序启动')
// 检查登录状态
userStore.checkLoginStatus().catch(err => {
console.error('启动时检查登录状态失败:', err)
})
})
return props.children
}
```
---
## 🚀 实施步骤
### 第 1 步:创建新文件
- [ ] 创建 `src/utils/openid.js` - 微信授权(openid)管理
- [ ] 创建 `src/stores/user.js` - 用户状态管理
### 第 2 步:修改现有文件
- [ ] 修改 `src/utils/request.js` - 更新请求拦截器
- 移除白名单配置
- 移除 sessionid 相关逻辑
- 更新 401 响应处理
- [ ] 修改 `src/app.js` - 启动时检查登录状态
- [ ] 删除 `src/utils/authRedirect.js` - 移除旧的授权逻辑
- [ ] 删除 `src/pages/auth/index.vue` - 不再需要单独的授权页
### 第 3 步:更新登录页
- [ ] 修改 `src/pages/login/index.vue` - 使用新的登录逻辑
- 调用 `userStore.login()`
- 处理登录成功/失败
### 第 4 步:测试验证
- [ ] 测试首次启动流程(无 openid)
- [ ] 测试 openid 授权流程
- [ ] 测试自动登录(openid 已绑定账号)
- [ ] 测试手动登录流程
- [ ] 测试 401 处理
- [ ] 测试已登录用户启动
### 第 5 步:文档更新
- [ ] 更新 `CLAUDE.md` 鉴权部分
- [ ] 更新 `docs/lessons-learned.md`
- [ ] 添加鉴权流程图
---
## ⚠️ 注意事项
### 1. sessionid 的作用
- **miniProgramAuthAPI 后端自动处理 sessionid**(如通过 cookie),前端不需要保存
- **loginAPI 不返回 sessionid**,后端通过 cookie 或其他方式自动处理
- 前端**不依赖** sessionid 判断用户是否登录
- 是否登录由后端通过 401 判断
### 2. 不需要白名单
- 所有接口直接发送
- 后端自己决定哪些接口需要鉴权
- 返回 401 的接口统一跳转登录页
### 3. miniProgramAuthAPI 的特殊逻辑
根据注释:
> 如果返回 **用户为空**,则需要调用登录接口(`loginAPI`)
> 如果返回 **用户非空**,则不需要调用登录接口,授权接口内部按照 OpenID 绑定的账号,**自动登录**
这意味着:
- **后端自动处理 sessionid**(如通过 cookie),前端不需要保存
- 用户第一次使用:`is_openid=false` → 调用 `miniProgramAuthAPI` → 返回 `user=null` → 跳转登录页
- 用户已绑定账号:`is_openid=true` → 调用 `miniProgramAuthAPI` → 返回 `user` → 自动登录
- **loginAPI 不返回 sessionid 和 user**,登录成功后需要单独调用 `getProfileAPI` 获取用户信息
### 4. 401 错误处理
- 401 只表示**用户未登录**,不表示 openid 未授权
- 401 时清除 sessionid,跳转到登录页
- **不要在 401 时重新调用 wx.login 或 miniProgramAuthAPI**
### 5. 登录流程
根据现有 API,登录流程应该是:
1. 小程序启动 → 检查 `is_openid`
2. 如果 `is_openid=false` → 调用 `wx.login` → 调用 `miniProgramAuthAPI`
3. 如果返回 `user=null` → 跳转登录页
4. 用户输入账号密码 → 调用 `loginAPI` 绑定
---
## 📊 状态机
```javascript
// 用户状态枚举
const UserState = {
// 未授权(未绑定 openid)
UNAUTHORIZED: 'unauthorized',
// 已授权,未登录(已绑定 openid,但未绑定业务账号)
AUTH_NOT_LOGIN: 'auth_not_login',
// 已登录(已绑定业务账号)
LOGGED_IN: 'logged_in'
}
// 状态转换
UNAUTHORIZED [miniProgramAuthAPI] AUTH_NOT_LOGIN [loginAPI] LOGGED_IN
[跳转登录页]
```
**实际字段映射**
- `UNAUTHORIZED``is_openid = false`
- `AUTH_NOT_LOGIN``is_openid = true && is_login = false`
- `LOGGED_IN``is_openid = true && is_login = true`
---
## 🔗 相关文档
- [Taro 登录流程](https://docs.taro.zone/docs/weapp/next/login)
- [微信小程序登录](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html)
- [项目经验教训](../lessons-learned.md)
- [API 文档](../../src/api/user.js)
- [微信 API 文档](../../src/api/wechat.js)
---
**最后更新**: 2026-02-02
**维护者**: Claude Code
......@@ -3,6 +3,7 @@ import { fn, fetch } from '@/api/fn';
const Api = {
GetProfile: '/srv/?a=user&t=get_profile',
Login: '/srv/?a=user&t=login',
LoginStatus: '/srv/?a=user&t=login_status',
Logout: '/srv/?a=user&t=logout',
UpdateProfile: '/srv/?a=user&t=update_profile',
}
......@@ -40,6 +41,21 @@ export const getProfileAPI = (params) => fn(fetch.get(Api.GetProfile, params));
export const loginAPI = (params) => fn(fetch.post(Api.Login, params));
/**
* @description 查询登录状态
* @remark
* @param {Object} params 请求参数
* @returns {Promise<{
* code: number; // 状态码
* msg: string; // 消息
* data: {
* is_login: boolean; // true=登录,false=未登录
* is_openid: boolean; // true=已授权,false=未授权
* };
* }>}
*/
export const loginStatusAPI = (params) => fn(fetch.get(Api.LoginStatus, params));
/**
* @description 退出登录并解绑openid
* @remark
* @param {Object} params 请求参数
......
......@@ -11,7 +11,6 @@ const pages = [
'pages/webview/index',
'pages/document-preview/index',
'pages/document-demo/index',
'pages/auth/index',
'pages/onboarding/index',
'pages/family-office/index',
'pages/knowledge-base/index',
......
/*
* @Date: 2025-06-28 10:33:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-31 19:52:31
* @LastEditTime: 2026-02-02 18:00:00
* @FilePath: /manulife-weapp/src/app.js
* @Description: 应用入口文件
*/
......@@ -9,45 +9,32 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './utils/polyfill'
import './app.less'
import { saveCurrentPagePath, hasAuth, silentAuth, navigateToAuth } from '@/utils/authRedirect'
import { useUserStore } from '@/stores/user'
const App = createApp({
// 对应 onLaunch
async onLaunch(options) {
const path = options?.path || ''
const query = options?.query || {}
const query_string = Object.keys(query)
.map((key) => `${key}=${encodeURIComponent(query[key])}`)
.join('&')
const full_path = query_string ? `${path}?${query_string}` : path
// 保存当前页面路径,用于授权后跳转回原页面
if (full_path) {
saveCurrentPagePath(full_path)
}
// 如果用户已授权,则不需要额外操作
if (hasAuth()) {
return
}
if (path === 'pages/auth/index') return
try {
// 尝试静默授权
await silentAuth()
} catch (error) {
console.error('静默授权失败:', error)
// 授权失败则跳转至授权页面
navigateToAuth(full_path || undefined)
}
return
},
onShow() {
},
});
// 对应 onLaunch
async onLaunch(options) {
console.log('小程序启动', options)
// 获取用户 store
const userStore = useUserStore()
// 检查登录状态
// - 如果 is_openid=false,会自动调用 wx.login 授权
// - 如果授权后返回 user,说明已自动登录
// - 如果 is_login=false,会跳转到登录页
try {
await userStore.checkLoginStatus()
} catch (error) {
console.error('启动时检查登录状态失败:', error)
// 即使失败也继续,让用户可以正常使用小程序
}
},
onShow() {
// 页面显示时的逻辑
},
})
App.use(createPinia())
......
export default {
navigationBarTitleText: '授权页',
usingComponents: {
},
}
<!--
* @Date: 2022-09-19 14:11:06
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-13 00:18:41
* @FilePath: /xyxBooking-weapp/src/pages/auth/index.vue
* @Description: 授权页
-->
<template>
<view class="auth-page">
<view class="loading">
<view>正在授权登录...</view>
</view>
</view>
</template>
<script setup>
import Taro, { useDidShow } from '@tarojs/taro'
import { silentAuth, returnToOriginalPage } from '@/utils/authRedirect'
let last_try_at = 0
let has_shown_fail_modal = false
let has_failed = false
useDidShow(() => {
if (has_failed) return
const now = Date.now()
if (now - last_try_at < 1200) return
last_try_at = now
/**
* 尝试静默授权
* - 授权成功后回跳到来源页
* - 授权失败则跳转至授权页面
*/
silentAuth()
.then(() => returnToOriginalPage())
.catch(async (error) => {
has_failed = true
if (has_shown_fail_modal) return
has_shown_fail_modal = true
await Taro.showModal({
title: '提示',
content: error?.message || '授权失败,请稍后再尝试',
showCancel: false,
confirmText: '我知道了',
})
})
})
</script>
<style lang="less">
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
.loading {
text-align: center;
color: #999;
}
}
</style>
......@@ -22,13 +22,13 @@
<!-- Form -->
<view class="space-y-[48rpx]">
<!-- Email -->
<!-- Account -->
<view class="border-b border-gray-200 pb-[16rpx]">
<view class="text-[28rpx] text-gray-900 font-medium mb-[16rpx]">邮箱</view>
<view class="text-[28rpx] text-gray-900 font-medium mb-[16rpx]">账号</view>
<input
v-model="form.email"
v-model="form.uuid"
type="text"
placeholder="请输入工作邮箱"
placeholder="请输入账号"
placeholder-class="text-gray-300"
class="w-full text-[32rpx] text-gray-900 h-[80rpx]"
/>
......@@ -68,38 +68,23 @@
<script setup>
import { reactive } from 'vue'
import Taro from '@tarojs/taro'
import { useGo } from '@/hooks/useGo'
import { useUserStore } from '@/stores/user'
import NavHeader from '@/components/NavHeader.vue'
const go = useGo()
const userStore = useUserStore()
const form = reactive({
email: '',
uuid: '',
password: ''
})
/**
* 验证邮箱格式
* @param {string} email - 邮箱地址
* @returns {boolean} 是否有效
*/
const isValidEmail = (email) => {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
return emailRegex.test(email)
}
/**
* Handle login action
*/
const handleLogin = () => {
// 验证邮箱
if (!form.email) {
Taro.showToast({ title: '请输入邮箱', icon: 'none' })
return
}
if (!isValidEmail(form.email)) {
Taro.showToast({ title: '请输入有效的邮箱地址', icon: 'none' })
const handleLogin = async () => {
// 验证账号
if (!form.uuid) {
Taro.showToast({ title: '请输入账号', icon: 'none' })
return
}
......@@ -114,16 +99,37 @@ const handleLogin = () => {
return
}
// Mock login success
// 调用登录接口
Taro.showLoading({ title: '登录中...', mask: true })
setTimeout(() => {
try {
const result = await userStore.login({
uuid: form.uuid,
password: form.password
})
if (result.success) {
Taro.hideLoading()
Taro.showToast({ title: '登录成功', icon: 'success' })
// 延迟后跳转到首页
setTimeout(() => {
Taro.reLaunch({ url: '/pages/index/index' })
}, 1500)
} else {
Taro.hideLoading()
Taro.showToast({
title: result.message || '登录失败',
icon: 'none'
})
}
} catch (error) {
Taro.hideLoading()
Taro.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
// Redirect to home or previous page
Taro.reLaunch({ url: '/pages/index/index' })
}, 1500)
}, 1000)
Taro.showToast({
title: error.message || '登录失败,请重试',
icon: 'none'
})
}
}
</script>
......
......@@ -12,13 +12,13 @@
>
<!-- Avatar -->
<view class="w-[160rpx] h-[160rpx] rounded-full overflow-hidden border-2 border-white shadow-sm shrink-0">
<img class="w-full h-full object-cover" :src="defaultAvatar" />
<img class="w-full h-full object-cover" :src="userInfo?.avatar_url || defaultAvatar" />
</view>
<!-- Info -->
<view class="ml-[32rpx] flex-1 flex flex-col justify-center">
<text class="text-[36rpx] font-bold text-gray-800 mb-[8rpx]">张三</text>
<text class="text-[28rpx] text-gray-500 mb-[4rpx]">工号: EMP2026001</text>
<text class="text-[36rpx] font-bold text-gray-800 mb-[8rpx]">{{ userInfo?.name || '加载中...' }}</text>
<text class="text-[28rpx] text-gray-500 mb-[4rpx]">ID: {{ userInfo?.id || '--' }}</text>
<text class="text-[24rpx] text-gray-400">点击修改头像</text>
</view>
......@@ -68,14 +68,54 @@
</template>
<script setup>
import { ref } from 'vue'
import { useGo } from '@/hooks/useGo'
import { mainStore } from '@/stores/main'
import IconFont from '@/components/IconFont.vue'
import TabBar from '@/components/TabBar.vue'
import NavHeader from '@/components/NavHeader.vue'
import Taro from '@tarojs/taro'
import { useLoad } from '@tarojs/taro'
import { getProfileAPI } from '@/api/user'
import defaultAvatar from '@/assets/images/icon/avatar.svg'
const go = useGo()
const store = mainStore()
/**
* @description 用户信息(响应式)
* @type {import('vue').Ref<{id?: number, name?: string, avatar_url?: string}|null>}
*/
const userInfo = ref(null)
/**
* @description 获取用户个人信息
* @description 进入页面时调用,401 自动跳转登录页(由 request.js 拦截器处理)
* @returns {Promise<void>}
*/
const fetchUserProfile = async () => {
try {
const res = await getProfileAPI()
if (res.code === 1 && res.data?.user) {
// 更新响应式数据
userInfo.value = res.data.user
// 更新全局状态
store.changeUserInfo(res.data.user)
} else {
// 接口返回失败(非 401,因为 401 已被 request.js 拦截器处理)
console.warn('获取用户信息失败:', res.msg)
}
} catch (err) {
console.error('获取用户信息异常:', err)
}
}
/**
* @description 页面加载时获取用户信息
*/
useLoad(() => {
fetchUserProfile()
})
const menuItems = [
{ title: '我的计划书', icon: 'order', path: '/pages/plan/index' },
......
/**
* 用户状态管理
*
* @description 管理用户登录状态、用户信息等
* @module stores/user
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import Taro from '@tarojs/taro'
import { loginStatusAPI, loginAPI, getProfileAPI, logoutAPI } from '@/api/user'
import { ensureOpenidAuthorized } from '@/utils/openid'
export const useUserStore = defineStore('user', () => {
// ========== 状态 ==========
/** 用户信息 */
const userInfo = ref(null)
/** 是否已授权(openid) */
const isOpenid = ref(false)
/** 是否已登录 */
const isLoggedIn = ref(false)
/** 加载状态 */
const loading = ref(false)
// ========== 方法 ==========
/**
* 检查登录状态
* @description 小程序启动时检查 openid 和登录状态
* - 只触发微信授权,不跳转登录页
* - 401 由接口拦截器统一处理
* @throws {Error} 检查失败时抛出错误
*
* @example
* await userStore.checkLoginStatus()
*/
async function checkLoginStatus() {
loading.value = true
try {
// 1. 确保 openid 已授权并尝试自动登录
const user = await ensureOpenidAuthorized()
if (user) {
// miniProgramAuthAPI 返回了用户信息,说明已自动登录
userInfo.value = user
isOpenid.value = true
isLoggedIn.value = true
return
}
// 2. 查询登录状态
const res = await loginStatusAPI()
if (res.code === 1) {
isOpenid.value = res.data.is_openid
isLoggedIn.value = res.data.is_login
// 3. 如果已登录,获取用户信息
if (isLoggedIn.value) {
await fetchUserInfo()
}
// 注意:这里不跳转登录页,让用户可以浏览小程序
// 当用户操作触发接口返回 401 时,会自动跳转登录页
} else {
throw new Error(res.msg || '查询登录状态失败')
}
} catch (err) {
console.error('检查登录状态失败:', err)
throw err
} finally {
loading.value = false
}
}
/**
* 获取用户信息
* @description 调用 getProfileAPI 获取用户信息
* @throws {Error} 获取失败时抛出错误
*
* @example
* await userStore.fetchUserInfo()
*/
async function fetchUserInfo() {
try {
const res = await getProfileAPI()
if (res.code === 1) {
userInfo.value = res.data.user
} else {
throw new Error(res.msg || '获取用户信息失败')
}
} catch (err) {
console.error('获取用户信息失败:', err)
throw err
}
}
/**
* 用户登录
* @description 调用 loginAPI 进行账号密码登录
* @param {Object} loginData 登录数据
* @param {string} loginData.uuid 账号
* @param {string} loginData.password 密码
* @returns {{success: boolean, message?: string}} 登录结果
*
* @example
* const result = await userStore.login({
* uuid: '13800138000',
* password: '123456'
* })
* if (result.success) {
* console.log('登录成功')
* }
*/
async function login(loginData) {
loading.value = true
try {
const res = await loginAPI(loginData)
if (res.code === 1) {
// 登录成功,获取用户信息
await fetchUserInfo()
isLoggedIn.value = true
return { success: true }
} else {
throw new Error(res.msg || '登录失败')
}
} catch (err) {
console.error('登录失败:', err)
return { success: false, message: err.message }
} finally {
loading.value = false
}
}
/**
* 用户登出
* @description 调用 logoutAPI 并清除本地状态
*
* @example
* await userStore.logout()
*/
async function logout() {
try {
// 调用登出接口
await logoutAPI()
// 清除本地状态
userInfo.value = null
isOpenid.value = false
isLoggedIn.value = false
} catch (err) {
console.error('登出失败:', err)
}
}
// ========== 返回 ==========
return {
// 状态
userInfo,
isOpenid,
isLoggedIn,
loading,
// 方法
checkLoginStatus,
fetchUserInfo,
login,
logout
}
})
import Taro from '@tarojs/taro'
import { routerStore } from '@/stores/router'
import { buildApiUrl } from './tools'
import { ENABLE_AUTH_MODE } from './config'
// 改进:添加全局状态变量注释
/**
* 上一次跳转到授权页的时间戳,用于防抖(避免短时间内重复跳转)
* @type {number}
*/
let last_navigate_auth_at = 0
/**
* 是否正在跳转到授权页,用于防重复(避免并发跳转)
* @type {boolean}
*/
let navigating_to_auth = false
/**
* 授权与回跳相关工具
* - 统一管理:保存来源页、静默授权、跳转授权页、授权后回跳
* - 约定:sessionid 存在于本地缓存 key 为 sessionid
* - 说明:refreshSession/silentAuth 使用单例 Promise,避免并发重复授权
*/
/**
* 获取当前页完整路径(含 query)
* @returns {string} 当前页路径,示例:pages/index/index?a=1;获取失败返回空字符串
*/
export const getCurrentPageFullPath = () => {
const pages = Taro.getCurrentPages()
if (!pages || pages.length === 0) return ''
const current_page = pages[pages.length - 1]
const route = current_page.route
const options = current_page.options || {}
// 改进:key 也需要编码,避免特殊字符导致 URL 解析错误
const query_params = Object.keys(options)
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(options[key])}`)
.join('&')
return query_params ? `${route}?${query_params}` : route
}
/**
* 保存当前页路径(用于授权成功后回跳)
* @param {string} custom_path 自定义路径,不传则取当前页完整路径
* @returns {void} 无返回值
*/
export const saveCurrentPagePath = (custom_path) => {
const router = routerStore()
const path = custom_path || getCurrentPageFullPath()
router.add(path)
}
/**
* 判断是否已授权
* @returns {boolean} true=已存在 sessionid,false=需要授权
*/
export const hasAuth = () => {
// 如果禁用了授权模式,直接视为已授权
if (!ENABLE_AUTH_MODE) return true
try {
const sessionid = Taro.getStorageSync('sessionid')
return !!sessionid && sessionid !== ''
} catch (error) {
console.error('检查授权状态失败:', error)
return false
}
}
let auth_promise = null
/**
* 从响应中提取 cookie
* 兼容小程序端和 H5 端的不同返回格式
* @param {object} response Taro.request 响应对象
* @returns {string|null} cookie 字符串或 null
*/
const extractCookie = (response) => {
// 小程序端优先从 response.cookies 取
if (response.cookies?.[0]) return response.cookies[0]
// H5 端从 header 取(兼容不同大小写)
const cookie = response.header?.['Set-Cookie'] || response.header?.['set-cookie']
if (Array.isArray(cookie)) return cookie[0]
return cookie || null
}
/**
* 刷新会话:通过 Taro.login 获取 code,换取后端会话 cookie 并写入缓存
* - 被 request.js 的 401 拦截器调用,用于自动“静默续期 + 原请求重放”
* - 复用 auth_promise,防止多个接口同时 401 时并发触发多次登录
* @param {object} options 可选项
* @param {boolean} options.show_loading 是否展示 loading,默认 true
* @returns {Promise<{code:number,msg?:string,data?:any,cookie?:string}>} 授权结果(会把 cookie 写入 storage 的 sessionid)
*/
export const refreshSession = async (options) => {
// 如果禁用了授权模式,直接返回模拟成功
if (!ENABLE_AUTH_MODE) {
return { code: 1, msg: '授权模式已禁用', cookie: 'mock_session_id' }
}
const show_loading = options?.show_loading !== false
// 已有授权进行中时,直接复用同一个 Promise
if (auth_promise) return auth_promise
auth_promise = (async () => {
try {
if (show_loading) {
Taro.showLoading({
title: '加载中...',
mask: true,
})
}
// 调用微信登录获取临时 code
const login_result = await new Promise((resolve, reject) => {
Taro.login({
success: resolve,
fail: reject,
})
})
if (!login_result || !login_result.code) {
throw new Error('获取微信登录code失败')
}
const request_data = {
code: login_result.code,
}
// 换取后端会话(服务端通过 Set-Cookie 返回会话信息)
const response = await Taro.request({
url: buildApiUrl('openid'),
method: 'POST',
data: request_data,
})
if (!response?.data || response.data.code !== 1) {
throw new Error(response?.data?.msg || '授权失败')
}
// 改进:使用 extractCookie 函数统一处理 cookie 提取逻辑
const cookie = extractCookie(response)
if (!cookie) {
throw new Error('授权失败:没有获取到有效的会话信息')
}
// NOTE: 写入本地缓存:后续请求会从缓存取 sessionid 并带到请求头
Taro.setStorageSync('sessionid', cookie)
/**
* refreshSession() 的返回值当前没有任何业务消费点:在 request.js 里只是 await refreshSession() ,不解构、不使用;其他地方也没直接调用它
* 所以 return { ...response.data, cookie } 目前属于“严谨保留”:方便未来需要拿 cookie / code / msg 做埋点、提示、分支处理时直接用(例如授权页显示更细错误、统计刷新成功率等)。
*/
return {
...response.data,
cookie,
}
} finally {
if (show_loading) {
Taro.hideLoading()
}
}
})().finally(() => {
auth_promise = null
})
return auth_promise
}
/**
* 执行静默授权:检查是否已授权,若否则调用 refreshSession 刷新会话
* @param {boolean} show_loading 是否展示 loading,默认 true
* @returns {Promise<{code:number,msg?:string,data?:any,cookie?:string}>} 授权结果
*
* 改进:使用下划线前缀表示私有函数,仅供 silentAuth 内部使用
*/
const _do_silent_auth = async (show_loading) => {
// 已有 sessionid 时直接视为已授权
if (hasAuth()) {
return { code: 1, msg: '已授权' }
}
// 需要授权时,走刷新会话逻辑
return await refreshSession({ show_loading })
}
/**
* 静默授权:用于启动阶段/分享页/授权页发起授权
* - 与 refreshSession 共用 auth_promise,避免并发重复调用
* @param {(result: any) => void} on_success 成功回调(可选)
* @param {(error: {message:string, original:Error}) => void} on_error 失败回调(可选,入参为错误对象)
* @param {object} options 可选项
* @param {boolean} options.show_loading 是否展示 loading,默认 true
* @returns {Promise<any>} 授权结果(成功 resolve,失败 reject)
*/
export const silentAuth = async (on_success, on_error, options) => {
const show_loading = options?.show_loading !== false
try {
// 未有授权进行中时才发起一次授权,并复用 Promise
if (!auth_promise) {
/**
* 用 auth_promise 做"单例锁",把同一时刻并发触发的多次授权合并成一次。
* 把正在执行的授权 Promise 存起来;后面如果又有人调用 silentAuth() ,
* 看到 auth_promise 不为空,就直接 await 同一个 Promise,避免同时发起多次 Taro.login / 换会话请求
* ---------------------------------------------------------------------------------------
* .finally(() => { auth_promise = null }) 不管授权成功还是失败(resolve/reject),都把"锁"释放掉。
* 不用 finally 的问题:如果授权失败抛错了,而你只在 .then 里清空,那么 auth_promise 会一直卡着旧的 rejected Promise;
* 后续再调用 silentAuth() 会复用这个失败的 Promise,导致永远失败、且永远不会重新发起授权。
* 用 finally :保证成功/失败都会清空,下一次调用才有机会重新走授权流程。
*/
auth_promise = _do_silent_auth(show_loading)
.finally(() => {
auth_promise = null
})
}
const result = await auth_promise
if (on_success) on_success(result)
/**
* 当前返回值 没有实际消费点 :全项目只在 3 处调用,全部都 不使用返回值 。
* - 启动预加载: await silentAuth() 仅等待,不用结果, app.js
* - 授权页: silentAuth().then(() => returnToOriginalPage()) then 里也没接 res , auth/index.vue
* - 分享场景: await silentAuth(successCb, errorCb) 只看成功/失败分支,不用返回值, handleSharePageAuth
* 所以这行 return result 的作用目前是 语义完整 + 未来扩展位 :
* 如果以后要在调用处根据 code/msg/cookie 做分支或埋点,返回值就能直接用;现在等价于"只用 resolve/reject 表达成功失败"。
*/
return result
} catch (error) {
// 改进:统一传递完整错误对象,包含 message 和 original error
const error_obj = {
message: error?.message || '授权失败,请稍后重试',
original: error
}
if (on_error) on_error(error_obj)
throw error
}
}
/**
* 防重复跳转冷却时间 (毫秒)
* @type {number}
*/
const NAVIGATE_AUTH_COOLDOWN_MS = 1200
/**
* 导航状态重置延迟时间 (毫秒)
* @type {number}
*/
const NAVIGATING_RESET_DELAY_MS = 300
/**
* 跳转到授权页(降级方案)
* - 会先保存回跳路径(默认当前页),授权成功后在 auth 页回跳
* @param {string} return_path 指定回跳路径(可选)
* @returns {Promise<void>} 无返回值
*/
export const navigateToAuth = async (return_path) => {
// 如果禁用了授权模式,直接返回不跳转
if (!ENABLE_AUTH_MODE) return
const pages = Taro.getCurrentPages()
const current_page = pages[pages.length - 1]
const current_route = current_page?.route
if (current_route === 'pages/auth/index') {
return
}
const now = Date.now()
if (navigating_to_auth) return
if (now - last_navigate_auth_at < NAVIGATE_AUTH_COOLDOWN_MS) return
last_navigate_auth_at = now
navigating_to_auth = true
if (return_path) {
saveCurrentPagePath(return_path)
} else {
saveCurrentPagePath()
}
// 改进:使用 try-finally 明确状态恢复逻辑,确保无论成功失败都会重置状态
try {
await Taro.navigateTo({ url: '/pages/auth/index' })
} catch (error) {
// 改进:添加错误日志,方便追踪降级场景
console.warn('navigateTo 失败,降级使用 redirectTo:', error)
await Taro.redirectTo({ url: '/pages/auth/index' })
} finally {
setTimeout(() => {
navigating_to_auth = false
}, NAVIGATING_RESET_DELAY_MS)
}
}
/**
* 授权成功后回跳到来源页
* - 优先使用 routerStore 里保存的路径
* - 失败降级:redirectTo -> reLaunch
* @param {string} default_path 未保存来源页时的默认回跳路径
* @returns {Promise<void>} 回跳完成
*/
export const returnToOriginalPage = async (default_path = '/pages/index/index') => {
const router = routerStore()
const saved_path = router.url
try {
router.remove()
const pages = Taro.getCurrentPages()
const current_page = pages[pages.length - 1]
const current_route = current_page?.route
let target_path = default_path
if (saved_path && saved_path !== '') {
target_path = saved_path.startsWith('/') ? saved_path : `/${saved_path}`
}
const target_route = target_path.split('?')[0].replace(/^\//, '')
if (current_route === target_route) {
return
}
try {
await Taro.redirectTo({ url: target_path })
} catch (error) {
// 改进:添加错误日志,方便追踪降级场景
console.warn('redirectTo 失败,降级使用 reLaunch:', error)
await Taro.reLaunch({ url: target_path })
}
} catch (error) {
console.error('returnToOriginalPage 执行出错:', error)
try {
await Taro.reLaunch({ url: default_path })
} catch (final_error) {
console.error('最终降级方案也失败了:', final_error)
}
}
}
/**
* 判断是否来自分享场景
* @param {object} options 页面 options
* @returns {boolean} true=来自分享场景,false=非分享场景
*/
export const isFromShare = (options) => {
return options && (options.from_share === '1' || options.scene)
}
/**
* 分享页进入时的授权处理
* - 来自分享且未授权:保存当前页路径,授权成功后回跳
* - 授权失败:返回 false,由调用方决定是否继续降级处理
* @param {object} options 页面 options
* @param {Function} callback 授权成功后的继续逻辑(可选)
* @returns {Promise<boolean>} true=授权已完成/无需授权,false=授权失败
*/
export const handleSharePageAuth = async (options, callback) => {
if (hasAuth()) {
if (typeof callback === 'function') callback()
return true
}
if (isFromShare(options)) {
saveCurrentPagePath()
}
try {
await silentAuth(
() => {
if (typeof callback === 'function') callback()
},
() => {
navigateToAuth()
}
)
return true
} catch (error) {
navigateToAuth()
return false
}
}
/**
* 为路径追加分享标记
* @param {string} path 原路径
* @returns {string} 追加后的路径
*/
export const addShareFlag = (path) => {
const separator = path.includes('?') ? '&' : '?'
return `${path}${separator}from_share=1`
}
......@@ -30,11 +30,4 @@ export const REQUEST_DEFAULT_PARAMS = {
f: 'manulife', // 业务模块标识
}
/**
* @description 是否启用授权模式
* - true: 启用授权检查、自动跳转登录、401自动续期
* - false: 禁用所有授权相关功能(所有授权检查直接通过,不跳转登录页)
*/
export const ENABLE_AUTH_MODE = true // 启用授权模式
export default BASE_URL
......
/**
* 微信授权(openid)管理
*
* @description 处理小程序授权逻辑,包括 wx.login 和 miniProgramAuthAPI 调用
* @module utils/openid
*/
import Taro from '@tarojs/taro'
import { miniProgramAuthAPI } from '@/api/wechat'
import { loginStatusAPI } from '@/api/user'
/**
* 小程序授权
* @description 调用 wx.login 获取 code,由后端授权获取 openid
* @returns {Promise<{user: Object|null}>} 返回用户信息(如果已自动登录)
*
* @example
* const user = await miniProgramAuth()
* if (user) {
* console.log('已自动登录', user)
* } else {
* console.log('需要手动登录')
* }
*/
export async function miniProgramAuth() {
try {
// 1. 调用 wx.login 获取 code
const { code } = await Taro.login()
if (!code) {
throw new Error('获取微信 code 失败')
}
// 2. 调用后端授权接口
const res = await miniProgramAuthAPI({ code })
if (res.code === 1) {
return res.data.user || null
} else {
throw new Error(res.msg || '小程序授权失败')
}
} catch (err) {
console.error('小程序授权失败:', err)
throw err
}
}
/**
* 检查 openid 状态
* @description 调用 loginStatusAPI 检查 is_openid
* @returns {Promise<boolean>} 是否已授权
*
* @example
* const isOpenid = await checkOpenidStatus()
* if (!isOpenid) {
* await miniProgramAuth()
* }
*/
export async function checkOpenidStatus() {
try {
const res = await loginStatusAPI()
if (res.code === 1) {
return res.data.is_openid
} else {
return false
}
} catch (err) {
console.error('检查 openid 状态失败:', err)
return false
}
}
/**
* 确保 openid 已授权并尝试自动登录
* @description 如果未授权,则调用 wx.login 授权
* @returns {Promise<{user: Object|null}>} 返回用户信息(如果已自动登录)
*
* @example
* const user = await ensureOpenidAuthorized()
* if (user) {
* console.log('已自动登录', user)
* } else {
* console.log('已授权但未登录,需要检查登录状态')
* }
*/
export async function ensureOpenidAuthorized() {
const isOpenid = await checkOpenidStatus()
if (!isOpenid) {
// 未授权,调用 wx.login 授权
return await miniProgramAuth()
}
// 已授权,返回 null(需要检查登录状态)
return null
}
/*
* @Date: 2022-09-19 14:11:06
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-29 18:35:55
* @LastEditTime: 2026-02-02 18:00:00
* @FilePath: /manulife-weapp/src/utils/request.js
* @Description: 简单axios封装,后续按实际处理
* @Description: HTTP 请求封装(简化版)
*/
// import axios from 'axios'
import axios from 'axios-miniprogram';
import axios from 'axios-miniprogram'
import Taro from '@tarojs/taro'
// import qs from 'qs'
// import { strExist } from './tools'
import { refreshSession, saveCurrentPagePath, navigateToAuth } from './authRedirect'
import { parseQueryString } from './tools'
// import { ProgressStart, ProgressEnd } from '@/components/axios-progress/progress';
// import store from '@/store'
// import { getToken } from '@/utils/auth'
import BASE_URL, { REQUEST_DEFAULT_PARAMS } from './config';
/**
* @description 获取 sessionid 的工具函数
* - sessionid 由 authRedirect.refreshSession 写入
* - 每次请求前动态读取,避免旧会话导致的 401
* @returns {string|null} sessionid或null
*/
export const getSessionId = () => {
try {
return Taro.getStorageSync("sessionid") || null;
} catch (error) {
console.error('获取sessionid失败:', error);
return null;
}
};
/**
* @description 设置 sessionid(一般不需要手动调用)
* - 正常情况下由 authRedirect.refreshSession 写入
* - 保留该方法用于极端场景的手动修复/兼容旧逻辑
* @param {string} sessionid cookie 字符串
* @returns {void} 无返回值
*/
export const setSessionId = (sessionid) => {
try {
if (!sessionid) return
Taro.setStorageSync('sessionid', sessionid)
} catch (error) {
console.error('设置sessionid失败:', error)
}
}
import BASE_URL, { REQUEST_DEFAULT_PARAMS } from './config'
/**
* @description 清空 sessionid(一般不需要手动调用)
* @returns {void} 无返回值
*/
export const clearSessionId = () => {
try {
Taro.removeStorageSync('sessionid')
} catch (error) {
console.error('清空sessionid失败:', error)
}
}
// const isPlainObject = (value) => {
// if (value === null || typeof value !== 'object') return false
// return Object.prototype.toString.call(value) === '[object Object]'
// }
/**
* @description axios 实例(axios-miniprogram)
* @description axios 实例
* - 统一 baseURL / timeout
* - 通过拦截器处理:默认参数、cookie 注入、401 自动续期、弱网降级
* - 通过拦截器处理:默认参数、401 跳转登录页、弱网降级
*/
const service = axios.create({
baseURL: BASE_URL, // url = base url + request url
// withCredentials: true, // send cookies when cross-domain requests
timeout: 5000, // request timeout
baseURL: BASE_URL,
timeout: 5000,
})
// service.defaults.params = {
// ...REQUEST_DEFAULT_PARAMS,
// };
let has_shown_timeout_modal = false
/**
......@@ -88,7 +27,6 @@ let has_shown_timeout_modal = false
* @param {Error} error 请求错误对象
* @returns {boolean} true=超时,false=非超时
*/
const is_timeout_error = (error) => {
const msg = String(error?.message || error?.errMsg || '')
if (error?.code === 'ECONNABORTED') return true
......@@ -134,7 +72,7 @@ const should_handle_bad_network = async (error) => {
/**
* @description 处理请求超时/弱网错误
* - 弹出弱网提示(统一文案由 uiText 管理)
* - 弹出弱网提示
* @returns {Promise<void>} 无返回值
*/
const handle_request_timeout = async () => {
......@@ -153,59 +91,29 @@ const handle_request_timeout = async () => {
}
}
// 请求拦截器:合并默认参数 / 注入 cookie
// 请求拦截器:合并默认参数
service.interceptors.request.use(
config => {
// console.warn(config)
// console.warn(store)
// 解析 URL 参数并合并
const url = config.url || ''
let url_params = {}
if (url.includes('?')) {
url_params = parseQueryString(url)
config.url = url.split('?')[0]
url_params = parseQueryString(url)
config.url = url.split('?')[0]
}
// 优先级:调用传参 > URL参数 > 默认参数
config.params = {
...REQUEST_DEFAULT_PARAMS,
...url_params,
...(config.params || {})
}
/**
* 动态获取 sessionid 并设置到请求头
* - 确保每个请求都带上最新的 sessionid
* - 注意:axios-miniprogram 的 headers 可能不存在,需要先兜底
*/
const sessionid = getSessionId();
if (sessionid) {
config.headers = config.headers || {}
config.headers.cookie = sessionid;
...REQUEST_DEFAULT_PARAMS,
...url_params,
...(config.params || {})
}
// 增加时间戳
if (config.method === 'get') {
config.params = { ...config.params, timestamp: (new Date()).valueOf() }
config.params = { ...config.params, timestamp: (new Date()).valueOf() }
}
// if ((config.method || '').toLowerCase() === 'post') {
// const url = config.url || ''
// const headers = config.headers || {}
// const contentType = headers['content-type'] || headers['Content-Type']
// const shouldUrlEncode =
// !contentType || String(contentType).includes('application/x-www-form-urlencoded')
// if (shouldUrlEncode && !strExist(['upload.qiniup.com'], url) && isPlainObject(config.data)) {
// config.headers = {
// ...headers,
// 'content-type': 'application/x-www-form-urlencoded'
// }
// config.data = qs.stringify(config.data)
// }
// }
return config
},
error => {
......@@ -214,68 +122,44 @@ service.interceptors.request.use(
}
)
// 响应拦截器:401 自动续期 / 弱网降级
// 响应拦截器:401 跳转登录页 / 弱网降级
service.interceptors.response.use(
/**
* 响应拦截器说明
* - 这里统一处理后端自定义 code(例如 401 未授权)
* - 如需拿到 headers/status 等原始信息,直接返回 response 即可
* @description 响应成功拦截器
* - 处理 401 未授权,跳转到登录页
* - 处理其他自定义错误消息
*/
async response => {
const res = response.data
// 401 未授权处理
if (res.code === 401 && ENABLE_AUTH_MODE) {
const config = response?.config || {}
/**
* 避免死循环/重复重试:
* - __is_retry:本次请求是 401 后的重试请求,如果仍 401,不再继续重试
*/
if (config.__is_retry) {
return response
}
/**
* 记录来源页:用于授权成功后回跳
* - 避免死循环:如果已经在 auth 页则不重复记录/跳转
*/
const pages = Taro.getCurrentPages();
const currentPage = pages[pages.length - 1];
if (currentPage && currentPage.route !== 'pages/auth/index') {
saveCurrentPagePath()
}
try {
// 优先走静默续期:成功后重放原请求
await refreshSession()
const retry_config = { ...config, __is_retry: true }
return await service(retry_config)
} catch (error) {
// 静默续期失败:降级跳转到授权页(由授权页完成授权并回跳)
const pages_retry = Taro.getCurrentPages();
const current_page_retry = pages_retry[pages_retry.length - 1];
if (current_page_retry && current_page_retry.route !== 'pages/auth/index') {
navigateToAuth()
}
return response
}
if (res.code === 401) {
// 跳转到登录页
Taro.navigateTo({
url: '/pages/login/index'
}).catch(() => {
// 如果跳转失败(如已经在登录页),则忽略
console.warn('跳转登录页失败,可能已在登录页')
})
}
// 处理特殊消息(不需要显示的错误)
if (['预约ID不存在'].includes(res.msg)) {
res.show = false;
res.show = false
}
return response
},
/**
* @description 响应失败拦截器
* - 处理网络错误、超时等
*/
async error => {
// Taro.showToast({
// title: error.message,
// icon: 'none',
// duration: 2000
// })
// 处理弱网/断网
if (await should_handle_bad_network(error)) {
handle_request_timeout()
}
return Promise.reject(error)
}
)
......