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
@@ -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('/')