Files
crawler-plugin/admin-frontend-vue/src/pages/login/LoginPage.vue
T
huangzd1997 b70557a077 feat(认证/通知): 单设备登录互踢 + 站内通知铃铛系统
- 单设备登录:登录成功即 last-login-wins 绑定 users.machine;非超管旧 token
  在下一次受保护请求抛 4011 下线,超管豁免;仅认 token 内签名 deviceId,不用请求头。
  前端两端接入踢下线跳转(?kicked=1 提示),V118 清空历史 machine
- 站内通知:新增 notification 模块(任务失败扫描 / 密钥欠费 / 下游服务探测三类来源),
  前后台铃铛组件 + 轮询;后台列表按主管数据范围(UserDataScopeSupport)过滤;V116 建表

均已于 2026-09-13 部署上线,此次补提交源码(此前仅存在于已部署 JAR/构建产物中)
2026-09-13 23:08:22 +08:00

351 lines
17 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">
/** 数富AI 后台 Vue 登录页:复刻旧登录页「品牌视觉 + 登录卡」蓝白设计;POST /login 建会话后进入后台。 */
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { http } from '@/api/http'
import { joinAdminPath } from '@/config/app'
import { useAdminSessionStore } from '@/stores/admin-session'
const route = useRoute()
const router = useRouter()
const session = useAdminSessionStore()
const username = ref('')
const password = ref('')
const showPassword = ref(false)
const loading = ref(false)
const errorMessage = ref('')
const logoUrl = joinAdminPath('assets', 'logo.jpg')
const inputType = computed(() => (showPassword.value ? 'text' : 'password'))
/** 单设备登录:被新设备顶下线后跳回登录页(整页跳转携带 ?kicked=1) */
const kickedNotice = computed(() => route.query.kicked === '1')
function deviceId(): string {
try {
const key = 'aiimage_console_device_id'
let value = localStorage.getItem(key)
if (!value) {
value = `web-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`
localStorage.setItem(key, value)
}
return value
} catch {
return `web-${Date.now()}`
}
}
function safeRedirect(value: unknown): string {
if (typeof value !== 'string') return '/'
let target = value
try {
target = decodeURIComponent(target)
} catch {
// 忽略非法编码
}
if (!target.startsWith('/') || target.startsWith('//')) return '/'
if (target.startsWith('/admin-vue')) target = target.slice('/admin-vue'.length) || '/'
return target
}
async function submit() {
const user = username.value.trim()
if (!user || !password.value) {
errorMessage.value = '请输入用户名和密码'
return
}
if (loading.value) return
errorMessage.value = ''
loading.value = true
try {
const did = deviceId()
await http.post<unknown>('/login', { username: user, password: password.value, deviceId: did })
session.$reset()
await session.initialize()
const target = safeRedirect(route.query.redirect)
await router.replace(target)
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : '用户名或密码错误'
loading.value = false
}
}
onMounted(() => {
if (session.initialized && session.user) {
router.replace('/')
}
})
</script>
<template>
<div class="auth-page">
<aside class="auth-visual" aria-label="数富AI 产品信息">
<div class="auth-visual-content">
<div class="auth-brand">
<span class="auth-brand-mark" aria-hidden="true"><img :src="logoUrl" alt=""></span>
<span class="auth-brand-copy">
<span class="auth-brand-name">数富AI</span>
<span class="auth-brand-sub">电商运营管理后台</span>
</span>
</div>
<div class="auth-visual-copy">
<span class="auth-kicker">数富AI · 运营工作台</span>
<h2 class="auth-visual-title">让每一次运营动作都有清晰的工作流</h2>
<p class="auth-visual-description">统一管理数据店铺任务与版本让团队在一个可靠的运营工作台中快速协作</p>
<ul class="auth-feature-list" aria-label="工作台能力">
<li class="auth-feature-item">
<span class="auth-feature-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 4 7v5c0 4.5 3.4 7.7 8 9 4.6-1.3 8-4.5 8-9V7l-8-4Z"></path><path d="m8.5 12 2.2 2.2 4.8-5"></path></svg></span>
<span>权限隔离操作边界清晰可控</span>
</li>
<li class="auth-feature-item">
<span class="auth-feature-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 2"></path><circle cx="12" cy="12" r="9"></circle><path d="M3 4v5h5"></path><path d="M3.5 9A9 9 0 0 1 19 5.5"></path></svg></span>
<span>任务状态进度反馈及时透明</span>
</li>
<li class="auth-feature-item">
<span class="auth-feature-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="m7 15 3-4 3 2 4-6"></path><path d="M17 7h3v3"></path></svg></span>
<span>数据工具支撑日常电商运营</span>
</li>
</ul>
</div>
</div>
<div class="auth-visual-footer">
<span>数富AI · 管理控制台</span>
<span class="auth-system-status">服务已就绪</span>
</div>
</aside>
<main class="auth-content">
<section class="login-card" aria-labelledby="loginTitle">
<header class="login-card-header">
<p class="login-card-eyebrow">欢迎回来</p>
<h1 class="login-title" id="loginTitle">登录工作台</h1>
<p class="login-subtitle">使用管理员账号进入数富AI运营后台</p>
</header>
<form novalidate @submit.prevent="submit">
<p v-if="kickedNotice" class="kicked-msg" role="alert">该账号已在其他设备登录本设备已下线如非本人操作请及时修改密码</p>
<p v-if="errorMessage" class="error-msg" role="alert">{{ errorMessage }}</p>
<div class="form-group">
<label for="loginUsername">用户名</label>
<div class="input-shell">
<span class="input-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"></circle><path d="M4 21a8 8 0 0 1 16 0"></path></svg></span>
<input id="loginUsername" v-model="username" type="text" autocomplete="username" placeholder="请输入用户名" autofocus>
</div>
</div>
<div class="form-group">
<label for="loginPassword">密码</label>
<div class="input-shell">
<span class="input-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="10" x="5" y="11" rx="2"></rect><path d="M8 11V7a4 4 0 0 1 8 0v4"></path></svg></span>
<input id="loginPassword" v-model="password" :type="inputType" autocomplete="current-password" placeholder="请输入密码" @keyup.enter="submit">
<button type="button" class="password-toggle" :aria-label="showPassword ? '隐藏密码' : '显示密码'" @click="showPassword = !showPassword">
<svg v-if="!showPassword" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"></path><circle cx="12" cy="12" r="2.5"></circle></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 3 18 18"></path><path d="M10.6 5.1A9.7 9.7 0 0 1 12 5c6 0 9.5 7 9.5 7a17 17 0 0 1-2.3 3"></path><path d="M6.6 6.6C3.7 8.6 2.5 12 2.5 12s3.5 7 9.5 7a9.7 9.7 0 0 0 4.5-1.1"></path></svg>
</button>
</div>
</div>
<button type="submit" class="btn-login" :class="{ 'is-loading': loading }" :disabled="loading" :aria-busy="loading">
<span class="btn-login-label">{{ loading ? '登录中...' : '登录' }}</span>
<span class="btn-login-spinner" aria-hidden="true"></span>
</button>
</form>
<p class="login-security-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3 4 7v5c0 4.5 3.4 7.7 8 9 4.6-1.3 8-4.5 8-9V7l-8-4Z"></path><path d="m8.5 12 2.2 2.2 4.8-5"></path></svg>
<span>请勿在公共设备保存账号凭据登录状态由本地安全会话管理</span>
</p>
<footer class="login-card-footer">数富AI · 电商运营管理工作台</footer>
</section>
</main>
</div>
</template>
<style scoped>
.auth-page {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(420px, 0.9fr) minmax(420px, 1.1fr);
color: #24384d;
background:
radial-gradient(circle at 8% 0%, rgba(178, 205, 229, 0.32), transparent 34rem),
radial-gradient(circle at 96% 100%, rgba(214, 226, 239, 0.28), transparent 28rem),
#f4f7fb;
}
button, input { font: inherit; }
.auth-visual {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 100vh;
padding: 36px clamp(32px, 5vw, 84px) 34px;
overflow: hidden;
border-right: 1px solid #d4e0eb;
background:
linear-gradient(160deg, rgba(232, 240, 248, 0.97), rgba(248, 251, 254, 0.99)),
radial-gradient(circle at 20% 8%, rgba(112, 148, 186, 0.16), transparent 28rem);
}
.auth-visual::before,
.auth-visual::after {
content: "";
position: absolute;
pointer-events: none;
}
.auth-visual::before {
inset: 0;
opacity: 0.3;
background-image: linear-gradient(rgba(79, 120, 165, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(79, 120, 165, 0.1) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, black, transparent 78%);
}
.auth-visual::after {
width: 360px;
height: 360px;
right: -150px;
bottom: -160px;
border: 1px solid rgba(79, 120, 165, 0.22);
border-radius: 50%;
box-shadow: 0 0 0 32px rgba(79, 120, 165, 0.055), 0 0 0 64px rgba(79, 120, 165, 0.035);
}
.auth-visual-content, .auth-visual-footer { position: relative; z-index: 1; }
.auth-brand { display: inline-flex; align-items: center; gap: 12px; width: fit-content; }
.auth-brand-mark {
display: inline-flex; align-items: center; justify-content: center;
width: 46px; height: 46px; border-radius: 13px; overflow: hidden;
background: #fff; box-shadow: 0 10px 24px rgba(79, 120, 165, 0.18);
}
.auth-brand-mark img { display: block; width: 100%; height: 100%; object-fit: cover; }
.auth-brand-copy { display: grid; gap: 1px; }
.auth-brand-name { font-size: 22px; font-weight: 700; letter-spacing: 0.2px; }
.auth-brand-sub { color: #77899b; font-size: 11px; }
.auth-visual-content { max-width: 540px; margin: auto 0; padding: 72px 0 96px; }
.auth-visual-copy { padding-top: 72px; }
.auth-kicker {
display: inline-flex; align-items: center; gap: 8px;
color: #2f5d8b; font-size: 11px; font-weight: 700; letter-spacing: 1.8px;
}
.auth-kicker::before { content: ""; width: 24px; height: 1px; background: #4f78a5; }
.auth-visual-title {
max-width: 560px; margin: 18px 0;
font-size: clamp(30px, 4vw, 52px); font-weight: 700; letter-spacing: -1.8px; line-height: 1.12;
}
.auth-visual-description { max-width: 470px; margin: 0; color: #60748a; font-size: 16px; line-height: 1.75; }
.auth-feature-list { display: grid; gap: 12px; margin: 34px 0 0; padding: 0; list-style: none; }
.auth-feature-item { display: flex; align-items: center; gap: 12px; color: #465d73; }
.auth-feature-icon {
display: inline-flex; align-items: center; justify-content: center;
flex: 0 0 30px; width: 30px; height: 30px;
border: 1px solid #c2d3e3; border-radius: 9px; background: #edf5fb; color: #2f5d8b;
}
.auth-feature-icon svg { width: 16px; height: 16px; }
.auth-visual-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #778b9f; font-size: 12px; }
.auth-system-status { display: inline-flex; align-items: center; gap: 7px; color: #3d7158; }
.auth-system-status::before {
content: ""; width: 7px; height: 7px; border-radius: 50%;
background: #4e806d; box-shadow: 0 0 0 4px rgba(78, 128, 108, 0.13);
}
.auth-content {
display: flex; align-items: center; justify-content: center;
min-width: 0; padding: 40px clamp(24px, 6vw, 96px);
background: rgba(249, 251, 253, 0.5);
}
.login-card {
width: min(100%, 452px);
padding: clamp(28px, 4vw, 48px);
border: 1px solid #d8e3ee;
border-radius: 22px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 26px 70px -38px rgba(39, 67, 94, 0.34);
}
.login-card-header { margin-bottom: 30px; }
.login-card-eyebrow { margin: 0 0 8px; color: #71859a; font-size: 15px; font-weight: 700; letter-spacing: 1.2px; }
.login-title { margin: 0; font-size: 30px; font-weight: 700; letter-spacing: -0.8px; line-height: 1.2; }
.login-subtitle { margin: 10px 0 0; color: #5b6f83; line-height: 1.65; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; margin-bottom: 8px; color: #5b6f83; font-size: 13px; font-weight: 600; }
.input-shell { position: relative; }
.input-icon { position: absolute; top: 50%; left: 14px; display: inline-flex; color: #8298ad; pointer-events: none; transform: translateY(-50%); }
.input-icon svg { width: 18px; height: 18px; }
.form-group input {
width: 100%; min-height: 48px; padding: 12px 52px 12px 44px;
border: 1px solid #cbd9e6; border-radius: 12px; outline: none;
background: #f8fbfd; color: #24384d; font-size: 14px;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover { border-color: #9fb7cd; }
.form-group input:focus { border-color: #5f85ad; background: #fff; box-shadow: 0 0 0 4px rgba(95, 133, 173, 0.16); }
.form-group input::placeholder { color: #8293a5; }
.password-toggle {
position: absolute; top: 50%; right: 4px;
display: inline-flex; align-items: center; justify-content: center;
width: 40px; height: 40px; border: 0; border-radius: 9px;
background: transparent; color: #8298ad; cursor: pointer; transform: translateY(-50%);
transition: background 160ms ease, color 160ms ease;
}
.password-toggle:hover { background: #edf5fb; color: #2f5d8b; }
.password-toggle svg { width: 18px; height: 18px; }
.error-msg {
display: flex; align-items: flex-start; gap: 9px;
margin: -4px 0 18px; padding: 11px 12px;
border: 1px solid #e4c2c5; border-radius: 11px; background: #f8ebeb; color: #91474f;
font-size: 13px; line-height: 1.55;
}
.error-msg::before {
content: "!";
display: inline-flex; align-items: center; justify-content: center;
flex: 0 0 18px; width: 18px; height: 18px;
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
}
/* 单设备登录:被新设备顶下线的提示 */
.kicked-msg {
display: flex; align-items: flex-start; gap: 9px;
margin: -4px 0 18px; padding: 11px 12px;
border: 1px solid #e0cf9f; border-radius: 11px; background: #fbf4e2; color: #8a6a1f;
font-size: 13px; line-height: 1.55;
}
.kicked-msg::before {
content: "!";
display: inline-flex; align-items: center; justify-content: center;
flex: 0 0 18px; width: 18px; height: 18px;
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
}
.btn-login {
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
width: 100%; min-height: 48px; padding: 12px 18px;
border: 1px solid #7196ba; border-radius: 12px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #fff; cursor: pointer; font-size: 15px; font-weight: 800;
box-shadow: 0 12px 24px -16px rgba(79, 120, 165, 0.82);
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease, opacity 160ms ease;
}
.btn-login:hover:not(:disabled) { background: linear-gradient(135deg, #7094ba, #5d83ac); color: #fff; box-shadow: 0 16px 28px -15px rgba(79, 120, 165, 0.86); transform: translateY(-1px); }
.btn-login:active:not(:disabled) { transform: translateY(1px) scale(0.99); }
.btn-login:disabled { cursor: wait; opacity: 0.7; }
.btn-login-spinner { display: none; width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, 0.35); border-top-color: #fff; border-radius: 50%; animation: login-spin 0.8s linear infinite; }
.btn-login.is-loading .btn-login-spinner { display: inline-block; }
@keyframes login-spin { to { transform: rotate(360deg); } }
.login-security-note { display: flex; align-items: flex-start; gap: 9px; margin: 22px 0 0; color: #71859a; font-size: 12px; line-height: 1.55; }
.login-security-note svg { flex: 0 0 auto; width: 16px; height: 16px; margin-top: 1px; color: #4e806d; }
.login-card-footer { margin-top: 34px; padding-top: 18px; border-top: 1px solid #dce5ee; color: #778b9f; font-size: 12px; text-align: center; }
@media (max-width: 900px) {
.auth-page { display: block; }
.auth-visual { min-height: auto; padding: 22px 24px; border-right: 0; border-bottom: 1px solid #d4e0eb; }
.auth-visual-content { display: block; max-width: none; margin: 0; padding: 0; }
.auth-visual-copy { display: none; }
.auth-visual-footer { display: none; }
.auth-content { min-height: calc(100vh - 87px); padding: 32px 24px 44px; }
}
@media (max-width: 520px) {
.auth-visual { padding: 18px 16px; }
.auth-content { padding: 24px 14px 32px; }
.login-card { padding: 26px 20px; border-radius: 18px; }
.login-title { font-size: 27px; }
}
</style>