| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- <template>
- <div class="portal-page">
- <a class="skip-link" href="#portal-main">跳转到主要内容</a>
- <header class="top-bar">
- <div class="logo-area">
- <img class="logo-icon" src="/logo.png" alt="平台 Logo">
- <span class="logo-text">沅陵县城市地下管网管控平台</span>
- </div>
- <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>
- </header>
- <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>
- <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>
- <template #footer><el-button type="primary" @click="noticeDialogVisible = false">关闭</el-button></template>
- </el-dialog>
- </div>
- </template>
- <script setup>
- import DOMPurify from 'dompurify'
- 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 permissionStore = usePermissionStore()
- const userStore = useUserStore()
- 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 shouldScrollNotices = 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(() => shouldScrollNotices.value ? notices.value.concat(notices.value) : 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) })
- }
- return MODULE_DEFINITIONS.map(item => matched.get(item.key)).filter(Boolean)
- })
- const safeNoticeContent = computed(() => sanitizeNoticeHtml(noticeDetail.value.noticeContent || ''))
- function findRoutePath(route, matcher, parentPath = '') {
- if (!route || route.hidden) return ''
- const currentPath = joinRoutePath(parentPath, route.path || '')
- if (matcher(route)) return currentPath
- for (const child of route.children || []) {
- const result = findRoutePath(child, matcher, currentPath)
- if (result) return result
- }
- return ''
- }
- function findFirstVisibleLeafPath(route, parentPath = '') {
- if (!route || route.hidden) return ''
- const currentPath = joinRoutePath(parentPath, route.path || '')
- const children = (route.children || []).filter(child => !child.hidden)
- if (!children.length) return currentPath
- for (const child of children) {
- const result = findFirstVisibleLeafPath(child, currentPath)
- if (result) return result
- }
- return currentPath
- }
- function resolveSubsystemEntryPath(route) {
- return findRoutePath(route, item => item.meta?.title === '首页') || findFirstVisibleLeafPath(route) || route.path || '/portal'
- }
- 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}`
- }
- function formatCount(value) {
- return value === null || value === undefined ? '--' : Number(value).toLocaleString('zh-CN')
- }
- 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' }
- }
- 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
- }
- 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)}`
- }
- function formatNoticeDate(date) {
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
- }
- function formatNoticeClock(date) {
- return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
- }
- function formatNoticeDateTime(value) {
- const date = parseDate(value)
- if (!date) return ''
- return `${formatNoticeDate(date)} ${formatNoticeClock(date)}:${String(date.getSeconds()).padStart(2, '0')}`
- }
- function noticeDotClass(index) {
- return ['red', 'orange', 'blue', 'green'][index % 4]
- }
- function animateNotices(timestamp) {
- if (!noticePaused.value && shouldScrollNotices.value && 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)
- }
- async function updateNoticeScrollState() {
- await nextTick()
- const scroll = noticeScrollRef.value
- const track = noticeTrackRef.value
- if (!scroll || !track || !notices.value.length) {
- shouldScrollNotices.value = false
- noticeOffset = 0
- return
- }
- const duplicateStart = track.querySelector('[data-copy-start="true"]')
- const firstNotice = track.firstElementChild
- const lastOriginalNotice = track.children[notices.value.length - 1]
- const contentWidth = duplicateStart && firstNotice
- ? lastOriginalNotice.getBoundingClientRect().right - firstNotice.getBoundingClientRect().left
- : track.scrollWidth
- const needsScroll = contentWidth > scroll.clientWidth + 1
- shouldScrollNotices.value = needsScroll
- if (!needsScroll) {
- noticeOffset = 0
- scroll.scrollLeft = 0
- } else {
- await nextTick()
- if (noticeScrollRef.value) noticeScrollRef.value.scrollLeft = Math.floor(noticeOffset)
- }
- }
- 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 : []
- noticeOffset = 0
- await updateNoticeScrollState()
- }
- }
- function handleNoticeResize() {
- updateNoticeScrollState()
- }
- 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
- }
- }
- function sanitizeNoticeHtml(html) {
- if (!html) return ''
- return DOMPurify.sanitize(html, {
- FORBID_TAGS: ['style', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select'],
- FORBID_ATTR: ['style']
- })
- }
- function enterModule(module) {
- if (module.path) router.push(module.path)
- }
- function logout() {
- ElMessageBox.confirm('确定要退出登录吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(async () => {
- await userStore.logOut()
- location.href = '/login'
- }).catch(() => {})
- }
- onMounted(() => {
- updateCurrentDate()
- dateTimer = window.setInterval(updateCurrentDate, 60000)
- window.addEventListener('resize', handleNoticeResize)
- noticeAnimationFrame = requestAnimationFrame(animateNotices)
- loadPortalData()
- })
- onUnmounted(() => {
- window.clearInterval(dateTimer)
- window.removeEventListener('resize', handleNoticeResize)
- cancelAnimationFrame(noticeAnimationFrame)
- })
- </script>
- <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: block; border-radius: 7px; object-fit: contain; background: #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; }
- }
- </style>
|