Files
geo/apps/ops-web/src/views/LoginView.vue
T
root 162abdc97c
Backend CI / Backend (push) Has been cancelled
Frontend CI / Frontend (push) Failing after 1m39s
chore(frontend): introduce prettier + eslint and prune unused code
- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports)
- Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier
- Add root scripts: format, format:check, lint, lint:fix
- Reformat 257 files across admin-web, ops-web, desktop-client, packages
- Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters
- Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex
- Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:39:09 +08:00

447 lines
9.9 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.
<template>
<div class="page">
<div
class="left"
:style="{
background: `linear-gradient(135deg, ${primaryColor}e6, ${primaryColor}, ${primaryColor}cc)`,
}"
>
<div class="brand">
<div class="brand-icon"><StarOutlined :style="{ fontSize: '16px' }" /></div>
<span>省心推运营控制台</span>
</div>
<div class="characters-area">
<AnimatedCharacters
:is-typing="isTyping"
:has-secret="!!formState.password"
:secret-visible="showPassword"
/>
</div>
<div class="footer-links">
<a href="#">隐私政策</a>
<a href="#">服务条款</a>
<a href="#">联系我们</a>
</div>
<div class="deco-grid" />
<div class="deco-circle deco-circle-1" />
<div class="deco-circle deco-circle-2" />
</div>
<div class="right">
<div class="form-wrapper">
<div class="mobile-brand">
<div class="brand-icon"><StarOutlined :style="{ fontSize: '16px' }" /></div>
<span>省心推运营控制台</span>
</div>
<div class="header">
<h1>欢迎回来</h1>
<p>运营管理系统 · 仅限授权人员登录</p>
</div>
<form class="form" @submit.prevent="handleSubmit">
<div class="field">
<label for="login-username">管理员账号</label>
<input
id="login-username"
v-model="formState.username"
type="text"
placeholder="请输入管理员账号"
autocomplete="off"
required
@focus="isTyping = true"
@blur="isTyping = false"
/>
</div>
<div class="field">
<label for="login-password">密码</label>
<div class="password-wrap">
<input
id="login-password"
v-model="formState.password"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
required
/>
<button type="button" class="eye-btn" @click="showPassword = !showPassword">
<EyeInvisibleOutlined v-if="showPassword" />
<EyeOutlined v-else />
</button>
</div>
</div>
<div class="options">
<label class="remember">
<input v-model="remember" type="checkbox" />
记住登录状态
</label>
</div>
<transition name="fade">
<div v-if="errorMessage" class="error-message">
<svg class="error-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
{{ errorMessage }}
</div>
</transition>
<button
type="submit"
class="btn-primary"
:disabled="submitting"
:style="{ background: primaryColor }"
>
{{ submitting ? '登录中...' : '登录系统' }}
</button>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { EyeInvisibleOutlined, EyeOutlined, StarOutlined } from '@ant-design/icons-vue'
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AnimatedCharacters from '@/components/login-animation/AnimatedCharacters.vue'
import { OpsApiError } from '@/lib/http'
import { readLoginPreference } from '@/lib/storage'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const router = useRouter()
const route = useRoute()
const primaryColor = ref('#4f46e5')
const submitting = ref(false)
const showPassword = ref(false)
const isTyping = ref(false)
const remember = ref(true)
const errorMessage = ref<string | null>(null)
const formState = reactive({
username: '',
password: '',
})
onMounted(() => {
const pref = readLoginPreference()
remember.value = pref.remember
if (pref.lastUsername) {
formState.username = pref.lastUsername
}
})
function formatLoginError(error: unknown): string {
if (error instanceof OpsApiError) {
const messages: Record<string, string> = {
invalid_credentials: '账号或密码错误',
account_disabled: '账号已停用',
login_rate_limited: '登录请求过于频繁',
login_locked: '登录已被临时保护',
login_in_progress: '登录请求正在处理中',
login_guard_unavailable: '登录保护暂时不可用',
}
const message = messages[error.message] ?? error.message
return error.detail ? `${message}${error.detail}` : message
}
if (error instanceof Error) {
return error.message
}
return '登录失败,请稍后重试'
}
async function handleSubmit(): Promise<void> {
if (submitting.value) {
return
}
errorMessage.value = null
if (!formState.username.trim() || !formState.password) {
errorMessage.value = '请输入账号和密码'
return
}
submitting.value = true
try {
await authStore.login(formState.username.trim(), formState.password, remember.value)
const redirect =
typeof route.query.redirect === 'string' ? route.query.redirect : '/admin-users'
await router.replace(redirect)
} catch (error) {
errorMessage.value = formatLoginError(error)
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.page {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 100vh;
width: 100%;
flex: 1;
}
.left {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 48px;
color: white;
overflow: hidden;
}
.brand {
position: relative;
z-index: 20;
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
}
.brand-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
}
.characters-area {
position: relative;
z-index: 20;
display: flex;
align-items: flex-end;
justify-content: center;
height: 500px;
transform: scale(0.95);
}
.footer-links {
position: relative;
z-index: 20;
display: flex;
gap: 32px;
font-size: 14px;
}
.footer-links a {
color: rgba(255, 255, 255, 0.6);
transition: color 0.2s;
}
.footer-links a:hover {
color: white;
}
.deco-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
}
.deco-circle {
position: absolute;
border-radius: 50%;
filter: blur(48px);
}
.deco-circle-1 {
top: 25%;
right: 25%;
width: 256px;
height: 256px;
background: rgba(255, 255, 255, 0.1);
}
.deco-circle-2 {
bottom: 25%;
left: 25%;
width: 384px;
height: 384px;
background: rgba(255, 255, 255, 0.05);
}
.right {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background: #fff;
}
.form-wrapper {
width: 100%;
max-width: 420px;
}
.mobile-brand {
display: none;
}
.header {
text-align: left;
margin-bottom: 40px;
}
.header h1 {
font-size: 30px;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 8px;
color: #18181b;
}
.header p {
color: #71717a;
font-size: 14px;
margin: 0;
}
.form {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 32px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.field label {
font-size: 14px;
font-weight: 500;
color: #18181b;
}
.field input {
height: 48px;
padding: 0 12px;
border-radius: 6px;
border: 1px solid #d4d4d8;
font-size: 14px;
outline: none;
background: #fff;
transition: border-color 0.2s;
color: #18181b;
}
.field input:focus {
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2);
}
.password-wrap {
position: relative;
}
.password-wrap input {
width: 100%;
padding-right: 40px;
box-sizing: border-box;
}
.eye-btn {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #a1a1aa;
transition: color 0.2s;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
cursor: pointer;
}
.eye-btn:hover {
color: #3f3f46;
}
.options {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
}
.remember {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: #18181b;
}
.remember input {
width: 16px;
height: 16px;
accent-color: #4f46e5;
cursor: pointer;
}
.error-message {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background-color: #fef2f2;
border-left: 4px solid #ef4444;
border-radius: 6px;
color: #b91c1c;
font-size: 14px;
font-weight: 500;
margin-top: 8px;
}
.error-icon {
width: 18px;
height: 18px;
}
.fade-enter-active,
.fade-leave-active {
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.btn-primary {
width: 100%;
height: 48px;
font-size: 16px;
font-weight: 500;
border: none;
border-radius: 6px;
color: white;
transition: opacity 0.2s;
cursor: pointer;
margin-top: 12px;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 1023px) {
.page {
grid-template-columns: 1fr;
}
.left {
display: none;
}
.mobile-brand {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
font-size: 18px;
font-weight: 600;
margin-bottom: 48px;
color: #18181b;
}
}
</style>