feat(认证/通知): 单设备登录互踢 + 站内通知铃铛系统

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

均已于 2026-09-13 部署上线,此次补提交源码(此前仅存在于已部署 JAR/构建产物中)
This commit is contained in:
2026-09-13 23:08:22 +08:00
parent a0f6582914
commit b70557a077
53 changed files with 3982 additions and 101 deletions
@@ -6,6 +6,7 @@
<div class="login-box">
<h1 class="login-title">登录</h1>
<p v-if="kickedNotice" class="kicked-msg">该账号已在其他设备登录本设备已下线如非本人操作请及时修改密码</p>
<p v-if="errorMessage" class="error-msg">{{ errorMessage }}</p>
<form id="loginForm" @submit.prevent="submitLogin">
<div class="form-group">
@@ -98,6 +99,7 @@
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { loginWithDevice } from '@/shared/api/user'
import { KICK_NOTICE_KEY } from '@/shared/auth/kick-handler'
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
import { clearApiSecretCache } from '@/shared/utils/api-secret-store'
import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue'
@@ -116,6 +118,7 @@ const password = ref('')
const passwordVisible = ref(false)
const loggingIn = ref(false)
const errorMessage = ref('')
const kickedNotice = ref(false)
const rememberPassword = ref(false)
const autoLogin = ref(false)
const updateOpen = ref(false)
@@ -244,6 +247,18 @@ function hasLogoutParam(): boolean {
return params.get('logout') === '1' || params.get('switch') === '1'
}
/** 被新设备顶下线后跳回登录页(query 会被路由守卫吞掉,故另看 localStorage 标记) */
function isKickedNav(): boolean {
try {
if (new URLSearchParams(window.location.search || '').get('kicked') === '1') {
return true
}
return lsGet(KICK_NOTICE_KEY) === '1'
} catch {
return false
}
}
function togglePassword() {
passwordVisible.value = !passwordVisible.value
}
@@ -431,10 +446,24 @@ onMounted(() => {
lsSet(AUTO_LOGIN_KEY, '0')
}
if (isKickedNav()) {
// 被新设备顶下线:提示原因并关闭自动登录——否则本机每次启动都会静默重登,
// 把对方又顶下线,两台机器来回互踢。标记展示后即清(避免登出时误显示)。
kickedNotice.value = true
autoLogin.value = false
lsSet(AUTO_LOGIN_KEY, '0')
lsRemove(KICK_NOTICE_KEY)
}
// 恢复记住的凭据并尝试自动登录(桌面与网页形态一致;登出/切号导航由 tryAutoLogin 内部豁免)
// loadCredentials 现为异步(AES 解密),须先恢复密码再触发自动登录
void (async () => {
await loadCredentials()
if (kickedNotice.value) {
// 刚被顶下线:不自动重登,等用户手动点登录(此时顶掉对方,最后登录者胜)
autoLogin.value = false
return
}
tryAutoLogin()
})()
})
@@ -592,6 +621,19 @@ body {
text-align: center;
}
/* 单设备登录:被新设备顶下线的提示 */
.kicked-msg {
color: #b8860b;
background: #fdf6e3;
border: 1px solid #f0d9a0;
border-radius: 6px;
font-size: 13px;
line-height: 1.6;
margin-bottom: 12px;
padding: 8px 12px;
text-align: center;
}
/* 记住密码 / 自动登录 */
.login-options {
display: flex;