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:
@@ -29,6 +29,7 @@
|
||||
</nav>
|
||||
|
||||
<div class="top-right">
|
||||
<NotificationBell />
|
||||
<BrandApiSecretSettingsButton variant="topbar" />
|
||||
<span class="admin-name">{{ username }}</span>
|
||||
</div>
|
||||
@@ -39,6 +40,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import BrandApiSecretSettingsButton from '@/pages/brand/components/BrandApiSecretSettingsButton.vue'
|
||||
import NotificationBell from '@/shared/components/NotificationBell.vue'
|
||||
import { filterGroupsByPermission, TOOL_GROUPS } from '@/pages/amazon/tool-catalog'
|
||||
import type { VisibleGroup } from '@/pages/amazon/tool-catalog'
|
||||
import { resolvePageHref } from '@/shared/page-prefix'
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<NotificationBell theme="light" />
|
||||
<span class="username-text">{{ username || '未登录' }}</span>
|
||||
<router-link :to="logoutHref" class="logout-link">退出</router-link>
|
||||
</div>
|
||||
@@ -63,6 +64,7 @@ import { restoreLoginUser } from '@/shared/auth/ensure-auth'
|
||||
import { getCurrentUserAppColumnRaw, readCachedAppColumnPermissions, type PermissionMenuItem } from '@/shared/api/permission'
|
||||
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
|
||||
import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue'
|
||||
import NotificationBell from '@/shared/components/NotificationBell.vue'
|
||||
import { resolvePageHref } from '@/shared/page-prefix'
|
||||
|
||||
const username = ref('')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<header class="setup-header">
|
||||
<span class="setup-title">数富AI</span>
|
||||
<div class="setup-header-right">
|
||||
<NotificationBell />
|
||||
<span class="username-text">{{ username || '未登录' }}</span>
|
||||
<router-link to="/login?logout=1" class="logout-link">退出</router-link>
|
||||
</div>
|
||||
@@ -34,8 +35,8 @@ import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ApiSecretSettingsPanel from '@/shared/components/ApiSecretSettingsPanel.vue'
|
||||
import NotificationBell from '@/shared/components/NotificationBell.vue'
|
||||
import {
|
||||
checkApiSecret,
|
||||
getStoredApiSecretSnapshot,
|
||||
listApiSecretModules,
|
||||
loadApiSecrets,
|
||||
@@ -47,7 +48,7 @@ const route = useRoute()
|
||||
const panelRef = ref<InstanceType<typeof ApiSecretSettingsPanel> | null>(null)
|
||||
const submitting = ref(false)
|
||||
const username = ref('')
|
||||
const hint = ref('保存后系统会自动检测密钥连通性;检测未通过的密钥需要修正后重试。')
|
||||
const hint = ref('填写密钥并保存后,请点击对应模块的「检测」按钮确认可用,全部检测通过后即可进入工具台。')
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
@@ -57,21 +58,7 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
/** 对已保存但尚未检测出结果的必填模块补一次检测,避免"已配置却因未检测被拦"。 */
|
||||
async function ensureAllChecked() {
|
||||
for (const module of listApiSecretModules()) {
|
||||
const moduleKey = module.moduleKey as ApiSecretModuleKey
|
||||
const snapshot = getStoredApiSecretSnapshot(moduleKey)
|
||||
if (!snapshot.exists) continue
|
||||
if (snapshot.checkStatus === 'passed' || snapshot.checkStatus === 'error') continue
|
||||
try {
|
||||
await checkApiSecret(moduleKey)
|
||||
} catch (error) {
|
||||
console.warn('[api-secret] 补齐检测失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交:保存后只校验检测状态;检测一律由用户手动点击「检测」触发(2026-09-13 起不再自动补检)。 */
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
const panel = panelRef.value
|
||||
@@ -81,14 +68,13 @@ async function submit() {
|
||||
const saved = await panel.saveAll({ requireAll: true })
|
||||
if (!saved) return
|
||||
|
||||
await ensureAllChecked()
|
||||
const state = await loadApiSecrets({ force: true })
|
||||
if (state === 'incomplete') {
|
||||
const failed = listApiSecretModules()
|
||||
.map((module) => getStoredApiSecretSnapshot(module.moduleKey as ApiSecretModuleKey))
|
||||
.filter((snapshot) => !snapshot.exists || (snapshot.checkStatus !== 'passed' && snapshot.checkStatus !== 'error'))
|
||||
hint.value = `以下密钥尚未通过检测:${failed.map((item) => item.masked || '未配置').join('、')},请点击「检测」确认。`
|
||||
ElMessage.warning('密钥尚未全部检测通过,请逐项检测确认')
|
||||
hint.value = `以下密钥尚未通过检测:${failed.map((item) => item.masked || '未配置').join('、')},请点击上方的「检测」按钮逐项确认后重试。`
|
||||
ElMessage.warning('密钥尚未全部检测通过,请点击「检测」按钮逐项确认')
|
||||
return
|
||||
}
|
||||
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
|
||||
|
||||
Reference in New Issue
Block a user