DCFronted/src/views/index.vue
2025-07-06 20:50:06 +08:00

881 lines
23 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>
import { computed, ref, onMounted, onUnmounted, defineAsyncComponent } from 'vue'
import { getUserInfo } from '@/utils/jwt'
import { useRouter } from 'vue-router'
import { hasPrivilege, hasPrivilegeWithTemp, mergePrivileges } from '@/utils/privilege'
import { requestTempPrivilege } from '@/api/login.js'
const PrivilegeRequestDialog = defineAsyncComponent(() => import('@/components/PrivilegeRequestDialog.vue'))
const SuccessDialog = defineAsyncComponent(() => import('@/components/SuccessDialog.vue'))
const ErrorDialog = defineAsyncComponent(() => import('@/components/ErrorDialog.vue'))
const isLoggedIn = computed(() => {
return !!localStorage.getItem('access_token') && !!currentUserData.value
})
const isAdmin = computed(() => {
return currentUserData.value && currentUserData.value.privilege === 'lv-admin';
})
const showMobileMenu = ref(false)
const currentUserData = ref(null)
const showDropdown = ref(false)
const userInfoNavRef = ref(null)
const router = useRouter()
const userAvatarUrl = computed(() => {
if (currentUserData.value && currentUserData.value.qq_code) {
return `https://q1.qlogo.cn/g?b=qq&nk=${currentUserData.value.qq_code}&s=40`
}
return null
})
// 获取用户最高权限(包括临时权限)
const userHighestPrivilege = computed(() => {
if (!currentUserData.value) {
return null
}
const mergedPrivileges = mergePrivileges(currentUserData.value.privilege, currentUserData.value.temp_privilege)
if (!mergedPrivileges) return null
const privileges = mergedPrivileges.split(';')
// 权限等级排序(从高到低)
const privilegeLevels = ['lv-admin', 'lv-mod', 'lv-competitor', 'lv-map', 'lv-user']
for (const level of privilegeLevels) {
if (privileges.includes(level)) {
return level
}
}
return null
})
// 权限显示名称映射
const privilegeDisplayName = computed(() => {
const privilege = userHighestPrivilege.value
if (!privilege) return ''
const displayNames = {
'lv-admin': '管理员',
'lv-mod': '模组',
'lv-map': '地图',
'lv-user': '用户',
'lv-competitor': '竞技'
}
// 检查是否是临时权限lv-user不添加临时前缀
const isTempPrivilege = privilege !== 'lv-user' &&
currentUserData.value &&
currentUserData.value.temp_privilege &&
currentUserData.value.temp_privilege.includes(privilege)
const baseName = displayNames[privilege] || privilege
return isTempPrivilege ? `临时:${baseName}` : baseName
})
// 权限标签样式
const privilegeTagClass = computed(() => {
const privilege = userHighestPrivilege.value
if (!privilege) return ''
const tagClasses = {
'lv-admin': 'privilege-tag admin',
'lv-mod': 'privilege-tag mod',
'lv-map': 'privilege-tag map',
'lv-user': 'privilege-tag user',
'lv-competitor': 'privilege-tag competitor'
}
return tagClasses[privilege] || 'privilege-tag'
})
const toggleMobileMenu = () => {
showMobileMenu.value = !showMobileMenu.value
}
const handleLogout = () => {
localStorage.removeItem('access_token')
currentUserData.value = null
showDropdown.value = false
router.push('/')
}
const handleClickOutside = (event) => {
if (userInfoNavRef.value && !userInfoNavRef.value.contains(event.target)) {
showDropdown.value = false
}
}
const toggleDropdown = () => {
showDropdown.value = !showDropdown.value
}
let privilegeCheckTimer = null
// 允许的临时权限列表
const allowedTempPrivileges = ['lv-mod', 'lv-map', 'lv-competitor', 'lv-user']
// 获取当前路由所需的权限数组(如果有)
function getRouteRequiredPrivilege(route) {
const record = route.matched.find(r => r.meta && r.meta.requiredPrivilege)
return record ? record.meta.requiredPrivilege : null
}
onMounted(() => {
if (localStorage.getItem('access_token')) {
getUserInfo().then(userInfo => {
if (userInfo) {
// 检测非法临时权限
if (hasInvalidTempPrivilege(userInfo)) {
errorDialogMessage.value = '账号异常:检测到非法临时权限,请重新登录或联系管理员。'
errorDialogVisible.value = true
}
currentUserData.value = userInfo
} else {
console.log('Index.vue: Failed to get user info, token might be invalid.')
}
})
}
// 添加点击外部关闭下拉菜单的监听器
document.addEventListener('click', handleClickOutside)
// 启动权限检查定时器每10秒检查一次
privilegeCheckTimer = setInterval(async () => {
if (localStorage.getItem('access_token') && currentUserData.value) {
try {
const userInfo = await getUserInfo()
if (userInfo) {
// 检测非法临时权限
if (hasInvalidTempPrivilege(userInfo)) {
errorDialogMessage.value = '账号异常:检测到非法临时权限,请重新登录或联系管理员。'
errorDialogVisible.value = true
}
// 检查临时权限是否消失
const hadTempPrivilege = currentUserData.value.temp_privilege && currentUserData.value.temp_privilege.trim() !== ''
const hasTempPrivilege = userInfo.temp_privilege && userInfo.temp_privilege.trim() !== ''
// 如果之前有临时权限,现在没有了,则使用 replace 重定向到 maps避免浏览器返回到无权限页
if (hadTempPrivilege && !hasTempPrivilege) {
router.replace('/maps')
}
// 如果当前路由需要特定权限,但用户已不满足,则重定向到 /maps
const requiredPriv = getRouteRequiredPrivilege(router.currentRoute.value)
if (requiredPriv && !hasPrivilegeWithTemp(userInfo, requiredPriv)) {
router.replace('/maps')
}
currentUserData.value = userInfo
}
} catch (error) {
console.log('权限检查失败:', error)
}
}
}, 10 * 1000) // 10秒
})
onUnmounted(() => {
// 移除事件监听器
document.removeEventListener('click', handleClickOutside)
// 清除定时器
if (privilegeCheckTimer) {
clearInterval(privilegeCheckTimer)
privilegeCheckTimer = null
}
})
// 判断每个一级菜单是否有可见项
const showTerrainList = true // 地形图列表始终可见
const showTerrainGenerate = computed(() => isLoggedIn.value && currentUserData.value)
const showWeaponMatch = computed(() => isLoggedIn.value && currentUserData.value && hasPrivilegeWithTemp(currentUserData.value, ['lv-admin', 'lv-mod']))
const showPic2Tga = computed(() => isLoggedIn.value && currentUserData.value)
const showDemands = computed(() => isLoggedIn.value && currentUserData.value)
const showCompetition = computed(() => isLoggedIn.value && currentUserData.value && hasPrivilegeWithTemp(currentUserData.value, ['lv-admin', 'lv-competitor']))
const showTerrainMenu = computed(() => showTerrainList || showTerrainGenerate.value)
const showOnlineToolsMenu = computed(() => showWeaponMatch.value || showPic2Tga.value)
const showPublicMenu = computed(() => showDemands.value)
const showCompetitionMenu = computed(() => showCompetition.value)
const errorDialogVisible = ref(false)
const errorDialogMessage = ref('')
const privilegeDialogVisible = ref(false)
const privilegeDialogName = ref('')
const privilegeDialogKey = ref('')
const successDialog = ref({ visible: false, message: '' })
const privilegeDisplayNames = {
'lv-admin': '管理员',
'lv-mod': '模组',
'lv-map': '地图',
'lv-user': '用户',
'lv-competitor': '竞技'
}
function showNoPrivilegeDialog(name = '', key = '') {
privilegeDialogName.value = name
privilegeDialogKey.value = key
privilegeDialogVisible.value = true
}
async function handlePrivilegeApply() {
privilegeDialogVisible.value = false
if (!isLoggedIn.value) {
successDialog.value = { visible: true, message: '请先登录后再申请权限!' }
return
}
try {
await requestTempPrivilege(privilegeDialogKey.value)
successDialog.value = { visible: true, message: '权限申请已提交,请等待审核。' }
} catch (e) {
successDialog.value = { visible: true, message: '权限申请失败,请重试或联系管理员。' }
}
}
function handleNavClick(route, privilegeList) {
if (!isLoggedIn.value || !currentUserData.value || !hasPrivilegeWithTemp(currentUserData.value, privilegeList)) {
// 取权限数组中优先级最高的非lv-admin权限如有排除lv-user
const order = ['lv-mod', 'lv-competitor', 'lv-map']
let privilegeKey = ''
for (const key of order) {
if (privilegeList && privilegeList.includes(key)) {
privilegeKey = key
break
}
}
// 如果没有其他权限才显示lv-admin
if (!privilegeKey && privilegeList && privilegeList.includes('lv-admin')) {
privilegeKey = 'lv-admin'
}
const privilegeName = privilegeDisplayNames[privilegeKey] || privilegeKey
showNoPrivilegeDialog(privilegeName, privilegeKey)
return
}
router.push(route)
}
// 检测临时权限是否合法
function hasInvalidTempPrivilege(user) {
if (!user || !user.temp_privilege) return false
const arr = user.temp_privilege.split(';').map(p => p.trim()).filter(Boolean)
return arr.some(p => !allowedTempPrivileges.includes(p))
}
</script>
<template>
<div class="app">
<nav class="navbar">
<div class="nav-container">
<div class="nav-brand">红色警戒3数据分析中心</div>
<button class="mobile-menu-toggle" @click="toggleMobileMenu">
<i class="fas fa-bars"></i>
</button>
<div class="nav-left" :class="{ active: showMobileMenu }">
<!-- 地图 一级菜单 -->
<div class="nav-dropdown">
<span class="nav-link">地图与作者推荐</span>
<div class="dropdown-content">
<router-link to="/maps" class="nav-link">全部上传地图</router-link>
<router-link to="/weekly" class="nav-link">本周上传地图</router-link>
<router-link to="/author" class="nav-link">活跃作者推荐</router-link>
</div>
</div>
<!-- 地形 一级菜单 -->
<div class="nav-dropdown">
<span class="nav-link">地形与纹理</span>
<div class="dropdown-content">
<router-link to="/terrain" class="nav-link">地形图列表</router-link>
<router-link v-if="isLoggedIn" to="/terrainGenerate" class="nav-link" @click.prevent="handleNavClick('/terrainGenerate', ['lv-admin','lv-mod','lv-map','lv-competitor'])">地形纹理合成工具</router-link>
</div>
</div>
<!-- 仅登录后显示的菜单项 -->
<template v-if="isLoggedIn">
<!-- 在线工具 一级菜单 -->
<div class="nav-dropdown">
<span class="nav-link">在线工具</span>
<div class="dropdown-content">
<router-link to="/weapon-match" class="nav-link" @click.prevent="handleNavClick('/weapon-match', ['lv-admin','lv-mod'])">Weapon 匹配</router-link>
<router-link to="/PIC2TGA" class="nav-link" @click.prevent="handleNavClick('/PIC2TGA', ['lv-admin','lv-mod','lv-map','lv-competitor'])">在线转tga工具</router-link>
</div>
</div>
<!-- 赛事信息 一级菜单 -->
<div class="nav-dropdown">
<span class="nav-link">赛事信息</span>
<div class="dropdown-content">
<!-- <router-link to="/competition" class="nav-link" @click.prevent="handleNavClick('/competition', ['lv-admin','lv-competitor'])">赛程信息</router-link>-->
<router-link to="/competition" class="nav-link">赛程信息</router-link>
</div>
</div>
<!-- 公共信息区 一级菜单 -->
<div class="nav-dropdown">
<span class="nav-link">公共信息区</span>
<div class="dropdown-content">
<router-link to="/demands" class="nav-link">办事大厅</router-link>
</div>
</div>
</template>
</div>
<div class="nav-right" :class="{ active: showMobileMenu }">
<router-link v-if="!isLoggedIn" to="/backend/login" class="nav-link login-btn">
<i class="fas fa-user"></i>
登录
</router-link>
<div v-if="isLoggedIn && currentUserData" class="user-info-nav" ref="userInfoNavRef" @click="toggleDropdown">
<img v-if="userAvatarUrl" :src="userAvatarUrl" alt="User Avatar" class="nav-avatar" />
<span class="nav-username">{{ currentUserData.username }}</span>
<span v-if="userHighestPrivilege" :class="privilegeTagClass">{{ privilegeDisplayName }}</span>
<i class="fas fa-chevron-down dropdown-icon"></i>
<div v-show="showDropdown" class="dropdown-menu">
<div v-if="isAdmin" class="dropdown-item" @click.stop="router.push('/backend/dashboard'); showDropdown = false">
<i class="fas fa-cog"></i>
管理后台
</div>
<div class="dropdown-item" @click.stop="handleLogout">
<i class="fas fa-sign-out-alt"></i>
退出登录
</div>
</div>
</div>
</div>
</div>
</nav>
<main class="main-content">
<router-view></router-view>
</main>
<footer class="footer">
<div class="footer-top">
<div class="footer-brand">
<img src="../assets/logo.png" class="footer-logo">
<span class="footer-title">红色警戒3数据分析中心</span>
</div>
</div>
<div class="footer-bottom">
<p>Byz解忧杂货铺</p>
<p class="beian">
<img src="../assets/备案图标.png" alt="公安备案图标" class="police-icon"/>
<a href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802045768" rel="noreferrer" target="_blank">京公网安备11010802045768号</a>
</p>
</div>
</footer>
<PrivilegeRequestDialog
:visible="privilegeDialogVisible"
:privilegeName="privilegeDialogName"
@close="privilegeDialogVisible = false"
@apply="handlePrivilegeApply"
/>
<SuccessDialog
:visible="successDialog.visible"
:message="successDialog.message"
@close="successDialog.visible = false"
/>
<ErrorDialog
:visible="errorDialogVisible"
:message="errorDialogMessage"
@close="errorDialogVisible = false"
/>
</div>
</template>
<style scoped>
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
width: 100%;
}
.main-content {
padding: 20px;
flex: 1;
max-width: 1400px;
width: 100%;
margin: 60px auto 0;
}
.footer {
background: linear-gradient(135deg, #416bdf 0%, #71eaeb 100%);
color: white;
padding: 2rem 0;
margin: 0;
width: 100vw;
position: relative;
left: 50%;
right: 50%;
margin-left: -50vw;
margin-right: -50vw;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
}
.navbar {
background: linear-gradient(135deg, #71eaeb 0%, #416bdf 100%);
padding: 0;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.nav-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 12px;
height: auto;
min-height: 60px;
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-start;
overflow-x: visible;
}
.nav-brand {
color: white;
font-size: 1.2rem;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
padding: 15px 0;
display: flex;
align-items: center;
margin-right: 8px;
}
.nav-left {
width: auto;
flex-direction: row;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: nowrap;
white-space: nowrap;
overflow-x: visible;
}
.nav-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
}
.nav-left,
.nav-right {
align-items: center;
gap: 4px;
width: 100%;
flex-direction: column;
display: none;
}
.nav-left.active {
display: flex;
}
.nav-right.active {
display: flex;
}
.nav-link {
color: rgba(255, 255, 255, 0.9);
text-decoration: none;
padding: 6px 12px;
border-radius: 6px;
transition: all 0.3s ease;
font-weight: 500;
position: relative;
width: auto;
text-align: center;
font-size: 0.9rem;
white-space: nowrap;
display: inline-block;
flex-shrink: 1;
min-width: 0;
}
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
color: white;
}
.nav-link.router-link-exact-active,
.nav-link.router-link-active {
background-color: rgba(255,255,255,0.18);
color: #fff;
font-weight: 700;
}
.nav-link:active {
background-color: rgba(255,255,255,0.28);
color: #fff;
}
.login-btn {
display: flex;
align-items: center;
gap: 6px;
background-color: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 10px 20px;
border-radius: 20px;
transition: all 0.3s ease;
width: auto;
justify-content: center;
}
.login-btn:hover {
background-color: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-1px);
}
.login-btn i {
font-size: 14px;
}
.user-info-nav {
display: flex;
align-items: center;
gap: 10px;
margin-left: 15px;
padding: 5px 10px;
cursor: pointer;
position: relative;
border-radius: 20px;
transition: background-color 0.3s;
}
.user-info-nav:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.dropdown-icon {
color: white;
font-size: 12px;
margin-left: 4px;
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
min-width: 160px;
z-index: 1000;
}
.dropdown-item {
padding: 12px 16px;
color: #333;
display: flex;
align-items: center;
gap: 8px;
transition: background-color 0.3s;
cursor: pointer;
}
.dropdown-item:hover {
background-color: #f5f5f5;
}
.dropdown-item i {
font-size: 14px;
color: #666;
}
.nav-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.5);
object-fit: cover;
}
.nav-username {
color: white;
font-weight: 500;
font-size: 0.9rem;
}
.privilege-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 16px;
font-size: 0.78rem;
font-weight: 500;
color: #fff;
margin-left: 8px;
background: rgba(0,0,0,0.18);
letter-spacing: 1px;
vertical-align: middle;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
border: none;
transition: background 0.2s;
}
.privilege-tag.admin {
background: #ff7675;
}
.privilege-tag.mod {
background: #6c5ce7;
}
.privilege-tag.competitor {
background: #00b894;
}
.privilege-tag.map {
background: #0984e3;
}
.privilege-tag.user {
background: #636e72;
}
.mobile-menu-toggle {
display: block;
font-size: 1.5rem;
color: white;
background: none;
border: none;
cursor: pointer;
padding: 10px;
}
.footer-bottom {
width: 100%;
max-width: 1200px;
text-align: center;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.footer-bottom p {
color: rgba(255, 255, 255, 0.8);
font-size: 0.9rem;
margin: 0;
}
.beian {
margin-top: 12px !important;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.police-icon {
height: 20px;
width: 18px;
object-fit: contain;
vertical-align: middle;
}
.beian a {
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
transition: color 0.3s ease;
font-size: 14px;
line-height: 1.5;
}
.beian a:hover {
color: white;
}
@media (min-width: 769px) {
.mobile-menu-toggle {
display: none;
}
.nav-left, .nav-right {
display: flex !important;
flex-direction: row;
width: auto;
padding-bottom: 0;
}
.nav-link {
width: auto;
padding: 10px 10px;
font-size: 0.9rem;
}
.nav-container {
flex-wrap: nowrap;
}
.nav-left, .nav-right {
gap: 6px;
}
}
.nav-dropdown {
position: relative;
display: inline-block;
}
.nav-dropdown > .nav-link {
cursor: pointer;
user-select: none;
}
.dropdown-content {
display: none;
position: absolute;
background-color: #fff;
min-width: 180px;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
z-index: 1001;
border-radius: 8px;
padding: 8px 0;
left: 0;
top: 100%;
}
.nav-dropdown:hover .dropdown-content {
display: block;
}
.dropdown-content .nav-link {
color: #333;
background: none;
padding: 10px 20px;
border-radius: 0;
display: block;
text-align: left;
font-weight: 400;
}
.dropdown-content .nav-link:hover {
background-color: #f5f5f5;
color: #416bdf;
}
@media (max-width: 768px) {
/* 整体导航栏布局 */
.nav-container {
flex-direction: column;
align-items: stretch;
}
.nav-brand {
text-align: center;
margin-right: 0;
}
/* 固定汉堡菜单按钮到右上角 */
.mobile-menu-toggle {
position: fixed;
top: 10px;
right: 12px;
z-index: 1002;
background-color: rgba(0, 0, 0, 0.1);
border-radius: 50%;
padding: 8px;
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
}
/* 移动端菜单展开后的容器样式 */
.nav-left.active,
.nav-right.active {
display: flex !important; /* 由脚本控制,我们只定义样式 */
flex-direction: column;
width: 100%;
gap: 5px;
padding: 10px 0;
}
.nav-right {
margin-left: 0;
}
/* 调整下拉菜单在一级菜单内的定位方式 */
.nav-dropdown {
position: static;
}
/* 下拉菜单内容区的移动端样式 */
.dropdown-content {
position: static; /* 覆盖桌面端的 absolute 定位 */
background-color: transparent;
box-shadow: none;
border-radius: 0;
padding: 8px 0 8px 15px; /* 子菜单缩进 */
margin-top: 5px;
border-left: 2px solid rgba(255, 255, 255, 0.15);
min-width: unset;
width: 100%;
}
/* 下拉菜单中的链接在移动端的样式 */
.dropdown-content .nav-link {
color: rgba(255, 255, 255, 0.9);
font-weight: normal;
}
.dropdown-content .nav-link:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.1);
}
/* 用户登录/信息区域的移动端样式 */
.login-btn,
.user-info-nav {
width: 100%;
justify-content: center;
margin: 5px 0;
}
}
.footer-top {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 1rem;
}
.footer-brand {
display: flex;
align-items: center;
gap: 10px;
background: none;
padding: 0;
border-radius: 0;
box-shadow: none;
}
.footer-logo {
width: 40px;
height: 40px;
border-radius: 8px;
box-shadow: none;
object-fit: contain;
background: transparent;
}
.footer-title {
font-size: 1rem;
font-weight: 600;
color: #fff;
letter-spacing: 1px;
text-shadow: none;
}
</style>