Files
replypier/frontend/src/views/LoginView.vue
T

1234 lines
38 KiB
Vue
Raw Normal View History

<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import BrandMark from '@/components/common/BrandMark.vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { authService } from '@/services/auth'
import { resolveLandingRoute } from '@/router/landing'
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
import { useForgotPassword } from '@/composables/useForgotPassword'
import InstallPrompt from '@/components/pwa/InstallPrompt.vue'
import { useTheme } from '@/composables/useTheme'
import { navIconSvg } from '@/components/layout/navIcons'
import { apiPath, getApiUrl } from '@/config/api'
import type { AxiosError } from 'axios'
interface ErrorResponse {
detail: string
}
const router = useRouter()
const { loadModule } = useEnterpriseFeatures()
const trackLogin = async (method: string) => {
const mod = await loadModule('/src/modules/enterprise/utils/analytics.ts') as any
mod?.trackLogin?.(method)
}
const { mode: themeMode, toggle: toggleTheme } = useTheme()
const themeTitle = computed(() =>
themeMode.value === 'dark' ? '主题:深色 — 点击切换为浅色'
: themeMode.value === 'light' ? '主题:浅色 — 点击切换为跟随系统'
: '主题:跟随系统 — 点击切换为深色'
)
const email = ref('')
const password = ref('')
const showPassword = ref(false)
const error = ref('')
const isLoading = ref(false)
// Check if enterprise module is available
const { hasEnterpriseModule } = useEnterpriseFeatures()
// Forgot password composable - only initialize if enterprise module is available
const showForgotPasswordModal = ref(false)
const forgotPassword = hasEnterpriseModule ? useForgotPassword() : null
// Destructure with fallbacks for when enterprise module is not available
const isForgotPasswordLoading = forgotPassword?.isLoading ?? ref(false)
const forgotPasswordError = forgotPassword?.error ?? ref('')
const forgotPasswordSuccess = forgotPassword?.success ?? ref('')
const forgotPasswordStep = forgotPassword?.currentStep ?? ref(1)
const forgotPasswordEmail = forgotPassword?.email ?? ref('')
const forgotPasswordOtp = forgotPassword?.otp ?? ref('')
const newPassword = forgotPassword?.newPassword ?? ref('')
const confirmPassword = forgotPassword?.confirmPassword ?? ref('')
const passwordValidation = forgotPassword?.passwordValidation ?? ref({
score: 0,
hasMinLength: false,
hasUpperCase: false,
hasLowerCase: false,
hasNumber: false,
hasSpecialChar: false
})
const requestPasswordReset = forgotPassword?.requestPasswordReset ?? (() => Promise.resolve(false))
const verifyAndResetPassword = forgotPassword?.verifyAndResetPassword ?? (() => Promise.resolve(false))
const resetForgotPasswordForm = forgotPassword?.resetForm ?? (() => {})
const goBackToEmailStep = forgotPassword?.goBackToEmailStep ?? (() => {})
// One ordered candidate list, shared with '/', the catch-all and the 403 page.
// It used to end at '/403', which is not somewhere to land — and the route was
// not even registered, so the user silently ended up on /ai-agents instead.
const getInitialRoute = () => resolveLandingRoute()
const handleLogin = async () => {
try {
isLoading.value = true
error.value = ''
await authService.login(email.value, password.value)
trackLogin('email')
// Check if this is Shopify flow (new managed installation)
const urlParams = new URLSearchParams(window.location.search)
const isShopifyFlow = urlParams.get('shopify_flow') === '1'
const shopId = urlParams.get('shop_id')
const returnTo = urlParams.get('return_to')
if (isShopifyFlow && shopId) {
console.log('🔗 Shopify flow detected, linking organization')
try {
// If return_to is specified, redirect there
if (returnTo) {
console.log('📍 Redirecting to return_to:', returnTo)
window.location.href = returnTo
return
}
} catch (linkError: any) {
console.error('❌ Failed to link organization:', linkError)
error.value = linkError.response?.data?.detail || '关联组织失败'
isLoading.value = false
return
}
}
// Check if this is an OLD embedded Shopify login (opened in popup) - for backward compatibility
const isEmbedded = router.currentRoute.value.query.embedded === 'true'
const legacyShopId = router.currentRoute.value.query.shop_id as string
if (isEmbedded && legacyShopId && window.opener) {
// This is a popup for Shopify embedded app (old flow)
console.log('Login successful in popup, notifying parent window')
// Show success state
isLoading.value = true
error.value = ''
// Notify the parent window of successful login
if (window.opener && !window.opener.closed) {
window.opener.postMessage(
{ type: 'login_success', shop_id: legacyShopId },
window.location.origin
)
// Close the popup after a brief delay to ensure message is sent
setTimeout(() => {
window.close()
}, 300)
} else {
// If opener is gone, just close the window
setTimeout(() => {
window.close()
}, 300)
}
return
}
// Check for Shopify flow (new simplified flow)
if (router.currentRoute.value.query.shopify_flow === '1') {
const shop = router.currentRoute.value.query.shop as string
const shopId = router.currentRoute.value.query.shop_id as string
const host = router.currentRoute.value.query.host as string
const returnTo = router.currentRoute.value.query.return_to as string
// Redirect to auth complete page
const query: any = { shop, shop_id: shopId, host }
if (returnTo) {
query.return_to = returnTo
}
router.push({
path: '/shopify/auth-complete',
query
})
return
}
// Check if there's a Shopify redirect pending in localStorage (legacy flow)
const shopifyRedirectData = localStorage.getItem('shopifyRedirect')
if (shopifyRedirectData) {
try {
const redirectInfo = JSON.parse(shopifyRedirectData)
// Clear the stored redirect
localStorage.removeItem('shopifyRedirect')
// Build the full URL with all query parameters
const queryParams = new URLSearchParams()
for (const [key, value] of Object.entries(redirectInfo)) {
queryParams.append(key, value as string)
}
// Redirect to the backend Shopify auth endpoint
window.location.href = apiPath(`/shopify/auth?${queryParams.toString()}`)
return // Don't do the normal navigation
} catch (e) {
console.error('Failed to parse shopifyRedirect data:', e)
// Clear invalid data
localStorage.removeItem('shopifyRedirect')
}
}
// Check for redirect query parameter (internal frontend route)
const redirectPath = router.currentRoute.value.query.redirect as string
if (redirectPath) {
// If it's an internal route (starts with /), use router.push
if (redirectPath.startsWith('/')) {
router.push(redirectPath)
return
}
// Otherwise treat as API redirect (legacy behavior)
window.location.href = `${getApiUrl()}${redirectPath}`
return
}
// Determine initial route based on permissions
const initialRoute = getInitialRoute()
router.push(initialRoute)
} catch (err) {
console.log(err)
// authService.login rethrows the backend's detail as a plain Error, so
// reading only the axios response showed "Login failed" for everything.
const detail = (err as AxiosError<ErrorResponse>).response?.data?.detail
error.value = (typeof detail === 'string' && detail) || (err instanceof Error && err.message) || '登录失败'
console.error('Login error:', err)
} finally {
isLoading.value = false
}
}
const navigateToSignup = () => {
// Preserve embedded, shop_id, return_to, redirect query params if present
const isEmbedded = router.currentRoute.value.query.embedded
const shopId = router.currentRoute.value.query.shop_id
const returnTo = router.currentRoute.value.query.return_to
const shopifyFlow = router.currentRoute.value.query.shopify_flow
const redirect = router.currentRoute.value.query.redirect
const query: any = {}
if (isEmbedded) query.embedded = isEmbedded
if (shopId) query.shop_id = shopId
if (returnTo) query.return_to = returnTo
if (shopifyFlow) query.shopify_flow = shopifyFlow
if (redirect) query.redirect = redirect
if (Object.keys(query).length > 0) {
console.log('Navigating to signup with params:', query)
router.push({
path: '/signup',
query
})
} else {
router.push('/signup')
}
}
const openForgotPasswordModal = () => {
resetForgotPasswordForm()
showForgotPasswordModal.value = true
}
const closeForgotPasswordModal = () => {
showForgotPasswordModal.value = false
resetForgotPasswordForm()
}
const handleRequestPasswordReset = async () => {
const success = await requestPasswordReset()
// Keep modal open to proceed to step 2
}
const handleVerifyAndResetPassword = async () => {
const success = await verifyAndResetPassword()
if (success) {
// Wait a moment to show success message, then close modal
setTimeout(() => {
closeForgotPasswordModal()
// Optionally pre-fill the email in login form
email.value = forgotPasswordEmail.value
}, 2000)
}
}
</script>
<template>
<div class="login">
<!-- Form column -->
<section class="form-col">
<div class="form-top reveal" style="--i: 0">
<BrandMark :size="34" />
<span class="wordmark" style="font-size: 21px">Chatter<i>Mate</i></span>
<button
class="icon-btn"
@click="toggleTheme"
:title="themeTitle"
:aria-label="themeTitle"
v-html="navIconSvg(themeMode === 'dark' ? 'moon' : themeMode === 'light' ? 'sun' : 'monitor', 18)"
></button>
</div>
<div class="form-wrap">
<div class="eyebrow reveal" style="--i: 1">Sign in · 登录</div>
<h1 class="title reveal" style="--i: 2">欢迎回来</h1>
<p class="lede reveal" style="--i: 3">登录工作台,继续今天的接待。</p>
<form class="login-form reveal" style="--i: 4" @submit.prevent="handleLogin" novalidate>
<div class="field">
<label for="email">邮箱</label>
<div class="input-wrap">
<span class="ic" v-html="navIconSvg('mail', 17)"></span>
<input
id="email"
v-model="email"
class="input"
type="email"
required
placeholder="you@company.com"
autocomplete="email"
/>
</div>
</div>
<div class="field">
<div class="label-row">
<label for="password">密码</label>
<a
v-if="hasEnterpriseModule"
href="#"
@click.prevent="openForgotPasswordModal"
>忘记密码?</a>
</div>
<div class="input-wrap">
<span class="ic" v-html="navIconSvg('lock', 17)"></span>
<input
id="password"
v-model="password"
class="input"
:type="showPassword ? 'text' : 'password'"
required
placeholder="••••••••"
autocomplete="current-password"
/>
<button
type="button"
class="icon-btn sm peek"
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
@click="showPassword = !showPassword"
v-html="navIconSvg('eye', 15)"
></button>
</div>
</div>
<div v-if="error" class="auth-error" role="alert">{{ error }}</div>
<button class="btn btn-primary btn-lg btn-block" type="submit" :disabled="isLoading">
<span v-if="isLoading">
{{ router.currentRoute.value.query.embedded === 'true' ? '连接中…' : '登录中…' }}
</span>
<template v-else>
登录工作台
<span class="icon-arrow" v-html="navIconSvg('arrowRight', 15)"></span>
</template>
</button>
</form>
<p v-if="hasEnterpriseModule" class="form-note reveal" style="--i: 5">
还没有账号?
<a href="#" class="inline-link" @click.prevent="navigateToSignup">注册</a>
</p>
<p v-else class="form-note reveal" style="--i: 5">
还没有账号?请联系组织管理员,从「人工客服」页面邀请你加入团队。
</p>
<div class="install-hint-slot">
<InstallPrompt />
</div>
</div>
<div class="form-foot reveal" style="--i: 6">
<span>© 2026 ChatterMate</span>
<span class="mono">APACHE-2.0 · SELF-HOSTED</span>
</div>
</section>
<!-- Brand stage — always night ink, even in the light theme -->
<section class="stage" data-theme="dark" aria-hidden="true">
<div class="stage-top">
<span><span class="pulse"></span>Open-source AI customer support</span>
<span>v0.1 · 本地部署</span>
</div>
<div class="vertical">妥帖接待每一次对话</div>
<div class="stage-body">
<div>
<h2 class="headline reveal" style="--i: 2">让每一次对话,<br />都被<em>妥善</em>接待。</h2>
<p class="stage-lede reveal" style="--i: 3">
AI 依据你的知识库即时作答;需要时,连同完整上下文交给人工。接待、评价与洞察,尽在一处。
</p>
</div>
<div class="specimen">
<div class="spec">青禾测试套餐的退款期多长,校验码是什么?</div>
<div class="spec">
退款期限为购买后 <strong>17 天</strong>内,客服校验码为 <strong>QH-7319</strong>。
<div class="spec-meta">
<span class="who">AI 客服</span>
<span class="cite">
<span v-html="navIconSvg('book', 12)"></span>依据 · 演示规则
</span>
</div>
</div>
<div class="spec">
<span v-html="navIconSvg('transfer', 12)"></span>已转接人工 · 客服 A 接管
</div>
<div class="spec">
<span class="stars">
<span v-for="n in 5" :key="n" v-html="navIconSvg('star', 15, 'icon')"></span>
</span>
<div>人工客服回复及时,问题已解决。</div>
<div class="spec-meta"><span>访客评价 · 5.0</span></div>
</div>
</div>
</div>
<div class="stage-foot reveal" style="--i: 5">
<div class="fig"><b class="num">24<small>/7</small></b><span>全天候 AI 接待</span></div>
<div class="fig"><b class="num">7<small>家</small></b><span>主流模型自由切换</span></div>
<div class="fig"><b class="num">100<small>%</small></b><span>开源,可二次开发</span></div>
</div>
</section>
<!-- Forgot Password Modal - only show if enterprise module is available -->
<div v-if="hasEnterpriseModule && showForgotPasswordModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2>{{ forgotPasswordStep === 1 ? '重置密码' : '验证并重置' }}</h2>
<button class="close-btn" @click="closeForgotPasswordModal" aria-label="关闭">×</button>
</div>
<div class="modal-body">
<!-- Step 1: Request OTP -->
<div v-if="forgotPasswordStep === 1" class="forgot-password-step">
<p class="step-description">输入你的邮箱,我们会发送验证码以重置密码。</p>
<div class="form-group">
<label for="forgot-email">邮箱地址</label>
<div class="input-wrapper">
<input
id="forgot-email"
v-model="forgotPasswordEmail"
type="email"
required
placeholder="输入邮箱"
:disabled="isForgotPasswordLoading"
/>
</div>
</div>
<div v-if="forgotPasswordError" class="error-message" role="alert">
{{ forgotPasswordError }}
</div>
<div v-if="forgotPasswordSuccess" class="success-message" role="status">
{{ forgotPasswordSuccess }}
</div>
<button
class="modal-submit-btn"
@click="handleRequestPasswordReset"
:disabled="isForgotPasswordLoading || !forgotPasswordEmail"
>
{{ isForgotPasswordLoading ? '发送中…' : '发送验证码' }}
</button>
</div>
<!-- Step 2: Verify OTP and Reset Password -->
<div v-if="forgotPasswordStep === 2" class="forgot-password-step">
<p class="step-description">输入发送到你邮箱的验证码和新密码。</p>
<div class="form-group">
<label for="forgot-otp">验证码</label>
<div class="input-wrapper">
<input
id="forgot-otp"
v-model="forgotPasswordOtp"
type="text"
required
placeholder="输入 6 位验证码"
maxlength="6"
:disabled="isForgotPasswordLoading"
/>
</div>
</div>
<div class="form-group">
<label for="new-password">新密码</label>
<div class="input-wrapper">
<input
id="new-password"
v-model="newPassword"
type="password"
required
placeholder="输入新密码"
:disabled="isForgotPasswordLoading"
/>
</div>
<div class="password-requirements">
<p class="requirements-title">密码需包含:</p>
<ul>
<li :class="{ valid: passwordValidation.hasMinLength }">至少 8 个字符</li>
<li :class="{ valid: passwordValidation.hasUpperCase }">包含大写字母</li>
<li :class="{ valid: passwordValidation.hasLowerCase }">包含小写字母</li>
<li :class="{ valid: passwordValidation.hasNumber }">包含数字</li>
<li :class="{ valid: passwordValidation.hasSpecialChar }">包含特殊字符(!@#$%^&*)</li>
</ul>
</div>
</div>
<div class="form-group">
<label for="confirm-password">确认密码</label>
<div class="input-wrapper">
<input
id="confirm-password"
v-model="confirmPassword"
type="password"
required
placeholder="再次输入新密码"
:disabled="isForgotPasswordLoading"
/>
</div>
</div>
<div v-if="forgotPasswordError" class="error-message" role="alert">
{{ forgotPasswordError }}
</div>
<div v-if="forgotPasswordSuccess" class="success-message" role="status">
{{ forgotPasswordSuccess }}
</div>
<div class="modal-actions">
<button
class="modal-back-btn"
@click="goBackToEmailStep"
:disabled="isForgotPasswordLoading"
>
返回
</button>
<button
class="modal-submit-btn"
@click="handleVerifyAndResetPassword"
:disabled="isForgotPasswordLoading || !forgotPasswordOtp || !newPassword || !confirmPassword"
>
{{ isForgotPasswordLoading ? '重置中…' : '重置密码' }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 墨与瓷 sign-in: a quiet porcelain form beside a night-ink stage that tells
the product story with real conversation fragments. */
.login {
display: grid;
grid-template-columns: minmax(440px, 40%) 1fr;
min-height: 100vh;
min-height: 100dvh;
background: var(--paper);
color: var(--ink);
font-family: var(--font-sans);
}
/* ── Form column ───────────────────────────────────── */
.form-col {
display: flex;
flex-direction: column;
padding: 36px 56px 32px;
position: relative;
}
.form-top {
display: flex;
align-items: center;
gap: 12px;
}
.form-top .icon-btn {
margin-left: auto;
}
.form-wrap {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
max-width: 380px;
margin: 0 auto;
padding: 48px 0;
}
.form-wrap .title {
font-size: 40px;
}
.form-wrap .lede {
margin-top: 10px;
}
.login-form {
margin-top: 40px;
display: flex;
flex-direction: column;
gap: 22px;
}
.label-row {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.label-row a {
font-size: 12.5px;
color: var(--muted);
border-bottom: 1px solid var(--line-2);
padding-bottom: 1px;
transition: color var(--t-fast), border-color var(--t-fast);
}
.label-row a:hover {
color: var(--ink);
border-color: var(--ink);
}
.input-wrap {
position: relative;
}
.input-wrap .input {
width: 100%;
padding-left: 44px;
}
.input-wrap > .ic {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
color: var(--faint);
pointer-events: none;
display: inline-flex;
}
.input-wrap .peek {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
color: var(--muted);
}
.login-form .btn-primary .icon-arrow {
display: inline-flex;
transition: transform var(--t-fast);
}
.login-form .btn-primary:hover .icon-arrow {
transform: translateX(3px);
}
.auth-error {
padding: 10px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--vermilion-line);
background: var(--vermilion-soft);
color: var(--vermilion);
font-size: 13px;
}
.form-note {
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid var(--line);
font-size: 12.5px;
color: var(--muted);
line-height: 1.7;
}
.inline-link {
color: var(--ink);
border-bottom: 1px solid var(--line-2);
}
.inline-link:hover {
border-color: var(--ink);
}
.form-foot {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: var(--muted);
}
.form-foot .mono {
font-size: 11px;
letter-spacing: 0.08em;
}
/* Install hint (mobile only) */
.install-hint-slot {
display: none;
margin-top: var(--space-lg);
padding-bottom: var(--safe-bottom);
}
/* ── Brand stage (always night ink) ────────────────── */
.stage {
position: relative;
overflow: hidden;
background: var(--paper);
color: var(--ink);
display: flex;
flex-direction: column;
padding: 48px 64px 44px;
isolation: isolate;
}
.stage::before {
content: '';
position: absolute;
inset: -20%;
z-index: -1;
background:
radial-gradient(40% 35% at 78% 22%, color-mix(in srgb, var(--vermilion) 22%, transparent), transparent 70%),
radial-gradient(45% 40% at 20% 85%, color-mix(in srgb, var(--celadon) 16%, transparent), transparent 70%),
radial-gradient(30% 30% at 60% 60%, color-mix(in srgb, var(--brass) 10%, transparent), transparent 70%);
filter: blur(10px);
animation: drift 22s ease-in-out infinite alternate;
}
@keyframes drift {
to {
transform: translate3d(-3%, 2%, 0) scale(1.06);
}
}
/* Fine ruled lines, like letter paper */
.stage::after {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background-image: linear-gradient(var(--line) 1px, transparent 1px);
background-size: 100% 44px;
mask-image: linear-gradient(180deg, transparent, #000 30%, #000 70%, transparent);
-webkit-mask-image: linear-gradient(180deg, transparent, #000 30%, #000 70%, transparent);
opacity: 0.5;
}
.stage-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--muted);
}
.stage-top span {
display: inline-flex;
align-items: center;
gap: 8px;
}
.stage-top .pulse {
color: var(--celadon);
}
.vertical {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
writing-mode: vertical-rl;
font-family: var(--font-serif);
font-size: 14px;
letter-spacing: 0.7em;
color: var(--faint);
display: flex;
align-items: center;
gap: 18px;
}
.vertical::before,
.vertical::after {
content: '';
width: 1px;
height: 64px;
background: var(--line-2);
}
.stage-body {
flex: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 344px);
gap: 40px;
align-items: center;
padding-right: 56px;
}
.headline {
font-family: var(--font-serif);
font-weight: 600;
font-size: clamp(28px, 2.7vw, 40px);
line-height: 1.42;
letter-spacing: 0.05em;
text-wrap: balance;
}
.headline em {
font-style: normal;
color: var(--vermilion);
text-emphasis: filled dot var(--vermilion);
text-emphasis-position: under right;
-webkit-text-emphasis: filled dot var(--vermilion);
-webkit-text-emphasis-position: under;
}
.stage-lede {
margin-top: 22px;
max-width: 26em;
color: var(--muted);
font-size: 14.5px;
line-height: 1.95;
}
.specimen {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
}
.spec {
background: color-mix(in srgb, var(--surface) 88%, transparent);
border: 1px solid var(--line-2);
border-radius: 16px;
padding: 14px 16px;
box-shadow: var(--shadow-lg);
backdrop-filter: blur(8px);
font-size: 13.5px;
line-height: 1.75;
opacity: 0;
animation: rise 0.8s var(--ease) forwards, bob 7s ease-in-out infinite;
}
.spec:nth-child(1) {
width: 72%;
animation-delay: 0.3s, 1.1s;
border-top-left-radius: 5px;
}
.spec:nth-child(2) {
width: 90%;
align-self: flex-end;
animation-delay: 0.7s, 1.5s;
border-top-right-radius: 5px;
background: color-mix(in srgb, var(--celadon) 10%, var(--surface));
border-color: var(--celadon-line);
}
.spec:nth-child(3) {
align-self: center;
animation-delay: 1.1s, 1.9s;
padding: 8px 14px;
border-radius: 999px;
font-size: 12.5px;
color: var(--vermilion);
border-color: var(--vermilion-line);
display: inline-flex;
align-items: center;
gap: 8px;
}
.spec:nth-child(4) {
width: 84%;
align-self: flex-end;
animation-delay: 1.5s, 2.3s;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes bob {
50% {
translate: 0 -5px;
}
}
.spec-meta {
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
color: var(--muted);
}
.spec-meta .who {
color: var(--celadon);
font-weight: 500;
}
.spec .cite {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 1px 8px;
border-radius: 999px;
border: 1px dashed var(--celadon-line);
color: var(--celadon);
font-size: 11px;
}
.spec strong {
color: var(--ink);
}
.spec .stars {
margin-bottom: 4px;
}
.spec .stars .icon {
width: 15px;
height: 15px;
}
.stage-foot {
display: grid;
grid-template-columns: repeat(3, auto);
justify-content: start;
border-top: 1px solid var(--line-2);
padding-top: 22px;
margin-right: 48px;
}
.fig {
padding-right: 44px;
margin-right: 44px;
border-right: 1px solid var(--line);
}
.fig:last-child {
border-right: 0;
}
.fig b {
display: block;
font-family: var(--font-num);
font-weight: 350;
font-size: 30px;
line-height: 1.1;
letter-spacing: -0.01em;
}
.fig b small {
font-size: 15px;
color: var(--muted);
margin-left: 3px;
}
.fig span {
font-size: 12.5px;
color: var(--muted);
}
/* ── Forgot-password modal ─────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: grid;
place-items: center;
padding: 24px;
background: var(--scrim);
backdrop-filter: blur(6px);
}
.modal-content {
width: 100%;
max-width: 460px;
max-height: 90vh;
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-card-lg);
box-shadow: var(--shadow-lg);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 22px 24px 0;
}
.modal-header h2 {
font-family: var(--font-serif);
font-size: 20px;
font-weight: 600;
letter-spacing: 0.02em;
}
.close-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: none;
color: var(--muted);
font-size: 20px;
line-height: 1;
cursor: pointer;
}
.close-btn:hover {
background: var(--surface-2);
border-color: var(--line);
color: var(--ink);
}
.modal-body {
padding: 20px 24px 24px;
}
.forgot-password-step {
display: flex;
flex-direction: column;
gap: 16px;
}
.step-description {
font-size: 13px;
color: var(--muted);
line-height: 1.7;
}
.input-wrapper input {
width: 100%;
height: 44px;
padding: 0 14px;
border-radius: var(--radius-input);
border: 1px solid var(--line-2);
background: var(--surface);
color: var(--text);
font: inherit;
font-size: 14px;
outline: none;
transition: border-color var(--t-fast), box-shadow var(--t-fast);
}
.input-wrapper input:focus {
border-color: var(--ink);
box-shadow: var(--ring);
}
.input-wrapper input:disabled {
background: var(--surface-2);
color: var(--muted);
}
.error-message {
padding: 9px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--vermilion-line);
background: var(--vermilion-soft);
color: var(--vermilion);
font-size: 12.5px;
}
.success-message {
padding: 9px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--celadon-line);
background: var(--celadon-soft);
color: var(--celadon);
font-size: 12.5px;
}
.modal-submit-btn {
height: 42px;
padding: 0 18px;
border: 1px solid transparent;
border-radius: var(--radius-btn);
background: var(--primary-bg);
color: var(--primary-fg);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background-color var(--t-fast), opacity var(--t-fast);
}
.modal-submit-btn:hover:not(:disabled) {
background: var(--primary-hover);
}
.modal-submit-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.modal-back-btn {
height: 42px;
padding: 0 16px;
border: 1px solid var(--line-2);
border-radius: var(--radius-btn);
background: var(--surface);
color: var(--text2);
font-size: 14px;
cursor: pointer;
}
.modal-back-btn:hover:not(:disabled) {
border-color: var(--line-3);
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.password-requirements {
margin-top: 10px;
padding: 12px 14px;
border-radius: var(--radius-sm);
background: var(--surface-2);
border: 1px solid var(--line);
}
.requirements-title {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.password-requirements ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 3px;
}
.password-requirements li {
font-size: 12px;
color: var(--muted);
padding-left: 14px;
position: relative;
}
.password-requirements li::before {
content: '·';
position: absolute;
left: 4px;
}
.password-requirements li.valid {
color: var(--celadon);
}
.password-requirements li.valid::before {
content: '✓';
left: 0;
}
/* ── Responsive ────────────────────────────────────── */
@media (max-width: 1180px) {
.stage-body {
grid-template-columns: 1fr;
}
.specimen {
display: none;
}
.stage {
padding: 40px 36px;
}
}
@media (max-width: 900px) {
.login {
grid-template-columns: 1fr;
}
.stage {
display: none;
}
.form-col {
padding: 28px 24px;
}
.install-hint-slot {
display: block;
}
}
@media (prefers-reduced-motion: reduce) {
.spec {
opacity: 1;
animation: none;
}
.stage::before {
animation: none;
}
}
</style>