|
@@ -1,209 +1,142 @@
|
|
|
<template>
|
|
<template>
|
|
|
- <div class="big-screen-home">
|
|
|
|
|
- <!-- 顶部栏 -->
|
|
|
|
|
- <header class="header-bar">
|
|
|
|
|
- <div class="header-left">
|
|
|
|
|
- <span class="time-text">{{ currentTime }}</span>
|
|
|
|
|
|
|
+ <div class="portal-page">
|
|
|
|
|
+ <a class="skip-link" href="#portal-main">跳转到主要内容</a>
|
|
|
|
|
+ <header class="top-bar">
|
|
|
|
|
+ <div class="logo-area">
|
|
|
|
|
+ <div class="logo-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" /></svg></div>
|
|
|
|
|
+ <span class="logo-text">沅陵县城市地下管网管控平台</span>
|
|
|
</div>
|
|
</div>
|
|
|
- <h1 class="system-title">沅陵县城市地下管网管控平台</h1>
|
|
|
|
|
- <div class="header-right">
|
|
|
|
|
- <span class="welcome-text">欢迎,<span class="username">{{ userStore.nickName || userStore.name }}</span></span>
|
|
|
|
|
- <button class="header-btn" @click="logout">退出系统</button>
|
|
|
|
|
|
|
+ <div class="top-right">
|
|
|
|
|
+ <span class="datetime">{{ currentDate }}</span>
|
|
|
|
|
+ <span class="user-badge">{{ displayName }}</span>
|
|
|
|
|
+ <button class="logout-button" type="button" @click="logout">
|
|
|
|
|
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z" /></svg>
|
|
|
|
|
+ 退出登录
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
</header>
|
|
</header>
|
|
|
|
|
|
|
|
- <!-- 功能模块入口(3D椭圆旋转环) -->
|
|
|
|
|
- <div class="ring-3d-container">
|
|
|
|
|
- <div
|
|
|
|
|
- class="module-item-3d"
|
|
|
|
|
- v-for="(item, index) in modules"
|
|
|
|
|
- :key="index"
|
|
|
|
|
- :style="moduleStyles[index]"
|
|
|
|
|
- @click="handleModuleClick(item)"
|
|
|
|
|
- @mouseenter="pauseRotation"
|
|
|
|
|
- @mouseleave="resumeRotation"
|
|
|
|
|
- >
|
|
|
|
|
- <div class="icon-wrapper">
|
|
|
|
|
- <div class="icon-circle">
|
|
|
|
|
- <i :class="item.iconClass" class="module-icon"></i>
|
|
|
|
|
- <span v-if="item.iconClass === 'icon-permission'" class="module-name">{{ item.name }}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <section class="notice-bar" aria-label="通知公告">
|
|
|
|
|
+ <div class="notice-tag"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" /></svg>通知公告</div>
|
|
|
|
|
+ <div ref="noticeScrollRef" class="notice-scroll" @mouseenter="noticePaused = true" @mouseleave="noticePaused = false" @focusin="noticePaused = true" @focusout="noticePaused = false">
|
|
|
|
|
+ <div v-if="notices.length" ref="noticeTrackRef" class="notice-track">
|
|
|
|
|
+ <button v-for="(notice, index) in scrollingNotices" :key="`${notice.noticeId}-${index}`" class="notice-item" type="button" :data-copy-start="index === notices.length ? 'true' : undefined" @click="openNotice(notice.noticeId)">
|
|
|
|
|
+ <span class="notice-dot" :class="noticeDotClass(index)"></span>
|
|
|
|
|
+ <span>{{ notice.noticeTitle }}</span>
|
|
|
|
|
+ <time class="notice-time">{{ formatNoticeTime(notice.createTime) }}</time>
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <span v-else class="notice-empty">暂无公告</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <main id="portal-main" class="main-content" tabindex="-1">
|
|
|
|
|
+ <div class="section-header"><h1 class="section-title">全网数据总览</h1></div>
|
|
|
|
|
+ <section class="stats-row" aria-label="全网数据总览">
|
|
|
|
|
+ <article class="stat-card">
|
|
|
|
|
+ <div class="stat-label">设备总数</div>
|
|
|
|
|
+ <div class="stat-value">{{ formatCount(statistics.deviceTotal) }}</div>
|
|
|
|
|
+ <div class="stat-sub">
|
|
|
|
|
+ <span>离线 <b class="text-primary">{{ formatCount(statistics.offlineDeviceCount) }}</b></span>
|
|
|
|
|
+ <span>预警 <b class="text-orange">{{ formatCount(statistics.alarmingDeviceCount) }}</b></span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </article>
|
|
|
|
|
+ <article class="stat-card">
|
|
|
|
|
+ <div class="stat-label">今日预警</div>
|
|
|
|
|
+ <div class="stat-value">{{ formatCount(statistics.todayWarningCount) }}</div>
|
|
|
|
|
+ <div class="stat-sub">较昨日 <span class="stat-trend" :class="warningTrend.className">{{ warningTrend.text }}</span></div>
|
|
|
|
|
+ </article>
|
|
|
|
|
+ <article class="stat-card">
|
|
|
|
|
+ <div class="stat-label">今日报警</div>
|
|
|
|
|
+ <div class="stat-value">{{ formatCount(statistics.todayAlarmCount) }}</div>
|
|
|
|
|
+ <div class="stat-sub">较昨日 <span class="stat-trend" :class="alarmTrend.className">{{ alarmTrend.text }}</span></div>
|
|
|
|
|
+ </article>
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="section-header">
|
|
|
|
|
+ <h2 class="section-title">子系统总览</h2>
|
|
|
|
|
+ <p class="section-subtitle">地下管网核心业务系统统一入口</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <section class="system-grid" aria-label="子系统入口">
|
|
|
|
|
+ <button v-for="module in modules" :key="module.key" class="system-card" type="button" :style="{ '--module-color': module.color, '--module-background': module.background }" @click="enterModule(module)">
|
|
|
|
|
+ <span class="card-row">
|
|
|
|
|
+ <span class="system-icon" aria-hidden="true">
|
|
|
|
|
+ <svg v-if="module.key === 'manhole'" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2" /><path d="M8 12h8M12 8v8" stroke="currentColor" stroke-width="1.5" fill="none" /></svg>
|
|
|
|
|
+ <svg v-else viewBox="0 0 24 24"><path :d="module.icon" /></svg>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span class="system-name">{{ module.name }}</span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span class="system-desc">{{ module.description[0] }}<br>{{ module.description[1] }}</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <div v-if="!modules.length" class="system-empty">当前账号暂无可访问的子系统</div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ </main>
|
|
|
|
|
+
|
|
|
|
|
+ <el-dialog v-model="noticeDialogVisible" class="portal-notice-dialog" :title="noticeDetail.noticeTitle || '通知公告'" width="620px" destroy-on-close>
|
|
|
|
|
+ <div v-loading="noticeDetailLoading" class="notice-detail">
|
|
|
|
|
+ <div v-if="noticeDetail.createTime" class="notice-detail-meta">发布时间:{{ formatNoticeDateTime(noticeDetail.createTime) }}</div>
|
|
|
|
|
+ <div v-if="noticeDetail.noticeContent" class="notice-detail-content" v-html="safeNoticeContent"></div>
|
|
|
|
|
+ <el-empty v-else-if="!noticeDetailLoading" description="暂无公告内容" :image-size="72" />
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
|
+ <template #footer><el-button type="primary" @click="noticeDialogVisible = false">关闭</el-button></template>
|
|
|
|
|
+ </el-dialog>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup>
|
|
<script setup>
|
|
|
-import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
|
|
|
|
-import { useRouter } from 'vue-router'
|
|
|
|
|
-import useUserStore from '@/store/modules/user'
|
|
|
|
|
-import usePermissionStore from '@/store/modules/permission'
|
|
|
|
|
|
|
+import DOMPurify from 'dompurify'
|
|
|
import { ElMessageBox } from 'element-plus'
|
|
import { ElMessageBox } from 'element-plus'
|
|
|
|
|
+import { getPortalNotice, getPortalNotices, getPortalStatistics } from '@/api/portal'
|
|
|
|
|
+import { joinRoutePath } from '@/utils/ruoyi'
|
|
|
|
|
+import usePermissionStore from '@/store/modules/permission'
|
|
|
|
|
+import useUserStore from '@/store/modules/user'
|
|
|
|
|
|
|
|
const router = useRouter()
|
|
const router = useRouter()
|
|
|
-const userStore = useUserStore()
|
|
|
|
|
const permissionStore = usePermissionStore()
|
|
const permissionStore = usePermissionStore()
|
|
|
|
|
+const userStore = useUserStore()
|
|
|
|
|
|
|
|
-const currentTime = ref('')
|
|
|
|
|
-let timer = null
|
|
|
|
|
-let rotationInterval = null
|
|
|
|
|
-let animationFrame = null
|
|
|
|
|
-let isPaused = ref(false)
|
|
|
|
|
-
|
|
|
|
|
-// 椭圆轨迹参数
|
|
|
|
|
-const radiusX = 500
|
|
|
|
|
-const radiusY = 100
|
|
|
|
|
-const modulesCount = ref(7)
|
|
|
|
|
-
|
|
|
|
|
-// 连续旋转核心变量
|
|
|
|
|
-let currentAngle = ref(0)
|
|
|
|
|
-const ANIMATION_DURATION = 500
|
|
|
|
|
-const ROTATE_INTERVAL = 5000
|
|
|
|
|
-// 每步旋转角度按实际模块数动态计算,避免去重后模块数量变化导致错位
|
|
|
|
|
-const stepAngle = computed(() => (2 * Math.PI) / Math.max(modulesCount.value, 1))
|
|
|
|
|
-
|
|
|
|
|
-// 动态模块(从路由获取)
|
|
|
|
|
-const modules = ref([])
|
|
|
|
|
-
|
|
|
|
|
-const resolveModuleIcon = (route) => {
|
|
|
|
|
- const identity = [route.name, route.path, route.meta?.title].filter(Boolean).join(' ').toLowerCase()
|
|
|
|
|
- const iconMappings = [
|
|
|
|
|
- { pattern: /权限管理|permissionmanagement/, iconClass: 'icon-permission' },
|
|
|
|
|
- { pattern: /生命线|lifecompany|citylifeline/, iconClass: 'icon-operation' },
|
|
|
|
|
- { pattern: /应急|指挥|dispatch/, iconClass: 'icon-sewage' },
|
|
|
|
|
- { pattern: /管网设施|pipenetwork/, iconClass: 'icon-bi' },
|
|
|
|
|
- { pattern: /供水|water/, iconClass: 'icon-flood' },
|
|
|
|
|
- { pattern: /燃气|gas/, iconClass: 'icon-water' },
|
|
|
|
|
- { pattern: /排水|drainage/, iconClass: 'icon-123' },
|
|
|
|
|
- { pattern: /窨井盖|manhole/, iconClass: 'icon-ps' }
|
|
|
|
|
- ]
|
|
|
|
|
- return iconMappings.find(({ pattern }) => pattern.test(identity))?.iconClass
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// 【优化1】缓存模块样式,避免频繁计算
|
|
|
|
|
-const moduleStyles = reactive({})
|
|
|
|
|
-
|
|
|
|
|
-// 【优化2】预计算三角函数值缓存
|
|
|
|
|
-const sinCache = new Map()
|
|
|
|
|
-const cosCache = new Map()
|
|
|
|
|
-
|
|
|
|
|
-const getCachedSin = (angle) => {
|
|
|
|
|
- const key = Math.round(angle * 1000)
|
|
|
|
|
- if (!sinCache.has(key)) {
|
|
|
|
|
- sinCache.set(key, Math.sin(angle))
|
|
|
|
|
- }
|
|
|
|
|
- return sinCache.get(key)
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-const getCachedCos = (angle) => {
|
|
|
|
|
- const key = Math.round(angle * 1000)
|
|
|
|
|
- if (!cosCache.has(key)) {
|
|
|
|
|
- cosCache.set(key, Math.cos(angle))
|
|
|
|
|
- }
|
|
|
|
|
- return cosCache.get(key)
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// 【优化3】批量更新所有模块样式
|
|
|
|
|
-const updateAllModuleStyles = () => {
|
|
|
|
|
- const count = modulesCount.value
|
|
|
|
|
- for (let i = 0; i < count; i++) {
|
|
|
|
|
- const offsetAngle = (3 * Math.PI / 2) + (i * 2 * Math.PI) / count
|
|
|
|
|
- const totalAngle = currentAngle.value + offsetAngle
|
|
|
|
|
-
|
|
|
|
|
- // 使用缓存的计算
|
|
|
|
|
- const cosVal = getCachedCos(totalAngle)
|
|
|
|
|
- const sinVal = getCachedSin(totalAngle)
|
|
|
|
|
-
|
|
|
|
|
- const x = cosVal * radiusX
|
|
|
|
|
- const y = sinVal * radiusY
|
|
|
|
|
-
|
|
|
|
|
- const scale = 0.7 + (sinVal + 1) / 2 * 0.6
|
|
|
|
|
- const opacity = 0.6 + (sinVal + 1) / 2 * 0.4
|
|
|
|
|
- const zIndex = Math.floor(sinVal * 10) + 10
|
|
|
|
|
-
|
|
|
|
|
- moduleStyles[i] = {
|
|
|
|
|
- transform: `translate(calc(-50% + ${x}px), calc(10% + ${y}px)) scale(${scale})`,
|
|
|
|
|
- margin:'auto',
|
|
|
|
|
- opacity: opacity,
|
|
|
|
|
- zIndex: zIndex,
|
|
|
|
|
- position: 'absolute',
|
|
|
|
|
- left: '50%',
|
|
|
|
|
- top: '50%'
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// 平滑旋转动画
|
|
|
|
|
-let startAngle = 0
|
|
|
|
|
-let targetAngle = 0
|
|
|
|
|
-let animationStartTime = 0
|
|
|
|
|
-
|
|
|
|
|
-const startRotateAnimation = () => {
|
|
|
|
|
- if (isPaused.value) return
|
|
|
|
|
- startAngle = currentAngle.value
|
|
|
|
|
- targetAngle = currentAngle.value + stepAngle.value
|
|
|
|
|
- animationStartTime = performance.now()
|
|
|
|
|
- animate()
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-const animate = () => {
|
|
|
|
|
- if (isPaused.value) return
|
|
|
|
|
- const now = performance.now()
|
|
|
|
|
- let progress = (now - animationStartTime) / ANIMATION_DURATION
|
|
|
|
|
- progress = Math.min(progress, 1)
|
|
|
|
|
-
|
|
|
|
|
- // 缓动函数:更丝滑
|
|
|
|
|
- const t = progress === 1 ? 1 : 1 - Math.pow(1 - progress, 3)
|
|
|
|
|
- currentAngle.value = startAngle + (targetAngle - startAngle) * t
|
|
|
|
|
-
|
|
|
|
|
- // 【优化4】只在角度变化时更新样式
|
|
|
|
|
- updateAllModuleStyles()
|
|
|
|
|
-
|
|
|
|
|
- if (progress < 1) {
|
|
|
|
|
- animationFrame = requestAnimationFrame(animate)
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// 暂停 / 恢复
|
|
|
|
|
-const pauseRotation = () => {
|
|
|
|
|
- isPaused.value = true
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-const resumeRotation = () => {
|
|
|
|
|
- isPaused.value = false
|
|
|
|
|
- // 恢复时立即开始新的旋转动画
|
|
|
|
|
- if (rotationInterval) {
|
|
|
|
|
- clearInterval(rotationInterval)
|
|
|
|
|
- rotationInterval = setInterval(() => {
|
|
|
|
|
- if (!isPaused.value) startRotateAnimation()
|
|
|
|
|
- }, ROTATE_INTERVAL)
|
|
|
|
|
|
|
+const MODULE_DEFINITIONS = [
|
|
|
|
|
+ { key: 'permission', keywords: ['权限管理', 'permissionmanagement', '/system'], description: ['统一用户认证与权限分配', '保障系统安全访问控制'], color: '#8b5cf6', background: 'rgba(139,92,246,.08)', icon: 'M12 1 3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z' },
|
|
|
|
|
+ { key: 'pipe', keywords: ['管网设施', 'pipenetwork'], description: ['统一管理管网设施档案', '协同开展巡检养护与隐患治理'], color: '#06b6d4', background: 'rgba(6,182,212,.08)', icon: 'M22 9V7h-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2v-2h-2V9h2zm-4 10H4V5h14v14zM6 13h5v4H6v-4zm6-6h4v3h-4V7zM6 7h5v5H6V7zm6 4h4v6h-4v-6z' },
|
|
|
|
|
+ { key: 'water', keywords: ['供水', 'watersupply'], description: ['监测供水压力、流量与水质', '及时发现漏损及运行异常'], color: '#10b981', background: 'rgba(16,185,129,.08)', icon: 'M12 2c-5.33 4.55-8 8.48-8 11.8C4 18.78 7.8 22 12 22s8-3.22 8-8.2C20 10.48 17.33 6.55 12 2z' },
|
|
|
|
|
+ { key: 'drainage', keywords: ['排水', 'drainage'], description: ['感知管网液位与排水工况', '辅助识别溢流、积水及内涝风险'], color: '#3b82f6', background: 'rgba(59,130,246,.08)', icon: 'M3 17h2v4H3v-4zm4-2h2v6H7v-6zm4-4h2v10h-2V11zm4 2h2v8h-2v-8zm4-4h2v12h-2V9zM3 5h18v2H3V5z' },
|
|
|
|
|
+ { key: 'gas', keywords: ['燃气', '/gas'], description: ['监测燃气浓度与管网压力', '识别泄漏隐患,保障用气安全'], color: '#f59e0b', background: 'rgba(245,158,11,.08)', icon: 'M12 2c-1 0-4 5.5-4 9.5C8 15.43 10.24 18 12 18s4-2.57 4-6.5C16 7.5 13 2 12 2z' },
|
|
|
|
|
+ { key: 'manhole', keywords: ['窨井盖', 'manhole'], description: ['感知井盖开启、位移及井内环境', '及时发现异常,守护道路安全'], color: '#8b5cf6', background: 'rgba(139,92,246,.08)', icon: '' },
|
|
|
|
|
+ { key: 'warning', keywords: ['生命线预警', 'lifecompany', 'citylifeline'], description: ['汇集风险预警并联动责任部门', '跟踪派单、处置与反馈全过程'], color: '#3b82f6', background: 'rgba(59,130,246,.08)', icon: 'M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z' },
|
|
|
|
|
+ { key: 'dispatch', keywords: ['应急', '指挥', 'dispatch'], description: ['统筹应急预案、队伍与物资', '支撑突发事件协同指挥调度'], color: '#06b6d4', background: 'rgba(6,182,212,.08)', icon: 'M21 6.28l-1.15-.46a8.934 8.934 0 0 0-1.43-.47L17 2H7L5.58 5.35c-.5.12-.98.28-1.43.47L3 6.28 2 8v2h1l1 10h16l1-10h1V8l-1-1.72z' }
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+const statistics = ref({ deviceTotal: null, offlineDeviceCount: null, alarmingDeviceCount: null, todayWarningCount: null, yesterdayWarningCount: null, todayAlarmCount: null, yesterdayAlarmCount: null })
|
|
|
|
|
+const notices = ref([])
|
|
|
|
|
+const currentDate = ref('')
|
|
|
|
|
+const noticeScrollRef = ref()
|
|
|
|
|
+const noticeTrackRef = ref()
|
|
|
|
|
+const noticePaused = ref(false)
|
|
|
|
|
+const noticeDialogVisible = ref(false)
|
|
|
|
|
+const noticeDetailLoading = ref(false)
|
|
|
|
|
+const noticeDetail = ref({})
|
|
|
|
|
+let dateTimer
|
|
|
|
|
+let noticeAnimationFrame
|
|
|
|
|
+let noticeOffset = 0
|
|
|
|
|
+let lastNoticeFrame = 0
|
|
|
|
|
+let noticeRequestId = 0
|
|
|
|
|
+
|
|
|
|
|
+const displayName = computed(() => userStore.nickName || userStore.name || '用户')
|
|
|
|
|
+const scrollingNotices = computed(() => notices.value.length ? notices.value.concat(notices.value) : [])
|
|
|
|
|
+const warningTrend = computed(() => buildTrend(statistics.value.todayWarningCount, statistics.value.yesterdayWarningCount))
|
|
|
|
|
+const alarmTrend = computed(() => buildTrend(statistics.value.todayAlarmCount, statistics.value.yesterdayAlarmCount))
|
|
|
|
|
+const modules = computed(() => {
|
|
|
|
|
+ const matched = new Map()
|
|
|
|
|
+ for (const route of permissionStore.routes || []) {
|
|
|
|
|
+ if (!route || route.hidden) continue
|
|
|
|
|
+ const identity = `${route.path || ''} ${route.name || ''} ${route.meta?.title || ''}`.toLowerCase()
|
|
|
|
|
+ const definition = MODULE_DEFINITIONS.find(item => item.keywords.some(keyword => identity.includes(keyword.toLowerCase())))
|
|
|
|
|
+ if (!definition || matched.has(definition.key)) continue
|
|
|
|
|
+ matched.set(definition.key, { ...definition, name: route.meta?.title || definition.name, path: resolveSubsystemEntryPath(route) })
|
|
|
}
|
|
}
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// ==============================================
|
|
|
|
|
-// 从文件1同步:更新时间
|
|
|
|
|
-// ==============================================
|
|
|
|
|
-const updateTime = () => {
|
|
|
|
|
- const now = new Date()
|
|
|
|
|
- const weekArr = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
|
|
|
|
- const year = now.getFullYear()
|
|
|
|
|
- const month = String(now.getMonth() + 1).padStart(2, '0')
|
|
|
|
|
- const day = String(now.getDate()).padStart(2, '0')
|
|
|
|
|
- const hours = String(now.getHours()).padStart(2, '0')
|
|
|
|
|
- const minutes = String(now.getMinutes()).padStart(2, '0')
|
|
|
|
|
- const seconds = String(now.getSeconds()).padStart(2, '0')
|
|
|
|
|
- const week = weekArr[now.getDay()]
|
|
|
|
|
- currentTime.value = `${year}/${month}/${day} ${hours}:${minutes}:${seconds} ${week}`
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// ==============================================
|
|
|
|
|
-// 从文件1同步:路由处理
|
|
|
|
|
-// ==============================================
|
|
|
|
|
-function joinRoutePath(parentPath = '', childPath = '') {
|
|
|
|
|
- const path = `${parentPath}/${childPath}`.replace(/\/+/g, '/')
|
|
|
|
|
- return path.startsWith('/') ? path : `/${path}`
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ return MODULE_DEFINITIONS.map(item => matched.get(item.key)).filter(Boolean)
|
|
|
|
|
+})
|
|
|
|
|
+const safeNoticeContent = computed(() => sanitizeNoticeHtml(noticeDetail.value.noticeContent || ''))
|
|
|
|
|
|
|
|
function findRoutePath(route, matcher, parentPath = '') {
|
|
function findRoutePath(route, matcher, parentPath = '') {
|
|
|
if (!route || route.hidden) return ''
|
|
if (!route || route.hidden) return ''
|
|
@@ -219,9 +152,9 @@ function findRoutePath(route, matcher, parentPath = '') {
|
|
|
function findFirstVisibleLeafPath(route, parentPath = '') {
|
|
function findFirstVisibleLeafPath(route, parentPath = '') {
|
|
|
if (!route || route.hidden) return ''
|
|
if (!route || route.hidden) return ''
|
|
|
const currentPath = joinRoutePath(parentPath, route.path || '')
|
|
const currentPath = joinRoutePath(parentPath, route.path || '')
|
|
|
- const visibleChildren = (route.children || []).filter(child => !child.hidden)
|
|
|
|
|
- if (!visibleChildren.length) return currentPath
|
|
|
|
|
- for (const child of visibleChildren) {
|
|
|
|
|
|
|
+ const children = (route.children || []).filter(child => !child.hidden)
|
|
|
|
|
+ if (!children.length) return currentPath
|
|
|
|
|
+ for (const child of children) {
|
|
|
const result = findFirstVisibleLeafPath(child, currentPath)
|
|
const result = findFirstVisibleLeafPath(child, currentPath)
|
|
|
if (result) return result
|
|
if (result) return result
|
|
|
}
|
|
}
|
|
@@ -229,288 +162,210 @@ function findFirstVisibleLeafPath(route, parentPath = '') {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function resolveSubsystemEntryPath(route) {
|
|
function resolveSubsystemEntryPath(route) {
|
|
|
- const homePath = findRoutePath(route, item => item.meta?.title === '首页')
|
|
|
|
|
- if (homePath) return homePath
|
|
|
|
|
- return findFirstVisibleLeafPath(route) || route.path || '/'
|
|
|
|
|
|
|
+ return findRoutePath(route, item => item.meta?.title === '首页') || findFirstVisibleLeafPath(route) || route.path || '/portal'
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function updateParentPath(routes) {
|
|
|
|
|
- return routes.map(parent => {
|
|
|
|
|
- return {
|
|
|
|
|
- ...parent,
|
|
|
|
|
- path: resolveSubsystemEntryPath(parent)
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
|
|
+function updateCurrentDate() {
|
|
|
|
|
+ const now = new Date()
|
|
|
|
|
+ const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][now.getDay()]
|
|
|
|
|
+ currentDate.value = `${now.getFullYear()}年${String(now.getMonth() + 1).padStart(2, '0')}月${String(now.getDate()).padStart(2, '0')}日 ${week}`
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// ==============================================
|
|
|
|
|
-// 加载动态驾驶舱
|
|
|
|
|
-// ==============================================
|
|
|
|
|
-const loadDynamicModules = () => {
|
|
|
|
|
- // 用 routes(仅 generateRoutes 时写入)而非 sidebarRouters:
|
|
|
|
|
- // sidebarRouters 会被 TopNav/主题设置覆盖成变换后的数据,SPA 内返回门户会读到脏数据
|
|
|
|
|
- const subSystemRoutes = permissionStore.routes.filter(route => route.name && !route.hidden)
|
|
|
|
|
- const newRoutes = updateParentPath(subSystemRoutes)
|
|
|
|
|
- // 按名称去重:sys_menu 中重复配置的同名子系统目录只展示一次
|
|
|
|
|
- const seenTitles = new Set()
|
|
|
|
|
- const res = []
|
|
|
|
|
- newRoutes.forEach(item => {
|
|
|
|
|
- const name = item.meta?.title || '驾驶舱'
|
|
|
|
|
- const iconClass = resolveModuleIcon(item)
|
|
|
|
|
- // 素材内嵌了子系统名称,未知路由不能使用其他系统的素材以免标题与入口不符。
|
|
|
|
|
- if (!iconClass) return
|
|
|
|
|
- if (seenTitles.has(name)) return
|
|
|
|
|
- seenTitles.add(name)
|
|
|
|
|
- res.push({
|
|
|
|
|
- name,
|
|
|
|
|
- iconClass,
|
|
|
|
|
- path: item.path || '/'
|
|
|
|
|
- })
|
|
|
|
|
- })
|
|
|
|
|
- modules.value = res
|
|
|
|
|
- modulesCount.value = res.length
|
|
|
|
|
- updateAllModuleStyles()
|
|
|
|
|
|
|
+function formatCount(value) {
|
|
|
|
|
+ return value === null || value === undefined ? '--' : Number(value).toLocaleString('zh-CN')
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// ==============================================
|
|
|
|
|
-// 退出系统(与 Navbar 保持一致)
|
|
|
|
|
-// ==============================================
|
|
|
|
|
-const logout = () => {
|
|
|
|
|
- ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
|
|
|
|
- confirmButtonText: '确定',
|
|
|
|
|
- cancelButtonText: '取消',
|
|
|
|
|
- type: 'warning'
|
|
|
|
|
- }).then(() => {
|
|
|
|
|
- // Store 中的方法名是 logOut,logout() 不存在,调用会静默失败导致没有真正退出
|
|
|
|
|
- userStore.logOut().then(() => {
|
|
|
|
|
- // 整页跳转回登录页,刷新路由实例以清除已动态注册的路由
|
|
|
|
|
- location.href = '/login'
|
|
|
|
|
- })
|
|
|
|
|
- }).catch(() => {})
|
|
|
|
|
|
|
+function buildTrend(today, yesterday) {
|
|
|
|
|
+ if (today === null || yesterday === null || today === undefined || yesterday === undefined) return { text: '--', className: 'flat' }
|
|
|
|
|
+ const difference = Number(today) - Number(yesterday)
|
|
|
|
|
+ if (difference > 0) return { text: `↑ ${difference}`, className: 'up' }
|
|
|
|
|
+ if (difference < 0) return { text: `↓ ${Math.abs(difference)}`, className: 'down' }
|
|
|
|
|
+ return { text: '持平', className: 'flat' }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// ==============================================
|
|
|
|
|
-// 点击驾驶舱
|
|
|
|
|
-// ==============================================
|
|
|
|
|
-const handleModuleClick = (item) => {
|
|
|
|
|
- if (item.path) router.push(item.path)
|
|
|
|
|
- // let ip = "192.168.110.147";
|
|
|
|
|
- // let port = 81;
|
|
|
|
|
- // localStorage.removeItem("routeTitle");
|
|
|
|
|
- // localStorage.setItem("routeTitle", item.name);
|
|
|
|
|
- // window.location.replace(`http://${ip}:${port}?username=${encodeURIComponent(item.name)}`);
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-// ==============================================
|
|
|
|
|
-// 生命周期
|
|
|
|
|
-// ==============================================
|
|
|
|
|
-onMounted(() => {
|
|
|
|
|
- updateTime()
|
|
|
|
|
- timer = setInterval(updateTime, 1000)
|
|
|
|
|
- loadDynamicModules()
|
|
|
|
|
-
|
|
|
|
|
- // 启动旋转定时器
|
|
|
|
|
- rotationInterval = setInterval(() => {
|
|
|
|
|
- if (!isPaused.value) startRotateAnimation()
|
|
|
|
|
- }, ROTATE_INTERVAL)
|
|
|
|
|
|
|
+function parseDate(value) {
|
|
|
|
|
+ if (!value) return null
|
|
|
|
|
+ const normalized = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}(?: |$)/.test(value)
|
|
|
|
|
+ ? value.replace(/-/g, '/')
|
|
|
|
|
+ : value
|
|
|
|
|
+ const date = new Date(normalized)
|
|
|
|
|
+ return Number.isNaN(date.getTime()) ? null : date
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- // 初始样式
|
|
|
|
|
- updateAllModuleStyles()
|
|
|
|
|
-})
|
|
|
|
|
|
|
+function formatNoticeTime(value) {
|
|
|
|
|
+ const date = parseDate(value)
|
|
|
|
|
+ if (!date) return ''
|
|
|
|
|
+ const now = new Date()
|
|
|
|
|
+ const calendarDay = item => Date.UTC(item.getFullYear(), item.getMonth(), item.getDate())
|
|
|
|
|
+ const daysAgo = (calendarDay(now) - calendarDay(date)) / 86400000
|
|
|
|
|
+ const relativeDay = ['今天', '昨天', '前天'][daysAgo]
|
|
|
|
|
+ return `${relativeDay || formatNoticeDate(date)} ${formatNoticeClock(date)}`
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-onUnmounted(() => {
|
|
|
|
|
- clearInterval(timer)
|
|
|
|
|
- if (rotationInterval) clearInterval(rotationInterval)
|
|
|
|
|
- if (animationFrame) cancelAnimationFrame(animationFrame)
|
|
|
|
|
|
|
+function formatNoticeDate(date) {
|
|
|
|
|
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- // 清理缓存
|
|
|
|
|
- sinCache.clear()
|
|
|
|
|
- cosCache.clear()
|
|
|
|
|
-})
|
|
|
|
|
-</script>
|
|
|
|
|
|
|
+function formatNoticeClock(date) {
|
|
|
|
|
+ return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-<style scoped lang="scss">
|
|
|
|
|
-/* 样式保持不变,省略... */
|
|
|
|
|
-.big-screen-home {
|
|
|
|
|
- width: 100vw;
|
|
|
|
|
- height: 100vh;
|
|
|
|
|
- background-image: url("../../assets/portal/back.png");
|
|
|
|
|
- background-size: 100% 100%;
|
|
|
|
|
- color: #fff;
|
|
|
|
|
- position: relative;
|
|
|
|
|
- overflow: hidden;
|
|
|
|
|
- font-family: 'Microsoft YaHei', sans-serif;
|
|
|
|
|
- perspective: 1200px;
|
|
|
|
|
|
|
+function formatNoticeDateTime(value) {
|
|
|
|
|
+ const date = parseDate(value)
|
|
|
|
|
+ if (!date) return ''
|
|
|
|
|
+ return `${formatNoticeDate(date)} ${formatNoticeClock(date)}:${String(date.getSeconds()).padStart(2, '0')}`
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.header-bar {
|
|
|
|
|
- width: 100%;
|
|
|
|
|
- height: 70px;
|
|
|
|
|
- background-image: url("../../assets/portal/headerBackground.png");
|
|
|
|
|
- display: flex;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- justify-content: space-between;
|
|
|
|
|
- padding: 0 30px;
|
|
|
|
|
- box-sizing: border-box;
|
|
|
|
|
- position: relative;
|
|
|
|
|
- z-index: 100;
|
|
|
|
|
|
|
+function noticeDotClass(index) {
|
|
|
|
|
+ return ['red', 'orange', 'blue', 'green'][index % 4]
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- .header-left {
|
|
|
|
|
- width: 300px;
|
|
|
|
|
- .time-text {
|
|
|
|
|
- font-size: 14px;
|
|
|
|
|
- color: #a0d8ff;
|
|
|
|
|
|
|
+function animateNotices(timestamp) {
|
|
|
|
|
+ if (!noticePaused.value && notices.value.length && timestamp - lastNoticeFrame >= 30) {
|
|
|
|
|
+ const scroll = noticeScrollRef.value
|
|
|
|
|
+ const track = noticeTrackRef.value
|
|
|
|
|
+ if (scroll && track) {
|
|
|
|
|
+ noticeOffset += 1
|
|
|
|
|
+ const duplicateStart = track.querySelector('[data-copy-start="true"]')
|
|
|
|
|
+ const firstNotice = track.firstElementChild
|
|
|
|
|
+ const resetAt = duplicateStart && firstNotice
|
|
|
|
|
+ ? duplicateStart.offsetLeft - firstNotice.offsetLeft
|
|
|
|
|
+ : 0
|
|
|
|
|
+ if (resetAt > scroll.clientWidth) {
|
|
|
|
|
+ if (noticeOffset >= resetAt) noticeOffset = 0
|
|
|
|
|
+ scroll.scrollLeft = Math.floor(noticeOffset)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
+ lastNoticeFrame = timestamp
|
|
|
}
|
|
}
|
|
|
|
|
+ noticeAnimationFrame = requestAnimationFrame(animateNotices)
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- .system-title {
|
|
|
|
|
- font-size: 36px;
|
|
|
|
|
- font-weight: 600;
|
|
|
|
|
- color: #fff;
|
|
|
|
|
- text-shadow: 0 0 10px rgba(0, 191, 255, 0.8);
|
|
|
|
|
- margin: 0;
|
|
|
|
|
- letter-spacing: 2px;
|
|
|
|
|
|
|
+async function loadPortalData() {
|
|
|
|
|
+ const [statisticsResult, noticesResult] = await Promise.allSettled([getPortalStatistics(), getPortalNotices()])
|
|
|
|
|
+ if (statisticsResult.status === 'fulfilled' && statisticsResult.value.data) statistics.value = statisticsResult.value.data
|
|
|
|
|
+ if (noticesResult.status === 'fulfilled') {
|
|
|
|
|
+ notices.value = Array.isArray(noticesResult.value.data) ? noticesResult.value.data : []
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+ noticeOffset = 0
|
|
|
|
|
+ if (noticeScrollRef.value) noticeScrollRef.value.scrollLeft = 0
|
|
|
}
|
|
}
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- .header-right {
|
|
|
|
|
- width: 300px;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- gap: 15px;
|
|
|
|
|
-
|
|
|
|
|
- .welcome-text {
|
|
|
|
|
- font-size: 14px;
|
|
|
|
|
- color: #a0d8ff;
|
|
|
|
|
- }
|
|
|
|
|
- .username {
|
|
|
|
|
- color: #fff;
|
|
|
|
|
- font-weight: bold;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- .header-btn {
|
|
|
|
|
- background: rgba(0, 127, 255, 0.3);
|
|
|
|
|
- border: 1px solid #00bfff;
|
|
|
|
|
- color: #fff;
|
|
|
|
|
- padding: 5px 12px;
|
|
|
|
|
- border-radius: 4px;
|
|
|
|
|
- cursor: pointer;
|
|
|
|
|
- font-size: 13px;
|
|
|
|
|
- transition: all 0.2s;
|
|
|
|
|
-
|
|
|
|
|
- &:hover {
|
|
|
|
|
- background: rgba(0, 127, 255, 0.5);
|
|
|
|
|
- box-shadow: 0 0 8px rgba(0,191,255,0.5);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+async function openNotice(noticeId) {
|
|
|
|
|
+ const requestId = ++noticeRequestId
|
|
|
|
|
+ noticeDialogVisible.value = true
|
|
|
|
|
+ noticeDetailLoading.value = true
|
|
|
|
|
+ noticeDetail.value = {}
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await getPortalNotice(noticeId)
|
|
|
|
|
+ if (requestId === noticeRequestId) noticeDetail.value = response.data || {}
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ if (requestId === noticeRequestId) noticeDetail.value = {}
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (requestId === noticeRequestId) noticeDetailLoading.value = false
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-.ring-3d-container {
|
|
|
|
|
- position: absolute;
|
|
|
|
|
- top: 65%;
|
|
|
|
|
- left: 50%;
|
|
|
|
|
- width: 0;
|
|
|
|
|
- height: 0;
|
|
|
|
|
- transform: translate(-50%, -50%);
|
|
|
|
|
-
|
|
|
|
|
- .module-item-3d {
|
|
|
|
|
- position: absolute;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- flex-direction: column;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- cursor: pointer;
|
|
|
|
|
- filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
|
|
|
|
|
- will-change: transform, opacity;
|
|
|
|
|
- transition: filter 0.2s ease;
|
|
|
|
|
|
|
+function sanitizeNoticeHtml(html) {
|
|
|
|
|
+ if (!html) return ''
|
|
|
|
|
+ return DOMPurify.sanitize(html, {
|
|
|
|
|
+ FORBID_TAGS: ['style', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select'],
|
|
|
|
|
+ FORBID_ATTR: ['style']
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- &:hover {
|
|
|
|
|
- filter: drop-shadow(0 0 15px rgba(0, 191, 255, 0.8));
|
|
|
|
|
- z-index: 100 !important;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+function enterModule(module) {
|
|
|
|
|
+ if (module.path) router.push(module.path)
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- .icon-wrapper {
|
|
|
|
|
- .icon-circle {
|
|
|
|
|
- width: 150px;
|
|
|
|
|
- height: 80px;
|
|
|
|
|
- border-radius: 50%;
|
|
|
|
|
- display: flex;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- justify-content: center;
|
|
|
|
|
- position: relative;
|
|
|
|
|
|
|
+function logout() {
|
|
|
|
|
+ ElMessageBox.confirm('确定要退出登录吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(async () => {
|
|
|
|
|
+ await userStore.logOut()
|
|
|
|
|
+ location.href = '/login'
|
|
|
|
|
+ }).catch(() => {})
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- .module-icon {
|
|
|
|
|
- width: 150px;
|
|
|
|
|
- height: 130px;
|
|
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ updateCurrentDate()
|
|
|
|
|
+ dateTimer = window.setInterval(updateCurrentDate, 60000)
|
|
|
|
|
+ noticeAnimationFrame = requestAnimationFrame(animateNotices)
|
|
|
|
|
+ loadPortalData()
|
|
|
|
|
+})
|
|
|
|
|
|
|
|
- &.icon-operation {
|
|
|
|
|
- background: url("../../assets/portal/smxyj.png") no-repeat center center;
|
|
|
|
|
- background-size: 100% 100%;
|
|
|
|
|
- }
|
|
|
|
|
- &.icon-permission {
|
|
|
|
|
- background: linear-gradient(180deg, #64efff 0%, #1595ff 100%);
|
|
|
|
|
- filter: drop-shadow(0 0 10px rgba(38, 202, 255, 0.8));
|
|
|
|
|
- mask: url("../../assets/icons/svg/lock.svg") no-repeat center 8px / 76px 76px;
|
|
|
|
|
- -webkit-mask: url("../../assets/icons/svg/lock.svg") no-repeat center 8px / 76px 76px;
|
|
|
|
|
- }
|
|
|
|
|
- &.icon-sewage {
|
|
|
|
|
- background: url("../../assets/portal/yjzh.png") no-repeat center center;
|
|
|
|
|
- background-size: 100% 100%;
|
|
|
|
|
- }
|
|
|
|
|
- &.icon-bi {
|
|
|
|
|
- background: url("../../assets/portal/gwss.png") no-repeat center center;
|
|
|
|
|
- background-size: 100% 100%;
|
|
|
|
|
- }
|
|
|
|
|
- &.icon-water {
|
|
|
|
|
- background: url("../../assets/portal/rq.png") no-repeat center center;
|
|
|
|
|
- background-size: 100% 100%;
|
|
|
|
|
- }
|
|
|
|
|
- &.icon-flood {
|
|
|
|
|
- background: url("../../assets/portal/gs.png") no-repeat center center;
|
|
|
|
|
- background-size: 100% 100%;
|
|
|
|
|
- }
|
|
|
|
|
- &.icon-123 {
|
|
|
|
|
- background: url("../../assets/portal/ps.png") no-repeat center center;
|
|
|
|
|
- background-size: 100% 100%;
|
|
|
|
|
- }
|
|
|
|
|
- &.icon-ps {
|
|
|
|
|
- background: url("../../assets/portal/jg.png") no-repeat center center;
|
|
|
|
|
- background-size: 100% 100%;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+onUnmounted(() => {
|
|
|
|
|
+ window.clearInterval(dateTimer)
|
|
|
|
|
+ cancelAnimationFrame(noticeAnimationFrame)
|
|
|
|
|
+})
|
|
|
|
|
+</script>
|
|
|
|
|
|
|
|
- .module-name {
|
|
|
|
|
- position: absolute;
|
|
|
|
|
- top: 98px;
|
|
|
|
|
- left: 50%;
|
|
|
|
|
- width: 180px;
|
|
|
|
|
- color: #80efff;
|
|
|
|
|
- font-size: 17px;
|
|
|
|
|
- font-weight: 600;
|
|
|
|
|
- line-height: 24px;
|
|
|
|
|
- text-align: center;
|
|
|
|
|
- text-shadow: 0 0 8px rgba(31, 197, 255, 0.8);
|
|
|
|
|
- transform: translateX(-50%);
|
|
|
|
|
- white-space: nowrap;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+<style scoped lang="scss">
|
|
|
|
|
+.portal-page {
|
|
|
|
|
+ --background: #e8ecf0; --panel: #fff; --border: #e8ecf1; --text-primary: #1a2332; --text-secondary: #4a5568; --text-muted: #8b98a8; --blue: #3b82f6; --cyan: #06b6d4; --green: #10b981; --orange: #f59e0b; --red: #ef4444;
|
|
|
|
|
+ min-height: 100vh; color: var(--text-primary); background: var(--background); font-family: ui-sans-serif, system-ui, "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
|
|
|
+}
|
|
|
|
|
+.skip-link { position: fixed; top: -100%; left: 16px; z-index: 200; padding: 8px 16px; border-radius: 6px; color: #fff; background: var(--blue); }
|
|
|
|
|
+.skip-link:focus { top: 16px; }
|
|
|
|
|
+.top-bar { height: 56px; padding: 0 48px; position: sticky; top: 0; z-index: 100; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); background: var(--panel); }
|
|
|
|
|
+.logo-area { display: flex; align-items: center; gap: 10px; }
|
|
|
|
|
+.logo-icon { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; border-radius: 7px; background: linear-gradient(135deg, #3b82f6, #06b6d4); }
|
|
|
|
|
+.logo-icon svg { width: 16px; height: 16px; fill: #fff; }
|
|
|
|
|
+.logo-text { font-size: 15px; font-weight: 700; letter-spacing: 1px; }
|
|
|
|
|
+.top-right { display: flex; align-items: center; gap: 16px; }
|
|
|
|
|
+.datetime { color: var(--text-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
|
|
|
+.user-badge { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 13px; }
|
|
|
|
|
+.user-badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
|
|
|
|
|
+.logout-button { min-height: 32px; padding: 6px 14px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #bfdbfe; border-radius: 6px; color: #2563eb; background: #eff6ff; cursor: pointer; font-size: 13px; font-weight: 600; line-height: 18px; transition: color .2s, border-color .2s, background-color .2s, box-shadow .2s; }
|
|
|
|
|
+.logout-button:hover { color: #1d4ed8; border-color: #93c5fd; background: #dbeafe; box-shadow: 0 2px 6px rgba(37, 99, 235, .12); }
|
|
|
|
|
+.logout-button:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; }
|
|
|
|
|
+.logout-button:active { background: #bfdbfe; box-shadow: none; }
|
|
|
|
|
+.logout-button svg { width: 15px; height: 15px; fill: currentColor; }
|
|
|
|
|
+.notice-bar { height: 36px; padding: 0 48px; display: flex; align-items: center; gap: 14px; overflow: hidden; border-bottom: 1px solid var(--border); background: var(--panel); }
|
|
|
|
|
+.notice-tag { flex: 0 0 auto; padding: 2px 10px; display: flex; align-items: center; gap: 4px; border: 1px solid rgba(59, 130, 246, .15); border-radius: 4px; color: var(--blue); background: rgba(59, 130, 246, .08); font-size: 11px; font-weight: 600; letter-spacing: 1px; }
|
|
|
|
|
+.notice-tag svg { width: 11px; height: 11px; fill: currentColor; }
|
|
|
|
|
+.notice-scroll { height: 100%; min-width: 0; flex: 1; display: flex; align-items: center; overflow: hidden; }
|
|
|
|
|
+.notice-track { width: max-content; display: flex; align-items: center; gap: 80px; white-space: nowrap; }
|
|
|
|
|
+.notice-item { padding: 0; flex: 0 0 auto; display: flex; align-items: center; gap: 8px; border: 0; color: var(--text-secondary); background: none; cursor: pointer; font-size: 12px; }
|
|
|
|
|
+.notice-item:hover { color: var(--blue); }
|
|
|
|
|
+.notice-dot { width: 5px; height: 5px; flex: 0 0 auto; border-radius: 50%; }
|
|
|
|
|
+.notice-dot.red { background: var(--red); } .notice-dot.orange { background: var(--orange); } .notice-dot.blue { background: var(--blue); } .notice-dot.green { background: var(--green); }
|
|
|
|
|
+.notice-time, .notice-empty { color: var(--text-muted); font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
|
|
|
+.main-content { max-width: 1440px; margin: 0 auto; padding: 40px 48px 64px; }
|
|
|
|
|
+.section-header { margin-bottom: 22px; }
|
|
|
|
|
+.section-title { margin: 0; color: var(--text-primary); font-size: 17px; font-weight: 700; line-height: 1.4; }
|
|
|
|
|
+.section-subtitle { margin: 4px 0 0; color: var(--text-muted); font-size: 12px; }
|
|
|
|
|
+.stats-row { margin-bottom: 40px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
|
|
|
|
+.stat-card { padding: 24px 28px; position: relative; overflow: hidden; border: 1px solid var(--border); border-radius: 10px; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .04); }
|
|
|
|
|
+.stat-card::before { content: ''; height: 3px; position: absolute; top: 0; right: 0; left: 0; }
|
|
|
|
|
+.stat-card:nth-child(1)::before { background: linear-gradient(90deg, var(--cyan), transparent); } .stat-card:nth-child(2)::before { background: linear-gradient(90deg, var(--orange), transparent); } .stat-card:nth-child(3)::before { background: linear-gradient(90deg, var(--red), transparent); }
|
|
|
|
|
+.stat-label { margin-bottom: 8px; color: var(--text-muted); font-size: 12px; letter-spacing: 1px; }
|
|
|
|
|
+.stat-value { font-size: 28px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
|
|
|
|
|
+.stat-card:nth-child(1) .stat-value { color: var(--cyan); } .stat-card:nth-child(2) .stat-value { color: var(--orange); } .stat-card:nth-child(3) .stat-value { color: var(--red); }
|
|
|
|
|
+.stat-sub { margin-top: 10px; display: flex; align-items: center; gap: 12px; color: var(--text-muted); font-size: 11px; }
|
|
|
|
|
+.stat-sub b { font-weight: 600; } .text-primary { color: var(--text-primary); } .text-orange { color: var(--orange); }
|
|
|
|
|
+.stat-trend { padding: 1px 6px; display: inline-flex; border-radius: 4px; font-size: 11px; font-weight: 500; }
|
|
|
|
|
+.stat-trend.up { color: var(--red); background: rgba(239, 68, 68, .06); } .stat-trend.down { color: var(--green); background: rgba(16, 185, 129, .06); } .stat-trend.flat { color: var(--text-secondary); background: rgba(74, 85, 104, .06); }
|
|
|
|
|
+.system-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 22px 24px; }
|
|
|
|
|
+.system-card { min-height: 150px; padding: 26px 22px; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 10px; color: inherit; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .04); cursor: pointer; text-align: left; transition: all .25s ease; }
|
|
|
|
|
+.system-card:hover { transform: translateY(-2px); border-color: var(--blue); box-shadow: 0 4px 16px rgba(0, 0, 0, .06); }
|
|
|
|
|
+.card-row { margin-bottom: 16px; display: flex; align-items: center; gap: 14px; }
|
|
|
|
|
+.system-icon { width: 42px; height: 42px; flex: 0 0 auto; display: flex; align-items: center; justify-content: center; border-radius: 9px; color: var(--module-color); background: var(--module-background); }
|
|
|
|
|
+.system-icon svg { width: 21px; height: 21px; fill: currentColor; }
|
|
|
|
|
+.system-name { color: var(--text-primary); font-size: 14px; font-weight: 600; line-height: 1.4; }
|
|
|
|
|
+.system-desc { margin-top: auto; color: var(--text-muted); font-size: 12px; line-height: 2; }
|
|
|
|
|
+.system-empty { grid-column: 1 / -1; padding: 50px 20px; color: var(--text-muted); text-align: center; }
|
|
|
|
|
+.notice-detail { min-height: 120px; }
|
|
|
|
|
+.notice-detail-meta { margin-bottom: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--border); color: var(--text-muted); font-size: 12px; }
|
|
|
|
|
+.notice-detail-content { overflow-wrap: anywhere; color: var(--text-secondary); font-size: 14px; line-height: 1.8; }
|
|
|
|
|
+:deep(.portal-notice-dialog) { border-radius: 8px; }
|
|
|
|
|
+:deep(.portal-notice-dialog .el-dialog__title) { color: var(--text-primary); font-size: 16px; font-weight: 600; }
|
|
|
|
|
+:deep(.notice-detail-content img) { max-width: 100%; height: auto; }
|
|
|
|
|
+:deep(.notice-detail-content a) { color: var(--blue); }
|
|
|
|
|
+@media (max-width: 1100px) { .system-grid { grid-template-columns: repeat(3, 1fr); } }
|
|
|
|
|
+@media (max-width: 820px) {
|
|
|
|
|
+ .top-bar, .notice-bar { padding-right: 24px; padding-left: 24px; } .main-content { padding: 28px 24px 48px; } .system-grid { grid-template-columns: repeat(2, 1fr); } .datetime { display: none; }
|
|
|
|
|
+}
|
|
|
|
|
+@media (max-width: 560px) {
|
|
|
|
|
+ .top-bar { padding: 0 16px; } .notice-bar { padding: 0 16px; } .logo-text { max-width: 190px; font-size: 13px; } .user-badge { display: none; } .main-content { padding: 24px 16px 40px; } .stats-row, .system-grid { grid-template-columns: 1fr; } .stat-card { padding: 20px 22px; } .system-card { min-height: 132px; } :deep(.portal-notice-dialog) { width: calc(100% - 24px) !important; }
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
-//@media (max-width: 1366px) {
|
|
|
|
|
-// .ring-3d-container .module-item-3d .icon-wrapper .icon-circle {
|
|
|
|
|
-// width: 70px;
|
|
|
|
|
-// height: 70px;
|
|
|
|
|
-//
|
|
|
|
|
-// .module-icon {
|
|
|
|
|
-// width: 35px;
|
|
|
|
|
-// height: 35px;
|
|
|
|
|
-// }
|
|
|
|
|
-// }
|
|
|
|
|
-//
|
|
|
|
|
-// .ring-3d-container .module-item-3d {
|
|
|
|
|
-// font-size: 12px;
|
|
|
|
|
-// padding: 3px 10px;
|
|
|
|
|
-// }
|
|
|
|
|
-//}
|
|
|
|
|
</style>
|
|
</style>
|