From ad76abea963367914d3593e9b425b86f1c62df79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Fri, 24 Jul 2026 23:07:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(passport):=20=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E7=A7=9F=E6=88=B7=E7=99=BB=E5=BD=95=E5=8F=8A=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E6=89=BE=E5=9B=9E=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增重置密码接口及参数支持手机号/邮箱找回 - 登录接口支持多租户场景,返回多租户选项供前端选择 - 增加登录状态持久化保存租户和用户信息 - 登录界面新增“忘记密码”链接并实现密码找回页 - 忘记密码页支持邮箱和手机两种找回方式,首3次发送验证码免图形验证码校验 - 登录页短信验证码发送优化,增加发送次数限制和图形验证码弹窗 - 请求拦截器支持跳过租户头注入,适配登录前找回等场景 - 路由表启用忘记密码页面路由 - 国际化新增密码找 --- .workbuddy/memory/2026-07-24.md | 22 + src/api/layout/index.ts | 20 +- src/api/layout/model/index.ts | 17 + src/api/passport/login/index.ts | 122 ++++- src/api/passport/login/model/index.ts | 24 + src/i18n/lang/en/login.ts | 26 +- src/i18n/lang/zh_CN/login.ts | 26 +- src/router/routes.ts | 10 +- src/utils/request.ts | 9 + src/views/passport/forget/index.vue | 621 ++++++++++++++++++++++++++ src/views/passport/login/index.vue | 186 ++++++-- 11 files changed, 1026 insertions(+), 57 deletions(-) create mode 100644 .workbuddy/memory/2026-07-24.md create mode 100644 src/views/passport/forget/index.vue diff --git a/.workbuddy/memory/2026-07-24.md b/.workbuddy/memory/2026-07-24.md new file mode 100644 index 0000000..c13e5a2 --- /dev/null +++ b/.workbuddy/memory/2026-07-24.md @@ -0,0 +1,22 @@ +# 2026-07-24 工作记录 + +## 从 guilixu-admin 复制登录模块到 mp-vue +mp-vue 是 Vue3 + Ant Design Vue + ele-admin-pro 模板项目,其登录模块是 guilixu-admin 改造前的旧版。本次把 guilixu-admin 已验证的登录增强功能完整移植过来。 + +### 新增/修改文件 +- `src/views/passport/forget/index.vue`(新建):忘记密码页,邮箱/手机两种找回,前3次免图形验证码,密码强度校验,调用 resetPassword。 +- `src/api/passport/login/model/index.ts`:新增 `TenantOption`、`LoginOutcome`;`LoginResult` 增加 `tenants?`;`LoginParam` 增加 `smsCode?`。 +- `src/api/passport/login/index.ts`:新增 `saveLoginState`、`loginBySelectTenant`、`sendSmsCaptchaByAdmin`、`sendEmailCaptcha`;`loginBySuperAdminSms` 改为返回 `LoginOutcome`(多租户 selectTenant / 单租户 ok)。 +- `src/utils/request.ts`:请求拦截器支持 `X-Tenant-Skip`(适配 axios 0.27 的 `config.headers.common` 风格),登录前找回场景跳过租户头注入避免串租户。 +- `src/api/layout/index.ts` + `model/index.ts`:新增 `resetPassword`(POST /resetPassword,带 X-Tenant-Skip 头)与 `ResetPasswordParam`。 +- `src/views/passport/login/index.vue`:发送验证码前3次免图形验证(handleSendCode);onLoginBySms 处理 LoginOutcome 多租户选择;新增选择租户弹窗;账号登录增加"忘记密码"链接跳 /forget。 +- `src/router/routes.ts`:启用 /forget 路由。 +- `src/i18n/lang/zh_CN/login.ts` / `en/login.ts`:新增 forgetPage 命名空间。 + +### 验证 +- `vue-tsc`:本次改动文件无类型错误(项目本身存在大量与本次无关的 JSX 解析历史错误)。 +- `vite build --outDir dist-verify-mp`:编译打包成功,forget 页 chunk 已生成,无 error/fail。临时产物已移至 /tmp。 + +### 注意点 +- mp-vue 用 axios 0.27.2,request 拦截器头写法与 guilixu-admin(axios 1.x)不同(用 `config.headers.common[...]`),X-Tenant-Skip 判断同时兼容 `config.headers['X-Tenant-Skip']` 与 `config.headers.common['X-Tenant-Skip']`。 +- 后端接口 /sendEmailCaptcha、/sendSmsCaptchaByAdmin、/resetPassword、/loginBySelectTenant 需 guilixu-java 已实现(与 website-admin 同源 server.websoft.top,通常已具备)。 diff --git a/src/api/layout/index.ts b/src/api/layout/index.ts index c6e22b7..fba80e7 100644 --- a/src/api/layout/index.ts +++ b/src/api/layout/index.ts @@ -1,7 +1,7 @@ import request from '@/utils/request'; import type { ApiResult } from '@/api'; import type { User } from '@/api/system/user/model'; -import type { UpdatePasswordParam, NoticeResult } from './model'; +import type { UpdatePasswordParam, ResetPasswordParam, NoticeResult } from './model'; import { SERVER_API_URL } from '@/config/setting'; import { Company } from '@/api/system/company/model'; import { CmsWebsite } from '@/api/cms/cmsWebsite/model'; @@ -108,6 +108,24 @@ export async function updatePassword( return Promise.reject(new Error(res.data.message)); } +/** + * 找回/重置密码(未登录场景,登录页"忘记密码"入口) + * 后端对应 POST /resetPassword,支持手机或邮箱两种方式 + */ +export async function resetPassword( + data: ResetPasswordParam +): Promise { + const res = await request.post>( + SERVER_API_URL + '/resetPassword', + data, + { headers: { 'X-Tenant-Skip': '1' } } + ); + if (res.data.code === 0) { + return res.data.message ?? '密码重置成功'; + } + return Promise.reject(new Error(res.data.message)); +} + /** * 创建完整网站并初始化 */ diff --git a/src/api/layout/model/index.ts b/src/api/layout/model/index.ts index 317b25d..ba6ae4a 100644 --- a/src/api/layout/model/index.ts +++ b/src/api/layout/model/index.ts @@ -24,6 +24,23 @@ export interface UpdatePasswordParam { oldPassword: string; } +/** + * 找回/重置密码参数(未登录场景,登录页"忘记密码"入口) + * 手机找回与邮箱找回二选一 + */ +export interface ResetPasswordParam { + /** 手机找回时必填 */ + phone?: string; + smsCode?: string; + /** 邮箱找回时必填 */ + email?: string; + emailCode?: string; + /** 新密码 */ + newPassword: string; + /** 确认新密码 */ + confirmPassword: string; +} + /** * 通知数据格式 */ diff --git a/src/api/passport/login/index.ts b/src/api/passport/login/index.ts index 1a395bd..4afd5c0 100644 --- a/src/api/passport/login/index.ts +++ b/src/api/passport/login/index.ts @@ -9,6 +9,30 @@ import type { } from './model'; import { User } from '@/api/system/user/model'; import { SERVER_API_URL } from '@/config/setting'; +import type { LoginOutcome, TenantOption } from './model'; + +/** + * 登录态落地:把后端返回的 access_token / 租户 / 用户 持久化到本地 + */ +function saveLoginState(result: LoginResult | undefined, remember?: boolean) { + if (!result) return; + if (result.access_token) { + setToken(result.access_token, remember); + } + const user = result.user; + if (user) { + if (user.tenantId != null) { + try { + localStorage.setItem('TenantId', String(user.tenantId)); + } catch { /* ignore */ } + } + if (user.userId != null) { + try { + localStorage.setItem('UserId', String(user.userId)); + } catch { /* ignore */ } + } + } +} /** * 登录 @@ -84,22 +108,58 @@ export async function loginByUserId(data: LoginParam) { /** * 超级管理员短信验证码登录 + * 后端对应 POST /loginBySuperAdminSms,参数为 { phone, code, tenantId } + * - 单租户:直接返回 ok + * - 多租户:返回 selectTenant,前端需调用 loginBySelectTenant 二次登录 */ -export async function loginBySuperAdminSms(data: LoginParam) { +export async function loginBySuperAdminSms( + data: LoginParam +): Promise { const res = await request.post>( SERVER_API_URL + '/loginBySuperAdminSms', - data + { phone: data.phone, code: data.code ?? data.smsCode, smsCode: data.smsCode, tenantId: data.tenantId } ); - if (res.data.code === 0) { - setToken(res.data.data?.access_token, data.remember); - if (res.data.data?.user) { - const user = res.data.data?.user; - localStorage.setItem('TenantId', String(user.tenantId)); - localStorage.setItem('UserId', String(user.userId)); - } - return res.data.message; + if (res.data.code !== 0) { + return Promise.reject(new Error(res.data.message)); } - return Promise.reject(new Error(res.data.message)); + const result = res.data.data; + if (result?.tenants && result.tenants.length > 0) { + return { + status: 'selectTenant', + tenants: result.tenants as TenantOption[], + message: res.data.message || '请选择要登录的租户' + }; + } + if (result?.user) { + saveLoginState(result, data.remember); + return { status: 'ok', user: result.user, message: res.data.message || '登录成功' }; + } + return Promise.reject(new Error(res.data.message || '登录失败')); +} + +/** + * 选择租户后登录(多租户短信登录专用二次登录接口) + * 后端对应 POST /loginBySelectTenant,参数为 { phone, code, tenantId } + */ +export async function loginBySelectTenant(data: { + phone: string; + code: string; + tenantId: number; + remember?: boolean; +}): Promise { + const res = await request.post>( + SERVER_API_URL + '/loginBySelectTenant', + { phone: data.phone, code: data.code, smsCode: data.code, tenantId: data.tenantId } + ); + if (res.data.code !== 0) { + return Promise.reject(new Error(res.data.message)); + } + const result = res.data.data; + if (result?.user) { + saveLoginState(result, data.remember); + return { status: 'ok', user: result.user, message: res.data.message || '登录成功' }; + } + return Promise.reject(new Error(res.data.message || '登录失败')); } /** @@ -136,6 +196,46 @@ export async function sendSmsCaptcha(data: LoginParam) { return Promise.reject(new Error(res.data.message)); } +/** + * 发送短信验证码(后台管理员,跨租户:只要该手机号是超管或管理员即发送) + * 后端对应 POST /sendSmsCaptchaByAdmin,参数为 { phone } + * @param options.skipTenant 为 true 时不携带 tenantId 请求头(登录前无法确定租户时使用) + */ +export async function sendSmsCaptchaByAdmin( + data: LoginParam, + options?: { skipTenant?: boolean } +) { + const res = await request.post>( + SERVER_API_URL + '/sendSmsCaptchaByAdmin', + data, + options?.skipTenant ? { headers: { 'X-Tenant-Skip': '1' } } : undefined + ); + if (res.data.code === 0) { + return res.data.message; + } + return Promise.reject(new Error(res.data.message)); +} + +/** + * 发送邮箱验证码(找回密码等登录前场景使用) + * 后端对应 POST /sendEmailCaptcha,参数为 { email } + * @param options.skipTenant 为 true 时不携带 tenantId 请求头(登录前无法确定租户时使用) + */ +export async function sendEmailCaptcha( + data: LoginParam, + options?: { skipTenant?: boolean } +) { + const res = await request.post>( + SERVER_API_URL + '/sendEmailCaptcha', + data, + options?.skipTenant ? { headers: { 'X-Tenant-Skip': '1' } } : undefined + ); + if (res.data.code === 0) { + return res.data.message; + } + return Promise.reject(new Error(res.data.message)); +} + /** * 登录 */ diff --git a/src/api/passport/login/model/index.ts b/src/api/passport/login/model/index.ts index cd2556a..096e482 100644 --- a/src/api/passport/login/model/index.ts +++ b/src/api/passport/login/model/index.ts @@ -19,6 +19,8 @@ export interface LoginParam { email?: string; // 短信验证码 code?: string; + // 短信验证码(别名,与后端 LoginParam.code 对应) + smsCode?: string; } /** @@ -29,8 +31,30 @@ export interface LoginResult { access_token?: string; // 用户信息 user?: User; + // 多租户场景下返回,此时 access_token / user 为 null + tenants?: TenantOption[]; } +/** + * 多租户登录时后端返回的租户选项 + */ +export interface TenantOption { + tenantId?: number; + tenantName?: string; + userId?: number; + username?: string; + nickname?: string; +} + +/** + * 登录接口统一返回结果 + * - ok:单租户直接登录成功 + * - selectTenant:多租户,需要前端弹出选择租户列表 + */ +export type LoginOutcome = + | { status: 'ok'; user: NonNullable; message: string } + | { status: 'selectTenant'; tenants: TenantOption[]; message: string }; + /** * 图形验证码返回结果 */ diff --git a/src/i18n/lang/en/login.ts b/src/i18n/lang/en/login.ts index e29569a..c4630da 100644 --- a/src/i18n/lang/en/login.ts +++ b/src/i18n/lang/en/login.ts @@ -10,5 +10,29 @@ export default { loading: 'loading', oldPassword: 'Old', newPassword: 'New', - confirm: 'Confirm' + confirm: 'Confirm', + forgetPage: { + title: 'Reset Password', + subTitle: 'Enter your account info to verify and reset password', + tabEmail: 'By Email', + tabPhone: 'By Phone', + emailPlaceholder: 'Please enter email', + phonePlaceholder: 'Please enter phone number', + codePlaceholder: 'Please enter code', + passwordPlaceholder: 'New password (min 8 chars, letters & numbers)', + confirmPlaceholder: 'Confirm new password', + getCode: 'Get Code', + reset: 'Reset', + backLogin: 'Back to Login', + sentEmail: 'Code sent to email, please check', + success: 'Password reset successfully', + emailRule: 'Please enter a valid email', + phoneRule: 'Please enter a valid phone number', + codeRule: 'Please enter the code', + newPasswordRule: 'Password must be at least 8 chars with letters and numbers', + confirmRule: 'Passwords do not match', + captchaTitle: 'Send Code', + captchaPlaceholder: 'Please enter the captcha', + sendNow: 'Send Now' + } }; diff --git a/src/i18n/lang/zh_CN/login.ts b/src/i18n/lang/zh_CN/login.ts index 08a41b5..87d521e 100644 --- a/src/i18n/lang/zh_CN/login.ts +++ b/src/i18n/lang/zh_CN/login.ts @@ -10,5 +10,29 @@ export default { loading: '登录中', oldPassword: '旧密码', newPassword: '新密码', - confirm: '确认密码' + confirm: '确认密码', + forgetPage: { + title: '找回密码', + subTitle: '请输入账号信息,验证身份后重置密码', + tabEmail: '邮箱找回', + tabPhone: '手机找回', + emailPlaceholder: '请输入邮箱', + phonePlaceholder: '请输入手机号', + codePlaceholder: '请输入验证码', + passwordPlaceholder: '请输入新密码(至少8位,含字母和数字)', + confirmPlaceholder: '请再次输入新密码', + getCode: '获取验证码', + reset: '重置密码', + backLogin: '返回登录', + sentEmail: '验证码已发送到邮箱,请注意查收', + success: '密码重置成功', + emailRule: '请输入正确的邮箱', + phoneRule: '请输入正确的手机号', + codeRule: '请输入验证码', + newPasswordRule: '密码至少8位,且必须包含字母和数字', + confirmRule: '两次输入的密码不一致', + captchaTitle: '发送验证码', + captchaPlaceholder: '请输入图形验证码', + sendNow: '立即发送' + } }; diff --git a/src/router/routes.ts b/src/router/routes.ts index 08fec94..d21b0bf 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -66,11 +66,11 @@ export const routes = [ component: () => import('@/views/ai/index.vue'), meta: { title: 'AI 测试' } }, - // { - // path: '/forget', - // component: () => import('@/views/passport/forget/index.vue'), - // meta: { title: '忘记密码' } - // }, + { + path: '/forget', + component: () => import('@/views/passport/forget/index.vue'), + meta: { title: '忘记密码' } + }, // { // path: '/wx-work-login', // component: () => import('@/views/passport/wx-work/index.vue'), diff --git a/src/utils/request.ts b/src/utils/request.ts index f361d91..1447d6c 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -48,6 +48,15 @@ service.interceptors.request.use( } // 获取租户ID if (config.headers) { + // 登录前场景(找回密码等)由调用方显式设置 X-Tenant-Skip, + // 此时不做任何租户/企业/商户/域名头注入,避免串租户 + const headersAny = config.headers as any; + const skipTenant = + headersAny['X-Tenant-Skip'] || + (headersAny.common && headersAny.common['X-Tenant-Skip']); + if (skipTenant) { + return config; + } // 附加企业ID const companyId = localStorage.getItem('CompanyId'); if (companyId) { diff --git a/src/views/passport/forget/index.vue b/src/views/passport/forget/index.vue new file mode 100644 index 0000000..91c8735 --- /dev/null +++ b/src/views/passport/forget/index.vue @@ -0,0 +1,621 @@ + + + + + diff --git a/src/views/passport/login/index.vue b/src/views/passport/login/index.vue index 4fb2ceb..f73cafe 100644 --- a/src/views/passport/login/index.vue +++ b/src/views/passport/login/index.vue @@ -93,9 +93,14 @@ - - {{ t('login.remember') }} - +
+ + {{ t('login.remember') }} + + +