index.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. <template>
  2. <div class="portal-page">
  3. <a class="skip-link" href="#portal-main">跳转到主要内容</a>
  4. <header class="top-bar">
  5. <div class="logo-area">
  6. <img class="logo-icon" src="/logo.png" alt="平台 Logo">
  7. <span class="logo-text">沅陵县城市地下管网管控平台</span>
  8. </div>
  9. <div class="top-right">
  10. <span class="datetime">{{ currentDate }}</span>
  11. <span class="user-badge">{{ displayName }}</span>
  12. <button class="logout-button" type="button" @click="logout">
  13. <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>
  14. 退出登录
  15. </button>
  16. </div>
  17. </header>
  18. <section class="notice-bar" aria-label="通知公告">
  19. <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>
  20. <div ref="noticeScrollRef" class="notice-scroll" @mouseenter="noticePaused = true" @mouseleave="noticePaused = false" @focusin="noticePaused = true" @focusout="noticePaused = false">
  21. <div v-if="notices.length" ref="noticeTrackRef" class="notice-track">
  22. <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)">
  23. <span class="notice-dot" :class="noticeDotClass(index)"></span>
  24. <span>{{ notice.noticeTitle }}</span>
  25. <time class="notice-time">{{ formatNoticeTime(notice.createTime) }}</time>
  26. </button>
  27. </div>
  28. <span v-else class="notice-empty">暂无公告</span>
  29. </div>
  30. </section>
  31. <main id="portal-main" class="main-content" tabindex="-1">
  32. <div class="section-header"><h1 class="section-title">全网数据总览</h1></div>
  33. <section class="stats-row" aria-label="全网数据总览">
  34. <article class="stat-card">
  35. <div class="stat-label">设备总数</div>
  36. <div class="stat-value">{{ formatCount(statistics.deviceTotal) }}</div>
  37. <div class="stat-sub">
  38. <span>离线 <b class="text-primary">{{ formatCount(statistics.offlineDeviceCount) }}</b></span>
  39. <span>报警 <b class="text-orange">{{ formatCount(statistics.alarmingDeviceCount) }}</b></span>
  40. </div>
  41. </article>
  42. <article class="stat-card">
  43. <div class="stat-label">今日预警</div>
  44. <div class="stat-value">{{ formatCount(statistics.todayWarningCount) }}</div>
  45. <div class="stat-sub">较昨日 <span class="stat-trend" :class="warningTrend.className">{{ warningTrend.text }}</span></div>
  46. </article>
  47. <article class="stat-card">
  48. <div class="stat-label">今日报警</div>
  49. <div class="stat-value">{{ formatCount(statistics.todayAlarmCount) }}</div>
  50. <div class="stat-sub">较昨日 <span class="stat-trend" :class="alarmTrend.className">{{ alarmTrend.text }}</span></div>
  51. </article>
  52. </section>
  53. <div class="section-header">
  54. <h2 class="section-title">子系统总览</h2>
  55. <p class="section-subtitle">地下管网核心业务系统统一入口</p>
  56. </div>
  57. <section class="system-grid" aria-label="子系统入口">
  58. <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)">
  59. <span class="card-row">
  60. <span class="system-icon" aria-hidden="true">
  61. <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>
  62. <svg v-else viewBox="0 0 24 24"><path :d="module.icon" /></svg>
  63. </span>
  64. <span class="system-name">{{ module.name }}</span>
  65. </span>
  66. <span class="system-desc">{{ module.description[0] }}<br>{{ module.description[1] }}</span>
  67. </button>
  68. <div v-if="!modules.length" class="system-empty">当前账号暂无可访问的子系统</div>
  69. </section>
  70. </main>
  71. <el-dialog v-model="noticeDialogVisible" class="portal-notice-dialog" :title="noticeDetail.noticeTitle || '通知公告'" width="620px" destroy-on-close>
  72. <div v-loading="noticeDetailLoading" class="notice-detail">
  73. <div v-if="noticeDetail.createTime" class="notice-detail-meta">发布时间:{{ formatNoticeDateTime(noticeDetail.createTime) }}</div>
  74. <div v-if="noticeDetail.noticeContent" class="notice-detail-content" v-html="safeNoticeContent"></div>
  75. <el-empty v-else-if="!noticeDetailLoading" description="暂无公告内容" :image-size="72" />
  76. </div>
  77. <template #footer><el-button type="primary" @click="noticeDialogVisible = false">关闭</el-button></template>
  78. </el-dialog>
  79. </div>
  80. </template>
  81. <script setup>
  82. import DOMPurify from 'dompurify'
  83. import { ElMessageBox } from 'element-plus'
  84. import { getPortalNotice, getPortalNotices, getPortalStatistics } from '@/api/portal'
  85. import { joinRoutePath } from '@/utils/ruoyi'
  86. import usePermissionStore from '@/store/modules/permission'
  87. import useUserStore from '@/store/modules/user'
  88. const router = useRouter()
  89. const permissionStore = usePermissionStore()
  90. const userStore = useUserStore()
  91. const MODULE_DEFINITIONS = [
  92. { 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' },
  93. { 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' },
  94. { 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' },
  95. { 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' },
  96. { 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' },
  97. { key: 'manhole', keywords: ['窨井盖', 'manhole'], description: ['感知井盖开启、位移及井内环境', '及时发现异常,守护道路安全'], color: '#8b5cf6', background: 'rgba(139,92,246,.08)', icon: '' },
  98. { 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' },
  99. { 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' }
  100. ]
  101. const statistics = ref({ deviceTotal: null, offlineDeviceCount: null, alarmingDeviceCount: null, todayWarningCount: null, yesterdayWarningCount: null, todayAlarmCount: null, yesterdayAlarmCount: null })
  102. const notices = ref([])
  103. const currentDate = ref('')
  104. const noticeScrollRef = ref()
  105. const noticeTrackRef = ref()
  106. const noticePaused = ref(false)
  107. const shouldScrollNotices = ref(false)
  108. const noticeDialogVisible = ref(false)
  109. const noticeDetailLoading = ref(false)
  110. const noticeDetail = ref({})
  111. let dateTimer
  112. let noticeAnimationFrame
  113. let noticeOffset = 0
  114. let lastNoticeFrame = 0
  115. let noticeRequestId = 0
  116. const displayName = computed(() => userStore.nickName || userStore.name || '用户')
  117. const scrollingNotices = computed(() => shouldScrollNotices.value ? notices.value.concat(notices.value) : notices.value)
  118. const warningTrend = computed(() => buildTrend(statistics.value.todayWarningCount, statistics.value.yesterdayWarningCount))
  119. const alarmTrend = computed(() => buildTrend(statistics.value.todayAlarmCount, statistics.value.yesterdayAlarmCount))
  120. const modules = computed(() => {
  121. const matched = new Map()
  122. for (const route of permissionStore.routes || []) {
  123. if (!route || route.hidden) continue
  124. const identity = `${route.path || ''} ${route.name || ''} ${route.meta?.title || ''}`.toLowerCase()
  125. const definition = MODULE_DEFINITIONS.find(item => item.keywords.some(keyword => identity.includes(keyword.toLowerCase())))
  126. if (!definition || matched.has(definition.key)) continue
  127. matched.set(definition.key, { ...definition, name: route.meta?.title || definition.name, path: resolveSubsystemEntryPath(route) })
  128. }
  129. return MODULE_DEFINITIONS.map(item => matched.get(item.key)).filter(Boolean)
  130. })
  131. const safeNoticeContent = computed(() => sanitizeNoticeHtml(noticeDetail.value.noticeContent || ''))
  132. function findRoutePath(route, matcher, parentPath = '') {
  133. if (!route || route.hidden) return ''
  134. const currentPath = joinRoutePath(parentPath, route.path || '')
  135. if (matcher(route)) return currentPath
  136. for (const child of route.children || []) {
  137. const result = findRoutePath(child, matcher, currentPath)
  138. if (result) return result
  139. }
  140. return ''
  141. }
  142. function findFirstVisibleLeafPath(route, parentPath = '') {
  143. if (!route || route.hidden) return ''
  144. const currentPath = joinRoutePath(parentPath, route.path || '')
  145. const children = (route.children || []).filter(child => !child.hidden)
  146. if (!children.length) return currentPath
  147. for (const child of children) {
  148. const result = findFirstVisibleLeafPath(child, currentPath)
  149. if (result) return result
  150. }
  151. return currentPath
  152. }
  153. function resolveSubsystemEntryPath(route) {
  154. return findRoutePath(route, item => item.meta?.title === '首页') || findFirstVisibleLeafPath(route) || route.path || '/portal'
  155. }
  156. function updateCurrentDate() {
  157. const now = new Date()
  158. const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][now.getDay()]
  159. currentDate.value = `${now.getFullYear()}年${String(now.getMonth() + 1).padStart(2, '0')}月${String(now.getDate()).padStart(2, '0')}日 ${week}`
  160. }
  161. function formatCount(value) {
  162. return value === null || value === undefined ? '--' : Number(value).toLocaleString('zh-CN')
  163. }
  164. function buildTrend(today, yesterday) {
  165. if (today === null || yesterday === null || today === undefined || yesterday === undefined) return { text: '--', className: 'flat' }
  166. const difference = Number(today) - Number(yesterday)
  167. if (difference > 0) return { text: `↑ ${difference}`, className: 'up' }
  168. if (difference < 0) return { text: `↓ ${Math.abs(difference)}`, className: 'down' }
  169. return { text: '持平', className: 'flat' }
  170. }
  171. function parseDate(value) {
  172. if (!value) return null
  173. const normalized = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}(?: |$)/.test(value)
  174. ? value.replace(/-/g, '/')
  175. : value
  176. const date = new Date(normalized)
  177. return Number.isNaN(date.getTime()) ? null : date
  178. }
  179. function formatNoticeTime(value) {
  180. const date = parseDate(value)
  181. if (!date) return ''
  182. const now = new Date()
  183. const calendarDay = item => Date.UTC(item.getFullYear(), item.getMonth(), item.getDate())
  184. const daysAgo = (calendarDay(now) - calendarDay(date)) / 86400000
  185. const relativeDay = ['今天', '昨天', '前天'][daysAgo]
  186. return `${relativeDay || formatNoticeDate(date)} ${formatNoticeClock(date)}`
  187. }
  188. function formatNoticeDate(date) {
  189. return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
  190. }
  191. function formatNoticeClock(date) {
  192. return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
  193. }
  194. function formatNoticeDateTime(value) {
  195. const date = parseDate(value)
  196. if (!date) return ''
  197. return `${formatNoticeDate(date)} ${formatNoticeClock(date)}:${String(date.getSeconds()).padStart(2, '0')}`
  198. }
  199. function noticeDotClass(index) {
  200. return ['red', 'orange', 'blue', 'green'][index % 4]
  201. }
  202. function animateNotices(timestamp) {
  203. if (!noticePaused.value && shouldScrollNotices.value && timestamp - lastNoticeFrame >= 30) {
  204. const scroll = noticeScrollRef.value
  205. const track = noticeTrackRef.value
  206. if (scroll && track) {
  207. noticeOffset += 1
  208. const duplicateStart = track.querySelector('[data-copy-start="true"]')
  209. const firstNotice = track.firstElementChild
  210. const resetAt = duplicateStart && firstNotice
  211. ? duplicateStart.offsetLeft - firstNotice.offsetLeft
  212. : 0
  213. if (resetAt > scroll.clientWidth) {
  214. if (noticeOffset >= resetAt) noticeOffset = 0
  215. scroll.scrollLeft = Math.floor(noticeOffset)
  216. }
  217. }
  218. lastNoticeFrame = timestamp
  219. }
  220. noticeAnimationFrame = requestAnimationFrame(animateNotices)
  221. }
  222. async function updateNoticeScrollState() {
  223. await nextTick()
  224. const scroll = noticeScrollRef.value
  225. const track = noticeTrackRef.value
  226. if (!scroll || !track || !notices.value.length) {
  227. shouldScrollNotices.value = false
  228. noticeOffset = 0
  229. return
  230. }
  231. const duplicateStart = track.querySelector('[data-copy-start="true"]')
  232. const firstNotice = track.firstElementChild
  233. const lastOriginalNotice = track.children[notices.value.length - 1]
  234. const contentWidth = duplicateStart && firstNotice
  235. ? lastOriginalNotice.getBoundingClientRect().right - firstNotice.getBoundingClientRect().left
  236. : track.scrollWidth
  237. const needsScroll = contentWidth > scroll.clientWidth + 1
  238. shouldScrollNotices.value = needsScroll
  239. if (!needsScroll) {
  240. noticeOffset = 0
  241. scroll.scrollLeft = 0
  242. } else {
  243. await nextTick()
  244. if (noticeScrollRef.value) noticeScrollRef.value.scrollLeft = Math.floor(noticeOffset)
  245. }
  246. }
  247. async function loadPortalData() {
  248. const [statisticsResult, noticesResult] = await Promise.allSettled([getPortalStatistics(), getPortalNotices()])
  249. if (statisticsResult.status === 'fulfilled' && statisticsResult.value.data) statistics.value = statisticsResult.value.data
  250. if (noticesResult.status === 'fulfilled') {
  251. notices.value = Array.isArray(noticesResult.value.data) ? noticesResult.value.data : []
  252. noticeOffset = 0
  253. await updateNoticeScrollState()
  254. }
  255. }
  256. function handleNoticeResize() {
  257. updateNoticeScrollState()
  258. }
  259. async function openNotice(noticeId) {
  260. const requestId = ++noticeRequestId
  261. noticeDialogVisible.value = true
  262. noticeDetailLoading.value = true
  263. noticeDetail.value = {}
  264. try {
  265. const response = await getPortalNotice(noticeId)
  266. if (requestId === noticeRequestId) noticeDetail.value = response.data || {}
  267. } catch {
  268. if (requestId === noticeRequestId) noticeDetail.value = {}
  269. } finally {
  270. if (requestId === noticeRequestId) noticeDetailLoading.value = false
  271. }
  272. }
  273. function sanitizeNoticeHtml(html) {
  274. if (!html) return ''
  275. return DOMPurify.sanitize(html, {
  276. FORBID_TAGS: ['style', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select'],
  277. FORBID_ATTR: ['style']
  278. })
  279. }
  280. function enterModule(module) {
  281. if (module.path) router.push(module.path)
  282. }
  283. function logout() {
  284. ElMessageBox.confirm('确定要退出登录吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(async () => {
  285. await userStore.logOut()
  286. location.href = '/login'
  287. }).catch(() => {})
  288. }
  289. onMounted(() => {
  290. updateCurrentDate()
  291. dateTimer = window.setInterval(updateCurrentDate, 60000)
  292. window.addEventListener('resize', handleNoticeResize)
  293. noticeAnimationFrame = requestAnimationFrame(animateNotices)
  294. loadPortalData()
  295. })
  296. onUnmounted(() => {
  297. window.clearInterval(dateTimer)
  298. window.removeEventListener('resize', handleNoticeResize)
  299. cancelAnimationFrame(noticeAnimationFrame)
  300. })
  301. </script>
  302. <style scoped lang="scss">
  303. .portal-page {
  304. --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;
  305. min-height: 100vh; color: var(--text-primary); background: var(--background); font-family: ui-sans-serif, system-ui, "PingFang SC", "Microsoft YaHei", sans-serif;
  306. }
  307. .skip-link { position: fixed; top: -100%; left: 16px; z-index: 200; padding: 8px 16px; border-radius: 6px; color: #fff; background: var(--blue); }
  308. .skip-link:focus { top: 16px; }
  309. .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); }
  310. .logo-area { display: flex; align-items: center; gap: 10px; }
  311. .logo-icon { width: 30px; height: 30px; display: block; border-radius: 7px; object-fit: contain; background: #fff; }
  312. .logo-text { font-size: 15px; font-weight: 700; letter-spacing: 1px; }
  313. .top-right { display: flex; align-items: center; gap: 16px; }
  314. .datetime { color: var(--text-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
  315. .user-badge { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 13px; }
  316. .user-badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
  317. .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; }
  318. .logout-button:hover { color: #1d4ed8; border-color: #93c5fd; background: #dbeafe; box-shadow: 0 2px 6px rgba(37, 99, 235, .12); }
  319. .logout-button:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; }
  320. .logout-button:active { background: #bfdbfe; box-shadow: none; }
  321. .logout-button svg { width: 15px; height: 15px; fill: currentColor; }
  322. .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); }
  323. .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; }
  324. .notice-tag svg { width: 11px; height: 11px; fill: currentColor; }
  325. .notice-scroll { height: 100%; min-width: 0; flex: 1; display: flex; align-items: center; overflow: hidden; }
  326. .notice-track { width: max-content; display: flex; align-items: center; gap: 80px; white-space: nowrap; }
  327. .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; }
  328. .notice-item:hover { color: var(--blue); }
  329. .notice-dot { width: 5px; height: 5px; flex: 0 0 auto; border-radius: 50%; }
  330. .notice-dot.red { background: var(--red); } .notice-dot.orange { background: var(--orange); } .notice-dot.blue { background: var(--blue); } .notice-dot.green { background: var(--green); }
  331. .notice-time, .notice-empty { color: var(--text-muted); font-size: 11px; font-variant-numeric: tabular-nums; }
  332. .main-content { max-width: 1440px; margin: 0 auto; padding: 40px 48px 64px; }
  333. .section-header { margin-bottom: 22px; }
  334. .section-title { margin: 0; color: var(--text-primary); font-size: 17px; font-weight: 700; line-height: 1.4; }
  335. .section-subtitle { margin: 4px 0 0; color: var(--text-muted); font-size: 12px; }
  336. .stats-row { margin-bottom: 40px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
  337. .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); }
  338. .stat-card::before { content: ''; height: 3px; position: absolute; top: 0; right: 0; left: 0; }
  339. .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); }
  340. .stat-label { margin-bottom: 8px; color: var(--text-muted); font-size: 12px; letter-spacing: 1px; }
  341. .stat-value { font-size: 28px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
  342. .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); }
  343. .stat-sub { margin-top: 10px; display: flex; align-items: center; gap: 12px; color: var(--text-muted); font-size: 11px; }
  344. .stat-sub b { font-weight: 600; } .text-primary { color: var(--text-primary); } .text-orange { color: var(--orange); }
  345. .stat-trend { padding: 1px 6px; display: inline-flex; border-radius: 4px; font-size: 11px; font-weight: 500; }
  346. .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); }
  347. .system-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 22px 24px; }
  348. .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; }
  349. .system-card:hover { transform: translateY(-2px); border-color: var(--blue); box-shadow: 0 4px 16px rgba(0, 0, 0, .06); }
  350. .card-row { margin-bottom: 16px; display: flex; align-items: center; gap: 14px; }
  351. .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); }
  352. .system-icon svg { width: 21px; height: 21px; fill: currentColor; }
  353. .system-name { color: var(--text-primary); font-size: 14px; font-weight: 600; line-height: 1.4; }
  354. .system-desc { margin-top: auto; color: var(--text-muted); font-size: 12px; line-height: 2; }
  355. .system-empty { grid-column: 1 / -1; padding: 50px 20px; color: var(--text-muted); text-align: center; }
  356. .notice-detail { min-height: 120px; }
  357. .notice-detail-meta { margin-bottom: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--border); color: var(--text-muted); font-size: 12px; }
  358. .notice-detail-content { overflow-wrap: anywhere; color: var(--text-secondary); font-size: 14px; line-height: 1.8; }
  359. :deep(.portal-notice-dialog) { border-radius: 8px; }
  360. :deep(.portal-notice-dialog .el-dialog__title) { color: var(--text-primary); font-size: 16px; font-weight: 600; }
  361. :deep(.notice-detail-content img) { max-width: 100%; height: auto; }
  362. :deep(.notice-detail-content a) { color: var(--blue); }
  363. @media (max-width: 1100px) { .system-grid { grid-template-columns: repeat(3, 1fr); } }
  364. @media (max-width: 820px) {
  365. .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; }
  366. }
  367. @media (max-width: 560px) {
  368. .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; }
  369. }
  370. </style>