Files
geo/apps/desktop-client/src/renderer/views/LoginView.vue
T
root aa96143754 style: format web apps with prettier and sort imports
Apply repo-wide Prettier/lint normalization across admin-web,
desktop-client and ops-web: single quotes, no semicolons, trailing
commas, consistent line wrapping, and import ordering. Also drop an
unused brand-logo import in DesktopShell.vue.

No behavior changes — formatting only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:56:18 +08:00

781 lines
16 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 brandLogo from '@brand-logo'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { DEFAULT_API_BASE_URL, useDesktopSession } from '../composables/useDesktopSession'
const { apiBaseURL, error, pending, setApiBaseURL, login } = useDesktopSession()
const menuOpen = ref(false)
const showServerDialog = ref(false)
const showPassword = ref(false)
const legacySavedIdentifier =
localStorage.getItem('geo_rankly_saved_identifier') ??
localStorage.getItem('geo_rankly_saved_email') ??
''
const rememberCredentials = ref(!!legacySavedIdentifier)
const form = reactive({
apiBaseURL: apiBaseURL.value,
identifier: legacySavedIdentifier,
password: '',
})
const menuRef = ref<HTMLElement | null>(null)
function toggleMenu() {
menuOpen.value = !menuOpen.value
}
async function onRememberChange(event: Event) {
rememberCredentials.value = (event.target as HTMLInputElement).checked
if (!rememberCredentials.value) {
await clearSavedCredentials()
}
}
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() {
await login({
identifier: form.identifier,
password: form.password,
})
await persistCredentialsPreference()
try {
await window.desktopBridge?.app?.setWindowMode?.('main', { source: 'login' })
} catch (err) {
console.warn('[login-view] switch to main window failed', {
message: err instanceof Error ? err.message : String(err),
})
}
}
async function hydrateSavedCredentials() {
try {
const saved = await window.desktopBridge?.app?.getLoginCredentials?.()
if (!saved) return
form.identifier = saved.identifier
form.password = saved.password
rememberCredentials.value = true
} catch (err) {
console.warn('[login-view] load saved credentials failed', err)
}
}
async function clearSavedCredentials() {
localStorage.removeItem('geo_rankly_saved_identifier')
localStorage.removeItem('geo_rankly_saved_email')
localStorage.removeItem('geo_rankly_saved_password')
try {
await window.desktopBridge?.app?.clearLoginCredentials?.()
} catch (err) {
console.warn('[login-view] clear saved credentials failed', err)
}
}
async function persistCredentialsPreference() {
if (!rememberCredentials.value) {
await clearSavedCredentials()
return
}
const identifier = form.identifier.trim()
localStorage.setItem('geo_rankly_saved_identifier', identifier)
localStorage.removeItem('geo_rankly_saved_email')
localStorage.removeItem('geo_rankly_saved_password')
try {
await window.desktopBridge?.app?.saveLoginCredentials?.({
identifier,
password: form.password,
})
} catch (err) {
console.warn('[login-view] save credentials failed', err)
}
}
const lockCountdown = ref(0)
let countdownTimer: ReturnType<typeof setInterval> | null = null
function clearCountdown() {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
watch(
error,
(msg) => {
clearCountdown()
lockCountdown.value = 0
if (!msg) return
const match = msg.match(/请\s*(\d+)\s*秒后重试/)
if (!match) return
lockCountdown.value = parseInt(match[1], 10)
countdownTimer = setInterval(() => {
lockCountdown.value--
if (lockCountdown.value <= 0) clearCountdown()
}, 1000)
},
{ flush: 'sync' },
)
const displayError = computed(() => {
if (!error.value) return null
if (error.value.startsWith('登录已被临时保护')) {
if (lockCountdown.value <= 0) return null
return error.value.replace(/\d+(?=\s*秒后重试)/, String(lockCountdown.value))
}
return error.value
})
onMounted(() => {
document.addEventListener('mousedown', handleOutsideClick)
void hydrateSavedCredentials()
})
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleOutsideClick)
clearCountdown()
})
</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 ref="menuRef" class="top-bar">
<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">
<img :src="brandLogo" alt="省心推" class="brand-logo-img" />
</div>
<h1 class="brand-name">省心推</h1>
</header>
<form class="login-form" @submit.prevent="submitLogin">
<div class="field">
<input
v-model.trim="form.identifier"
type="text"
autocomplete="username"
placeholder="邮箱 / 手机号"
required
/>
</div>
<div class="field field--password">
<input
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
placeholder="密码"
required
/>
<button
type="button"
class="eye-toggle"
tabindex="-1"
@click="showPassword = !showPassword"
>
<svg
v-if="showPassword"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"
/>
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
</button>
</div>
<div class="options-row">
<label class="check-label">
<input type="checkbox" :checked="rememberCredentials" @change="onRememberChange" />
<span>记住账号和密码</span>
</label>
</div>
<button type="submit" class="submit-button" :disabled="pending || lockCountdown > 0">
{{ pending ? '登录中...' : '登 录' }}
</button>
<p v-if="displayError" class="error-text">{{ displayError }}</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="DEFAULT_API_BASE_URL"
spellcheck="false"
/>
</label>
</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: 34px;
right: 14px;
z-index: 10;
-webkit-app-region: no-drag;
}
:global(html[data-platform='darwin']) .top-bar {
left: 52px;
right: auto;
}
.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, #6e55ff 0%, #00c6ff 100%);
box-shadow:
0 10px 22px rgba(110, 85, 255, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
.brand-logo-img {
width: 72%;
height: 72%;
object-fit: contain;
}
.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);
}
.field--password {
position: relative;
}
.field--password input {
padding-right: 42px;
}
.eye-toggle {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
color: #9aa4b8;
cursor: pointer;
}
.eye-toggle:hover {
color: #1677ff;
}
.eye-toggle svg {
width: 16px;
height: 16px;
}
.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: #fff0f0;
color: #e53935;
}
.dialog-close:active {
background: #ffe0e0;
color: #c62828;
}
.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>