Files
geo/apps/desktop-client/src/renderer/views/LoginView.vue
T
root 01b289ba0b fix(desktop/login): pass identifier alongside email and add phone to preview user
Aligns the desktop client with the server's phone-or-email login API so
sign-in works after the identifier-based auth switch.
2026-04-29 15:59:35 +08:00

625 lines
12 KiB
Vue
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.
<script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { useDesktopSession } from "../composables/useDesktopSession";
const { apiBaseURL, error, pending, setApiBaseURL, login } = useDesktopSession();
const menuOpen = ref(false);
const showServerDialog = ref(false);
const rememberPassword = ref(!!localStorage.getItem("geo_rankly_saved_password"));
const form = reactive({
apiBaseURL: apiBaseURL.value,
email: localStorage.getItem("geo_rankly_saved_email") || "",
password: localStorage.getItem("geo_rankly_saved_password") || "",
});
const menuRef = ref<HTMLElement | null>(null);
function toggleMenu() {
menuOpen.value = !menuOpen.value;
}
function onRememberChange(event: Event) {
rememberPassword.value = (event.target as HTMLInputElement).checked;
}
function handleOutsideClick(event: MouseEvent) {
if (!menuOpen.value) return;
const root = menuRef.value;
if (root && !root.contains(event.target as Node)) {
menuOpen.value = false;
}
}
function openServerSettings() {
menuOpen.value = false;
form.apiBaseURL = apiBaseURL.value;
showServerDialog.value = true;
}
function closeServerSettings() {
showServerDialog.value = false;
}
function saveServerSettings() {
setApiBaseURL(form.apiBaseURL);
showServerDialog.value = false;
}
async function submitLogin() {
if (rememberPassword.value) {
localStorage.setItem("geo_rankly_saved_email", form.email);
localStorage.setItem("geo_rankly_saved_password", form.password);
} else {
localStorage.removeItem("geo_rankly_saved_email");
localStorage.removeItem("geo_rankly_saved_password");
}
await login({
identifier: form.email,
email: form.email,
password: form.password,
});
}
onMounted(() => {
document.addEventListener("mousedown", handleOutsideClick);
});
onBeforeUnmount(() => {
document.removeEventListener("mousedown", handleOutsideClick);
});
</script>
<template>
<section class="login-shell">
<div class="bg-layer">
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="orb orb-3"></div>
</div>
<div class="top-bar" ref="menuRef">
<button
type="button"
class="menu-trigger"
:class="{ active: menuOpen }"
aria-label="菜单"
@click="toggleMenu"
>
<span></span>
<span></span>
<span></span>
</button>
<div v-if="menuOpen" class="menu-dropdown">
<button type="button" class="menu-item" @click="openServerSettings">
服务器设置
</button>
</div>
</div>
<div class="auth-wrapper">
<header class="brand-header">
<div class="brand-logo">
<span class="brand-core"></span>
</div>
<h1 class="brand-name">GEO Rankly</h1>
</header>
<form class="login-form" @submit.prevent="submitLogin">
<div class="field">
<input
v-model.trim="form.email"
type="email"
autocomplete="username"
placeholder="邮箱 / 账号"
required
/>
</div>
<div class="field">
<input
v-model="form.password"
type="password"
autocomplete="current-password"
placeholder="密码"
required
/>
</div>
<div class="options-row">
<label class="check-label">
<input
type="checkbox"
:checked="rememberPassword"
@change="onRememberChange"
/>
<span>记住密码</span>
</label>
</div>
<button type="submit" class="submit-button" :disabled="pending">
{{ pending ? "登录中..." : "登 录" }}
</button>
<p v-if="error" class="error-text">{{ error }}</p>
</form>
</div>
<Transition name="fade">
<div
v-if="showServerDialog"
class="dialog-mask"
@click.self="closeServerSettings"
>
<div class="dialog" role="dialog" aria-modal="true">
<header class="dialog-header">
<h3>服务器设置</h3>
<button
type="button"
class="dialog-close"
aria-label="关闭"
@click="closeServerSettings"
>
×
</button>
</header>
<div class="dialog-body">
<label class="dialog-field">
<span>网关 API 地址</span>
<input
v-model.trim="form.apiBaseURL"
type="text"
placeholder="http://localhost:8080"
spellcheck="false"
/>
</label>
<p class="dialog-hint">修改后对本机下次登录生效</p>
</div>
<footer class="dialog-footer">
<button type="button" class="btn-ghost" @click="closeServerSettings">
取消
</button>
<button type="button" class="btn-primary" @click="saveServerSettings">
保存
</button>
</footer>
</div>
</div>
</Transition>
</section>
</template>
<style scoped>
.login-shell {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
background: linear-gradient(180deg, #eaf2ff 0%, #e4ecff 40%, #e8efff 100%);
padding: 0 24px 28px;
-webkit-app-region: drag;
}
.login-shell input,
.login-shell button,
.login-shell a,
.login-shell label,
.login-shell .options-row,
.login-shell .options-row * {
-webkit-app-region: no-drag;
}
.bg-layer {
position: absolute;
inset: 0;
overflow: hidden;
z-index: 0;
pointer-events: none;
}
.orb {
position: absolute;
border-radius: 50%;
filter: blur(60px);
opacity: 0.55;
}
.orb-1 {
width: 520px;
height: 520px;
background: radial-gradient(circle, rgba(120, 170, 255, 0.45), rgba(120, 170, 255, 0));
top: -180px;
left: -160px;
}
.orb-2 {
width: 420px;
height: 420px;
background: radial-gradient(circle, rgba(180, 210, 255, 0.5), rgba(180, 210, 255, 0));
bottom: -140px;
right: -120px;
}
.orb-3 {
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(200, 220, 255, 0.45), rgba(200, 220, 255, 0));
top: 55%;
left: 60%;
}
.top-bar {
position: absolute;
top: 10px;
right: 10px;
z-index: 10;
-webkit-app-region: no-drag;
}
.menu-trigger {
width: 28px;
height: 28px;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s ease;
}
.menu-trigger span {
width: 14px;
height: 2px;
border-radius: 2px;
background: #3a4a66;
}
.menu-trigger:hover,
.menu-trigger.active {
background: rgba(255, 255, 255, 0.85);
}
.menu-dropdown {
position: absolute;
top: 44px;
right: 0;
min-width: 140px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 12px 32px rgba(31, 58, 112, 0.18);
padding: 8px;
animation: dropdown-in 0.15s ease;
}
@keyframes dropdown-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.menu-item {
width: 100%;
text-align: left;
padding: 8px 12px;
background: transparent;
border: none;
border-radius: 8px;
font-size: 14px;
color: #1f2937;
cursor: pointer;
transition: background 0.15s ease;
}
.menu-item:hover {
background: #f2f6ff;
}
.auth-wrapper {
position: relative;
z-index: 1;
width: 100%;
max-width: 280px;
display: flex;
flex-direction: column;
align-items: stretch;
animation: slideUpFade 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes slideUpFade {
0% {
opacity: 0;
transform: translateY(12px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.brand-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 18px;
}
.brand-logo {
width: 72px;
height: 72px;
border-radius: 50%;
display: grid;
place-items: center;
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
box-shadow: 0 10px 22px rgba(22, 119, 255, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
.brand-core {
width: 26px;
height: 26px;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.25);
position: relative;
}
.brand-core::after {
content: "";
position: absolute;
inset: 7px;
border-radius: 3px;
background: linear-gradient(135deg, #1677ff, #69b1ff);
}
.brand-name {
margin: 10px 0 0;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
color: #1f2937;
}
.login-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.field input {
width: 100%;
height: 40px;
padding: 0 14px;
border: 1px solid transparent;
border-radius: 10px;
background: #ffffff;
font-size: 14px;
color: #1f2937;
box-shadow: 0 4px 12px rgba(31, 58, 112, 0.08);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
outline: none;
box-sizing: border-box;
}
.field input::placeholder {
color: #9aa4b8;
}
.field input:focus {
border-color: #1677ff;
box-shadow: 0 4px 14px rgba(31, 58, 112, 0.08), 0 0 0 3px rgba(22, 119, 255, 0.12);
}
.options-row {
display: flex;
justify-content: flex-end;
padding: 2px 4px;
}
.check-label {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
font-size: 13px;
color: #6b7280;
}
.check-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #1677ff;
cursor: pointer;
margin: 0;
}
.submit-button {
margin-top: 6px;
height: 40px;
background: #1677ff;
color: #ffffff;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.4em;
padding-left: 0.4em;
cursor: pointer;
box-shadow: 0 6px 16px rgba(22, 119, 255, 0.25);
transition: background 0.2s ease, transform 0.1s ease;
}
.submit-button:hover:not(:disabled) {
background: #4096ff;
}
.submit-button:active:not(:disabled) {
transform: translateY(1px);
}
.submit-button:disabled {
opacity: 0.65;
cursor: not-allowed;
box-shadow: none;
}
.error-text {
margin: 4px 0 0;
padding: 10px 12px;
background: rgba(255, 77, 79, 0.08);
border: 1px solid rgba(255, 77, 79, 0.3);
border-radius: 10px;
color: #d9363e;
font-size: 13px;
text-align: center;
}
.dialog-mask {
position: fixed;
inset: 0;
background: rgba(17, 24, 39, 0.35);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
width: min(360px, calc(100vw - 32px));
background: #ffffff;
border-radius: 16px;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.25);
overflow: hidden;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #eef1f6;
}
.dialog-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.dialog-close {
background: transparent;
border: none;
font-size: 20px;
line-height: 1;
color: #9aa4b8;
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
}
.dialog-close:hover {
background: #f2f6ff;
color: #1677ff;
}
.dialog-body {
padding: 18px 20px 8px;
}
.dialog-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.dialog-field span {
font-size: 13px;
color: #4b5563;
}
.dialog-field input {
height: 38px;
padding: 0 12px;
border: 1px solid #d6dde8;
border-radius: 8px;
background: #ffffff;
font-size: 14px;
color: #1f2937;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.dialog-field input:focus {
border-color: #1677ff;
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
}
.dialog-hint {
margin: 10px 0 0;
font-size: 12px;
color: #9aa4b8;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 20px 18px;
}
.btn-ghost,
.btn-primary {
height: 34px;
padding: 0 16px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.2s ease, border-color 0.2s ease;
}
.btn-ghost {
background: transparent;
border-color: #d6dde8;
color: #4b5563;
}
.btn-ghost:hover {
background: #f6f8fc;
}
.btn-primary {
background: #1677ff;
color: #ffffff;
}
.btn-primary:hover {
background: #4096ff;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>