Files
nexus/Project/fonrey/UI_DESIGN/登录_账号密码_UI.html
2026-04-29 15:43:49 +08:00

317 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1280">
<title>Fonrey 登录管理 · 账号密码登录Story 2</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' },
info: { 50: '#EFF6FF', 600: '#2563EB' }
}
}
}
}
</script>
<style>
[x-cloak] { display: none !important; }
.captcha-track { background: linear-gradient(90deg, #E2E8F0 0%, #F1F5F9 100%); }
.captcha-success { background: linear-gradient(90deg, #F0FDF4 0%, #16A34A 100%); }
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
}
.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()">
<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">经纪人账号登录</h1>
<p class="mt-4 text-primary-100 text-base leading-7 max-w-xl">
已完成 Tenant 识别,请使用经纪人账号和密码登录。
本页对应 PRD《用户登录管理模块》User Story 2。
</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 truncate" x-text="tenantName || '未识别租户'"></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">账号密码 + 滑块验证</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">
<template x-if="tenantMissing">
<div class="rounded-md border border-warning-600/30 bg-warning-50 px-3 py-2 text-xs text-warning-600 mb-4 flex items-center justify-between gap-2">
<span>未检测到有效 Tenant 信息,请先完成识别</span>
<a href="./登录_UI.html" class="text-primary-600 hover:underline">返回识别页</a>
</div>
</template>
<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="请输入用户名" 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>
</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' || 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>
<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="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>
<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>
<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' || 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>
<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>
</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 story2LoginPage() {
return {
tenantId: '',
tenantName: '',
tenantMissing: false,
username: '',
password: '',
passwordVisible: false,
captchaTarget: 46,
sliderValue: 0,
captchaState: 'idle',
loginLoading: false,
loginError: '',
accountState: 'active',
failedCount: 0,
sessionExpiredNotice: false,
openSwitchModal: false,
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.sessionExpiredNotice = params.get('reason') === 'session_expired'
this.refreshCaptcha()
},
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.username.trim()) {
this.loginError = '请输入用户名'
return
}
if (!this.password) {
this.loginError = '请输入密码'
return
}
if (this.captchaState !== 'pass') {
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.password = ''
this.refreshCaptcha()
const displayName = '王顺'
window.location.href = `./房源列表_UI.html?from=login&login=success&name=${encodeURIComponent(displayName)}`
return
}
this.failedCount += 1
this.loginError = '用户名或密码错误,请重新输入'
this.password = ''
this.refreshCaptcha()
if (this.failedCount >= 5) {
this.accountState = 'locked'
this.loginError = '账号已被临时锁定,请 30 分钟后重试,或联系管理员解锁'
}
}, 900)
},
confirmSwitchCompany() {
this.openSwitchModal = false
localStorage.removeItem('tenant_id')
localStorage.removeItem('tenant_name')
window.location.href = './登录_UI.html?from=switch-company'
}
}
}
</script>
</body>
</html>