merge: WU1(后端+前端) 并入内测阶段1 integration

This commit is contained in:
lili 2026-07-07 12:47:07 -07:00
commit 7ecf016914
5 changed files with 247 additions and 22 deletions

View File

@ -39,6 +39,35 @@ export interface InviteRegisterReq {
nickname?: string
}
/**
* PasswordRegisterReqVOWU1 §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
}
/**
* PasswordLoginReqVOWU1 §3.2
* 1-002-090-005
*/
export interface PasswordLoginReq {
/** 用户名(登录主键之一;^[a-zA-Z0-9]{4,30}$ */
username: string
/** 密码(明文;登录不重复长度校验,仅非空) */
password: string
/** 客户端 anonId可选登录不落库仅遥测归因透传 */
anonId?: string
}
/**
* / LoginRespVOsms-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<LoginResp> {
})
}
/**
* @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<LoginResp> {
return request<LoginResp>({
url: '/app-api/passport/password-register',
method: 'post',
data: dto,
})
}
/**
* @PermitAll 1-002-090-005WU1 §3.2
* POST /app-api/passport/password-login
* @param dto username + password + anonId
* @returns OAuth2 token sms-login
*/
export function passwordLogin(dto: PasswordLoginReq): Promise<LoginResp> {
return request<LoginResp>({
url: '/app-api/passport/password-login',
method: 'post',
data: dto,
})
}
/**
* 401 request.ts 401
* GET /app-api/passport/me

View File

@ -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 ——

View File

@ -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——

View File

@ -3,10 +3,13 @@
* 登录页 | Login.vue路由 /login真实鉴权件 spec §9.1
* ----------------------------------------------------------------------------
* 职责
* 1. Tab 登录/注册
* 1. Tab 登录/注册
* 手机号 + 验证码主路径拍板1Csend-sms-code 发码 sms-login 登录/自动注册一体
* 邀请码注册受限激活期陌生玩家入口拍板2/3mobile + inviteCode invite-register
* 页内文案注明内测邀请
* 用户名密码内测种子用户WU1 §3.5username + 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<string>('')
/** 短信验证码sms Tab */
const code = ref<string>('')
/** 邀请码invite Tab */
const inviteCode = ref<string>('')
/** 用户名password Tab */
const username = ref<string>('')
/** 密码password Tab不 trim允许含空格绝不落日志/遥测) */
const password = ref<string>('')
/** 用户名密码 Tab 子模式:'login'=登录(默认)/ 'register'=注册(建号即登录) */
const passwordMode = ref<'login' | 'register'>('login')
/** 隐私政策/用户协议勾选(占位;未勾选不可提交) */
const agreed = ref<boolean>(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 分派
* - smssmsLogin已注册登录 / 未注册自动注册一体
* - inviteinviteRegister手机号+邀请码受限激活
* 两路均携带 anonId身份衔接可选失败由拦截器 Toast 错误码仅解锁
* - inviteinviteRegister手机号+邀请码受限激活
* - password登录子模式 passwordLogin / 注册子模式 passwordRegister建号即登录
* 三路均携带 anonId身份衔接可选失败由拦截器 Toast 错误码1-002-090/091仅解锁
*/
async function onSubmit(): Promise<void> {
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<void> {
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<void> {
<p class="login-sub">{{ t('login.subtitle') }}</p>
</header>
<!-- Tab 切换验证码登录 / 邀请码注册 -->
<!-- Tab 切换验证码登录 / 邀请码注册 / 用户名密码 -->
<div class="login-tabs">
<button
class="login-tab"
@ -194,13 +256,21 @@ async function onSubmit(): Promise<void> {
>
{{ t('login.tabInvite') }}
</button>
<button
class="login-tab"
:class="{ 'is-active': activeTab === 'password' }"
type="button"
@click="activeTab = 'password'"
>
{{ t('login.tabPassword') }}
</button>
</div>
<!-- 表单区 -->
<section class="login-form">
<!-- 手机号 Tab 共用保留原生 inputmaxlength/inputmode/type 属输入校验 UX
与倒计时/校验逻辑相关不替成 FieldField 不透传 maxlength/inputmode仅令牌化样式 + i18n 占位 -->
<div class="field">
<!-- 手机号sms/invite Tab 共用password Tab 无手机号不渲染保留原生 input
maxlength/inputmode/type 属输入校验 UX与倒计时/校验逻辑相关不替成 Field仅令牌化样式 + i18n 占位 -->
<div v-if="activeTab !== 'password'" class="field">
<input
v-model="mobile"
class="field-input"
@ -230,7 +300,7 @@ async function onSubmit(): Promise<void> {
</template>
<!-- 邀请码注册邀请码受限激活期陌生玩家入口 -->
<template v-else>
<template v-else-if="activeTab === 'invite'">
<div class="field">
<input
v-model="inviteCode"
@ -243,6 +313,51 @@ async function onSubmit(): Promise<void> {
<p class="login-hint">{{ t('login.inviteHint') }}</p>
</template>
<!-- 用户名密码子模式登录/注册切换 + 用户名 + 密码内测种子用户WU1 §3.5 -->
<template v-else>
<!-- 登录/注册子模式切换注册走 password-register 建号即登录登录走 password-login -->
<div class="pwd-mode">
<button
class="pwd-mode-btn"
:class="{ 'is-active': passwordMode === 'login' }"
type="button"
@click="passwordMode = 'login'"
>
{{ t('login.modeLogin') }}
</button>
<button
class="pwd-mode-btn"
:class="{ 'is-active': passwordMode === 'register' }"
type="button"
@click="passwordMode = 'register'"
>
{{ t('login.modeRegister') }}
</button>
</div>
<div class="field">
<input
v-model="username"
class="field-input"
type="text"
inputmode="text"
autocomplete="username"
maxlength="30"
:placeholder="t('login.usernamePlaceholder')"
/>
</div>
<div class="field">
<input
v-model="password"
class="field-input"
type="password"
:autocomplete="passwordMode === 'register' ? 'new-password' : 'current-password'"
maxlength="32"
:placeholder="t('login.passwordPlaceholder')"
/>
</div>
<p class="login-hint">{{ t('login.passwordHint') }}</p>
</template>
<!-- 隐私政策/用户协议占位勾选未勾选不可提交协议/隐私链接名 + 占位说明均抽 key -->
<label class="agree">
<input v-model="agreed" type="checkbox" class="agree-box" />
@ -255,9 +370,9 @@ async function onSubmit(): Promise<void> {
</span>
</label>
<!-- 提交按当前 Tab 文案二 -->
<!-- 提交按当前 Tab / password 子模式文案多 -->
<AppButton block size="large" :loading="submitting" :disabled="!canSubmit" @click="onSubmit">
{{ activeTab === 'sms' ? t('login.submitSms') : t('login.submitInvite') }}
{{ submitLabel }}
</AppButton>
</section>
</div>
@ -365,6 +480,31 @@ async function onSubmit(): Promise<void> {
opacity: 0.6;
}
/* —— 用户名密码:登录/注册子模式切换(比主 Tab 更轻,作段控) —— */
.pwd-mode {
display: flex;
gap: 6px;
padding: 3px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.pwd-mode-btn {
flex: 1;
padding: 8px 0;
border: 0;
border-radius: calc(var(--radius) - 3px);
background: transparent;
color: var(--muted);
font-size: 13px;
font-weight: 600;
transition: background 0.15s ease, color 0.15s ease;
}
.pwd-mode-btn.is-active {
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent);
}
/* —— 提示文案 —— */
.login-hint {
margin: -6px 2px 0;

View File

@ -54,12 +54,13 @@ const meLoading = ref(true)
/**
* userStore 现有字段构造兜底账号信息getMe 失败/mock 模式时用
* 手机号 store 无持久化兜底留空passport.me 才回脱敏手机号
* 手机号/用户名 store 无持久化兜底留空passport.me 才回脱敏手机号与用户名
*/
function fallbackMe(): PlayerMeResp {
return {
userId: Number(userStore.userId) || 0,
mobile: '',
username: '',
nickname: userStore.nickname,
avatar: userStore.avatar,
creatorFlag: userStore.creatorFlag,