登录模块审核

This commit is contained in:
Shen Wei
2026-04-30 18:40:55 +08:00
parent 4030a91100
commit 57600598ac
34 changed files with 2544 additions and 2431 deletions

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1280">
<title>Fonrey 登录管理 · 静态原型</title>
<title>Fonrey 登录管理 · Tenant 识别Story 1</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script>
@@ -12,36 +12,17 @@
extend: {
colors: {
primary: {
50: '#F0FDFA',
100: '#CCFBF1',
200: '#99F6E4',
500: '#14B8A6',
600: '#0F766E',
700: '#115E59',
800: '#134E4A'
50: '#F0FDFA', 100: '#CCFBF1', 200: '#99F6E4',
500: '#14B8A6', 600: '#0F766E', 700: '#115E59', 800: '#134E4A'
},
neutral: {
50: '#F8FAFC',
100: '#F1F5F9',
200: '#E2E8F0',
300: '#CBD5E1',
400: '#94A3B8',
500: '#64748B',
600: '#475569',
700: '#334155',
800: '#1E293B',
900: '#0F172A'
50: '#F8FAFC', 100: '#F1F5F9', 200: '#E2E8F0', 300: '#CBD5E1',
400: '#94A3B8', 500: '#64748B', 600: '#475569', 700: '#334155',
800: '#1E293B', 900: '#0F172A'
},
success: { 50: '#F0FDF4', 600: '#16A34A' },
warning: { 50: '#FFFBEB', 600: '#D97706' },
danger: { 50: '#FEF2F2', 600: '#DC2626' },
info: { 50: '#EFF6FF', 600: '#2563EB' }
},
boxShadow: {
xs: '0 1px 2px rgba(15,23,42,0.04)'
},
fontFamily: {
sans: ['Inter', 'PingFang SC', 'Microsoft YaHei', 'sans-serif']
danger: { 50: '#FEF2F2', 600: '#DC2626' }
}
}
}
@@ -49,12 +30,9 @@
</script>
<style>
[x-cloak] { display: none !important; }
.tabular-nums { font-variant-numeric: tabular-nums; }
.captcha-track { background: linear-gradient(90deg, #E2E8F0 0%, #F1F5F9 100%); }
.captcha-success { background: linear-gradient(90deg, #F0FDF4 0%, #16A34A 100%); }
</style>
</head>
<body class="min-h-screen bg-neutral-50 font-sans text-sm text-neutral-700 antialiased" x-data="loginPrototype()">
<body class="min-h-screen bg-neutral-50 font-sans text-sm text-neutral-700 antialiased" x-data="tenantVerifyPage()">
<div class="fixed inset-0 -z-10">
<div class="absolute inset-y-0 left-0 w-[56%] bg-gradient-to-br from-primary-800 via-primary-700 to-primary-600"></div>
<div class="absolute -top-16 -left-24 w-96 h-96 rounded-full bg-white/10 blur-2xl"></div>
@@ -69,369 +47,126 @@
<span class="text-base font-semibold">Fonrey 房睿</span>
</div>
<h1 class="mt-8 text-4xl font-semibold leading-tight">面向经纪业务的<br>高密度工作台</h1>
<h1 class="mt-8 text-4xl font-semibold leading-tight">欢迎使用 Fonrey 房睿</h1>
<p class="mt-4 text-primary-100 text-base leading-7 max-w-xl">
多租户隔离、角色权限控制、房客源高频操作一致体验
本页面原型覆盖 Tenant 识别、账号密码登录、验证码验证、锁定与会话过期等 P0 场景
首次启动客户端时,请先输入 12 位公司识别码完成租户识别
识别成功后将自动进入该租户的登录页面
</p>
</div>
<div class="grid grid-cols-2 gap-4 max-w-2xl">
<div class="rounded-lg border border-white/20 bg-white/10 p-4">
<div class="text-xs text-primary-100">多租户识别</div>
<div class="mt-1 text-xl font-semibold tabular-nums">12位 Tenant ID</div>
<div class="text-xs text-primary-100">识别码格式</div>
<div class="mt-1 text-xl font-semibold">12 位纯数字</div>
</div>
<div class="rounded-lg border border-white/20 bg-white/10 p-4">
<div class="text-xs text-primary-100">安全策略</div>
<div class="mt-1 text-xl font-semibold tabular-nums">5次失败锁定30分钟</div>
<div class="mt-1 text-xl font-semibold">公共接口限流保护</div>
</div>
</div>
</section>
<section class="col-span-5 px-10 py-10 flex items-center justify-center">
<div class="w-full max-w-md rounded-xl bg-white border border-neutral-200 shadow-lg p-6">
<h2 class="text-xl font-semibold text-neutral-800">Tenant 识别</h2>
<p class="mt-2 text-sm text-neutral-500">请输入您公司的专属识别码以继续</p>
<template x-if="view === 'tenant'">
<div x-cloak>
<h2 class="text-xl font-semibold text-neutral-800">欢迎使用 Fonrey 房睿</h2>
<p class="mt-2 text-sm text-neutral-500">请输入您公司的专属识别码,以进入对应租户登录页</p>
<div class="mt-6 space-y-1.5">
<label for="tenant-code" class="block text-sm font-medium text-neutral-700">公司识别码Tenant Code<span class="text-danger-600">*</span></label>
<input
id="tenant-code"
type="text"
inputmode="numeric"
maxlength="12"
:disabled="loading"
x-model="tenantCode"
@input="sanitizeTenantCode"
placeholder="请输入12位数字识别码"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20 disabled:bg-neutral-100 disabled:text-neutral-400"
aria-describedby="tenant-help tenant-error"
>
<p id="tenant-help" class="text-xs text-neutral-500">支持粘贴,系统将自动去除前后空格并过滤非数字字符</p>
<div class="min-h-[22px]">
<p id="tenant-error" x-show="errorText" x-text="errorText" class="text-xs text-danger-600"></p>
</div>
</div>
<div class="mt-6 space-y-1.5">
<label for="tenant-id" class="block text-sm font-medium text-neutral-700">公司识别码Tenant ID<span class="text-danger-600">*</span></label>
<input
id="tenant-id"
type="text"
inputmode="numeric"
maxlength="12"
:disabled="tenantLoading"
x-model="tenantId"
@input="sanitizeTenantId"
placeholder="请输入12位数字识别码"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20 disabled:bg-neutral-100 disabled:text-neutral-400"
aria-describedby="tenant-help tenant-error"
>
<p id="tenant-help" class="text-xs text-neutral-500">支持粘贴,系统将自动去除空格与非数字字符</p>
<div class="min-h-[22px]">
<p id="tenant-error" x-show="tenantError" x-text="tenantError" class="text-xs text-danger-600"></p>
</div>
</div>
<button
type="button"
@click="submitTenantCode"
:disabled="loading"
class="mt-1 inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg x-show="loading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.75 7.25a6.5 6.5 0 1 0 0 9.5"/></svg>
<span x-text="loading ? '识别中…' : '确认'"></span>
</button>
<button
type="button"
@click="submitTenant"
:disabled="tenantLoading"
class="mt-1 inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg x-show="tenantLoading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.75 7.25a6.5 6.5 0 1 0 0 9.5"/></svg>
<span x-text="tenantLoading ? '识别中…' : '确认'"></span>
</button>
<template x-if="tenantNetworkError">
<div class="mt-3 rounded-md border border-warning-600/30 bg-warning-50 px-3 py-2 text-xs text-warning-600 flex items-center justify-between">
<span>网络连接失败,请检查网络后重试</span>
<button @click="tenantNetworkError=false" class="text-primary-600 hover:underline">重试</button>
</div>
</template>
<p class="mt-4 text-xs text-neutral-500">不知道识别码?请联系您公司的系统管理员</p>
<template x-if="networkError">
<div class="mt-3 rounded-md border border-warning-600/30 bg-warning-50 px-3 py-2 text-xs text-warning-600 flex items-center justify-between gap-2">
<span>网络连接失败,请检查网络后重试</span>
<button type="button" @click="networkError=false" class="text-primary-600 hover:underline">重试</button>
</div>
</template>
<template x-if="view === 'login'">
<div x-cloak>
<div class="flex items-center justify-between mb-4">
<div class="min-w-0">
<p class="text-xs text-neutral-500">正在登录</p>
<p class="text-sm font-semibold text-neutral-800 truncate" x-text="tenantName"></p>
</div>
<button type="button" @click="openSwitchModal = true" class="text-sm text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">切换公司</button>
</div>
<template x-if="sessionExpiredNotice">
<div class="mb-3 rounded-md border border-warning-600/30 bg-warning-50 px-3 py-2 text-xs text-warning-600 flex items-start justify-between gap-2">
<span>登录已过期,请重新登录</span>
<button @click="sessionExpiredNotice=false" class="text-neutral-500 hover:text-neutral-700" aria-label="关闭会话过期提示"></button>
</div>
</template>
<h2 class="text-xl font-semibold text-neutral-800">账号登录</h2>
<p class="mt-1 text-sm text-neutral-500">请输入用户名和密码,并完成行为验证</p>
<form class="mt-5 space-y-4" @submit.prevent="submitLogin">
<div class="space-y-1">
<label for="username" class="block text-sm font-medium text-neutral-700">用户名<span class="text-danger-600">*</span></label>
<input id="username" type="text" x-model.trim="username" placeholder="请输入用户名"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="loginLoading || accountState==='locked' || accountState==='disabled'">
</div>
<div class="space-y-1">
<label for="password" class="block text-sm font-medium text-neutral-700">密码<span class="text-danger-600">*</span></label>
<div class="relative">
<input id="password" :type="passwordVisible ? 'text' : 'password'" x-model="password" placeholder="请输入密码"
class="block w-full px-3 py-2 pr-10 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="loginLoading || accountState==='locked' || accountState==='disabled'">
<button type="button" @click="passwordVisible=!passwordVisible" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700" aria-label="显示或隐藏密码">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.644C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.009 9.963 7.178.07.207.07.431 0 .638C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.009-9.964-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
</button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-neutral-700">行为验证<span class="text-danger-600">*</span></label>
<button type="button" @click="refreshCaptcha" class="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700" aria-label="刷新验证码">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"/></svg>
刷新
</button>
</div>
<div class="rounded-lg border border-neutral-200 p-3">
<div class="relative h-24 rounded-md overflow-hidden bg-gradient-to-r from-neutral-100 to-neutral-200">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(15,118,110,0.18),transparent_45%),radial-gradient(circle_at_80%_70%,rgba(37,99,235,0.15),transparent_45%)]"></div>
<div class="absolute top-7 h-10 w-9 rounded bg-neutral-300/90 border border-neutral-400/70" :style="`left:${captchaTarget}%`"></div>
<div class="absolute top-7 h-10 w-9 rounded border border-primary-600/70 bg-primary-100/90 transition-all" :style="`left: calc(${sliderValue}% - 18px)`"></div>
</div>
<div class="mt-2 rounded-md p-2 border"
:class="captchaState==='pass' ? 'captcha-success border-success-600/30' : 'captcha-track border-neutral-200'">
<input type="range" min="0" max="100" step="1" x-model="sliderValue" @change="verifyCaptcha" @input="captchaState='idle'"
:disabled="captchaState==='pass' || loginLoading || accountState==='locked' || accountState==='disabled'"
class="w-full accent-primary-600">
</div>
<p class="mt-1 text-xs"
:class="captchaState==='pass' ? 'text-success-600' : (captchaState==='fail' ? 'text-danger-600' : 'text-neutral-500')"
x-text="captchaState==='pass' ? '验证通过' : (captchaState==='fail' ? '验证失败,请重试' : '拖动滑块完成拼图')"></p>
</div>
</div>
<template x-if="loginError">
<div class="rounded-md border border-danger-600/30 bg-danger-50 px-3 py-2 text-xs text-danger-600" x-text="loginError"></div>
</template>
<button type="submit"
class="inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!canSubmit || loginLoading || accountState==='locked' || accountState==='disabled'">
<svg x-show="loginLoading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.75 7.25a6.5 6.5 0 1 0 0 9.5"/></svg>
<span x-text="loginLoading ? '登录中…' : '登录'"></span>
</button>
</form>
<div class="mt-3 flex items-center justify-between text-xs">
<a href="javascript:void(0)" class="text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">忘记用户名</a>
<a href="javascript:void(0)" class="text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">忘记密码</a>
</div>
<div class="mt-4 border-t border-neutral-200 pt-4 space-y-2">
<button type="button" disabled class="w-full px-3 py-2 rounded-md border border-neutral-300 bg-neutral-100 text-neutral-400 cursor-not-allowed text-sm">手机验证码登录(即将开放)</button>
<button type="button" disabled class="w-full px-3 py-2 rounded-md border border-neutral-300 bg-neutral-100 text-neutral-400 cursor-not-allowed text-sm">微信扫码登录(即将开放)</button>
</div>
<details class="mt-4 rounded-md border border-neutral-200 bg-neutral-50 p-2">
<summary class="cursor-pointer text-xs text-neutral-500">原型状态切换(仅评审演示)</summary>
<div class="mt-2 grid grid-cols-2 gap-2 text-xs">
<button @click="simulateInvalidCredential" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟账号密码错误</button>
<button @click="simulateLock" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟账号锁定</button>
<button @click="simulateDisabled" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟账号停用</button>
<button @click="sessionExpiredNotice=true" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟会话过期</button>
<button @click="resetLoginState" class="col-span-2 px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">重置状态</button>
</div>
</details>
</div>
<template x-if="successText">
<div class="mt-3 rounded-md border border-success-600/30 bg-success-50 px-3 py-2 text-xs text-success-600" x-text="successText"></div>
</template>
<p class="mt-4 text-xs text-neutral-500">不知道识别码请联系您公司的Tenant Admin租户管理员</p>
</div>
</section>
</main>
<div x-show="openSwitchModal" x-cloak class="fixed inset-0 z-50" @keydown.escape.window="openSwitchModal=false">
<div class="absolute inset-0 bg-neutral-900/40" @click="openSwitchModal=false"></div>
<div class="absolute inset-0 flex items-center justify-center p-4 pointer-events-none">
<div class="w-full max-w-sm bg-white rounded-xl shadow-lg border border-neutral-200 pointer-events-auto">
<div class="p-5 text-center space-y-3">
<div class="mx-auto w-12 h-12 rounded-full bg-warning-50 flex items-center justify-center">
<svg class="w-6 h-6 text-warning-600" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9.303 3.376c.866 1.5-.217 3.374-1.948 3.374H4.646c-1.73 0-2.813-1.874-1.948-3.374l7.354-12.748c.866-1.5 3.03-1.5 3.896 0l7.355 12.748Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5h.008v.008H12v-.008Z"/></svg>
</div>
<h3 class="text-base font-semibold text-neutral-800">切换公司</h3>
<p class="text-sm text-neutral-500">切换公司将清除当前租户识别信息,并返回识别页。是否继续?</p>
</div>
<div class="flex items-center justify-center gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50">
<button @click="openSwitchModal=false" class="px-4 py-1.5 text-sm border border-neutral-300 rounded-md bg-white hover:bg-neutral-50">取消</button>
<button @click="confirmSwitchCompany" class="px-4 py-1.5 text-sm rounded-md bg-danger-600 text-white hover:bg-danger-600/90">继续切换</button>
</div>
</div>
</div>
</div>
<script>
function loginPrototype() {
function tenantVerifyPage() {
return {
view: 'tenant',
tenantId: '',
tenantName: '',
tenantLoading: false,
tenantError: '',
tenantNetworkError: false,
tenantCode: '',
loading: false,
networkError: false,
errorText: '',
successText: '',
username: '',
password: '',
passwordVisible: false,
captchaTarget: 46,
sliderValue: 0,
captchaState: 'idle',
loginLoading: false,
loginError: '',
accountState: 'active',
failedCount: 0,
sessionExpiredNotice: false,
openSwitchModal: false,
sanitizeTenantId() {
this.tenantId = this.tenantId.replace(/\D/g, '').slice(0, 12)
this.tenantError = ''
this.tenantNetworkError = false
sanitizeTenantCode() {
this.tenantCode = this.tenantCode.replace(/\s+/g, '').replace(/\D/g, '').slice(0, 12)
this.errorText = ''
this.networkError = false
this.successText = ''
},
submitTenant() {
this.tenantError = ''
this.tenantNetworkError = false
submitTenantCode() {
this.errorText = ''
this.networkError = false
this.successText = ''
if (this.tenantId.length !== 12) {
this.tenantError = '识别码须为 12 位数字'
if (this.tenantCode.length !== 12) {
this.errorText = '识别码须为 12 位数字'
return
}
this.tenantLoading = true
setTimeout(() => {
this.tenantLoading = false
this.loading = true
if (this.tenantId === '999999999999') {
this.tenantNetworkError = true
setTimeout(() => {
this.loading = false
if (this.tenantCode === '999999999999') {
this.networkError = true
return
}
if (this.tenantId === '202500010001') {
this.tenantName = '沪居地产(演示租户)'
localStorage.setItem('tenant_id', this.tenantId)
localStorage.setItem('tenant_name', this.tenantName)
// 串联到 Story 2 独立登录页
this.view = 'login'
if (this.tenantCode === '202500010001') {
const tenantName = '沪居地产(演示租户)'
localStorage.setItem('tenant_code', this.tenantCode)
localStorage.setItem('tenant_name', tenantName)
this.successText = `识别成功,正在登录:${tenantName}`
setTimeout(() => {
window.location.href = `./登录_账号密码_UI.html?tenantId=${this.tenantId}&tenantName=${encodeURIComponent(this.tenantName)}`
}, 350)
this.resetLoginState()
} else {
this.tenantError = '识别码无效,请联系您的系统管理员获取正确的识别码'
window.location.href = `./登录_账号密码_UI.html?tenantCode=${this.tenantCode}&tenantName=${encodeURIComponent(tenantName)}`
}, 500)
return
}
this.errorText = '识别码无效请联系您的Tenant Admin租户管理员获取正确的识别码'
}, 800)
},
refreshCaptcha() {
this.captchaTarget = Math.floor(Math.random() * 60) + 20
this.sliderValue = 0
this.captchaState = 'idle'
},
verifyCaptcha() {
const diff = Math.abs(this.sliderValue - this.captchaTarget)
if (diff <= 3) {
this.captchaState = 'pass'
this.loginError = ''
} else {
this.captchaState = 'fail'
setTimeout(() => this.refreshCaptcha(), 700)
}
},
get canSubmit() {
return this.username.trim() && this.password && this.captchaState === 'pass'
},
submitLogin() {
this.loginError = ''
if (this.accountState === 'locked') {
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
return
}
if (this.accountState === 'disabled') {
this.loginError = '账号已停用,请联系您的管理员'
return
}
if (!this.canSubmit) {
this.loginError = '请先完成用户名、密码和行为验证'
return
}
this.loginLoading = true
setTimeout(() => {
this.loginLoading = false
const credentialPass = this.username === 'agent_001' && this.password === 'Fonrey@2025'
if (credentialPass) {
this.loginError = ''
this.failedCount = 0
this.sessionExpiredNotice = false
this.password = ''
this.refreshCaptcha()
// 静态原型串联:登录成功后跳转到主页(当前用房源列表页作为首页)
setTimeout(() => {
window.location.href = './房源列表_UI.html?from=login&login=success'
}, 350)
return
}
this.failedCount += 1
this.loginError = '用户名或密码错误,请重新输入'
this.password = ''
this.refreshCaptcha()
if (this.failedCount >= 5) {
this.accountState = 'locked'
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
}
}, 900)
},
simulateInvalidCredential() {
this.loginError = '用户名或密码错误,请重新输入'
this.password = ''
this.refreshCaptcha()
},
simulateLock() {
this.accountState = 'locked'
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
},
simulateDisabled() {
this.accountState = 'disabled'
this.loginError = '账号已停用,请联系您的管理员'
},
resetLoginState() {
this.username = ''
this.password = ''
this.passwordVisible = false
this.loginLoading = false
this.loginError = ''
this.failedCount = 0
this.accountState = 'active'
this.sessionExpiredNotice = false
this.refreshCaptcha()
},
confirmSwitchCompany() {
this.openSwitchModal = false
this.view = 'tenant'
this.tenantName = ''
this.tenantId = ''
this.tenantError = ''
this.tenantNetworkError = false
this.resetLoginState()
}
}
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1280">
<title>Fonrey 登录管理 · 账号密码登录Story 2</title>
<title>Fonrey 登录管理 · 双方式登录Story 2 + Story 5</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script>
@@ -22,8 +22,7 @@
},
success: { 50: '#F0FDF4', 600: '#16A34A' },
warning: { 50: '#FFFBEB', 600: '#D97706' },
danger: { 50: '#FEF2F2', 600: '#DC2626' },
info: { 50: '#EFF6FF', 600: '#2563EB' }
danger: { 50: '#FEF2F2', 600: '#DC2626' }
}
}
}
@@ -41,7 +40,7 @@
.captcha-shake { animation: shake .22s linear 2; }
</style>
</head>
<body class="min-h-screen bg-neutral-50 font-sans text-sm text-neutral-700 antialiased" x-data="story2LoginPage()">
<body class="min-h-screen bg-neutral-50 font-sans text-sm text-neutral-700 antialiased" x-data="dualLoginPage()" x-init="init()">
<div class="fixed inset-0 -z-10">
<div class="absolute inset-y-0 left-0 w-[56%] bg-gradient-to-br from-primary-800 via-primary-700 to-primary-600"></div>
<div class="absolute -top-16 -left-24 w-96 h-96 rounded-full bg-white/10 blur-2xl"></div>
@@ -56,10 +55,10 @@
<span class="text-base font-semibold">Fonrey 房睿</span>
</div>
<h1 class="mt-8 text-4xl font-semibold leading-tight">经纪人账号登录</h1>
<h1 class="mt-8 text-4xl font-semibold leading-tight">经纪人登录</h1>
<p class="mt-4 text-primary-100 text-base leading-7 max-w-xl">
已完成 Tenant 识别,请使用经纪人账号和密码登录。
本页对应 PRD《用户登录管理模块》User Story 2
支持两种登录方式:手机号密码登录、手机号验证码登录。
微信扫码登录保留为“即将开放”禁用态入口
</p>
</div>
@@ -70,7 +69,7 @@
</div>
<div class="rounded-lg border border-white/20 bg-white/10 p-4">
<div class="text-xs text-primary-100">登录策略</div>
<div class="mt-1 text-xl font-semibold">账号密码 + 滑块验证</div>
<div class="mt-1 text-xl font-semibold">双 Tab 登录 + 滑块验证</div>
</div>
</div>
</section>
@@ -90,7 +89,7 @@
<p class="text-xs text-neutral-500">正在登录</p>
<p class="text-sm font-semibold text-neutral-800 truncate" x-text="tenantName || '租户未识别'"></p>
</div>
<button type="button" @click="openSwitchModal = true" class="text-sm text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">切换公司</button>
<button type="button" @click="openSwitchModal=true" class="text-sm text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">切换公司</button>
</div>
<template x-if="sessionExpiredNotice">
@@ -100,79 +99,230 @@
</div>
</template>
<h2 class="text-xl font-semibold text-neutral-800">账号登录</h2>
<p class="mt-1 text-sm text-neutral-500">请输入用户名和密码,并完成行为验证</p>
<form class="mt-5 space-y-4" @submit.prevent="submitLogin">
<div class="space-y-1">
<label for="username" class="block text-sm font-medium text-neutral-700">用户名<span class="text-danger-600">*</span></label>
<input id="username" type="text" x-model.trim="username" placeholder="请输入用户名" maxlength="50"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="loginLoading || accountState==='locked' || accountState==='disabled' || tenantMissing">
<p class="text-xs text-neutral-500">支持英文字母、数字、下划线,最大 50 字符</p>
<template x-if="loginSuccessNotice">
<div class="mb-3 rounded-md border border-success-600/30 bg-success-50 px-3 py-2 text-xs text-success-600 flex items-start justify-between gap-2">
<span>密码已重置,请使用新密码登录</span>
<button @click="loginSuccessNotice=false" class="text-neutral-500 hover:text-neutral-700" aria-label="关闭重置成功提示"></button>
</div>
</template>
<div class="space-y-1">
<label for="password" class="block text-sm font-medium text-neutral-700">密码<span class="text-danger-600">*</span></label>
<div class="relative">
<input id="password" :type="passwordVisible ? 'text' : 'password'" x-model="password" placeholder="请输入密码"
class="block w-full px-3 py-2 pr-10 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="loginLoading || accountState==='locked' || accountState==='disabled' || tenantMissing">
<button type="button" @click="passwordVisible=!passwordVisible" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700" aria-label="显示或隐藏密码">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.644C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.009 9.963 7.178.07.207.07.431 0 .638C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.009-9.964-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
</button>
</div>
</div>
<h2 class="text-xl font-semibold text-neutral-800">登录</h2>
<p class="mt-1 text-sm text-neutral-500">请选择登录方式并完成验证</p>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-neutral-700">行为验证<span class="text-danger-600">*</span></label>
<button type="button" @click="refreshCaptcha" class="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700" aria-label="刷新验证码">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"/></svg>
刷新
</button>
<div class="mt-4 grid grid-cols-2 rounded-lg border border-neutral-200 bg-neutral-50 p-1">
<button
type="button"
@click="switchTab('password')"
class="h-9 rounded-md text-sm font-medium transition"
:class="activeTab==='password' ? 'bg-white text-primary-700 shadow-xs border border-primary-200' : 'text-neutral-600 hover:text-neutral-800'"
>密码登录</button>
<button
type="button"
@click="switchTab('sms')"
class="h-9 rounded-md text-sm font-medium transition"
:class="activeTab==='sms' ? 'bg-white text-primary-700 shadow-xs border border-primary-200' : 'text-neutral-600 hover:text-neutral-800'"
>验证码登录</button>
</div>
<template x-if="activeTab==='password'">
<form class="mt-5 space-y-4" @submit.prevent="submitPasswordLogin" x-cloak>
<div class="space-y-1">
<label for="phone-password" class="block text-sm font-medium text-neutral-700">手机号<span class="text-danger-600">*</span></label>
<input
id="phone-password"
type="text"
inputmode="numeric"
maxlength="11"
x-model="phonePassword"
@input="sanitizePhone('password')"
placeholder="请输入您的手机号"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="passwordLoading || accountState!=='active' || tenantMissing"
>
<p x-show="passwordFieldError" x-text="passwordFieldError" class="text-xs text-danger-600"></p>
</div>
<div :class="captchaState==='fail' ? 'captcha-shake' : ''" class="rounded-lg border border-neutral-200 p-3">
<div class="relative h-24 rounded-md overflow-hidden bg-gradient-to-r from-neutral-100 to-neutral-200">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(15,118,110,0.18),transparent_45%),radial-gradient(circle_at_80%_70%,rgba(37,99,235,0.15),transparent_45%)]"></div>
<div class="absolute top-7 h-10 w-9 rounded bg-neutral-300/90 border border-neutral-400/70" :style="`left:${captchaTarget}%`"></div>
<div class="absolute top-7 h-10 w-9 rounded border border-primary-600/70 bg-primary-100/90 transition-all" :style="`left: calc(${sliderValue}% - 18px)`"></div>
<div class="space-y-1">
<label for="password" class="block text-sm font-medium text-neutral-700">密码<span class="text-danger-600">*</span></label>
<div class="relative">
<input
id="password"
:type="passwordVisible ? 'text' : 'password'"
x-model="password"
placeholder="请输入密码"
class="block w-full px-3 py-2 pr-10 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="passwordLoading || accountState!=='active' || tenantMissing"
>
<button type="button" @click="passwordVisible=!passwordVisible" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700" aria-label="显示或隐藏密码">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.644C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.009 9.963 7.178.07.207.07.431 0 .638C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.009-9.964-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
</button>
</div>
<p x-show="passwordInputError" x-text="passwordInputError" class="text-xs text-danger-600"></p>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-neutral-700">滑块验证<span class="text-danger-600">*</span></label>
<button type="button" @click="refreshCaptcha('password')" class="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700" aria-label="刷新滑块验证">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"/></svg>
刷新
</button>
</div>
<div class="mt-2 rounded-md p-2 border" :class="captchaState==='pass' ? 'captcha-success border-success-600/30' : 'captcha-track border-neutral-200'">
<input type="range" min="0" max="100" step="1" x-model="sliderValue" @change="verifyCaptcha" @input="captchaState='idle'"
:disabled="captchaState==='pass' || loginLoading || accountState==='locked' || accountState==='disabled' || tenantMissing"
class="w-full accent-primary-600">
<div :class="passwordCaptchaState==='fail' ? 'captcha-shake' : ''" class="rounded-lg border border-neutral-200 p-3">
<div class="relative h-24 rounded-md overflow-hidden bg-gradient-to-r from-neutral-100 to-neutral-200">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(15,118,110,0.18),transparent_45%),radial-gradient(circle_at_80%_70%,rgba(37,99,235,0.15),transparent_45%)]"></div>
<div class="absolute top-7 h-10 w-9 rounded bg-neutral-300/90 border border-neutral-400/70" :style="`left:${passwordCaptchaTarget}%`"></div>
<div class="absolute top-7 h-10 w-9 rounded border border-primary-600/70 bg-primary-100/90 transition-all" :style="`left: calc(${passwordSliderValue}% - 18px)`"></div>
</div>
<div class="mt-2 rounded-md p-2 border" :class="passwordCaptchaState==='pass' ? 'captcha-success border-success-600/30' : 'captcha-track border-neutral-200'">
<input
type="range"
min="0"
max="100"
step="1"
x-model="passwordSliderValue"
@change="verifyCaptcha('password')"
@input="passwordCaptchaState='idle'"
:disabled="passwordCaptchaState==='pass' || passwordLoading || accountState!=='active' || tenantMissing"
class="w-full accent-primary-600"
>
</div>
<p class="mt-1 text-xs"
:class="passwordCaptchaState==='pass' ? 'text-success-600' : (passwordCaptchaState==='fail' ? 'text-danger-600' : 'text-neutral-500')"
x-text="passwordCaptchaState==='pass' ? '验证通过' : (passwordCaptchaState==='fail' ? '验证失败,请重试' : '拖动滑块完成拼图')"></p>
</div>
</div>
<template x-if="passwordError">
<div class="rounded-md border border-danger-600/30 bg-danger-50 px-3 py-2 text-xs text-danger-600" x-text="passwordError"></div>
</template>
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!canPasswordLogin || passwordLoading || accountState!=='active' || tenantMissing"
>
<svg x-show="passwordLoading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.75 7.25a6.5 6.5 0 1 0 0 9.5"/></svg>
<span x-text="passwordLoading ? '登录中…' : '登录'"></span>
</button>
</form>
</template>
<template x-if="activeTab==='sms'">
<form class="mt-5 space-y-4" @submit.prevent="submitSmsLogin" x-cloak>
<div class="space-y-1">
<label for="phone-sms" class="block text-sm font-medium text-neutral-700">手机号<span class="text-danger-600">*</span></label>
<input
id="phone-sms"
type="text"
inputmode="numeric"
maxlength="11"
x-model="phoneSms"
@input="sanitizePhone('sms')"
placeholder="请输入您的手机号"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="smsLoading || accountState!=='active' || tenantMissing"
>
<p x-show="smsPhoneError" x-text="smsPhoneError" class="text-xs text-danger-600"></p>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-neutral-700">滑块验证<span class="text-danger-600">*</span></label>
<button type="button" @click="refreshCaptcha('sms')" class="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700" aria-label="刷新滑块验证">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"/></svg>
刷新
</button>
</div>
<p class="mt-1 text-xs"
:class="captchaState==='pass' ? 'text-success-600' : (captchaState==='fail' ? 'text-danger-600' : 'text-neutral-500')"
x-text="captchaState==='pass' ? '验证通过' : (captchaState==='fail' ? '验证码有误,请重新输入' : '拖动滑块完成拼图')"></p>
<div :class="smsCaptchaState==='fail' ? 'captcha-shake' : ''" class="rounded-lg border border-neutral-200 p-3">
<div class="relative h-24 rounded-md overflow-hidden bg-gradient-to-r from-neutral-100 to-neutral-200">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(15,118,110,0.18),transparent_45%),radial-gradient(circle_at_80%_70%,rgba(37,99,235,0.15),transparent_45%)]"></div>
<div class="absolute top-7 h-10 w-9 rounded bg-neutral-300/90 border border-neutral-400/70" :style="`left:${smsCaptchaTarget}%`"></div>
<div class="absolute top-7 h-10 w-9 rounded border border-primary-600/70 bg-primary-100/90 transition-all" :style="`left: calc(${smsSliderValue}% - 18px)`"></div>
</div>
<div class="mt-2 rounded-md p-2 border" :class="smsCaptchaState==='pass' ? 'captcha-success border-success-600/30' : 'captcha-track border-neutral-200'">
<input
type="range"
min="0"
max="100"
step="1"
x-model="smsSliderValue"
@change="verifyCaptcha('sms')"
@input="smsCaptchaState='idle'"
:disabled="smsCaptchaState==='pass' || smsLoading || accountState!=='active' || tenantMissing"
class="w-full accent-primary-600"
>
</div>
<p class="mt-1 text-xs"
:class="smsCaptchaState==='pass' ? 'text-success-600' : (smsCaptchaState==='fail' ? 'text-danger-600' : 'text-neutral-500')"
x-text="smsCaptchaState==='pass' ? '验证通过' : (smsCaptchaState==='fail' ? '验证失败,请重试' : '拖动滑块完成拼图')"></p>
</div>
</div>
</div>
<template x-if="loginError">
<div class="rounded-md border border-danger-600/30 bg-danger-50 px-3 py-2 text-xs text-danger-600" x-text="loginError"></div>
</template>
<div class="space-y-1">
<label for="sms-code" class="block text-sm font-medium text-neutral-700">短信验证码<span class="text-danger-600">*</span></label>
<div class="grid grid-cols-[1fr_auto] gap-2">
<input
id="sms-code"
type="text"
inputmode="numeric"
maxlength="6"
x-model="smsCode"
@input="sanitizeSmsCode"
placeholder="请输入6位验证码"
class="block w-full px-3 py-2 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="smsLoading || accountState!=='active' || tenantMissing"
>
<button
type="button"
@click="sendSmsCode"
class="px-3 h-[38px] rounded-md border border-primary-200 text-primary-700 bg-primary-50 hover:bg-primary-100 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!canSendSms || accountState!=='active' || tenantMissing"
x-text="otpCountdown>0 ? `重新获取(${otpCountdown}s)` : (otpSending ? '发送中…' : '获取验证码')"
></button>
</div>
<p x-show="smsCodeError" x-text="smsCodeError" class="text-xs text-danger-600"></p>
</div>
<button type="submit"
class="inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!canSubmit || loginLoading || accountState==='locked' || accountState==='disabled' || tenantMissing">
<svg x-show="loginLoading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.75 7.25a6.5 6.5 0 1 0 0 9.5"/></svg>
<span x-text="loginLoading ? '登录中…' : '登录'"></span>
</button>
</form>
<template x-if="smsError">
<div class="rounded-md border border-danger-600/30 bg-danger-50 px-3 py-2 text-xs text-danger-600" x-text="smsError"></div>
</template>
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!canSmsLogin || smsLoading || accountState!=='active' || tenantMissing"
>
<svg x-show="smsLoading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.75 7.25a6.5 6.5 0 1 0 0 9.5"/></svg>
<span x-text="smsLoading ? '登录中…' : '登录'"></span>
</button>
</form>
</template>
<div class="mt-3 flex items-center justify-between text-xs">
<a href="javascript:void(0)" class="text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">忘记用户名</a>
<a href="javascript:void(0)" class="text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">忘记密码</a>
<a href="./登录_重置密码_UI.html?mode=recover" class="text-primary-600 hover:text-primary-700 hover:underline underline-offset-2">忘记密码</a>
<span class="text-neutral-400">账号锁定后请联系管理员</span>
</div>
<div class="mt-4 border-t border-neutral-200 pt-4 space-y-2">
<button type="button" disabled class="w-full px-3 py-2 rounded-md border border-neutral-300 bg-neutral-100 text-neutral-400 cursor-not-allowed text-sm">手机验证码登录(即将开放)</button>
<button type="button" disabled class="w-full px-3 py-2 rounded-md border border-neutral-300 bg-neutral-100 text-neutral-400 cursor-not-allowed text-sm">微信扫码登录(即将开放)</button>
</div>
<details class="mt-4 rounded-md border border-neutral-200 bg-neutral-50 p-2">
<summary class="cursor-pointer text-xs text-neutral-500">原型状态切换(仅评审演示)</summary>
<div class="mt-2 grid grid-cols-2 gap-2 text-xs">
<button @click="simulateLock" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟账号锁定</button>
<button @click="simulateDisabled" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟账号停用</button>
<button @click="sessionExpiredNotice=true" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">模拟会话过期</button>
<button @click="resetAllState" class="px-2 py-1.5 rounded border border-neutral-300 bg-white hover:bg-neutral-100">重置状态</button>
</div>
</details>
</div>
</section>
</main>
@@ -186,7 +336,7 @@
<svg class="w-6 h-6 text-warning-600" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9.303 3.376c.866 1.5-.217 3.374-1.948 3.374H4.646c-1.73 0-2.813-1.874-1.948-3.374l7.354-12.748c.866-1.5 3.03-1.5 3.896 0l7.355 12.748Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5h.008v.008H12v-.008Z"/></svg>
</div>
<h3 class="text-base font-semibold text-neutral-800">切换公司</h3>
<p class="text-sm text-neutral-500">切换公司将清除当前租户识别信息,并返回识别页。是否继续?</p>
<p class="text-sm text-neutral-500">切换公司将退出当前账号并清除租户识别缓存,是否继续?</p>
</div>
<div class="flex items-center justify-center gap-2 px-5 py-3 border-t border-neutral-200 bg-neutral-50">
<button @click="openSwitchModal=false" class="px-4 py-1.5 text-sm border border-neutral-300 rounded-md bg-white hover:bg-neutral-50">取消</button>
@@ -197,115 +347,329 @@
</div>
<script>
function story2LoginPage() {
function dualLoginPage() {
return {
tenantId: '',
tenantCode: '',
tenantName: '',
tenantMissing: false,
sessionExpiredNotice: false,
loginSuccessNotice: false,
openSwitchModal: false,
username: '',
activeTab: 'password',
accountState: 'active', // active | locked | disabled
phonePassword: '',
password: '',
passwordVisible: false,
captchaTarget: 46,
sliderValue: 0,
captchaState: 'idle',
loginLoading: false,
loginError: '',
accountState: 'active',
failedCount: 0,
sessionExpiredNotice: false,
openSwitchModal: false,
passwordFieldError: '',
passwordInputError: '',
passwordError: '',
passwordLoading: false,
passwordFailCount: 0,
phoneSms: '',
smsCode: '',
smsPhoneError: '',
smsCodeError: '',
smsError: '',
smsLoading: false,
otpSending: false,
otpCountdown: 0,
otpTimer: null,
otpSent: false,
otpFailCount: 0,
mockOtpCode: '123456',
passwordCaptchaTarget: 46,
passwordSliderValue: 0,
passwordCaptchaState: 'idle',
smsCaptchaTarget: 52,
smsSliderValue: 0,
smsCaptchaState: 'idle',
init() {
const params = new URLSearchParams(window.location.search)
const tenantId = params.get('tenantId') || localStorage.getItem('tenant_id') || ''
const tenantName = params.get('tenantName') || localStorage.getItem('tenant_name') || ''
this.tenantId = tenantId
this.tenantName = tenantName
this.tenantMissing = !tenantId || !tenantName
this.tenantCode = params.get('tenantCode') || localStorage.getItem('tenant_code') || ''
this.tenantName = params.get('tenantName') || localStorage.getItem('tenant_name') || ''
this.tenantMissing = !this.tenantCode || !this.tenantName
this.sessionExpiredNotice = params.get('reason') === 'session_expired'
this.refreshCaptcha()
this.loginSuccessNotice = params.get('reason') === 'password_reset_success'
this.refreshCaptcha('password')
this.refreshCaptcha('sms')
},
refreshCaptcha() {
this.captchaTarget = Math.floor(Math.random() * 60) + 20
this.sliderValue = 0
this.captchaState = 'idle'
switchTab(tab) {
if (this.activeTab === tab) return
this.activeTab = tab
this.clearTabStates()
},
verifyCaptcha() {
const diff = Math.abs(this.sliderValue - this.captchaTarget)
if (diff <= 3) {
this.captchaState = 'pass'
this.loginError = ''
clearTabStates() {
this.passwordFieldError = ''
this.passwordInputError = ''
this.passwordError = ''
this.smsPhoneError = ''
this.smsCodeError = ''
this.smsError = ''
this.phonePassword = ''
this.password = ''
this.phoneSms = ''
this.smsCode = ''
this.otpSent = false
this.otpFailCount = 0
this.stopOtpCountdown()
this.refreshCaptcha('password')
this.refreshCaptcha('sms')
},
sanitizePhone(scene) {
if (scene === 'password') {
this.phonePassword = this.phonePassword.replace(/\D/g, '').slice(0, 11)
this.passwordFieldError = ''
} else {
this.captchaState = 'fail'
setTimeout(() => this.refreshCaptcha(), 700)
this.phoneSms = this.phoneSms.replace(/\D/g, '').slice(0, 11)
this.smsPhoneError = ''
}
},
get canSubmit() {
return this.username.trim() && this.password && this.captchaState === 'pass'
sanitizeSmsCode() {
this.smsCode = this.smsCode.replace(/\D/g, '').slice(0, 6)
this.smsCodeError = ''
},
submitLogin() {
this.loginError = ''
refreshCaptcha(scene) {
if (scene === 'password') {
this.passwordCaptchaTarget = Math.floor(Math.random() * 60) + 20
this.passwordSliderValue = 0
this.passwordCaptchaState = 'idle'
} else {
this.smsCaptchaTarget = Math.floor(Math.random() * 60) + 20
this.smsSliderValue = 0
this.smsCaptchaState = 'idle'
}
},
verifyCaptcha(scene) {
if (scene === 'password') {
const diff = Math.abs(this.passwordSliderValue - this.passwordCaptchaTarget)
if (diff <= 3) {
this.passwordCaptchaState = 'pass'
this.passwordError = ''
} else {
this.passwordCaptchaState = 'fail'
setTimeout(() => this.refreshCaptcha('password'), 700)
}
return
}
const diff = Math.abs(this.smsSliderValue - this.smsCaptchaTarget)
if (diff <= 3) {
this.smsCaptchaState = 'pass'
this.smsError = ''
} else {
this.smsCaptchaState = 'fail'
setTimeout(() => this.refreshCaptcha('sms'), 700)
}
},
get canPasswordLogin() {
return this.phonePassword.length === 11 && !!this.password && this.passwordCaptchaState === 'pass'
},
get canSendSms() {
return this.phoneSms.length === 11 && this.smsCaptchaState === 'pass' && this.otpCountdown === 0 && !this.otpSending
},
get canSmsLogin() {
return this.phoneSms.length === 11 && this.smsCode.length === 6
},
submitPasswordLogin() {
this.passwordError = ''
this.passwordFieldError = ''
this.passwordInputError = ''
if (this.accountState === 'locked') {
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
this.passwordError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
return
}
if (this.accountState === 'disabled') {
this.loginError = '账号已停用,请联系您的管理员'
this.passwordError = '账号已停用,请联系您的管理员'
return
}
if (!this.username.trim()) {
this.loginError = '请输入用户名'
if (this.phonePassword.length === 0) {
this.passwordFieldError = '请输入手机号'
return
}
if (this.phonePassword.length < 11) {
this.passwordFieldError = '请输入完整的 11 位手机号'
return
}
if (!this.password) {
this.loginError = '请输入密码'
this.passwordInputError = '请输入密码'
return
}
if (this.captchaState !== 'pass') {
this.loginError = '请输入验证'
if (this.passwordCaptchaState !== 'pass') {
this.passwordError = '请完成滑块验证'
return
}
this.loginLoading = true
this.passwordLoading = true
setTimeout(() => {
this.loginLoading = false
this.passwordLoading = false
const credentialPass = this.username === 'agent_001' && this.password === 'Fonrey@2025'
const normalPass = this.phonePassword === '13800138000' && this.password === 'Fonrey@2025'
const initialPasswordPass = this.phonePassword === '13800138001' && this.password === 'Fonrey@2025'
if (credentialPass) {
this.loginError = ''
this.failedCount = 0
this.password = ''
this.refreshCaptcha()
const displayName = '王顺'
window.location.href = `./房源列表_UI.html?from=login&login=success&name=${encodeURIComponent(displayName)}`
if (normalPass) {
window.location.href = './房源列表_UI.html?from=login&login=success&name=' + encodeURIComponent('王顺')
return
}
this.failedCount += 1
this.loginError = '用户名或密码错误,请重新输入'
this.password = ''
this.refreshCaptcha()
if (initialPasswordPass) {
window.location.href = './登录_重置密码_UI.html?mode=initial&phone=' + this.phonePassword
return
}
if (this.failedCount >= 5) {
this.passwordFailCount += 1
this.passwordError = '手机号或密码错误,请重新输入'
this.password = ''
this.refreshCaptcha('password')
if (this.passwordFailCount >= 5) {
this.accountState = 'locked'
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
this.passwordError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
}
}, 900)
},
sendSmsCode() {
this.smsError = ''
this.smsPhoneError = ''
if (this.smsCaptchaState !== 'pass') {
this.smsError = '请先完成滑块验证'
return
}
if (this.phoneSms.length === 0 || this.phoneSms.length < 11) {
this.smsPhoneError = '请输入完整的 11 位手机号'
return
}
this.otpSending = true
setTimeout(() => {
this.otpSending = false
this.otpSent = true
this.smsError = '验证码已发送请注意查收演示码123456'
this.startOtpCountdown()
}, 600)
},
submitSmsLogin() {
this.smsError = ''
this.smsPhoneError = ''
this.smsCodeError = ''
if (this.accountState === 'locked') {
this.smsError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
return
}
if (this.accountState === 'disabled') {
this.smsError = '账号已停用,请联系您的管理员'
return
}
if (this.phoneSms.length < 11) {
this.smsPhoneError = '请输入完整的 11 位手机号'
return
}
if (this.smsCode.length < 6) {
this.smsCodeError = '请输入 6 位验证码'
return
}
if (!this.otpSent) {
this.smsError = '请先获取验证码'
return
}
this.smsLoading = true
setTimeout(() => {
this.smsLoading = false
if (this.smsCode !== this.mockOtpCode) {
this.otpFailCount += 1
if (this.otpFailCount >= 5) {
this.smsError = '验证码已失效,请重新获取'
this.smsCode = ''
this.otpSent = false
this.otpFailCount = 0
this.stopOtpCountdown()
} else {
this.smsError = '验证码有误,请重新输入'
}
return
}
const initialUser = this.phoneSms === '13800138001'
if (initialUser) {
window.location.href = './登录_重置密码_UI.html?mode=initial&phone=' + this.phoneSms
return
}
window.location.href = './房源列表_UI.html?from=login&login=success&name=' + encodeURIComponent('王顺')
}, 800)
},
startOtpCountdown() {
this.stopOtpCountdown()
this.otpCountdown = 60
this.otpTimer = setInterval(() => {
this.otpCountdown -= 1
if (this.otpCountdown <= 0) {
this.stopOtpCountdown()
}
}, 1000)
},
stopOtpCountdown() {
if (this.otpTimer) {
clearInterval(this.otpTimer)
this.otpTimer = null
}
this.otpCountdown = 0
},
simulateLock() {
this.accountState = 'locked'
this.passwordError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
this.smsError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
},
simulateDisabled() {
this.accountState = 'disabled'
this.passwordError = '账号已停用,请联系您的管理员'
this.smsError = '账号已停用,请联系您的管理员'
},
resetAllState() {
this.accountState = 'active'
this.passwordFailCount = 0
this.passwordLoading = false
this.smsLoading = false
this.passwordVisible = false
this.sessionExpiredNotice = false
this.clearTabStates()
},
confirmSwitchCompany() {
this.openSwitchModal = false
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant_code')
localStorage.removeItem('tenant_name')
window.location.href = './登录_UI.html?from=switch-company'
}

View File

@@ -0,0 +1,247 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1280">
<title>Fonrey 登录管理 · 设置新密码</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
50: '#F0FDFA', 100: '#CCFBF1', 200: '#99F6E4',
500: '#14B8A6', 600: '#0F766E', 700: '#115E59', 800: '#134E4A'
},
neutral: {
50: '#F8FAFC', 100: '#F1F5F9', 200: '#E2E8F0', 300: '#CBD5E1',
400: '#94A3B8', 500: '#64748B', 600: '#475569', 700: '#334155',
800: '#1E293B', 900: '#0F172A'
},
success: { 50: '#F0FDF4', 600: '#16A34A' },
warning: { 50: '#FFFBEB', 600: '#D97706' },
danger: { 50: '#FEF2F2', 600: '#DC2626' }
}
}
}
}
</script>
<style>
[x-cloak] { display: none !important; }
</style>
</head>
<body class="min-h-screen bg-neutral-50 font-sans text-sm text-neutral-700 antialiased" x-data="passwordResetPage()" x-init="init()">
<div class="fixed inset-0 -z-10">
<div class="absolute inset-y-0 left-0 w-[56%] bg-gradient-to-br from-primary-800 via-primary-700 to-primary-600"></div>
<div class="absolute -top-16 -left-24 w-96 h-96 rounded-full bg-white/10 blur-2xl"></div>
<div class="absolute bottom-0 left-1/3 w-80 h-80 rounded-full bg-primary-200/15 blur-2xl"></div>
</div>
<main class="mx-auto max-w-[1440px] min-h-screen grid grid-cols-12">
<section class="col-span-7 px-12 py-12 text-white flex flex-col justify-between">
<div>
<div class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-white/10 border border-white/20">
<div class="w-7 h-7 rounded-md bg-primary-500/90 flex items-center justify-center text-white font-semibold">F</div>
<span class="text-base font-semibold">Fonrey 房睿</span>
</div>
<h1 class="mt-8 text-4xl font-semibold leading-tight" x-text="pageTitle"></h1>
<p class="mt-4 text-primary-100 text-base leading-7 max-w-xl" x-text="pageDesc"></p>
</div>
<div class="grid grid-cols-2 gap-4 max-w-2xl">
<div class="rounded-lg border border-white/20 bg-white/10 p-4">
<div class="text-xs text-primary-100">密码规则</div>
<div class="mt-1 text-xl font-semibold">至少 8 位,含字母+数字</div>
</div>
<div class="rounded-lg border border-white/20 bg-white/10 p-4">
<div class="text-xs text-primary-100">当前模式</div>
<div class="mt-1 text-xl font-semibold" x-text="mode === 'initial' ? '首次登录强制修改' : '找回密码重置'"></div>
</div>
</div>
</section>
<section class="col-span-5 px-10 py-10 flex items-center justify-center">
<div class="w-full max-w-md rounded-xl bg-white border border-neutral-200 shadow-lg p-6">
<h2 class="text-xl font-semibold text-neutral-800" x-text="pageTitle"></h2>
<p class="mt-2 text-sm text-neutral-500" x-text="pageDesc"></p>
<form class="mt-5 space-y-4" @submit.prevent="submitReset">
<div class="space-y-1">
<label for="new-password" class="block text-sm font-medium text-neutral-700">新密码<span class="text-danger-600">*</span></label>
<div class="relative">
<input
id="new-password"
:type="newPasswordVisible ? 'text' : 'password'"
x-model="newPassword"
@input="validatePassword"
placeholder="请输入新密码"
class="block w-full px-3 py-2 pr-10 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="submitting"
>
<button type="button" @click="newPasswordVisible=!newPasswordVisible" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700" aria-label="显示或隐藏新密码">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.644C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.009 9.963 7.178.07.207.07.431 0 .638C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.009-9.964-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
</button>
</div>
<p x-show="newPasswordError" x-text="newPasswordError" class="text-xs text-danger-600"></p>
</div>
<div class="space-y-1">
<label for="confirm-password" class="block text-sm font-medium text-neutral-700">确认新密码<span class="text-danger-600">*</span></label>
<div class="relative">
<input
id="confirm-password"
:type="confirmPasswordVisible ? 'text' : 'password'"
x-model="confirmPassword"
@input="validatePassword"
placeholder="请再次输入新密码"
class="block w-full px-3 py-2 pr-10 text-sm rounded-md border border-neutral-300 placeholder:text-neutral-400 focus:outline-none focus:border-primary-600 focus:ring-2 focus:ring-primary-600/20"
:disabled="submitting"
>
<button type="button" @click="confirmPasswordVisible=!confirmPasswordVisible" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700" aria-label="显示或隐藏确认密码">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.644C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.009 9.963 7.178.07.207.07.431 0 .638C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.009-9.964-7.178Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
</button>
</div>
<p x-show="confirmPasswordError" x-text="confirmPasswordError" class="text-xs text-danger-600"></p>
</div>
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 space-y-2">
<p class="text-xs font-medium text-neutral-700">密码强度校验</p>
<div class="text-xs flex items-center gap-2" :class="rules.minLength ? 'text-success-600' : 'text-neutral-500'">
<span x-text="rules.minLength ? '✓' : '✗'"></span>
<span>长度至少 8 位</span>
</div>
<div class="text-xs flex items-center gap-2" :class="rules.hasLetter ? 'text-success-600' : 'text-neutral-500'">
<span x-text="rules.hasLetter ? '✓' : '✗'"></span>
<span>包含字母</span>
</div>
<div class="text-xs flex items-center gap-2" :class="rules.hasNumber ? 'text-success-600' : 'text-neutral-500'">
<span x-text="rules.hasNumber ? '✓' : '✗'"></span>
<span>包含数字</span>
</div>
</div>
<template x-if="globalError">
<div class="rounded-md border border-danger-600/30 bg-danger-50 px-3 py-2 text-xs text-danger-600" x-text="globalError"></div>
</template>
<template x-if="successMessage">
<div class="rounded-md border border-success-600/30 bg-success-50 px-3 py-2 text-xs text-success-600" x-text="successMessage"></div>
</template>
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="submitting"
x-text="submitting ? '提交中…' : submitText"
></button>
</form>
<div class="mt-4 text-xs text-neutral-500" x-show="mode === 'recover'">
<a href="./登录_账号密码_UI.html" class="text-primary-600 hover:underline">返回登录页</a>
</div>
</div>
</section>
</main>
<script>
function passwordResetPage() {
return {
mode: 'recover', // initial | recover
pageTitle: '',
pageDesc: '',
submitText: '',
newPassword: '',
confirmPassword: '',
newPasswordVisible: false,
confirmPasswordVisible: false,
newPasswordError: '',
confirmPasswordError: '',
globalError: '',
successMessage: '',
submitting: false,
rules: {
minLength: false,
hasLetter: false,
hasNumber: false
},
init() {
const params = new URLSearchParams(window.location.search)
this.mode = params.get('mode') === 'initial' ? 'initial' : 'recover'
if (this.mode === 'initial') {
this.pageTitle = '欢迎使用 Fonrey请先设置您的登录密码'
this.pageDesc = '您当前使用的是初始密码,为保障账号安全,请立即设置新密码后开始使用'
this.submitText = '确认并进入系统'
} else {
this.pageTitle = '重置您的登录密码'
this.pageDesc = '请输入您的新密码,设置完成后请使用新密码重新登录'
this.submitText = '确认重置密码'
}
},
validatePassword() {
this.newPasswordError = ''
this.confirmPasswordError = ''
this.globalError = ''
this.rules.minLength = this.newPassword.length >= 8
this.rules.hasLetter = /[A-Za-z]/.test(this.newPassword)
this.rules.hasNumber = /\d/.test(this.newPassword)
if (this.confirmPassword && this.newPassword !== this.confirmPassword) {
this.confirmPasswordError = '两次输入密码不一致'
}
},
submitReset() {
this.successMessage = ''
this.globalError = ''
this.validatePassword()
if (!this.newPassword) {
this.newPasswordError = '请输入新密码'
return
}
if (!this.rules.minLength || !this.rules.hasLetter || !this.rules.hasNumber) {
this.globalError = '密码强度不足,请满足全部规则后再提交'
return
}
if (!this.confirmPassword) {
this.confirmPasswordError = '请再次输入新密码'
return
}
if (this.newPassword !== this.confirmPassword) {
this.confirmPasswordError = '两次输入密码不一致'
return
}
this.submitting = true
setTimeout(() => {
this.submitting = false
if (this.mode === 'initial') {
this.successMessage = '密码设置成功,正在进入系统…'
setTimeout(() => {
window.location.href = './房源列表_UI.html?from=reset&mode=initial&status=success'
}, 700)
return
}
this.successMessage = '密码已重置,请使用新密码登录'
setTimeout(() => {
window.location.href = './登录_账号密码_UI.html?reason=password_reset_success'
}, 900)
}, 800)
}
}
}
</script>
</body>
</html>

View File

@@ -1,239 +1,219 @@
# 登录管理 UI 设计文档
# 登录管理 UI 设计文档PRD v2.0 对齐)
> **版本**v1.0 · **日期**2026-04-27
> **依赖规范**`UI_SYSTEM/UI_SYSTEM.md v1.2`、`UI_SYSTEM/组件规范设计.md v1.0`
> **PRD 来源**`PRD/登录管理/用户登录管理模块PRD.md`Story 1、Story 2 + 会话相关要求)
> **版本**v2.0 · **日期**2026-04-30
> **PRD 来源**`PRD/登录管理/用户登录管理模块PRD.md`v2.0
> **数据模型来源**`DATA_MODEL/DATA_MODEL_LOGIN.md`
> **技术约束来源**`TECH_STACK/登录管理技术方案.md`
> **技术约束来源**`TECH_STACK/登录管理技术方案.md`、`AGENTS.md`
---
## 1. 模块概述
## 1. 模块目标与覆盖范围
### 1.1 设计目标P0
本次 UI 调整目标:将登录管理相关设计与 PRD v2.0 全面对齐,重点修正以下差异:
本设计文档覆盖 `TASK.md` 登录管理 P0 范围
- **US-ACCOUNT-001**:账号密码登录(含验证码、错误提示、锁定态)
- **US-ACCOUNT-002**多租户识别Tenant ID 识别与切换公司
- **US-ACCOUNT-003**Token/会话超时相关前端状态(过期提示、重新登录入口
### 1.2 页面职责
| 页面 | URL 建议 | 优先级 | 对应 US |
|---|---|---|---|
| Tenant 识别页 | `/account/tenant/verify/` | P0 🔴 | US-ACCOUNT-002 |
| 登录页 | `/account/login/` | P0 🔴 | US-ACCOUNT-001 / US-ACCOUNT-003 |
| 切换公司确认弹窗 | 登录页内 Modal | P0 🔴 | US-ACCOUNT-002 |
| 会话过期提示态 | 登录页内 Alert/Toast | P0 🔴 | US-ACCOUNT-003 |
> 注:`忘记用户名/忘记密码` 链接在登录页展示;其完整流程页面在后续登录模块增强迭代中展开。
1. 登录页从“单一账号密码登录”升级为**双登录方式 Tab**
- 手机号 + 密码登录(默认)
- 手机号 + 短信验证码登录
2. 删除“忘记用户名”入口(该流程已废弃
3. 补齐“重置密码/设置新密码”页面(公共组件,支持两种入口文案
4. 保留“微信扫码登录(即将开放)”禁用态入口
---
## 2. 视觉与组件基线(对齐 UI System
## 2. 页面清单P0
### 2.1 色彩与层级
- 页面背景:`bg-neutral-50`
- 登录主按钮:`bg-primary-600 hover:bg-primary-700 active:bg-primary-800`
- 错误提示:`text-danger-600`
- 成功提示:`text-success-600`
- 卡片容器:`bg-white border border-neutral-200 rounded-xl shadow-lg`
### 2.2 组件复用清单
| 场景 | 组件规范来源 | 使用说明 |
|---|---|---|
| 主/次按钮 | `UI_SYSTEM.md §3.1` | 登录=Primary刷新验证码/切换公司=Secondary/Link |
| 输入框/密码框 | `UI_SYSTEM.md §3.2` | 统一 Label 在上、错误提示在下、密码可见切换 |
| 确认弹窗 | `UI_SYSTEM.md §3.6` | 切换公司二次确认使用 Confirm Modal |
| Toast 提示 | `UI_SYSTEM.md §3.8` | 网络异常、登录成功/失败统一 Toast 反馈 |
| 登录页布局模板 | `UI_SYSTEM.md §5.7` | 独立布局,无 Sidebar品牌区 + 表单区 |
### 2.3 主题策略说明
依据 `UI_SYSTEM.md §9.1`**v1 仅 Light 主题**。
本页面不提供用户可见主题切换按钮,但保留 `data-theme="light"` 扩展点,为后续主题系统接入预留。
---
## 3. 页面设计规范
## 3.1 Tenant 识别页P0 🔴)
### 3.1.1 页面结构
```
┌────────────────────────────────────────────────────────────┐
│ 左侧品牌区Logo + Slogan + 租户价值说明) │
│ 右侧识别卡片 │
│ 标题:欢迎使用 Fonrey 房睿 │
│ 描述:请输入您公司的专属识别码 │
│ [公司识别码输入框] │
│ [确认按钮] │
│ 错误提示区(固定高度,防布局抖动) │
│ 帮助文案:不知道识别码?请联系管理员 │
└────────────────────────────────────────────────────────────┘
```
### 3.1.2 字段与校验
| 字段 | 类型 | 必填 | 规则 |
|---|---|---|---|
| Tenant ID | 文本输入(仅数字) | 是 | 固定 12 位;自动 trim非数字过滤 |
### 3.1.3 交互状态
| 状态 | 触发 | 视觉反馈 |
|---|---|---|
| Idle | 首次进入 | 按钮可点击(输入满足 12 位) |
| Loading | 点击“确认”后 | 按钮 Loading + 禁用;输入框禁用 |
| Success | 验证通过 | 展示租户名,自动跳转登录页 |
| Invalid | Tenant 无效 | 输入框下方红色文案:识别码无效… |
| Network Error | 请求失败/超时 | 错误提示 + “重试”按钮 |
### 3.1.4 API 对齐
- `POST /api/auth/tenant/verify/`PRD
- 请求体:`{ tenant_id }`
- 成功返回:`tenant_name / tenant_logo_url / login_url`
- 失败返回:`TENANT_NOT_FOUND`
---
## 3.2 登录页P0 🔴)
### 3.2.1 布局结构
```
┌────────────────────────────────────────────────────────────┐
│ 左侧品牌区(租户 Logo / 公司名 / 产品卖点) │
│ 右侧登录卡片max-w-md
│ [用户名] │
│ [密码 + 显示/隐藏] │
│ [滑块拼图验证区域 + 刷新] │
│ [登录按钮] │
│ [忘记用户名] [忘记密码] │
│ [手机验证码登录(即将开放,禁用)] │
│ [微信扫码登录(即将开放,禁用)] │
│ [切换公司]Link
└────────────────────────────────────────────────────────────┘
```
### 3.2.2 字段规范
| 字段 | 组件 | 必填 | 校验 | 数据模型映射 |
| 页面 | 文件 | URL 建议 | 对应 Story/US | 状态 |
|---|---|---|---|---|
| 用户名 | Input | 是 | 1~50 字符;允许字母/数字/下划线(兼容管理员) | `user_accounts.username` |
| 密码 | Password Input | 是 | 非空;提交后后端校验 | `user_accounts.password`(哈希) |
| 验证码通过票据 | 滑块拼图区域 | 是 | 位置偏差 ±5px + 轨迹特征校验 | Redis `captcha_pass:*` |
| Tenant 识别页 | `UI_DESIGN/登录_UI.html` | `/account/tenant/verify/` | Story 1 / US-ACCOUNT-002 | ✅ |
| 登录页(双 Tab | `UI_DESIGN/登录_账号密码_UI.html` | `/account/login/` | Story 2、Story 5、Story 6(禁用态) | ✅ |
| 设置新密码页(公共组件) | `UI_DESIGN/登录_重置密码_UI.html` | `/account/password/reset/` | Story 3 步骤三 + §5.3.4 | ✅(新增) |
### 3.2.3 主要交互规则
> 注:找回密码 Step1/Step2手机号+验证码校验)流程在本轮以登录页入口与重置页组件方式完成设计闭环;后续可独立补全完整 Stepper 页面原型。
1. 用户名/密码/验证码三者满足后,“登录”按钮可点击。
2. 点击登录后按钮进入 `loading`,避免重复提交。
3. 登录失败(账号或密码错误):
- 统一提示 `用户名或密码错误,请重新输入`
- 自动刷新验证码
- 清空密码,保留用户名
4. 验证码失败:提示 `验证码有误,请重新验证`,不计入密码错误次数。
5. 连续密码错误 ≥ 5 次:
- 展示 `账号已被临时锁定请30分钟后重试`
- 登录按钮禁用
6. 账号停用:提示 `账号已停用,请联系管理员`
7. Session 过期跳转后,顶部显示提示条:`登录已过期,请重新登录`
8. 登录成功后,前端跳转到首页路由(本静态原型当前映射为 `./房源列表_UI.html?from=login&login=success`,后续可替换为正式 `/home/`)。
---
### 3.2.4 登录页状态矩阵
## 3. 设计基线UI System 对齐)
| 状态 | 触发条件 | UI 表现 |
### 3.1 色彩与组件 Token
- 背景:`neutral-50`
- 主按钮:`primary-600 / 700 / 800`
- 错误提示:`danger-600`
- 成功提示:`success-600`
- 警告提示:`warning-600`
- 卡片:`bg-white + border-neutral-200 + rounded-xl + shadow-lg`
### 3.2 交互组件
- 输入框Label 在上,错误提示在下
- 密码框:支持显示/隐藏
- 滑块拼图:支持刷新、成功态、失败抖动态
- 登录方式 Tab默认“密码登录”切换后清空两侧输入并重置滑块
- CTA 状态disabled / loading / success 跳转
### 3.3 主题策略
- 本模块静态原型仅提供 Light 模式展示(`data-theme="light"`
- 不提供主题切换控件
---
## 4. 页面规范
## 4.1 Tenant 识别页(`登录_UI.html`
### 页面结构
- 左侧品牌区Logo、产品价值描述
- 右侧Tenant 识别卡片
- 标题:欢迎使用 Fonrey 房睿
- 副标题:请输入您公司的专属识别码以继续
- 字段公司识别码12位纯数字
- 按钮:确认
- 错误区:固定高度,避免布局抖动
- 帮助文案:联系 Tenant Admin 获取识别码
### 校验与状态
- 输入仅保留数字,超长截断为 12 位
- 少于 12 位提交:提示“识别码须为 12 位数字”
- 成功:缓存 tenant 信息并跳转登录页(双 Tab
- 失败提示“识别码无效请联系您的Tenant Admin租户管理员获取正确的识别码”
- 网络异常:提示“网络连接失败,请检查网络后重试”
### 接口映射
- `POST /api/auth/tenant/verify/`
- Request`{ tenant_code }`
---
## 4.2 登录页(`登录_账号密码_UI.html`
### 顶部信息
- 显示当前租户名:`正在登录:{tenant_name}`
- 提供“切换公司”入口 + 二次确认弹窗
- 可显示 Session 过期提示条
### 登录方式 Tab核心
| Tab | 默认 | 字段组成 |
|---|---|---|
| Default | 初始打开 | 空表单 + 新验证码 |
| Captcha Passed | 验证通过 | 验证区绿色对勾 + 文案 |
| Submitting | 点击登录后 | 按钮 spinner表单禁用 |
| Invalid Credential | 401 | 错误 Alert + 密码清空 |
| Locked | 423/锁定态 | 锁定警示条 + 按钮 disabled |
| Disabled | 账号停用 | 错误提示 + 禁止提交 |
| Session Expired | 过期重定向 | 顶部 warning 条 |
| 密码登录 | ✅ | 手机号 + 密码 + 滑块验证 + 登录 |
| 验证码登录 | - | 手机号 + 滑块验证 + 短信验证码 + 登录 |
### Tab 切换规则
- 切换后:
- 清空当前 Tab 输入
- 清空错误提示
- 重置滑块状态
- 清空短信验证码倒计时状态
### 密码登录Story 2
- 手机号11 位数字,自动过滤非数字
- 密码:必填,显示/隐藏切换
- 滑块:必须先通过
- 登录按钮:三项满足后可点击
- 失败提示:统一“手机号或密码错误,请重新输入”
- 锁定提示:“账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁”
- 停用提示:“账号已停用,请联系您的管理员”
### 验证码登录Story 5
- 手机号11 位数字
- 获取验证码按钮:
- 需先通过滑块后才可点击
- 点击后进入 60s 冷却
- 验证码输入6 位数字
- 登录按钮:手机号 + 验证码均满足后可点击
- 验证失败提示:
- “验证码有误,请重新输入”
- 过期:“验证码已过期,请重新获取”
### 其他入口
- 忘记密码(保留)
- 微信扫码登录(即将开放,禁用态)
- 删除“忘记用户名”入口
### 接口映射
- `POST /api/auth/login/`
- `POST /api/auth/login/phone/`
- `POST /api/auth/logout/`
- `POST /api/auth/recover/password/request/`
- `POST /api/auth/recover/password/verify/`
- `POST /api/auth/recover/password/reset/`
---
## 3.3 切换公司确认弹窗P0 🔴
## 4.3 设置新密码页(`登录_重置密码_UI.html`
### 3.3.1 触发入口
本页面是公共组件页面,支持两种业务上下文:
- 登录卡片底部 Link`切换公司`
| 模式 | 标题 | 提示文案 | 按钮文案 | 提交后行为 |
|---|---|---|---|---|
| `initial`(首次登录强制修改) | 欢迎使用 Fonrey请先设置您的登录密码 | 您当前使用的是初始密码,为保障账号安全,请立即设置新密码后开始使用 | 确认并进入系统 | 保持 Session进入首页 |
| `recover`(找回密码步骤三) | 重置您的登录密码 | 请输入您的新密码,设置完成后请使用新密码重新登录 | 确认重置密码 | 使所有 Session 失效,跳转登录页并提示 |
### 3.3.2 弹窗内容
### 字段与校验
- 标题:`切换公司`
- 文案:`切换公司将清除当前租户识别信息,并返回识别页。是否继续?`
- 按钮:`取消`Secondary/ `继续切换`Danger
- 新密码
- 确认新密码
- 实时强度校验(逐条):
- 长度 ≥ 8
- 包含字母
- 包含数字
- 一致性校验:两次输入必须一致
### 3.3.3 行为
### 强制约束initial 模式)
- 确认后:清除本地 tenant 缓存并跳转 `/account/tenant/verify/`
- 取消后:关闭弹窗,不改变当前状态
- 不显示“跳过”
- 不允许返回业务页面
- 页面仅保留密码设置流程
---
## 3.4 会话过期提示P0 🔴)
## 5. 状态矩阵
- 场景:用户访问业务页时 Session 失效,被重定向回登录页
- 位置:登录卡片顶部 Alertwarning
- 文案:`登录已过期,请重新登录`
- 可关闭:是(仅隐藏提示,不恢复会话)
---
## 4. 与数据模型/技术方案映射
## 4.1 关键字段映射
| UI 关注点 | 数据模型字段/实体 | 说明 |
| 页面 | 状态 | 说明 |
|---|---|---|
| 账号状态 | `user_accounts.status` | `active/disabled/locked` 驱动登录态文案 |
| 锁定截止时间 | `user_accounts.locked_until` | 锁定倒计时文案来源 |
| 初始密码标记 | `user_accounts.is_initial_password` | 登录成功后是否强制跳转改密页 |
| 登录失败计数 | Redis `login_fail:{tenant}:{username}` | 达阈值触发锁定 |
| 登录审计 | `login_attempts` | 失败原因不在前端细分展示 |
## 4.2 API 映射(前端使用)
| 目标 | 接口 |
|---|---|
| 租户识别 | `/api/auth/tenant/verify/` |
| 获取验证码 | `/api/account/captcha/generate/` |
| 校验验证码 | `/api/account/captcha/verify/` |
| 登录提交 | `/api/account/login/` |
| 登出 | `/api/account/logout/` |
| Tenant 识别页 | idle/loading/success/error/network_error | 完整覆盖 Story 1 |
| 登录页-密码 Tab | default/captcha_pass/submitting/credential_error/locked/disabled/session_expired | 完整覆盖 Story 2 |
| 登录页-验证码 Tab | default/captcha_required/otp_sending/otp_countdown/otp_error/otp_expired/submitting | 完整覆盖 Story 5 |
| 设置新密码页 | default/strength_checking/mismatch_error/submitting/success | 覆盖 Story 3 Step3 + §5.3.4 |
---
## 5. 可访问性与易用性
## 6. 可访问性与实现约束
1. 所有输入框均有可见 Label使用仅 placeholder 方案。
2. 错误信息与字段通过 `aria-describedby` 关联
3. 图标按钮(显示密码刷新验证码)必须有 `aria-label`
4. `Tab` 顺序Tenant ID/用户名 → 密码 → 验证区 → 登录按钮 → 辅助链接。
5. Enter 键:当表单合法时触发提交。
1. 输入框均有 Label不仅依赖 placeholder
2. 错误文案使用 `aria-describedby` 关联字段
3. 图标按钮(显示密码/刷新验证码)提供 `aria-label`
4. 支持键盘 Tab 顺序与 Enter 提交
5. 静态原型以原生 JS + Alpine 为主,确保 `file://` 可直接评审
---
## 6. 交付物与实现顺序
## 7. 本轮交付与验收清单
1. 本文档:`UI_DESIGN/登录管理/登录_UI.md`(当前)
2. 静态原型:`UI_DESIGN/登录_UI.html`(基于本文档)
3. 评审后迭代:先改 HTML再回写本 UI 文档
### 交付文件
---
- `UI_DESIGN/登录管理/登录_UI.md`(本文档)
- `UI_DESIGN/登录_UI.html`Tenant 识别)
- `UI_DESIGN/登录_账号密码_UI.html`(双 Tab 登录)
- `UI_DESIGN/登录_重置密码_UI.html`(新增:公共设置新密码页)
## 7. 验收检查清单UI 维度)
### 验收项
- [ ] Tenant ID 12 位数字校验与错误提示完整
- [ ] 登录页三要素(用户名/密码/验证码)联动提交规则完整
- [ ] 锁定态、停用态、会话过期态均有明确视觉反馈
- [ ] 切换公司有二次确认弹窗
- [ ] 所有颜色/按钮/输入框样式遵循 UI_SYSTEM Token 与组件规范
- [ ] 静态页可用于你进行第一轮视觉与交互评审
- [ ] 登录页存在“密码登录 / 验证码登录”双 Tab 且可切换
- [ ] Tab 切换后输入与滑块状态已重置
- [ ] 密码登录按手机号校验11位数字
- [ ] 验证码登录需先滑块通过才能“获取验证码”
- [ ] “忘记用户名”入口已移除,“忘记密码”入口保留
- [ ] 新增“设置新密码”页面并覆盖 initial/recover 两种上下文
- [ ] 微信扫码登录为禁用“即将开放”态
- [ ] 关键交互无控制台报错