b70557a077
- 单设备登录:登录成功即 last-login-wins 绑定 users.machine;非超管旧 token 在下一次受保护请求抛 4011 下线,超管豁免;仅认 token 内签名 deviceId,不用请求头。 前端两端接入踢下线跳转(?kicked=1 提示),V118 清空历史 machine - 站内通知:新增 notification 模块(任务失败扫描 / 密钥欠费 / 下游服务探测三类来源), 前后台铃铛组件 + 轮询;后台列表按主管数据范围(UserDataScopeSupport)过滤;V116 建表 均已于 2026-09-13 部署上线,此次补提交源码(此前仅存在于已部署 JAR/构建产物中)
351 lines
17 KiB
Vue
351 lines
17 KiB
Vue
<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>
|