From da05ecc0b17c3dbae44e8c84c271c8a6a2ff5da8 Mon Sep 17 00:00:00 2001 From: lili Date: Tue, 7 Jul 2026 12:04:02 -0700 Subject: [PATCH] =?UTF-8?q?feat(studio):=20WU1=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=99=BB=E5=BD=95=E9=A1=B5=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=90=8D=E5=AF=86=E7=A0=81=20Tab=EF=BC=88?= =?UTF-8?q?=E6=B3=A8=E5=86=8C/=E7=99=BB=E5=BD=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WU1 §3.5 步骤④前端落地:Login.vue 在 sms/invite 之外加第三个「用户名密码」 Tab,内含登录/注册子模式段控——用户名、密码两输入框,前端做与后端一致的 用户名正则 ^[a-zA-Z0-9]{4,30}$ + 密码 6-32 位预校验(仅提示,信任边界在后端); 复用既有隐私政策勾选、?redirect= 回跳、setLogin 落持久化、user_login 遥测 双带四段,登录成功路径与短信路一致(同拿 LoginResp)。 passport.ts 加 PasswordRegisterReq/PasswordLoginReq 入参接口与 passwordRegister/ passwordLogin 两函数(贴 VO 契约、走同一 request 信封解包),PlayerMeResp 补 username;Profile.vue fallbackMe 同步补 username 空串兜底。entry.zh/en 补 Tab 标题、用户名/密码占位、子模式与提交按钮多态文案、两条本地预校验提示。 自证:npm run build(vue-tsc -b && vite build)退出 0。 Co-Authored-By: Claude Opus 4.8 (1M context) --- game-studio/src/api/passport.ts | 61 +++++++- game-studio/src/locales/entry.en.ts | 12 ++ game-studio/src/locales/entry.zh.ts | 17 ++- game-studio/src/views/login/Login.vue | 176 +++++++++++++++++++--- game-studio/src/views/profile/Profile.vue | 3 +- 5 files changed, 247 insertions(+), 22 deletions(-) diff --git a/game-studio/src/api/passport.ts b/game-studio/src/api/passport.ts index 79206aa9..ff2706c5 100644 --- a/game-studio/src/api/passport.ts +++ b/game-studio/src/api/passport.ts @@ -39,6 +39,35 @@ export interface InviteRegisterReq { nickname?: string } +/** + * 用户名密码注册请求体(贴 PasswordRegisterReqVO;内测种子用户建号即登录,WU1 §3.2)。 + * 前端校验仅提示(信任边界在后端):username 须匹配 ^[a-zA-Z0-9]{4,30}$、password 6-32 位; + * 用户名已占用后端返回 1-002-090-004,由 request 拦截器统一 Toast。 + */ +export interface PasswordRegisterReq { + /** 用户名(登录主键之一;^[a-zA-Z0-9]{4,30}$) */ + username: string + /** 密码(明文,6-32 位;后端立即 BCrypt 编码,前端不落日志、不回显) */ + password: string + /** 昵称(可选;不传则后端默认取用户名) */ + nickname?: string + /** 客户端 anonId(可选;仅注册落 first_anon_id 一次,匿名↔登录归因) */ + anonId?: string +} + +/** + * 用户名密码登录请求体(贴 PasswordLoginReqVO;WU1 §3.2)。 + * 防枚举:后端对「查无用户名」与「密码错误」统一返 1-002-090-005(文案一致,不可区分)。 + */ +export interface PasswordLoginReq { + /** 用户名(登录主键之一;^[a-zA-Z0-9]{4,30}$) */ + username: string + /** 密码(明文;登录不重复长度校验,仅非空) */ + password: string + /** 客户端 anonId(可选;登录不落库,仅遥测归因透传) */ + anonId?: string +} + /** * 登录/注册成功响应(贴 LoginRespVO;sms-login 与 invite-register 共用)。 * token 不落日志、手机号不在此返回(安全基线)。 @@ -65,8 +94,10 @@ export interface LoginResp { export interface PlayerMeResp { /** 玩家编号 */ userId: number - /** 脱敏手机号(如 138****0000;后端禁回明文) */ + /** 脱敏手机号(如 138****0000;后端禁回明文;用户名用户 mobile 为 null 时回空串) */ mobile: string + /** 用户名(用户名密码路身份展示,非敏感明文;手机号用户为空串,WU1 §3.2) */ + username: string /** 昵称 */ nickname: string /** 头像 URL */ @@ -117,6 +148,34 @@ export function inviteRegister(dto: InviteRegisterReq): Promise { }) } +/** + * 用户名密码注册(建号即登录,@PermitAll 免登;受总开关 password-auth-enabled 管控,WU1 §3.2)。 + * POST /app-api/passport/password-register + * @param dto 注册请求体(username + password + 可选 nickname/anonId) + * @returns 真 OAuth2 token 与玩家身份摘要(与短信路 token 一字不差) + */ +export function passwordRegister(dto: PasswordRegisterReq): Promise { + return request({ + url: '/app-api/passport/password-register', + method: 'post', + data: dto, + }) +} + +/** + * 用户名密码登录(@PermitAll 免登;防枚举:查无用户名与密码错误统一 1-002-090-005,WU1 §3.2)。 + * POST /app-api/passport/password-login + * @param dto 登录请求体(username + password + 可选 anonId) + * @returns 真 OAuth2 token 与玩家身份摘要(同 sms-login 响应) + */ +export function passwordLogin(dto: PasswordLoginReq): Promise { + return request({ + url: '/app-api/passport/password-login', + method: 'post', + data: dto, + }) +} + /** * 当前登录玩家信息(登录态必需;未登录返回 401,由 request.ts 401 拦截兜底)。 * GET /app-api/passport/me diff --git a/game-studio/src/locales/entry.en.ts b/game-studio/src/locales/entry.en.ts index ece12aa8..186cc731 100644 --- a/game-studio/src/locales/entry.en.ts +++ b/game-studio/src/locales/entry.en.ts @@ -9,18 +9,28 @@ export default { subtitle: 'Sign in to create and interact with your mini-games', tabSms: 'Code login', tabInvite: 'Invite register', + tabPassword: 'Username', mobileLabel: 'Phone', mobilePlaceholder: 'Enter phone number', codeLabel: 'Code', codePlaceholder: 'Enter SMS code', inviteLabel: 'Invite code', invitePlaceholder: 'Enter beta invite code', + modeLogin: 'Log in', + modeRegister: 'Register', + usernameLabel: 'Username', + usernamePlaceholder: '4-30 letters or digits', + passwordLabel: 'Password', + passwordPlaceholder: '6-32 characters', + passwordHint: 'Seed users: register and sign in with a username and password, no phone needed', sendCode: 'Get code', sending: 'Sending…', resendIn: 'Resend in {n}s', smsHint: 'Unregistered numbers will be registered and signed in automatically', inviteHint: 'Beta invite: currently in restricted activation, an invite code is required', invalidMobile: 'Please enter a valid phone number', + invalidUsername: 'Username must be 4-30 letters or digits', + invalidPassword: 'Password must be 6-32 characters', codeSent: 'Code sent (seed-phase Debug channel, check the backend for the code)', needAgree: 'Please read and accept the User Agreement and Privacy Policy first', loginSuccess: 'Signed in', @@ -31,6 +41,8 @@ export default { agreePlaceholder: '(placeholder, final copy pending legal)', submitSms: 'Log in / register', submitInvite: 'Register & log in', + submitPasswordLogin: 'Log in', + submitPasswordRegister: 'Register & log in', }, // —— Share landing —— diff --git a/game-studio/src/locales/entry.zh.ts b/game-studio/src/locales/entry.zh.ts index 15703888..4f04e288 100644 --- a/game-studio/src/locales/entry.zh.ts +++ b/game-studio/src/locales/entry.zh.ts @@ -10,9 +10,10 @@ export default { login: { // 品牌副标题(标题本身用 common.brand) subtitle: '登录后即可创作、互动你的小游戏', - // 两 Tab 名 + // 三 Tab 名 tabSms: '验证码登录', tabInvite: '邀请码注册', + tabPassword: '用户名密码', // 手机号 / 验证码 / 邀请码占位符 + 标签 mobileLabel: '手机号', mobilePlaceholder: '请输入手机号', @@ -20,6 +21,14 @@ export default { codePlaceholder: '请输入短信验证码', inviteLabel: '邀请码', invitePlaceholder: '请输入内测邀请码', + // 用户名密码(第三 Tab,内测种子用户;WU1 §3.5):子模式切换 + 输入占位 + 提示 + modeLogin: '登录', + modeRegister: '注册', + usernameLabel: '用户名', + usernamePlaceholder: '4-30 位数字或字母', + passwordLabel: '密码', + passwordPlaceholder: '6-32 位密码', + passwordHint: '内测种子用户:用户名 + 密码即可注册登录,无需手机号', // 发码按钮三态:默认 / 发送中 / 倒计时重发({n} 为剩余秒数,模板插值) sendCode: '获取验证码', sending: '发送中…', @@ -29,6 +38,8 @@ export default { inviteHint: '内测邀请:当前为受限激活期,需邀请码注册', // 表单校验 / 流程 Toast invalidMobile: '请输入正确的手机号', + invalidUsername: '用户名为 4-30 位数字或字母', + invalidPassword: '密码长度为 6-32 位', codeSent: '验证码已发送(种子期 Debug 渠道,后台查码)', needAgree: '请先阅读并勾选用户协议与隐私政策', loginSuccess: '登录成功', @@ -38,9 +49,11 @@ export default { agreeAnd: '与', agreePrivacy: '《隐私政策》', agreePlaceholder: '(占位,正式文案待法务)', - // 提交按钮两态(按当前 Tab) + // 提交按钮多态(按当前 Tab;用户名密码 Tab 再按登录/注册子模式) submitSms: '登录 / 注册', submitInvite: '注册并登录', + submitPasswordLogin: '登录', + submitPasswordRegister: '注册并登录', }, // —— 分享落地页(Share.vue,/share/:gameId)—— diff --git a/game-studio/src/views/login/Login.vue b/game-studio/src/views/login/Login.vue index 857b66c6..988c3820 100644 --- a/game-studio/src/views/login/Login.vue +++ b/game-studio/src/views/login/Login.vue @@ -3,10 +3,13 @@ * 登录页 | Login.vue(路由 /login,真实鉴权件 spec §9.1) * ---------------------------------------------------------------------------- * 职责: - * 1. 两 Tab 登录/注册: + * 1. 三 Tab 登录/注册: * ① 手机号 + 验证码(主路径,拍板1C):send-sms-code 发码 → sms-login 登录/自动注册一体; * ② 邀请码注册(受限激活期陌生玩家入口,拍板2/3):mobile + inviteCode → invite-register, * 页内文案注明「内测邀请」。 + * ③ 用户名密码(内测种子用户,WU1 §3.5):username + password,子模式登录/注册二选一 + * → password-login / password-register(建号即登录);前端做与后端一致的用户名正则+ + * 密码长度预校验(仅提示,信任边界在后端)。 * 2. 隐私政策/用户协议占位勾选(P-ACC-02 占位提前;未勾选不可提交)。 * 3. 携带 useUserStore().anonId 入参(匿名↔登录身份衔接,拍板7=B)。 * 4. 支持 ?redirect= 回跳(守卫/互动跳转登录后回到原页)。 @@ -19,7 +22,14 @@ import { useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { showToast } from 'vant' import { useUserStore } from '../../store/user' -import { sendSmsCode, smsLogin, inviteRegister, type LoginResp } from '../../api/passport' +import { + sendSmsCode, + smsLogin, + inviteRegister, + passwordRegister, + passwordLogin, + type LoginResp, +} from '../../api/passport' import { track } from '../../telemetry' import { AppButton } from '../../components' import entryZh from '../../locales/entry.zh' @@ -36,15 +46,21 @@ const route = useRoute() const router = useRouter() const userStore = useUserStore() -/** 当前 Tab:'sms'=验证码登录(默认主路径) / 'invite'=邀请码注册 */ -const activeTab = ref<'sms' | 'invite'>('sms') +/** 当前 Tab:'sms'=验证码登录(默认主路径) / 'invite'=邀请码注册 / 'password'=用户名密码 */ +const activeTab = ref<'sms' | 'invite' | 'password'>('sms') -/** 手机号(两 Tab 共用) */ +/** 手机号(sms/invite 两 Tab 共用;password Tab 不用) */ const mobile = ref('') /** 短信验证码(sms Tab) */ const code = ref('') /** 邀请码(invite Tab) */ const inviteCode = ref('') +/** 用户名(password Tab) */ +const username = ref('') +/** 密码(password Tab;不 trim,允许含空格,绝不落日志/遥测) */ +const password = ref('') +/** 用户名密码 Tab 子模式:'login'=登录(默认)/ 'register'=注册(建号即登录) */ +const passwordMode = ref<'login' | 'register'>('login') /** 隐私政策/用户协议勾选(占位;未勾选不可提交) */ const agreed = ref(false) @@ -60,6 +76,21 @@ const MOBILE_RE = /^1[3-9]\d{9}$/ /** 手机号是否合法 */ const mobileValid = computed(() => MOBILE_RE.test(mobile.value.trim())) +/** 用户名校验:与后端 PasswordReqVO 一致 ^[a-zA-Z0-9]{4,30}$(前端仅提示,信任边界在后端) */ +const USERNAME_RE = /^[a-zA-Z0-9]{4,30}$/ +/** 用户名是否合法 */ +const usernameValid = computed(() => USERNAME_RE.test(username.value.trim())) +/** + * 密码是否合法(与后端一致的长度预校验,仅提示): + * - 注册:6-32 位(后端 @Length(6,32)); + * - 登录:仅非空(后端登录不重复长度校验,避免给出策略线索)。 + * 密码不 trim(前导/尾随空格可能是密码一部分)。 + */ +const passwordValid = computed(() => { + const len = password.value.length + return passwordMode.value === 'register' ? len >= 6 && len <= 32 : len > 0 +}) + /** * 解析回跳地址(?redirect=)。 * 仅接受站内相对路径(以 / 开头且非 //),防开放重定向;缺省回 /feed。 @@ -78,11 +109,26 @@ const canSend = computed(() => mobileValid.value && !sending.value && countdown. /** 提交按钮是否可点(按当前 Tab 校验必填项 + 已勾选协议 + 不在提交中) */ const canSubmit = computed(() => { - if (!agreed.value || submitting.value || !mobileValid.value) return false + if (!agreed.value || submitting.value) return false + if (activeTab.value === 'password') { + // 用户名密码 Tab:不依赖手机号,按 username 正则 + 密码长度(子模式相关)预校验 + return usernameValid.value && passwordValid.value + } + // sms / invite 两 Tab 共用手机号校验 + if (!mobileValid.value) return false if (activeTab.value === 'sms') return code.value.trim().length > 0 return inviteCode.value.trim().length > 0 }) +/** 提交按钮文案(按当前 Tab;用户名密码 Tab 再按登录/注册子模式二态) */ +const submitLabel = computed(() => { + if (activeTab.value === 'sms') return t('login.submitSms') + if (activeTab.value === 'invite') return t('login.submitInvite') + return passwordMode.value === 'register' + ? t('login.submitPasswordRegister') + : t('login.submitPasswordLogin') +}) + /** 启动 60s 发码倒计时 */ function startCountdown(): void { countdown.value = 60 @@ -138,12 +184,21 @@ function afterLoginSuccess(resp: LoginResp): void { /** * 提交(按当前 Tab 分派): * - sms:smsLogin(已注册登录 / 未注册自动注册一体); - * - invite:inviteRegister(手机号+邀请码受限激活)。 - * 两路均携带 anonId(身份衔接,可选);失败由拦截器 Toast 错误码,仅解锁。 + * - invite:inviteRegister(手机号+邀请码受限激活); + * - password:登录子模式 passwordLogin / 注册子模式 passwordRegister(建号即登录)。 + * 三路均携带 anonId(身份衔接,可选);失败由拦截器 Toast 错误码(1-002-090/091),仅解锁。 */ async function onSubmit(): Promise { if (!canSubmit.value) { - if (!agreed.value) showToast({ message: t('login.needAgree'), type: 'fail' }) + if (!agreed.value) { + showToast({ message: t('login.needAgree'), type: 'fail' }) + return + } + // 用户名密码 Tab 的预校验提示(仅本地提示,后端仍会二次校验) + if (activeTab.value === 'password') { + if (!usernameValid.value) showToast({ message: t('login.invalidUsername'), type: 'fail' }) + else if (!passwordValid.value) showToast({ message: t('login.invalidPassword'), type: 'fail' }) + } return } submitting.value = true @@ -152,16 +207,23 @@ async function onSubmit(): Promise { let resp: LoginResp if (activeTab.value === 'sms') { resp = await smsLogin({ mobile: mobile.value.trim(), code: code.value.trim(), anonId }) - } else { + } else if (activeTab.value === 'invite') { resp = await inviteRegister({ mobile: mobile.value.trim(), inviteCode: inviteCode.value.trim(), anonId, }) + } else { + // 用户名密码路:username 去空白后提交;password 原样(不 trim);子模式决定注册/登录端点 + const uname = username.value.trim() + resp = + passwordMode.value === 'register' + ? await passwordRegister({ username: uname, password: password.value, anonId }) + : await passwordLogin({ username: uname, password: password.value, anonId }) } afterLoginSuccess(resp) } catch { - // 错误码(验证码错误 / 邀请码无效·耗尽·过期 / 已注册等 1-002-090/091)已由拦截器 Toast;解锁停留本页 + // 错误码(验证码错误 / 邀请码无效·耗尽·过期 / 用户名已占用·用户名或密码错误 1-002-090/091)已由拦截器 Toast;解锁停留本页 submitting.value = false } // 成功后 router.replace 跳转,本组件卸载,无需复位 submitting @@ -176,7 +238,7 @@ async function onSubmit(): Promise { - +